卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+1911
View File
File diff suppressed because it is too large. Load diff
+626
View File
@@ -0,0 +1,626 @@
// server/sim/ai.js — operator attack loop and enemy AI (route following, blocking, attacks) (DESIGN §5.5).
//
// Operators/tokens: attack cooldown counts down while able to act; when ready and a valid target exists the
// DEFAULT skill trigger is checked ("about to attack"), then the attack is performed with the effective profile
// (profession profile + active skill overrides). Ranged profiles fire projectiles; damage applies on impact.
// Enemies: follow compiled route legs (move / wait / disappear / appear). WALK legs follow the official flow field
// of the leg's target (grid.js, research 08 §3.4): from the tile it stands on the enemy walks straight to the centre
// of `next[tile]`, then on to `next[next[tile]]` … (tile centre to tile centre, so the Bresenham line-of-sight check
// of the smoothing guarantees it never clips a wall or crate corner); the plan is re-read when the grid version
// changes (obstacles) or content displaces the enemy (route.pts = null). FLY legs fly straight between checkpoints. Ground enemies entering a
// tile held by an ally with free block capacity get blocked. Blocked enemies fight their blocker (ranged ones may
// pick anyone in range, blocker first). Unblocked ranged enemies attack allies within their radius and pause
// ATTACK_PAUSE seconds after each attack. Reaching the final leg's end = leak. An `attract` (诱导) status suspends the
// route: the enemy walks to the status point instead (moveAttracted) and re-plans its route when released.
import { ATTACK_PAUSE, MOVE_SCALE, PROJECTILE_SPEEDS, PROJECTILE_SPEED, COLS } from './constants.js';
import { sortEnemyTargets, sortAllyTargets, canTargetEnemy, canTargetAlly, tileKeyOf } from './targeting.js';
import { reduceElement } from './damage.js';
import { OB_CRATE } from './grid.js';
// ---------------------------------------------------------------------------------------------------------------
// profiles
/** Effective attack profile of an ally unit (base profile + active skill overrides). */
export function effectiveProfile(u) {
const base = u.profile;
const sk = u.skill;
const ov = sk ? sk.attackOverride() : null;
const tg = sk ? sk.targetingOverride() : null;
if (!ov && !tg) return base;
const p = Object.assign({}, base);
p.isSkill = true;
if (ov) {
for (const k in ov) {
if (k === 'onHit') p.skillOnHit = ov.onHit;
else if (k === 'onEachHit') p.skillOnEachHit = ov.onEachHit;
else if (k === 'dmgMul' && typeof ov.dmgMul === 'number') p.skillDmgMul = ov.dmgMul;
else p[k] = ov[k];
}
if (ov.dmgType === 'heal' && !p.heal) p.heal = { mode: 'single' };
if (ov.dmgType && ov.dmgType !== 'heal' && !ov.heal) p.heal = null;
}
if (tg) {
if (tg.maxTargets != null) p.maxTargets = tg.maxTargets;
if (tg.priority) p.priority = tg.priority;
if (tg.allInRange != null) p.allInRange = tg.allInRange;
if (tg.canHitFly != null) p.canHitFly = tg.canHitFly;
}
return p;
}
// ---------------------------------------------------------------------------------------------------------------
// ally attack loop
export function updateAlly(b, u, dt) {
if (u.atkCd > 0 && u.canAct) u.atkCd = Math.max(0, u.atkCd - dt);
if (u.blocking.length) enforceBlockCapacity(b, u);
if (!u.canAct || !u.profile) return;
// a rangeExtend change (buff added / expired) rebuilds the range — also for units that never attack (auras)
if (b.rangeChanged(u)) b._refreshRange(u);
const sk = u.skill;
let prof = effectiveProfile(u);
if (prof.noAttack) return;
if (prof.noAttackUnlessSkill && !(sk && sk.active)) return;
if (u.s.flags.disarm) return;
if (u.atkCd > 0) return;
if (prof.canAttack && !prof.canAttack(b, u)) return;
let targets = acquireTargets(b, u, prof);
if (!targets.length) { u.trait.hadTarget = false; return; }
u.trait.hadTarget = true;
if (sk && sk.onAboutToAttack()) {
prof = effectiveProfile(u);
if (prof.noAttack || !u.alive) return;
targets = acquireTargets(b, u, prof);
if (!targets.length) return;
}
performAttack(b, u, prof, targets);
u.atkCd = Math.max(u.atkCd, u.s.interval);
}
/** Release blocked enemies beyond the current block capacity (latest blocked first). */
export function enforceBlockCapacity(b, u) {
const cap = u.alive && u.deployed ? u.s.blockCnt : 0;
let used = 0;
for (const e of u.blocking) used += e.blockWeight ?? 1;
while (used > cap && u.blocking.length) {
const e = u.blocking.pop();
if (e.blockedBy === u) e.blockedBy = null;
used -= e.blockWeight ?? 1;
}
}
/** Collect targets for an ally with profile `prof`. */
export function acquireTargets(b, u, prof) {
if (prof.heal && prof.dmgType === 'heal') {
const cands = b.injuredAlliesInKeys(u.rangeKeys, u, !!prof.heal.elementHealRatio);
if (!cands.length) return cands;
let n = prof.heal.mode === 'multi' ? Math.max(1, prof.heal.count || 3) : 1;
if (prof.maxTargets > n) n = Math.floor(prof.maxTargets); // skill targeting override (e.g. heal 2 targets)
return cands.slice(0, n + Math.max(0, Math.floor(u.s.maxTargets)));
}
// fortress: melee while blocking, ranged splash otherwise
if (prof.fortress) {
if (u.blocking.length) {
const t = u.blocking.filter((e) => canTargetEnemy(u, e, { canHitFly: false }));
prof._fortressMelee = true;
return t.slice(0, 1);
}
prof._fortressMelee = false;
}
if (prof.hitAllBlocked && u.blocking.length) {
const t = u.blocking.filter((e) => canTargetEnemy(u, e, prof));
if (t.length) return t;
}
const cands = b.enemiesInKeys(u.rangeKeys, u, prof);
if (!cands.length) return cands;
if (prof.allInRange) return cands;
const n = Math.max(1, Math.floor((prof.maxTargets || 1) + u.s.maxTargets));
sortEnemyTargets(b, u, cands, prof.priority);
return n >= cands.length ? cands : cands.slice(0, n);
}
/** Perform an attack/heal with profile `prof` against `targets`. opts: { noAmmo } (Battle.forceAttack). */
export function performAttack(b, u, prof, targets, opts = null) {
const isSkill = !!prof.isSkill;
if (b._hooks.beforeAttack) {
const ctx = { attacker: u, targets, isSkill, profile: prof };
b.emit('beforeAttack', ctx);
targets = (ctx.targets || []).filter((t) => t && t.alive);
if (!targets.length || !u.alive) return;
}
u.lastAttackAt = b.time;
u.stats.attacks++;
const attackId = ++b._attackSeq; // every damage instance of this attack (all targets, splash, chain) carries it
const isHeal = !!(prof.heal && prof.dmgType === 'heal');
const ranged = !prof._fortressMelee && prof.attack === 'ranged' && prof.projectile && prof.projectile !== 'none' && prof.projectile !== 'beam';
const vis = prof._fortressMelee ? 'none' : (prof.projectile || 'none');
for (let i = 0; i < targets.length; i++) {
const t = targets[i];
b._ev(['atk', u.id, t.id, vis]);
if (isHeal) { doHeal(b, u, prof, t); continue; }
const info = { isSkill, index: i, attackId };
if (ranged && t.side === 'enemy') {
const speed = PROJECTILE_SPEEDS[prof.projectile] ?? PROJECTILE_SPEED;
// projectiles land even if the shooter died meanwhile (damage is credited to it)
b.addProjectile({ from: u, target: t, speed, visual: prof.projectile, source: u, hitDead: prof.splashRadius > 0,
onHit: (c) => resolveHit(b, u, prof, c.target, info, c.x, c.y) });
} else {
resolveHit(b, u, prof, t, info, t.x, t.y);
}
}
if (b._hooks.attack) b.emit('attack', { attacker: u, targets, isSkill });
if (u.skill) u.skill.onAttackPerformed(targets, isSkill, !!(opts && opts.noAmmo));
if (prof.afterAttack) b._safe(() => prof.afterAttack(b, u, targets), 'profile.afterAttack', u);
}
/** Apply one attack hit (called on impact for projectiles). `target` may be null (splash on a dead target's spot). */
export function resolveHit(b, u, prof, target, info, x, y) {
const atk = u.s.atk;
const scale = (prof.atkScale ?? 1) * u.s.atkScaleMul;
let dealtTotal = 0;
const baseType = prof.dmgType === 'heal' || prof.dmgType === 'none' ? 'phys' : prof.dmgType;
const skillMul = prof.skillDmgMul ?? 1;
const attackId = info.attackId ?? 0;
// per-victim callbacks (main target, every splash / chain victim): profile `onEachHit(b, u, victim, hctx)` and
// SkillSpec `attack.onEachHit(ctx)` — `attack.onHit` stays once per attack with the main target
const each = prof.onEachHit || prof.skillOnEachHit ? (victim, dealt, kind) => {
const hc = { dealt, kind, isSplash: kind === 'splash', isChain: kind === 'chain', main: target, attackId, isSkill: info.isSkill };
if (prof.onEachHit) b._safe(() => prof.onEachHit(b, u, victim, hc), 'profile.onEachHit', u);
if (prof.skillOnEachHit && u.skill) {
const fn = prof.skillOnEachHit;
b._safe(() => fn({ battle: b, unit: u, skill: u.skill, bb: u.skill.bb, target: victim, ...hc }), 'skill.attack.onEachHit', u);
}
} : null;
if (target && target.alive) {
let mulT = skillMul;
if (prof.dmgMul) { const m = typeof prof.dmgMul === 'function' ? prof.dmgMul(b, u, target) : prof.dmgMul; if (Number.isFinite(m)) mulT *= m; }
const hits = prof.hitsFn ? prof.hitsFn(b, u) : Math.max(1, prof.hits || 1);
let dealtMain = 0;
for (let h = 0; h < hits && target.alive; h++) {
dealtMain += b.dealDamage(u, target, { amount: atk * scale * mulT, type: baseType, isAttack: true, isSkill: info.isSkill, tags: prof.tags || [], attackId });
}
dealtTotal += dealtMain;
if (prof.onHitStatus && target.alive) b.applyStatus(target, prof.onHitStatus.key, { duration: prof.onHitStatus.duration, source: u, value: prof.onHitStatus.value });
if (each) each(target, dealtMain, 'main');
x = target.x; y = target.y;
}
// splash
if (prof.splashRadius > 0) {
const r = prof.splashRadius;
const sc = prof.splashScale ?? 1;
for (const e of b.enemiesInRadius(x, y, r)) {
if (e === target) continue;
if (prof.groundOnly && e.isFlying) continue;
if (!prof.canHitFly && e.isFlying && !prof.splashHitsFly) continue;
if (e.s.flags.untargetable) continue;
const d = b.dealDamage(u, e, { amount: atk * scale * sc * skillMul, type: baseType, isAttack: true, isSplash: true, isSkill: info.isSkill, attackId });
dealtTotal += d;
if (each) each(e, d, 'splash');
}
}
// chain
if (prof.chain && target) {
const hit = new Set([target.id]);
let prev = target;
const n = Math.max(1, prof.chain.count || 3);
for (let k = 1; k < n; k++) {
let best = null, bd = Infinity;
for (const e of b.enemiesInRadius(prev.x, prev.y, prof.chain.radius || 1.8)) {
if (hit.has(e.id) || !canTargetEnemy(u, e, prof)) continue;
const d = Math.hypot(e.x - prev.x, e.y - prev.y);
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && e.spawnSeq < best.spawnSeq)) { bd = d; best = e; }
}
if (!best) break;
hit.add(best.id);
b._ev(['atk', prev.id, best.id, 'chain']);
const d = b.dealDamage(u, best, { amount: atk * scale * skillMul * Math.pow(1 - (prof.chain.falloff ?? 0.15), k), type: baseType, isAttack: true, isSkill: info.isSkill, tags: ['chain'], attackId });
dealtTotal += d;
if (prof.chain.sluggish && best.alive) b.applyStatus(best, 'sluggish', { duration: prof.chain.sluggish, source: u });
if (each) each(best, d, 'chain');
prev = best;
}
if (prof.chain.sluggish && target.alive) b.applyStatus(target, 'sluggish', { duration: prof.chain.sluggish, source: u });
}
const hctx = { dealt: dealtTotal, x, y, isSkill: info.isSkill };
if (prof.afterHit) b._safe(() => prof.afterHit(b, u, target, hctx), 'profile.afterHit', u);
if (prof.skillOnHit && u.skill) {
const fn = prof.skillOnHit;
b._safe(() => fn({ battle: b, unit: u, skill: u.skill, bb: u.skill.bb, target, dealt: dealtTotal, x, y }), 'skill.attack.onHit', u);
}
if (u.skill && u.skill.active && u.skill.spec.onHit) {
const fn = u.skill.spec.onHit;
b._safe(() => fn({ battle: b, unit: u, skill: u.skill, bb: u.skill.bb, target, dealt: dealtTotal, x, y }), 'skill.onHit', u);
}
}
function doHeal(b, u, prof, t) {
const atk = u.s.atk;
const scale = (prof.atkScale ?? 1) * (prof.healScale ?? 1) * u.s.atkScaleMul;
const h = prof.heal || { mode: 'single' };
let amount = atk * scale;
if (h.farMul && Math.max(Math.abs(t.tileR - u.tileR), Math.abs(t.tileC - u.tileC)) > (h.nearDist ?? 2)) amount *= h.farMul;
if (h.elementHealRatio) reduceElement(t, atk * h.elementHealRatio);
b.heal(u, t, amount);
if (h.mode === 'chain') {
const seen = new Set([t.id]);
let prev = t;
const n = Math.max(1, h.count || 3);
for (let k = 1; k < n; k++) {
let best = null, bd = Infinity;
for (const a of b.alliesInRadius(prev.x, prev.y, 2.5, null)) {
if (seen.has(a.id) || a.hp >= a.s.maxHp || a.kind === 'device') continue;
const d = a.hpRatio;
if (d < bd) { bd = d; best = a; }
}
if (!best) break;
seen.add(best.id);
b._ev(['atk', prev.id, best.id, 'chainHeal']);
b.heal(u, best, amount * Math.pow(1 - (h.falloff ?? 0.25), k));
prev = best;
}
}
if (u.skill && u.skill.active && u.skill.spec.onHit) {
const fn = u.skill.spec.onHit;
b._safe(() => fn({ battle: b, unit: u, skill: u.skill, bb: u.skill.bb, target: t, heal: amount, x: t.x, y: t.y }), 'skill.onHit', u);
}
}
// ---------------------------------------------------------------------------------------------------------------
// enemy routes
/**
* Compile a RouteSpec into legs. With `rect`, positional legs are clamped onto the field: a checkpoint outside the
* rect (e.g. h07_01's extra fly route along row 6) could never be reached because positions are clamped to the rect
* every tick — the enemy would hover at the border forever instead of finishing its route.
*/
export function compileRoute(route, rect = null) {
const legs = [];
const cr = (r) => (rect ? Math.max(rect.r0, Math.min(rect.r1, r)) : r);
const cc = (c) => (rect ? Math.max(rect.c0, Math.min(rect.c1, c)) : c);
for (const cp of route.checkpoints || []) {
if (cp.type === 'MOVE') legs.push({ t: 'move', r: cr(cp.pos[0]), c: cc(cp.pos[1]) });
else if (cp.type === 'WAIT') legs.push({ t: 'wait', time: Number.isFinite(cp.time) ? Math.max(0, cp.time) : 0 });
else if (cp.type === 'DISAPPEAR') legs.push({ t: 'disappear' });
else if (cp.type === 'APPEAR') legs.push({ t: 'appear', r: cr(cp.pos[0]), c: cc(cp.pos[1]) });
}
if (route.end) legs.push({ t: 'move', r: cr(route.end[0]), c: cc(route.end[1]), final: true });
return legs;
}
/**
* Flow field of a WALK leg target usable from tile key `k`: the live field, else the obstacle-free one (the goal is
* walled off by hard blocks), else null.
*/
function legField(b, r, c, k) {
const g = b.grid;
const f = g.flowField(r, c);
if (k >= 0 && f.dist[k] >= 0) return f;
const f2 = g.flowField(r, c, { ignoreObstacles: true });
if (k >= 0 && f2.dist[k] >= 0) return f2;
return null;
}
const tileKey = (b, e) => {
const r = Math.round(e.y), c = Math.round(e.x);
return b.grid.inBounds(r, c) ? r * COLS + c : -1;
};
/**
* Build the waypoint list (world points) for the current move leg. Ground legs: the smoothed flow-field chain from
* the enemy's tile (next[tile], next[next[tile]], …, the target) — straight to the target when no field reaches it.
*/
function planLeg(b, e, leg) {
const R = e.route;
let pts;
if (e.motion === 'FLY') {
pts = [{ x: leg.c, y: leg.r }];
} else {
const k = tileKey(b, e);
const f = legField(b, leg.r, leg.c, k);
pts = [];
if (f) {
let x = k;
let guard = COLS * 32;
while (x !== f.dest && guard-- > 0) {
x = f.next[x];
if (x < 0) break;
pts.push({ x: x % COLS, y: (x / COLS) | 0 });
}
}
const last = pts[pts.length - 1];
if (!last || last.x !== leg.c || last.y !== leg.r) pts.push({ x: leg.c, y: leg.r });
// The smoothed chain is line-of-sight clear from the tile CENTRE only. An enemy re-planning off-centre (pushed,
// released by 诱导, an obstacle change mid-segment) first steps back to its tile centre when the straight line to
// the first waypoint would cut a tile it cannot walk (a fence corner, a crate) — else it would walk inside it.
if (f && (e.x !== k % COLS || e.y !== ((k / COLS) | 0)) && !straightClear(b.grid, e.x, e.y, pts[0])) {
pts.unshift({ x: k % COLS, y: (k / COLS) | 0 });
}
}
R.pts = pts;
R.ptIdx = 0;
R.version = b.grid.version;
// suffix lengths for remaining-distance queries
const suf = new Float64Array(pts.length);
for (let i = pts.length - 2; i >= 0; i--) suf[i] = suf[i + 1] + Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
R.suffix = suf;
}
/**
* Whether a straight move from (x, y) to the centre of tile p = {x: col, y: row} only crosses ground-walkable,
* crate-free tiles (the start and end tiles excepted). Exact grid traversal (tile (r,c) spans c ± 0.5, r ± 0.5); a
* line through a tile corner needs both side tiles clear (as the grid's Bresenham smoothing).
*/
function straightClear(g, x, y, p) {
let c = Math.round(x), r = Math.round(y);
const dx = p.x - x, dy = p.y - y;
const sc = dx > 0 ? 1 : -1, sr = dy > 0 ? 1 : -1;
const ddx = dx !== 0 ? Math.abs(1 / dx) : Infinity, ddy = dy !== 0 ? Math.abs(1 / dy) : Infinity;
let tx = dx !== 0 ? (c + 0.5 * sc - x) / dx : Infinity;
let ty = dy !== 0 ? (r + 0.5 * sr - y) / dy : Infinity;
const clear = (rr, cc) => (rr === p.y && cc === p.x) || (g.walkable(rr, cc) && !(g.obstacle[rr * COLS + cc] & OB_CRATE));
for (let guard = 4 * COLS; guard > 0 && (r !== p.y || c !== p.x); guard--) {
if (tx >= 1 && ty >= 1) break;
if (Math.abs(tx - ty) < 1e-9) {
if (!clear(r, c + sc) || !clear(r + sr, c)) return false;
c += sc; r += sr; tx += ddx; ty += ddy;
} else if (tx < ty) { c += sc; tx += ddx; } else { r += sr; ty += ddy; }
if (!clear(r, c)) return false;
}
return true;
}
/** Estimated length of legs after index `idx` (cached per grid version). */
function tailLength(b, e, idx) {
const R = e.route;
if (R.tailVersion !== b.grid.version || !R.tail) {
R.tail = new Float64Array(R.legs.length + 1);
// walk legs backwards: need the start point of each move leg = target of the previous positional leg
const starts = [];
let pr = Math.round(e.spawnY ?? e.y), pc = Math.round(e.spawnX ?? e.x);
for (let i = 0; i < R.legs.length; i++) {
starts.push([pr, pc]);
const L = R.legs[i];
if (L.t === 'move' || L.t === 'appear') { pr = L.r; pc = L.c; }
}
for (let i = R.legs.length - 1; i >= 0; i--) {
const L = R.legs[i];
let len = 0;
if (L.t === 'move') {
const [sr, sc] = starts[i];
len = Math.hypot(L.r - sr, L.c - sc);
if (e.motion !== 'FLY' && b.grid.inBounds(sr, sc)) {
const f = legField(b, L.r, L.c, sr * COLS + sc);
const fl = f ? b.grid.fieldLength(f, sr * COLS + sc) : Infinity;
if (Number.isFinite(fl)) len = fl;
}
}
R.tail[i] = R.tail[i + 1] + len;
}
R.tailVersion = b.grid.version;
}
return R.tail[idx] ?? 0;
}
/** Remaining path distance of an enemy to its goal (tiles). */
export function remainingDistance(b, e) {
const R = e.route;
if (!R) return 0;
let d = tailLength(b, e, R.legIdx + 1);
const leg = R.legs[R.legIdx];
if (leg && leg.t === 'move' && R.pts && R.ptIdx < R.pts.length) {
const p = R.pts[R.ptIdx];
d += Math.hypot(p.x - e.x, p.y - e.y) + R.suffix[R.ptIdx];
} else if (leg && leg.t === 'move') {
d += Math.hypot(leg.c - e.x, leg.r - e.y);
}
return d;
}
// ---------------------------------------------------------------------------------------------------------------
// enemy update
export function updateEnemy(b, e, dt) {
const R = e.route;
if (!e.alive) return;
// hidden (teleporting) enemies only advance wait legs
const stunned = e.s.flags.stun;
if (e.atkCd > 0 && !stunned && !e.hidden) e.atkCd = Math.max(0, e.atkCd - dt);
if (!e.hidden && !stunned) enemyAttack(b, e);
if (!e.alive) return;
if (stunned && !e.hidden) return;
if (e.blockedBy) {
const bl = e.blockedBy;
// (unblockable/levitate/fear may also arrive through a plain addBuff, which does not unblock by itself; a
// stunned/sleeping blocker — noBlock — lets go)
const ef = e.s.flags;
if (!bl.alive || !bl.deployed || bl.hidden || bl.s.flags.noBlock || bl.s.flags.sleep || ef.unblockable || ef.levitate || ef.fear) b._unblock(e);
else return;
}
if (!e.hidden && b._checkBlock(e)) return;
if (b.time < e.pauseUntil) return;
if (e.s.flags.noMove) return;
// 诱导 (ba.attract "无法被阻挡并向目标位置移动"): walks to the attract point instead of following its route
if (e.s.flags.attract) { moveAttracted(b, e, dt); return; }
// 恐惧 (ba.fear "无法被阻挡并四散逃跑"): the enemy flees on the spot instead of advancing along its route
if (e.s.flags.fear) { e.moving = false; return; }
advanceRoute(b, e, dt, R);
}
/**
* 诱导: walk the enemy (own speed; ground: grid path re-planned when obstacles change, flyers: straight) to the point
* of its `attract` status (Battle._setAttractPoint) and keep it there. Its route re-plans from wherever it stands
* once the status ends (route.pts is reset whenever it moved).
*/
function moveAttracted(b, e, dt) {
let A = null;
for (const x of e.buffs) if (x.status === 'attract' && x.data && x.data.attract) { A = x.data.attract; break; }
if (!A) { e.moving = false; return; }
if (!A.pts || A.ver !== b.grid.version) {
const sr = Math.round(e.y), sc = Math.round(e.x);
let pts = null;
if (!e.isFlying) {
const path = b.grid.waypoints(sr, sc, A.r, A.c) || b.grid.waypoints(sr, sc, A.r, A.c, { ignoreObstacles: true });
if (path) {
pts = [];
for (let i = 0; i < path.length; i++) {
const [r, c] = path[i];
if (i === 0 && Math.abs(e.x - c) < 1e-6 && Math.abs(e.y - r) < 1e-6) continue;
pts.push({ x: c, y: r });
}
}
}
A.pts = pts || [{ x: A.c, y: A.r }];
A.i = 0;
A.ver = b.grid.version;
}
let dist = e.s.moveSpeed * MOVE_SCALE * dt;
let moved = false;
while (dist > 1e-9 && A.i < A.pts.length) {
const p = A.pts[A.i];
const dx = p.x - e.x, dy = p.y - e.y, d = Math.hypot(dx, dy);
if (d <= dist) { e.x = p.x; e.y = p.y; dist -= d; A.i++; } else { e.x += (dx / d) * dist; e.y += (dy / d) * dist; dist = 0; }
moved = true;
}
e.moving = moved;
if (moved && e.route) e.route.pts = null;
}
function advanceRoute(b, e, dt, R) {
let budget = dt;
let guard = 16;
while (budget > 1e-9 && guard-- > 0 && e.alive) {
const leg = R.legs[R.legIdx];
if (!leg) { b.leak(e); return; }
if (leg.t === 'wait') {
if (R.waitLeft == null) R.waitLeft = leg.time;
const use = Math.min(budget, R.waitLeft);
R.waitLeft -= use;
budget -= use;
e.moving = false;
if (R.waitLeft <= 1e-9) { R.waitLeft = null; R.legIdx++; R.pts = null; }
continue;
}
if (leg.t === 'disappear') {
b._setHidden(e, true);
R.legIdx++; R.pts = null;
continue;
}
if (leg.t === 'appear') {
e.x = leg.c; e.y = leg.r;
b._setHidden(e, false);
R.legIdx++; R.pts = null;
continue;
}
// move
if (!R.pts || R.version !== b.grid.version) planLeg(b, e, leg);
const speed = e.s.moveSpeed * MOVE_SCALE;
if (speed <= 0) { e.moving = false; return; }
let dist = speed * budget;
e.moving = true;
let steps = 64;
while (dist > 1e-9 && steps-- > 0) {
if (!R.pts || R.version !== b.grid.version) planLeg(b, e, leg);
if (R.ptIdx >= R.pts.length) break;
const p = R.pts[R.ptIdx];
const dx = p.x - e.x, dy = p.y - e.y;
const d = Math.hypot(dx, dy);
if (d <= dist) {
e.x = p.x; e.y = p.y;
dist -= d;
R.ptIdx++;
} else {
e.x += (dx / d) * dist;
e.y += (dy / d) * dist;
dist = 0;
}
if (b._checkBlock(e)) return;
}
budget = dist / speed;
if (R.pts && R.ptIdx >= R.pts.length) {
if (leg.final) { b.leak(e); return; }
R.legIdx++;
R.pts = null;
}
}
}
function enemyAttack(b, e) {
const def = e.def;
if (e.profile && e.profile.noAttack) return;
if (def.dmgType === 'none' || e.s.atk <= 0) return;
if (e.s.flags.fear || e.s.flags.disarm) return;
if (e.s.flags.tremble && e.blockedBy) return; // 战栗: 被阻挡后无法进行普通攻击
if (e.atkCd > 0) return;
if (def.dmgType === 'heal') { enemyHeal(b, e, e.base.rangeRadius); return; }
// applyWay MELEE enemies only ever hit their blocker, even when their data carries a rangeRadius (粉碎攻坚手 2.5,
// 宿主士兵 2.5, 深池伙友卫队 1.4 … — that radius belongs to their abilities/splash, handled by content).
// Content may flip it with `e.profile.melee = false`.
const melee = e.profile?.melee ?? def.applyWay === 'MELEE';
const radius = melee ? 0 : e.base.rangeRadius;
let targets = [];
if (e.blockedBy) {
const bl = e.blockedBy;
if (radius > 0) {
targets = b.alliesInRadius(e.x, e.y, radius, null).filter((a) => a === bl || canTargetAlly(e, a, true));
if (!targets.includes(bl) && bl.alive) targets.push(bl);
sortAllyTargets(e, targets);
} else if (bl.alive && bl.deployed) targets = [bl];
} else if (radius > 0) {
targets = b.alliesInRadius(e.x, e.y, radius, null).filter((a) => canTargetAlly(e, a, true));
sortAllyTargets(e, targets);
}
if (!targets.length) return;
// 麻痹 (ba.palsy): each stack interrupts one normal attack
const palsy = e.buffs.length ? e.findBuff('palsy') : null;
if (palsy) {
if (--palsy.stacks <= 0) b.removeBuff(e, palsy); else e.markDirty();
e.atkCd = e.s.interval;
if (!e.blockedBy && radius > 0) e.pauseUntil = b.time + ATTACK_PAUSE;
b.fx('palsy', { x: e.x, y: e.y, id: e.id });
return;
}
const n = Math.max(1, Math.floor(e.profile?.maxTargets ?? 1) + Math.floor(e.s.maxTargets));
if (targets.length > n) targets = targets.slice(0, n);
if (b._hooks.beforeAttack) {
const ctx = { attacker: e, targets, isSkill: false, profile: e.profile };
b.emit('beforeAttack', ctx);
targets = (ctx.targets || []).filter((t) => t && t.alive);
if (!targets.length || !e.alive) return;
}
e.lastAttackAt = b.time;
e.stats.attacks++;
const attackId = ++b._attackSeq;
const type = def.dmgType === 'heal' ? 'arts' : def.dmgType;
const rangedShot = radius > 0 && !(e.blockedBy && targets[0] === e.blockedBy && radius < 1);
for (const t of targets) {
b._ev(['atk', e.id, t.id, rangedShot ? 'enemy' : 'none']);
const hit = (tt) => {
if (!tt || !tt.alive || !e.alive && !rangedShot) return;
b.dealDamage(e, tt, { amount: e.s.atk * (e.profile?.atkScale ?? 1), type, isAttack: true, attackId });
};
if (rangedShot && Math.hypot(t.x - e.x, t.y - e.y) > 0.75) {
b.addProjectile({ from: e, target: t, speed: PROJECTILE_SPEEDS.enemy, visual: 'enemy', source: e, onHit: (c) => hit(c.target) });
} else hit(t);
}
if (b._hooks.attack) b.emit('attack', { attacker: e, targets, isSkill: false });
e.atkCd = e.s.interval;
if (!e.blockedBy && radius > 0) e.pauseUntil = b.time + ATTACK_PAUSE;
}
/** Enemy healers (dmgType 'heal'): heal the lowest-HP% other enemy within their radius. */
function enemyHeal(b, e, radius) {
let best = null;
for (const o of b.enemiesInRadius(e.x, e.y, Math.max(radius, 1))) {
if (o === e || o.hp >= o.s.maxHp || o.bossPool) continue;
if (!best || o.hpRatio < best.hpRatio) best = o;
}
if (!best) return;
e.lastAttackAt = b.time;
e.stats.attacks++;
b._ev(['atk', e.id, best.id, 'orb']);
b.heal(e, best, e.s.atk);
e.atkCd = e.s.interval;
}
export { tileKeyOf, COLS };
+187
View File
@@ -0,0 +1,187 @@
// server/sim/buffs.js — Buff model, mod keys, status catalogue and immunities (DESIGN §5.3).
//
// Buff = { key, source, duration (s, Infinity), timeLeft, stacks, maxStacks, refresh, mods, flags, onTick,
// interval, onExpire, onRemove, tags[], shield, shieldHits, persist, status, visible }
// refresh: 'replace' (default: new instance replaces the old one), 'extend' (keep the longer remaining time,
// take the new mods), 'stack' (stacks+1 up to maxStacks, timer reset), 'independent' (separate instances with
// their own timers, at most maxStacks alive — the oldest is dropped), 'keep' (ignore when already present).
// Additive mod keys scale with stacks (value × stacks); *Mul keys multiply (value ^ stacks).
import { COLD_ASPD, COLD_FREEZE_DURATION, FREEZE_RES_DOWN, RESIST_DEFAULT, RESIST_PALSY_DECAY } from './constants.js';
/** 抵抗: "麻痹等状态每5秒流失1层" — tick of the resist buff. */
function resistPalsyDecay({ battle, unit }) {
const p = unit.findBuff('palsy');
if (!p) return;
if (--p.stacks <= 0) battle.removeBuff(unit, p); else unit.markDirty();
}
/** Additive mod keys (summed; × stacks). */
export const ADD_KEYS = Object.freeze([
'atkFlat', 'atkPct', 'defFlat', 'defPct', 'hpFlat', 'hpPct', 'resFlat', 'aspd', 'batPct', 'blockCnt',
'rangeExtend', 'defIgnoreFlat', 'defIgnorePct', 'resIgnoreFlat', 'resIgnorePct', 'dodgePhys', 'dodgeArts',
'spRecoveryFlat', 'maxTargets', 'taunt', 'hpRegen', 'hpRegenRatio', 'spCostFlat', 'moveFlat', 'massFlat',
]);
/** Multiplicative mod keys (product; ^ stacks). */
export const MUL_KEYS = Object.freeze([
'atkMul', 'defMul', 'hpMul', 'resMul', 'moveMul', 'dmgDealtMul', 'dmgTakenMul', 'physTakenMul', 'artsTakenMul',
'trueTakenMul', 'elemTakenMul', 'healingDealtMul', 'healingTakenMul', 'spRecoveryMul', 'redeployMul',
'atkScaleMul', 'physDealtMul', 'artsDealtMul',
]);
/** Boolean flag keys (OR). `taunt` is also accepted as a numeric mod. */
export const FLAG_KEYS = Object.freeze([
'stun', 'freeze', 'sleep', 'silence', 'disarm', 'stealth', 'invulnerable', 'unblockable', 'levitate', 'fear',
'cold', 'reveal', 'bind', 'noHeal', 'untargetable', 'blockFly', 'noMove', 'noSp', 'burstLock', 'hidden',
'noBlock', 'tremble', 'hitCount', 'hitCountArts', 'attract',
]);
/**
* Status catalogue: key → template. `value` semantics per status are documented inline. Effects follow the official
* term table (gamedata_const termDescriptionDict, ba.*). `valued: default` marks "同名效果取最高" statuses: a weaker
* application never overrides a stronger running one (it resumes if it outlasts it) — see Battle.applyStatus.
* Content applies statuses with `battle.applyStatus(target, key, { duration, source, value })`.
*/
export const STATUS = Object.freeze({
// 晕眩: 无法移动、阻挡、攻击及使用技能 (a stunned operator releases the enemies it blocks)
stun: { flags: { stun: true, noBlock: true }, immune: 'stun' },
// 冻结: 无法移动、攻击及使用技能; 敌方被冻结时,法术抗性-15 (the RES cut applies to enemies only)
freeze: { flags: { freeze: true, stun: true }, mods: { resFlat: -FREEZE_RES_DOWN }, enemyOnlyMods: true, immune: 'frozen' },
cold: { flags: { cold: true }, mods: { aspd: COLD_ASPD } },
// 沉睡: 无敌且无法行动 (untargetable, takes no damage — except from `hitSleep` attackers — and blocks nothing)
sleep: { flags: { sleep: true, noBlock: true }, immune: 'sleep' },
// value = fraction of speed removed (0.8 ⇒ moveMul 0.2). Default 0.5.
slow: { mods: (v) => ({ moveMul: 1 - clamp01(v ?? 0.5) }), valued: 0.5 },
// 停顿 (sluggish): −80 % move speed.
sluggish: { mods: { moveMul: 0.2 } },
bind: { flags: { bind: true, noMove: true }, mods: { moveMul: 0 } },
// value = extra damage taken (0.3 ⇒ ×1.3). Default 0.3.
fragile: { mods: (v) => ({ dmgTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
artsFragile: { mods: (v) => ({ artsTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
physFragile: { mods: (v) => ({ physTakenMul: 1 + (v ?? 0.3) }), valued: 0.3 },
elemFragile: { mods: (v) => ({ elemTakenMul: 1 + (v ?? 0.2) }), valued: 0.2 },
silence: { flags: { silence: true }, immune: 'silence' },
// 恐惧: 无法被阻挡并四散逃跑 (no attacks, unblockable, stops advancing along its route)
fear: { flags: { fear: true, unblockable: true }, immune: 'feared' },
// 战栗: 被阻挡后无法进行普通攻击
tremble: { flags: { tremble: true }, immune: 'feared' },
disarm: { flags: { disarm: true } },
stealth: { flags: { stealth: true } },
reveal: { flags: { reveal: true } },
invulnerable: { flags: { invulnerable: true } },
// 浮空: 无法移动、攻击及使用技能; 对重量大于3的单位持续时间减半
levitate: { flags: { levitate: true, stun: true, unblockable: true }, immune: 'levitate' },
// 麻痹: value = stacks (default 1, max 3); each stack cancels one enemy normal attack; lasts until consumed
palsy: { palsy: true },
// value = taunt level delta (default +1)
taunt: { mods: (v) => ({ taunt: v ?? 1 }) },
// value = ATK fraction removed (default 0.3)
weaken: { mods: (v) => ({ atkMul: 1 - clamp01(v ?? 0.3) }), valued: 0.3 },
// value = aspd delta (negative slows attacks), default −30
aspdDown: { mods: (v) => ({ aspd: v ?? -30 }), valued: -30 },
// value = DEF fraction removed (default 0.3)
defDown: { mods: (v) => ({ defMul: 1 - clamp01(v ?? 0.3) }), valued: 0.3 },
// value = RES flat removed (default 20)
resDown: { mods: (v) => ({ resFlat: -(v ?? 20) }), valued: 20 },
unblockable: { flags: { unblockable: true } },
// 诱导 (ba.attract): 无法被阻挡并向目标位置移动 — the engine walks the enemy (own speed, grid path; flyers straight)
// to `opts.point` (default: the source's tile) and keeps it there until the status ends, then it resumes its route
attract: { flags: { attract: true, unblockable: true }, attract: true },
// 抵抗 (ba.buffres): the RESIST_STATUSES applied to the unit last (1 − value) as long, value default 0.5 (减半);
// 同名效果不叠加 — several sources never compound (strongest value wins, a weaker one resumes if it outlasts it);
// a resisting unit also loses one 麻痹 stack every RESIST_PALSY_DECAY s (Battle.applyStatus)
resist: { resist: true, mods: () => null, valued: RESIST_DEFAULT, buff: Object.freeze({ interval: RESIST_PALSY_DECAY, onTick: resistPalsyDecay }) },
// element burst states (engine-managed; listed for immunity/visibility)
burnBurst: { flags: { burstLock: true } },
neuralBurst: { flags: { burstLock: true } },
necrosisBurst: { flags: { burstLock: true } },
apoptosisBurst: { flags: { burstLock: true } },
erosionBurst: { flags: { burstLock: true } },
});
/**
* Control statuses (异常状态) shortened by 抵抗: 晕眩 冻结 寒冷 沉睡 恐惧 战栗 诱导 浮空 束缚 沉默 缴械 停顿 减速. The
* official term lists 晕眩/寒冷/冻结/恐惧/诱导…; the rest follow the operator kits that grant 抵抗 [ASSUMED]. 麻痹 is
* stack-based: it decays instead (RESIST_PALSY_DECAY).
*/
export const RESIST_STATUSES = Object.freeze(new Set(['stun', 'freeze', 'cold', 'sleep', 'fear', 'tremble', 'attract', 'levitate',
'bind', 'silence', 'disarm', 'sluggish', 'slow']));
/** Statuses that should be reported to clients as `['status', id, key, on]`. */
export const VISIBLE_STATUS = new Set(Object.keys(STATUS));
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
let buffSeq = 0;
/** Normalise a buff descriptor (does not attach it). */
export function makeBuff(b) {
const duration = b.duration == null ? Infinity : Number(b.duration);
return {
key: String(b.key ?? 'buff'),
source: b.source ?? null,
duration: Number.isFinite(duration) || duration === Infinity ? duration : Infinity,
timeLeft: Number.isFinite(duration) || duration === Infinity ? duration : Infinity,
stacks: Math.max(1, b.stacks ?? 1),
maxStacks: Math.max(1, b.maxStacks ?? 1),
refresh: b.refresh ?? 'replace',
mods: b.mods ?? null,
flags: b.flags ?? null,
onTick: b.onTick ?? null,
interval: b.interval ?? 0,
_acc: 0,
onExpire: b.onExpire ?? null,
onRemove: b.onRemove ?? null,
tags: b.tags ?? [],
shield: b.shield ?? (b.mods && b.mods.shield) ?? 0,
shieldHits: b.shieldHits ?? 0,
persist: !!b.persist,
status: b.status ?? null,
visible: b.visible ?? false,
data: b.data ?? {},
seq: ++buffSeq,
};
}
/**
* Compute aggregated modifiers of a buff list. Returns `{ add: {k: sum}, mul: {k: product}, flags: {k: bool}, shield,
* permRangeExtend }` — permRangeExtend = the rangeExtend of persistent never-expiring buffs (talents, modules: part of
* the initial range, Battle._refreshRange).
*/
export function aggregateMods(buffs) {
const add = Object.create(null);
const mul = Object.create(null);
const flags = Object.create(null);
let shield = 0;
let permRangeExtend = 0;
// dodge sources are independent rolls: total = 1 − Π(1 − p)^stacks (one single source keeps its exact value)
const dodge = { dodgePhys: null, dodgeArts: null };
for (let i = 0; i < buffs.length; i++) {
const b = buffs[i];
const st = b.stacks;
const m = b.mods;
if (m) {
for (const k in m) {
const v = m[k];
if (typeof v !== 'number' || !Number.isFinite(v)) continue;
if (k === 'shield') continue;
if (k.endsWith('Mul')) mul[k] = (mul[k] ?? 1) * (st === 1 ? v : Math.pow(v, st));
else if ((k === 'dodgePhys' || k === 'dodgeArts') && v > 0) {
const p = Math.min(1, v);
const d = dodge[k];
if (!d) dodge[k] = { n: st, p, miss: Math.pow(1 - p, st) };
else { d.n += st; d.miss *= Math.pow(1 - p, st); }
} else {
add[k] = (add[k] ?? 0) + v * st;
if (k === 'rangeExtend' && b.persist && b.duration === Infinity) permRangeExtend += v * st;
}
}
}
if (b.flags) for (const k in b.flags) if (b.flags[k]) flags[k] = true;
if (b.shield > 0) shield += b.shield;
}
for (const k of ['dodgePhys', 'dodgeArts']) {
const d = dodge[k];
if (d) add[k] = (add[k] ?? 0) + (d.n === 1 ? d.p : 1 - d.miss);
}
return { add, mul, flags, shield, permRangeExtend };
}
+107
View File
@@ -0,0 +1,107 @@
// server/sim/constants.js — simulation timing, conversions and tuning knobs (DESIGN §3, §4, §5).
// Pure data; every value here is safe to tweak for balancing. Nothing in the sim reads wall-clock time.
import { GEO } from '../../shared/constants.js';
/** Fixed simulation step in game seconds (DESIGN §4). */
export const TICK = 1 / 30;
/** Snapshots are produced every N ticks by the match (20 Hz at 2× real time). */
export const SNAPSHOT_EVERY = 3;
export const ROWS = GEO.ROWS;
export const COLS = GEO.COLS;
/** tilesPerSecond = moveSpeed × MOVE_SCALE (DESIGN §3). */
export const MOVE_SCALE = 0.5;
/** Ranged enemies stop moving this long after each attack (DESIGN §5.5). */
export const ATTACK_PAUSE = 0.35;
/** Default projectile speed in tiles/s for ranged operators/enemies. */
export const PROJECTILE_SPEED = 12;
/** Projectile speeds per visual kind (tiles/s). `none`/`beam` are instant. */
export const PROJECTILE_SPEEDS = Object.freeze({ arrow: 14, bolt: 11, bomb: 8, lob: 8, orb: 10, drone: 16, enemy: 10 });
/** Minimum damage ratio after mitigation (5 % of the pre-mitigation amount). */
export const MIN_DAMAGE_RATIO = 0.05;
/** Element gauge capacity of operators and normal/elite enemies; leaders (rank BOSS) hold 2000 (ba.dt.*2). */
export const ELEMENT_GAUGE_MAX = 1000;
export const ELEMENT_GAUGE_MAX_LEADER = 2000;
/** Element names with a gauge on every unit. `necrosis` is a legacy spare gauge (凋亡 is `apoptosis`). */
export const ELEMENTS = Object.freeze(['burn', 'neural', 'necrosis', 'apoptosis', 'erosion']);
/**
* Element bursts — official term table (gamedata_const termDescriptionDict):
* `ally` = an operator/summon hit by enemy damage (ba.dt.burning / neural / apoptosis / erosion),
* `enemy` = an enemy hit by operators (the "·我方" terms ba.dt.burning2 / neural2 / apoptosis2 / erosion2;
* `elemDamage` is 元素伤害: HP damage ignoring DEF/RES, × elemTakenMul).
* `necrosis` keeps the engine's earlier invented burst (no official counterpart) for legacy content.
*/
export const ELEMENT = Object.freeze({
burn: Object.freeze({
burstDamage: 1200, burstType: 'arts', resDown: 20, duration: 10,
ally: Object.freeze({ damage: 1200, type: 'arts', resDown: 20, duration: 10 }),
enemy: Object.freeze({ elemDamage: 7000, resDown: 20, duration: 10 }),
}),
neural: Object.freeze({
burstDamage: 1000, burstType: 'true', stun: 10, duration: 10,
ally: Object.freeze({ damage: 1000, type: 'true', stun: 10, duration: 10 }),
enemy: Object.freeze({ elemDamage: 6000, palsy: 3, duration: 10 }),
}),
apoptosis: Object.freeze({
dps: 100, duration: 15,
ally: Object.freeze({ dps: 100, dpsType: 'arts', spLossPerSec: 1, duration: 15 }),
enemy: Object.freeze({ elemDps: 800, weaken: 0.5, duration: 15 }),
}),
erosion: Object.freeze({
ally: Object.freeze({ damage: 800, type: 'phys', defDown: 100, duration: 0 }),
enemy: Object.freeze({ elemDamage: 5000, defDown: 120, duration: 8 }),
}),
necrosis: Object.freeze({ dps: 100, duration: 12, atkDownPct: 0.2 }),
});
/** 麻痹 (ba.palsy): each stack cancels one normal attack of an enemy; at most 3 stacks, lasts until consumed. */
export const PALSY_MAX = 3;
/** Freeze caused by cold on cold (DESIGN §5.3). */
export const COLD_FREEZE_DURATION = 3;
/** 浮空 (ba.levitate): the duration is halved on units heavier than this weight (massLevel). */
export const LEVITATE_HALF_WEIGHT = 3;
/** 抵抗 (ba.buffres): default share of a resisted status's duration that is removed (0.5 = 减半). */
export const RESIST_DEFAULT = 0.5;
/** 抵抗: a resisting unit loses one 麻痹 stack every RESIST_PALSY_DECAY seconds ("麻痹等状态每5秒流失1层"). */
export const RESIST_PALSY_DECAY = 5;
export const COLD_ASPD = -30;
export const FREEZE_RES_DOWN = 15;
/** Default DP rules (DESIGN §5.5), overridable by Battle opts.flags. */
export const DP_DEFAULTS = Object.freeze({ dpInit: 10, dpPerSec: 1, dpMax: 99 });
/** Safety cap for battles with an infinite time limit (boss rounds are force-ended by the match). */
export const MAX_BATTLE_TIME = 3600;
/** A battle that raised this many internal errors is force-ended as a timeout. */
export const MAX_INTERNAL_ERRORS = 200;
/** Time (s) a dead unit stays in snapshots with the DIE animation. */
export const DIE_ANIM_TIME = 0.8;
/** Time (s) the ATTACK / DEPLOY animation code is reported after the action. */
export const ATTACK_ANIM_TIME = 0.35;
export const DEPLOY_ANIM_TIME = 0.5;
/** Stage devices that act as ground obstacles for pathing (阻隔工事). */
export const OBSTACLE_DEVICES = Object.freeze({ trap_1105_accrate: { hp: 100, name: '阻隔工事' } });
/** Default enemy/op aggregation clamps. */
export const ASPD_MIN = 10;
export const ASPD_MAX = 600;
/** Client event buffer cap (events are dropped oldest-first beyond this when nobody drains). */
export const EVENT_BUFFER_CAP = 50000;
/** Nested hook emits deeper than this are skipped (content recursion, e.g. a `damaged` handler dealing damage). */
export const MAX_HOOK_DEPTH = 32;
/** spawnEnemy refuses to exceed this many living enemies on one field (runaway content spawn loops). */
export const MAX_ALIVE_ENEMIES = 600;
/** Heal-over-time / regen events are aggregated and only emitted when they reach this amount. */
export const REGEN_EVENT_MIN = 1;
/** Row offset mapping board rows (9..12) onto boss-field rows (2..5). */
export const BOSS_ROW_OFFSET = -7;
+27
View File
@@ -0,0 +1,27 @@
// server/sim/content/bands.js — the 40 strategies (bands, 策略/分队), battle side + prep side (DESIGN §7, research 01 §11).
//
// Entry point imported by content/index.js:
// install(battle) IN_BATTLE band buffs of every player of the battle — bands/battle.js
// registerMeta(registry) prep-side band effects (band:<bandId>) + the 鸭爵 global — bands/meta.js
// Starting LP (totalHp), the draft and 坎诺特's kept leftover funds are the match's own rules (Match / PlayerState).
// Parts are loaded with guarded dynamic imports and run in isolation: one failing part never disables the other
// (the first error is re-thrown afterwards so content/index.js records it in battle.errors / the server log).
async function load(path) {
try {
return await import(path);
} catch (e) {
console.error(`[content] failed to load ${path}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`);
return {};
}
}
const [battlePart, metaPart] = await Promise.all([load('./bands/battle.js'), load('./bands/meta.js')]);
export function install(battle) {
if (typeof battlePart.install === 'function') battlePart.install(battle);
}
export function registerMeta(registry) {
if (typeof metaPart.registerMeta === 'function') metaPart.registerMeta(registry);
}
+269
View File
@@ -0,0 +1,269 @@
// server/sim/content/bands/battle.js — battle side of the strategies (bands, data/bands.json; research 01 §11).
//
// Each player of a battle installs the IN_BATTLE parts of its own band, dispatched on the band buff's bbStr.key
// (numbers from the blackboard, never hard-coded). Band buffs apply in every battle kind (normal / 联防 / boss /
// hidden); IN_BATTLE layer gains are no-ops where the match disables them (support.gainLayers).
//
// act1autochess_band2_buff 阿米娅 众志合一 ≥ value_i active bonds (highest i) ⇒ every operator ATK × (1 + atk_i),
// HP × (1 + max_hp_i) (直接乘算, bonds snapshot at combat start)
// act1autochess_band28_buff 埃芒加德 命结之秘 the first max_respawn_cnt operator knock-downs of the battle revive at
// once at full HP (runs after every other death saver, items included)
// act1autochess_band13_buff 克莱门莎 崇高牺牲 a <bond_id> operator knocked down ⇒ +its tier (等阶) <bond_id> layers
// (bond_add_type by_charlevel; no "已激活" in the text ⇒ requireActive false)
// act1autochess_band16_buff 大帝 加急调派 "每次部署后再部署时间减少50%": every deployment of an operator stacks one
// redeploy ×(1 + respawn_time) for the rest of the battle [ASSUMED cumulative:
// "每次" — the first redeploy is −50 % under either reading]
// act1autochess_band17_buff 桑葚 药枚实验 at combat start the player's units on the front-most (最右边) column: each
// attack has `prob` to gain 1 shield layer (max 1)
// act1autochess_band18_buff 休谟斯 回收利用 a ground (地面) operator's skill ends ⇒ a random operator on its 4
// neighbouring tiles gains `sp` SP
// act1autochess_band15_buff 卡莱莎 食腐之蝶 an operator knocked down ⇒ every other unit of the player on the field
// ATK × (1 + atk × n), n ≤ max_stack_cnt (+20 % … +200 %, additive stacks),
// until that unit is knocked down itself or the battle ends
// act1autochess_band19_buff 陈 以己之长 the player's operators' physical / arts damage becomes 弱点伤害
// act2autochess_band12_buff 夕 墨色真颜 at combat start operators of which ≥ 2 of the same name (same base chess,
// elite or not) stand on the field get ATK × (1 + atk)
// act2autochess_band3_buff 伊奥莱塔 统御号令 at combat start n = elite operators on the field ⇒ every elite operator
// ATK × (1 + atk_per_cnt × n), HP × (1 + max_hp_per_cnt × n)
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": while any alive player of the match
// holds band_amedic, every player's field gets the map character of the
// variant whose common_condition holds for that player's elites on the field
// (condition_golden_chess_le cnt → 预备干员-医疗, _ge cnt → Touch), spawned at
// combat start on its stage slot (tokens.spawnMapChar)
// Prep-only bands (economy / shop / grants / layers at prep end or round start) live in bands/meta.js.
import {
num, buffsOf, bandRecord, isOp, onField, isElite, tierOf, unitBonds, activeBondIds, playerOps, passiveBuff, fxOn,
matchBands, gainLayers, alliesAround, N4, baseChessId, isGroundOp,
} from '../support/index.js';
import { weaknessRetype, addShieldLayer, PRIO_REVIVE } from '../items/battle.js';
import { spawnMapChar } from '../tokens.js';
export const AMEDIC_BAND = 'band_amedic';
/** 'fatal' priority of 埃芒加德: after the operators' own items (PRIO_REVIVE) and every talent / skill saver. */
export const PRIO_BAND_REVIVE = PRIO_REVIVE - 10;
const keyOf = (bandId, part = '') => `band:${bandId}${part ? `:${part}` : ''}`;
const deployedOps = (battle, pid) => playerOps(battle, pid, { fieldOnly: true });
// ---------------------------------------------------------------------------------------------------------------
// band buff behaviours: (battle, ps, params, bandId)
const BY_KEY = {
// 阿米娅 众志合一
act1autochess_band2_buff(battle, ps, p, bandId) {
const n = activeBondIds(battle, ps.playerId).length;
let atk = 0, hp = 0;
for (let i = 1; i <= 9 && p[`value_${i}`] != null; i++) {
if (n >= num(p[`value_${i}`], Infinity)) { atk = num(p[`atk_${i}`]); hp = num(p[`max_hp_${i}`]); }
}
if (!(atk > 0 || hp > 0)) return;
const mods = {};
if (atk) mods.atkMul = 1 + atk;
if (hp) mods.hpMul = 1 + hp;
for (const u of playerOps(battle, ps.playerId)) passiveBuff(battle, u, keyOf(bandId), mods);
},
// 埃芒加德 命结之秘
act1autochess_band28_buff(battle, ps, p, bandId) {
const max = Math.floor(num(p.max_respawn_cnt, 3));
if (!(max > 0)) return;
let used = 0;
battle.on('fatal', (c) => {
const u = c.unit;
if (c.prevented || used >= max || !isOp(u) || u.ownerId !== ps.playerId || !u.alive) return;
used++;
c.prevented = true;
u.hp = u.s.maxHp;
fxOn(battle, 'revive', u, keyOf(bandId), bandId, { left: max - used });
}, { priority: PRIO_BAND_REVIVE });
},
// 克莱门莎 崇高牺牲
act1autochess_band13_buff(battle, ps, p, bandId) {
const bond = typeof p.bond_id === 'string' && p.bond_id ? p.bond_id : null;
if (!bond) return;
const byLevel = p.bond_add_type == null || p.bond_add_type === 'by_charlevel';
battle.on('death', (c) => {
const u = c.unit;
if (c.reason !== 'killed' || !isOp(u) || u.ownerId !== ps.playerId || !unitBonds(u).includes(bond)) return;
const n = byLevel ? tierOf(u) : Math.max(1, Math.floor(num(p.value, 1)));
if (gainLayers(battle, { playerId: ps.playerId, bonds: bond, n, requireActive: false, source: u, reason: 'band' }) > 0) {
fxOn(battle, 'layer', u, keyOf(bandId), bandId, { bond, n });
}
});
},
// 大帝 加急调派
act1autochess_band16_buff(battle, ps, p, bandId) {
const mul = Math.max(0, 1 + num(p.respawn_time, 0));
if (mul === 1) return;
const key = keyOf(bandId);
battle.on('deploy', (c) => {
const u = c.unit;
if (!isOp(u) || u.ownerId !== ps.playerId) return;
battle.addBuff(u, { key, mods: { redeployMul: mul }, refresh: 'stack', stacks: 1, maxStacks: 20, persist: true, allowDead: true });
});
},
// 桑葚 药枚实验
act1autochess_band17_buff(battle, ps, p, bandId) {
const pr = num(p.prob);
if (!(pr > 0)) return;
const key = keyOf(bandId, 'shield');
const chosen = new Set();
battle.on('battleStart', () => {
const units = battle.allies(ps.playerId);
// "最右边的所有我方单位": the rightmost column of the player's own board (a board position, whatever the units'
// directions; the mirrored Final Assault right side counts from the field's left)
const boardCol = (u) => (ps.mirror ? -u.tileC : u.tileC);
let front = -Infinity;
for (const u of units) front = Math.max(front, boardCol(u));
for (const u of units) if (boardCol(u) === front) chosen.add(u);
});
battle.on('attack', (c) => {
const u = c.attacker;
if (!chosen.has(u) || !u.alive) return;
if (!(pr >= 1 || battle.rng() < pr)) return;
if (addShieldLayer(battle, u, key, 1)) fxOn(battle, 'shield', u, keyOf(bandId), bandId);
});
},
// 休谟斯 回收利用
act1autochess_band18_buff(battle, ps, p, bandId) {
const sp = num(p.sp);
if (!(sp > 0)) return;
battle.on('skillEnd', (c) => {
const u = c.unit;
if (!isOp(u) || u.ownerId !== ps.playerId || c.reason === 'death' || !onField(u) || !isGroundOp(u)) return;
const cands = alliesAround(battle, u, N4).filter((a) => a.kind === 'op' && a.skill && !a.skill.noSkill && a.skill.kind !== 'passive');
const t = cands.length ? battle.rng.pick(cands) : null;
if (!t) return;
t.skill.gainSp(sp, 'band');
fxOn(battle, 'spGain', t, keyOf(bandId), bandId, { n: sp, from: u.id });
});
},
// 卡莱莎 食腐之蝶
act1autochess_band15_buff(battle, ps, p, bandId) {
const step = num(p.atk);
const cap = Math.max(1, Math.floor(num(p.max_stack_cnt, 10)));
if (!(step > 0)) return;
const key = keyOf(bandId);
battle.on('death', (c) => {
const d = c.unit;
if (c.reason !== 'killed' || !isOp(d) || d.ownerId !== ps.playerId) return;
for (const a of battle.allies(ps.playerId)) {
if (a === d) continue;
const cur = a.findBuff(key);
const n = Math.min(cap, (cur && cur.data ? cur.data.n : 0) + 1);
battle.addBuff(a, { key, mods: { atkMul: 1 + step * n }, refresh: 'replace', data: { n }, visible: true });
}
});
},
// 陈 以己之长
act1autochess_band19_buff(battle, ps) {
battle.on('hit', (c) => {
const s = c.source;
if (!isOp(s) || s.ownerId !== ps.playerId || !c.target || c.target.side !== 'enemy') return;
weaknessRetype(c.dmg, s, c.target);
}, { priority: 5 });
},
// 夕 墨色真颜
act2autochess_band12_buff(battle, ps, p, bandId) {
const atk = num(p.atk);
if (!atk) return;
battle.on('battleStart', () => {
const groups = new Map();
for (const u of deployedOps(battle, ps.playerId)) {
const b = baseChessId(u);
if (!groups.has(b)) groups.set(b, []);
groups.get(b).push(u);
}
for (const list of groups.values()) {
if (list.length < 2) continue;
for (const u of list) passiveBuff(battle, u, keyOf(bandId), { atkMul: 1 + atk });
}
});
},
// 伊奥莱塔 统御号令
act2autochess_band3_buff(battle, ps, p, bandId) {
const a = num(p.atk_per_cnt), h = num(p.max_hp_per_cnt);
if (!(a > 0 || h > 0)) return;
battle.on('battleStart', () => {
const elites = deployedOps(battle, ps.playerId).filter(isElite);
const n = elites.length;
if (!n) return;
const mods = {};
if (a) mods.atkMul = 1 + a * n;
if (h) mods.hpMul = 1 + h * n;
for (const u of elites) passiveBuff(battle, u, keyOf(bandId), mods);
});
},
};
// ---------------------------------------------------------------------------------------------------------------
// Touch 外勤医疗 (auto_chess_change_map, every player's field)
/** Map-character variants of band_amedic: [{ cond, cnt, chars: [tokenId…] }]. */
export function amedicVariants() {
const out = [];
for (const b of buffsOf(bandRecord(AMEDIC_BAND))) {
if (b.key !== 'auto_chess_change_map') continue;
const chars = new Set();
for (const [k, v] of Object.entries(b.p)) if (k.includes('#') && num(v) > 0) chars.add(k.split('#')[0]);
out.push({ cond: String(b.p.common_condition || ''), cnt: num(b.p.cnt, 0), chars: [...chars] });
}
return out;
}
/** Map characters for a player with `elites` elite operators on the field. */
export function amedicCharsFor(elites, variants = amedicVariants()) {
for (const v of variants) {
const ok = /_ge$/.test(v.cond) ? elites >= v.cnt : /_le$/.test(v.cond) ? elites <= v.cnt : false;
if (ok) return v.chars;
}
return [];
}
function installMapChars(battle) {
const variants = amedicVariants();
if (!variants.length) return;
const holders = battle.players.filter((ps) => matchBands(battle, ps.playerId).includes(AMEDIC_BAND));
if (!holders.length) return;
battle.on('battleStart', () => {
for (const ps of holders) {
const elites = deployedOps(battle, ps.playerId).filter(isElite).length;
for (const id of amedicCharsFor(elites, variants)) {
const u = spawnMapChar(battle, ps.playerId, id);
if (u) fxOn(battle, 'summon', u, keyOf(AMEDIC_BAND), AMEDIC_BAND);
}
}
});
}
// ---------------------------------------------------------------------------------------------------------------
/** Band buff keys with a battle part (tests / tooling). */
export const BATTLE_BUFF_KEYS = Object.freeze([...Object.keys(BY_KEY), 'auto_chess_change_map']);
/** Does this band have a battle part? */
export function hasBattlePart(bandId) {
return buffsOf(bandRecord(bandId)).some((b) => (b.bbKey && BY_KEY[b.bbKey]) || b.key === 'auto_chess_change_map');
}
export function install(battle) {
for (const ps of battle.players) {
const band = ps.bandId ? bandRecord(ps.bandId) : null;
if (!band) continue;
for (const b of buffsOf(band)) {
const f = b.bbKey ? BY_KEY[b.bbKey] : null;
if (!f) continue;
try { f(battle, ps, b.p, ps.bandId); } catch (e) { battle._handlerError?.(`band:${ps.bandId}`, null, e); }
}
}
installMapChars(battle);
}
+466
View File
@@ -0,0 +1,466 @@
// server/sim/content/bands/meta.js — prep side of the 40 strategies (bands, data/bands.json).
//
// Every band handler is assembled from the band's buffs by their official buff key (numbers from bb / bbStr, never
// hard-coded); keys that only act in battle (env_gbuff_new_with_verify, auto_chess_change_map) live in battle.js.
// Starting LP (totalHp) and 坎诺特's leftover funds are the match's own rules.
//
// prep_finish_char_bond_add_layer {layer} 华法琳 prep end: per distinct board tier, 1 random op → its bonds +layer (无需激活)
// band_first_self_refresh_present_char {count,bond} 杜遥夜 first 2 manual refreshes/round: ≥count <bond> chess (tier ≤ shop level)
// band_shop_refresh_copy_max_lv_char {count} 梓兰 every manual refresh: highest-tier shop chess copied into another slot, copy frozen
// preparation_start_gain_chess_from_round {round} 梓兰/小贾斯汀/昆图斯/马克维茨/变形者集群/Pith/夕/杰西卡/缪尔赛思/芬 round start of `round`: bbStr.chess
// preparation_start_gain_chess_every_n_round {round} 杜宾 every `round` rounds (round % n = 0): bbStr.chess
// give_coin_in_round {round,coin} 老鲤 income of round `round` = coin (R1/R2 0, R3 15)
// prep_start_gain_chess_from_pool_in_round {round} 老鲤 round start of `round`: 1 chess of pool
// band_coin_cost_gain_random_char_by_shop_level 绮良 every coin_cnt funds spent → count random chess ≤ shop level
// up_shop_next_refresh_must_present_bond_char 佩佩 level-up to a level of lvlist → +1 free refresh (price 0) that prefers <bond>
// gain_bond_char_per_round {round,preround,bond} 哈洛德 rounds round, round+preround, …: a <bond> chess (≤ shop level, else any tier)
// first_buy_in_round_char_price_change {price,bond} 休露丝 first <bond> chess of the round costs `price`
// band_cost_coin_reach_cnt_gain_chess_from_pool 潘格尼尼 once coin_cnt funds spent in total: 1 chess of pool (elite 拉特兰 ≥ T4)
// round_start_bond_check_gain_layer 余 round start of `round`: exactly factioncount active bonds → +count1, else each active +count2
// up_shop_add_special_goods {count,choice,pool} 凯瑟琳 every level-up: pick 1 of `count` items of pool (free)
// coin_carry_over {capital,interest,max} 坎诺特 leftover ≥ capital at round start → +min(max, ⌊left/capital⌋×interest) income
// round_start_all_player_change_enemy_2 鸭爵 global: every alive player's normal battle from `round` (own + teammates)
// round_start_activate_char_chess_effect_in_board 铃兰 round start: 获得时 traits of the right-most (then bottom-most) board op with one
// first_sell_char_chess_exchange_char_chess_in_shop 巫恋 first sale of a normal op per round: swapped with a random shop chess (no funds)
// round_start_gain_char_chess_in_shop_every_n_round 松桐 round % n = 0: a random shop chess for free (its slot empties)
// round_start_gain_coin_by_bond_char_chess_buy 玛恩纳 each <bond> chess bought → +count funds next round (≤ max_count / round)
// refresh_shop_count_gain_coin_bond_char_chess 贾维 every refresh_count manual refreshes (match total) → <bond> chess ≤ shop level (≤ max_count / round)
// preparation_start_add_special_goods_every_n_round 娜仁图亚 round % n = 0: pick choice_cnt of refresh_cnt items of pool (free)
// "进入休整期时" / "回合开始时" = onRoundStart (research 01 §5 step 6).
import { buffsOf, num, bandRecord, gameData } from '../support/index.js';
import { metaBonds } from '../support/meta.js';
import * as garrisons from '../garrisons.js';
const int = (v, d = 0) => Math.trunc(num(v, d));
const list = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const hasBond = (ctx, id, bond) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bond)); };
const isNormalChess = (ctx, id) => { const c = ctx.gd.chess(id); return !!(c && !c.isGolden); };
/** Per-round counter: reset lazily when the round changes. */
function roundCounter(ctx, key) {
if (ctx.counter(`${key}:r`) !== ctx.round) { ctx.setCounter(`${key}:r`, ctx.round); ctx.setCounter(key, 0); }
return ctx.counter(key);
}
const bumpRound = (ctx, key, n = 1) => { roundCounter(ctx, key); return ctx.incCounter(key, n); };
/** Grant a chess or an item by id. */
function grant(ctx, id, opts = {}) {
if (ctx.gd.item(id)) return ctx.grantItem(id, opts);
if (ctx.gd.chess(id)) return ctx.grantChess(id, opts);
return null;
}
/** Indices of unsold chess slots of the shop. */
const chessSlots = (ctx) => ctx.shopSlots().map((s, i) => (s && s.kind === 'chess' && !s.sold ? i : -1)).filter((i) => i >= 0);
/** Up to `n` distinct item ids of an equip pool. */
function rollItems(ctx, poolId, n) {
const out = [];
for (let k = 0; k < n * 6 && out.length < n; k++) {
const r = ctx.rollPool(poolId);
if (!r || r.kind !== 'item') break;
if (!out.includes(r.id)) out.push(r.id);
}
return out;
}
/** Random chess of `bond`, tier ≤ shop level (fallback: any tier when `anyTier`). */
function rollBond(ctx, bond, { anyTier = false } = {}) {
return ctx.rollChess({ bond, maxTier: Math.max(1, ctx.shopLevel()) }) || (anyTier ? ctx.rollChess({ bond, maxTier: 6 }) : null);
}
// ---------------------------------------------------------------------------------------------------------------
// buff key → (params[], bandId) → hooks
const K = {};
K.prep_finish_char_bond_add_layer = (ps) => ({
onPrepEnd(ctx) {
const n = int(ps[0].layer, 0);
if (n <= 0) return;
const byTier = new Map();
for (const p of ctx.board()) {
if (!p || p.kind !== 'chess') continue;
const t = p.tier ?? ctx.gd.tierOf(p.id);
if (!byTier.has(t)) byTier.set(t, []);
byTier.get(t).push(p);
}
for (const t of [...byTier.keys()].sort((a, b) => a - b)) {
const p = ctx.rng.pick(byTier.get(t));
for (const b of metaBonds(ctx, p)) ctx.addLayers(b, n, { requireActive: false, reason: 'band' });
}
},
});
K.band_first_self_refresh_present_char = (ps) => ({
onRefresh(ctx) {
const p = ps[0];
if ((ctx.roundStats().refreshes || 0) > 2) return;
const need = Math.max(1, int(p.count, 1));
const idx = chessSlots(ctx);
const slots = ctx.shopSlots();
let have = idx.filter((i) => hasBond(ctx, slots[i].id, p.bond)).length;
const others = ctx.rng.shuffle(idx.filter((i) => !hasBond(ctx, slots[i].id, p.bond)));
for (const i of others) {
if (have >= need) break;
const id = rollBond(ctx, p.bond);
if (!id) break;
ctx.setShopSlot(i, { kind: 'chess', id });
have++;
}
},
});
K.band_shop_refresh_copy_max_lv_char = () => ({
onRefresh(ctx) {
const slots = ctx.shopSlots();
const idx = chessSlots(ctx);
if (idx.length < 2) return;
let best = -1;
for (const i of idx) best = Math.max(best, ctx.gd.tierOf(slots[i].id));
const src = ctx.rng.pick(idx.filter((i) => ctx.gd.tierOf(slots[i].id) === best));
const dst = ctx.rng.pick(idx.filter((i) => i !== src));
ctx.setShopSlot(dst, { kind: 'chess', id: slots[src].id, frozen: true });
},
});
K.preparation_start_gain_chess_from_round = (ps) => ({
onRoundStart(ctx) {
for (const p of ps) {
const id = p.chess ?? p.chess_id;
if (ctx.round !== int(p.round, -1) || !id) continue;
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) grant(ctx, id, { requirePool: false, source: 'band' });
}
},
});
K.preparation_start_gain_chess_every_n_round = (ps) => ({
onRoundStart(ctx) {
for (const p of ps) {
const n = int(p.round, 0);
const id = p.chess ?? p.chess_id;
if (n <= 0 || ctx.round % n !== 0 || !id) continue;
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) grant(ctx, id, { source: 'band' });
}
},
});
K.give_coin_in_round = (ps) => ({
onIncome(ctx, ev) {
for (const p of ps) if (ctx.round === int(p.round, -1)) ev.income = Math.max(0, int(p.coin, 0));
},
});
K.prep_start_gain_chess_from_pool_in_round = (ps) => ({
onRoundStart(ctx) {
for (const p of ps) {
if (ctx.round !== int(p.round, -1)) continue;
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) {
const r = ctx.rollPool(p.pool);
if (r) grant(ctx, r.id, { source: 'band' });
}
}
},
});
K.band_coin_cost_gain_random_char_by_shop_level = (ps) => ({
onSpend(ctx, ev) {
const p = ps[0];
const step = int(p.coin_cnt, 0);
if (step <= 0 || !(ev.amount > 0)) return;
let acc = ctx.incCounter('band:kirara:acc', ev.amount);
while (acc >= step) {
acc = ctx.incCounter('band:kirara:acc', -step);
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) {
const id = ctx.rollChess({ maxTier: Math.max(1, ctx.shopLevel()) });
if (id) ctx.grantChess(id, { source: 'band' });
}
}
},
});
K.up_shop_next_refresh_must_present_bond_char = (ps) => ({
onLevelUp(ctx, ev) {
const p = ps[0];
if (!list(p.lvlist).map(Number).includes(ev.level)) return;
ctx.incCounter('band:pepe:special', 1);
if (int(p.price, 0) === 0) ctx.grantFreeRefresh(1);
},
onRefresh(ctx) {
if (ctx.counter('band:pepe:special') <= 0) return;
ctx.incCounter('band:pepe:special', -1);
const bond = ps[0].bond;
const slots = ctx.shopSlots();
for (const i of chessSlots(ctx)) {
if (hasBond(ctx, slots[i].id, bond)) continue;
const id = rollBond(ctx, bond);
if (!id) break;
ctx.setShopSlot(i, { kind: 'chess', id });
}
},
});
K.gain_bond_char_per_round = (ps) => ({
onRoundStart(ctx) {
const p = ps[0];
const r0 = int(p.round, 1), every = Math.max(1, int(p.preround, 1));
if (ctx.round < r0 || (ctx.round - r0) % every !== 0) return;
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) {
const id = rollBond(ctx, p.bond, { anyTier: true });
if (id) ctx.grantChess(id, { source: 'band' });
}
},
});
K.first_buy_in_round_char_price_change = (ps) => ({
onPrice(ctx, ev) {
const p = ps[0];
if (ev.kind !== 'chess' || !hasBond(ctx, ev.id, p.bond)) return;
if (ctx.counter('band:sciurus:r') === ctx.round) return;
ctx.setPrice(Math.min(num(ev.price, 0), int(p.price, 1)));
},
onBuy(ctx, ev) {
if (ev.kind === 'chess' && hasBond(ctx, ev.slot.id, ps[0].bond)) ctx.setCounter('band:sciurus:r', ctx.round);
},
});
K.band_cost_coin_reach_cnt_gain_chess_from_pool = (ps) => ({
onSpend(ctx, ev) {
const p = ps[0];
if (ctx.counter('band:paganini:done') > 0) return;
const spent = ctx.incCounter('band:paganini:spent', Math.max(0, int(ev.amount, 0)));
if (spent < int(p.coin_cnt, 0)) return;
const r = ctx.rollPool(p.pool);
if (r && grant(ctx, r.id, { source: 'band' })) ctx.setCounter('band:paganini:done', 1);
},
});
K.round_start_bond_check_gain_layer = (ps) => ({
onRoundStart(ctx) {
const p = ps[0];
if (ctx.round !== int(p.round, -1)) return;
const active = Object.entries(ctx.bonds()).filter(([, b]) => b && b.active).map(([id]) => id);
if (active.length === int(p.factioncount, 1)) {
for (const b of active) ctx.addLayers(b, int(p.count1, 0), { requireActive: true, reason: 'band' });
} else {
for (const b of active) ctx.addLayers(b, int(p.count2, 0), { requireActive: true, reason: 'band' });
}
},
});
K.up_shop_add_special_goods = (ps) => ({
onLevelUp(ctx) {
const p = ps[0];
const ids = rollItems(ctx, p.pool, Math.max(1, int(p.count, 3)));
if (ids.length) ctx.offerItems(ids, { source: 'band' });
},
});
K.coin_carry_over = (ps) => ({
onIncome(ctx, ev) {
const p = ps[0];
const cap = int(p.capital, 0), left = ctx.funds();
if (cap <= 0 || left < cap) return;
const bonus = Math.min(int(p.max, Infinity), Math.floor(left / cap) * int(p.interest, 0));
if (bonus > 0) ev.income = int(ev.income, 0) + bonus;
},
});
K.round_start_activate_char_chess_effect_in_board = (ps) => ({
onRoundStart(ctx) {
const p = ps[0];
const event = p.event_type || 'SERVER_GAIN';
const cands = ctx.board().filter((v) => v && v.kind === 'chess' && ctx.garrisonsOf(v.uid).some((g) => g.eventType === event));
if (!cands.length) return;
cands.sort((a, b) => (b.col - a.col) || (a.row - b.row));
for (const v of cands.slice(0, Math.max(1, int(p.count, 1)))) {
if (event === 'SERVER_GAIN' && typeof garrisons.triggerGainEffects === 'function') garrisons.triggerGainEffects(ctx, v);
else ctx.triggerGarrisons(v.uid, event);
}
},
});
K.first_sell_char_chess_exchange_char_chess_in_shop = (ps) => ({
onSold(ctx, ev) {
const piece = ev.piece;
if (!piece || piece.kind !== 'chess' || !isNormalChess(ctx, piece.id)) return;
if (roundCounter(ctx, 'band:vodfox') >= Math.max(1, int(ps[0].count, 1))) return;
const slots = ctx.shopSlots();
const idx = chessSlots(ctx);
if (!idx.length) return;
const i = ctx.rng.pick(idx);
const got = ctx.grantChess(slots[i].id, { source: 'band' });
if (!got) return;
ctx.setShopSlot(i, { kind: 'chess', id: piece.id });
ev.gain = 0;
bumpRound(ctx, 'band:vodfox');
},
});
K.round_start_gain_char_chess_in_shop_every_n_round = (ps) => ({
onRoundStart(ctx) {
const p = ps[0];
const n = int(p.round, 0);
if (n <= 0 || ctx.round % n !== 0) return;
for (let k = 0; k < Math.max(1, int(p.count, 1)); k++) {
const idx = chessSlots(ctx);
if (!idx.length) return;
const i = ctx.rng.pick(idx);
if (ctx.grantChess(ctx.shopSlots()[i].id, { source: 'band' })) ctx.setShopSlot(i, null);
}
},
});
K.round_start_gain_coin_by_bond_char_chess_buy = (ps) => ({
onBuy(ctx, ev) {
const p = ps[0];
if (ev.kind !== 'chess' || !hasBond(ctx, ev.slot.id, p.bond)) return;
const n = int(p.count, 1), max = int(p.max_count, Infinity);
const used = roundCounter(ctx, 'band:mlynar');
const add = Math.min(n, max - used);
if (add <= 0) return;
ctx.addPendingFunds(add);
bumpRound(ctx, 'band:mlynar', add);
},
});
K.refresh_shop_count_gain_coin_bond_char_chess = (ps) => ({
onRefresh(ctx) {
const p = ps[0];
const every = int(p.refresh_count, 0);
if (every <= 0) return;
if (ctx.incCounter('band:chiave:refreshes', 1) % every !== 0) return;
if (roundCounter(ctx, 'band:chiave') >= int(p.max_count, Infinity)) return;
const id = rollBond(ctx, p.bond);
if (id && ctx.grantChess(id, { source: 'band' })) bumpRound(ctx, 'band:chiave');
},
});
K.preparation_start_add_special_goods_every_n_round = (ps) => ({
onRoundStart(ctx) {
const p = ps[0];
const n = int(p.round, 0);
if (n <= 0 || ctx.round % n !== 0) return;
const ids = rollItems(ctx, p.pool, Math.max(1, int(p.refresh_cnt, 2)));
for (let k = 0; k < Math.max(1, int(p.choice_cnt, 1)) && ids.length; k++) ctx.offerItems(ids, { source: 'band' });
},
});
// ---------------------------------------------------------------------------------------------------------------
// 鸭爵: "你和队友遭遇的部分敌人" — a global handler rewrites the normal-battle spawn list of every alive player while
// the band is held by that player or a teammate. From the `minweight`–`maxweight` share of the wave (time order),
// min..max NORMAL ground enemies are replaced by a random enemylist enemy worth 1 fund to its killer (bounty, also in
// 联防). The originals never exist, so none of their death / kill / leak effects happen.
export const DUCK_BAND = 'band_ducklord';
export const DUCK_COINS = 1;
function duckParams() {
for (const b of buffsOf(bandRecord(DUCK_BAND))) if (b.key === 'round_start_all_player_change_enemy_2') return b.p;
return null;
}
/** Rewrite `spawns` in place (exported for tests). Returns the replaced spawn specs. */
export function duckReplace(ctx, spawns, p, ownerPlayerId) {
const gd = ctx.gd;
const ducks = list(p.enemylist).filter((k) => gd.enemy(k));
if (!ducks.length || !Array.isArray(spawns)) return [];
const lo = Math.max(0, int(p.min, 0)), hi = Math.max(lo, int(p.max, lo));
const want = lo + ctx.rng.int(hi - lo + 1);
if (want <= 0) return [];
// one entry per enemy (split multi-count specs), in spawn-time order
const flat = [];
for (let i = 0; i < spawns.length; i++) {
const s = spawns[i];
const cnt = Math.max(1, int(s.count, 1));
for (let k = 0; k < cnt; k++) flat.push({ i, k, t: num(s.time, 0) + k * num(s.interval, 0) });
}
flat.sort((a, b) => a.t - b.t || a.i - b.i || a.k - b.k);
const w0 = num(p.minweight, 0), w1 = num(p.maxweight, 1);
const n = flat.length;
const ok = flat.filter((f, j) => {
const s = spawns[f.i];
const e = gd.enemy(s.enemyKey);
const frac = n > 1 ? j / (n - 1) : 0;
return !s.tag && e && e.rank === 'NORMAL' && e.stats?.motion !== 'FLY' && frac >= w0 - 1e-9 && frac <= w1 + 1e-9;
});
const picked = ctx.rng.shuffle(ok).slice(0, want);
if (!picked.length) return [];
const byIdx = new Map();
for (const f of picked) { if (!byIdx.has(f.i)) byIdx.set(f.i, new Set()); byIdx.get(f.i).add(f.k); }
const out = [];
const add = [];
for (const [i, ks] of byIdx) {
const s = spawns[i];
const cnt = Math.max(1, int(s.count, 1));
const keep = cnt - ks.size;
for (const k of ks) {
const d = {
...s, mods: s.mods ? { ...s.mods } : undefined, count: 1, interval: 0,
time: num(s.time, 0) + k * num(s.interval, 0), enemyKey: ctx.rng.pick(ducks), tag: 'duck',
bounty: { coins: DUCK_COINS, ownerPlayerId },
};
if (d.mods) delete d.mods.slot;
add.push(d);
out.push(d);
}
if (keep <= 0) spawns[i] = null;
else if (ks.size) {
// keep the unreplaced copies of a multi-count spec as single specs at their original times
const rest = [];
for (let k = 0; k < cnt; k++) if (!ks.has(k)) rest.push({ ...s, mods: s.mods ? { ...s.mods } : undefined, count: 1, interval: 0, time: num(s.time, 0) + k * num(s.interval, 0) });
spawns[i] = null;
add.push(...rest);
}
}
for (let i = spawns.length - 1; i >= 0; i--) if (spawns[i] == null) spawns.splice(i, 1);
spawns.push(...add);
return out;
}
const duckGlobal = {
onBattleStart(ctx, ev) {
if (!ev || ev.kind !== 'normal' || !Array.isArray(ev.spawns)) return;
const p = duckParams();
if (!p || ctx.round < int(p.round, 1)) return;
const held = ctx.bandId() === DUCK_BAND || ctx.teammates().some((t) => t.bandId() === DUCK_BAND);
if (!held) return;
duckReplace(ctx, ev.spawns, p, ctx.playerId);
},
};
// ---------------------------------------------------------------------------------------------------------------
/** Merge hook objects (same hook → called in order). */
function merge(parts) {
const out = {};
for (const h of parts) {
for (const [name, fn] of Object.entries(h)) {
if (typeof fn !== 'function') continue;
const prev = out[name];
out[name] = prev ? function merged(ctx, ev) { prev(ctx, ev); fn(ctx, ev); } : fn;
}
}
return out;
}
/** Hooks of one band (null when it has no prep effect). */
export function bandMetaHandler(bandId) {
const groups = new Map();
for (const b of buffsOf(bandRecord(bandId))) {
if (!b.key || !K[b.key]) continue;
if (!groups.has(b.key)) groups.set(b.key, []);
groups.get(b.key).push(b.p);
}
if (!groups.size) return null;
return merge([...groups].map(([k, ps]) => K[k](ps, bandId)));
}
export function registerMeta(registry) {
for (const id of Object.keys(gameData().bands || {})) {
const h = bandMetaHandler(id);
if (h) registry.band(id, h);
}
registry.global('bands_ducklord', duckGlobal);
}
+40
View File
@@ -0,0 +1,40 @@
// server/sim/content/bonds.js — the 23 bonds (盟约), battle side + prep side (DESIGN §7, research 02).
//
// Entry point imported by content/index.js. The work is split into two submodules that each export
// install(battle) / registerMeta(registry):
// bonds/core.js 8 core bonds 炎 萨尔贡 维多利亚 谢拉格 拉特兰 阿戈尔 叙拉古 卡西米尔
// bonds/addon.js 15 add-on bonds 精准 迅捷 灵巧 奥术 坚守 助力 远见 奇迹 投资人 突袭 不屈 调和 协防干员 独行 绝技
// plus support/meta.js (shared prep→battle `contentInfo`). Parts are loaded with guarded dynamic imports and run in
// isolation: one failing part never disables the others (the first error is re-thrown afterwards so
// content/index.js records it in battle.errors / the server log).
// Membership (own bonds, 变形同构体 grants, 调和 enjoying active core bonds) lives in support/index.js.
async function load(path) {
try {
return await import(path);
} catch (e) {
console.error(`[content] failed to load ${path}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`);
return {};
}
}
const [core, addon, supportMeta] = await Promise.all([load('./bonds/core.js'), load('./bonds/addon.js'), load('./support/meta.js')]);
const PARTS = Object.freeze([['bonds/core', core], ['bonds/addon', addon]]);
function runAll(fnName, arg, pre = null) {
let first = null;
if (pre) { try { pre(arg); } catch (e) { first = first ?? e; } }
for (const [name, mod] of PARTS) {
if (typeof mod[fnName] !== 'function') continue;
try { mod[fnName](arg); } catch (e) {
if (!first) { first = e; try { e.message = `${name}.${fnName}: ${e.message}`; } catch { /* frozen */ } }
}
}
if (first) throw first;
}
export function install(battle) { runAll('install', battle); }
export function registerMeta(registry) {
runAll('registerMeta', registry, typeof supportMeta.registerSupportMeta === 'function' ? supportMeta.registerSupportMeta : null);
}
+12
View File
@@ -0,0 +1,12 @@
// server/sim/content/bonds/addon.js — the 15 add-on bonds (research 02 §3.9–§3.23), loaded by content/bonds.js.
//
// addon/battle.js 精准 迅捷 灵巧 奥术 坚守 助力 突袭 不屈 协防干员 独行 绝技 (battle effects)
// addon/meta.js 助力 (prep-end layers) 远见 (layer funds, price discounts) 奇迹 (free refresh, layer funds)
// 投资人's ×2/×3 "获得时" repeat lives in the match dispatcher, 调和's +1 core count in server/match/bondsMeta.js and its
// "enjoys active core bonds" in support/index.js isMember().
import { install as installBattle } from './addon/battle.js';
import { registerMeta as registerAddonMeta } from './addon/meta.js';
export function install(battle) { installBattle(battle); }
export function registerMeta(registry) { registerAddonMeta(registry); }
+469
View File
@@ -0,0 +1,469 @@
// server/sim/content/bonds/addon/battle.js — battle side of the 15 add-on bonds (research 02 §3.9–§3.23).
//
// Every number comes from data/bonds.json (`env_gbuff_new` buff blackboards). Effects only apply to the owning
// player's units (`unit.ownerId === playerId`): two players sharing a boss / 联防 field never buff each other.
// Layer-dependent values read the LIVE layers (`battle.getPlayer(pid).bonds[id].layers`): an IN_BATTLE layer gain
// (engine `layerGain` hook, which fires before the layers are written) schedules one deferred recompute per player.
//
// 精准 preciShip members ATK ×(1 + base_atk + atk_per_stack·L); tier 2 (3 distinct): members + RANGED operators,
// which also ignore power_def_penetrate DEF / power_magic_resist_penetrate RES (defIgnorePct / resIgnorePct)
// 迅捷 swiftShip member skill end → p = min(1, base_prob + prob_per_stack·L): +normal_sp SP; L ≥ power_bond_stack_cnt:
// every operator's skill end rolls p again for +power_sp (members roll both, research [ASSUMED])
// 灵巧 skillfulShip aura: members + operators on their 4 (L ≥ 40: 8) adjacent tiles ASPD +(base + per·L), once per unit
// 奥术 arcaneShip member arts damage → target arts taken ×(base + per·L) for weak_duration s (one refreshing instance
// per player); tier 2: ×power_weak_scale when the target is below hp_ratio at application
// 坚守 steadShip all operators max HP ×(1 + base + per·L); tier 2: 40 % of a non-member operator's damage is borne by
// the members on the field (split evenly, sourceless true damage — already mitigated), members’ thorns
// (base + per·L arts, sourceless, ≤ 1 per cd_duration per member) + 脆弱 ×damage_scale for weak[limit] s
// 助力 deputShip all operators DEF ×(1 + base + per·L), redeploy time ×(1 + respawn_time)
// 突袭 raidShip member idle ≥ no_attack_duration s (or skill ready) with no enemy in range → "保留技力立即再部署"
// next to the most advanced ground enemy: a real redeployment (retreat + free redeploy on the
// landing tile, full HP, `deploy` fires — 部署时 traits such as 史尔特尔, 突袭手雷, 卡西米尔, 叙拉古)
// with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP ×(1 + base + per·L)
// until it leaves the field; later redeploys use its board tile again;
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed
// 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy; tier 2: every
// operator on the field +sp SP
// 协防 emptyShip all operators phys/arts taken ×(1 − damage_resistance); members dealt ×damage_scale_normal
// (elite ×damage_scale_extra)
// 独行 soloShip the member(s) ATK ×(1 + atk), HP ×(1 + max_hp), +sp SP on every deploy
// 绝技 suntShip tier 1: elite operators ATK ×(1 + power_atk); tier 2: elites (and their summons) SP cost ×sp_ratio
// 远见 / 奇迹 / 投资人 / 调和 have no battle effect (prep side, membership in support/index.js).
//
// Hooks registered only when a bond needs them. Priorities: `hit` −20 (坚守 redirect, after other modifiers had their
// say), `death` 10 (不屈, the bond slot of the revive/redeploy convention); everything else 0.
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
import { localOrder, localBefore } from '../../../dir.js';
import {
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
fxOn, N4, N8,
} from '../../support/index.js';
export const ID = Object.freeze({
preci: 'preciShip', swift: 'swiftShip', skillful: 'skillfulShip', arcane: 'arcaneShip', stead: 'steadShip',
deput: 'deputShip', raid: 'raidShip', indom: 'indomShip', empty: 'emptyShip', solo: 'soloShip', sunt: 'suntShip',
});
export const KEY = Object.freeze({
preci: 'bond:preciShip', stead: 'bond:steadShip', deput: 'bond:deputShip', skillful: 'bond:skillfulShip',
raid: 'bond:raidShip', raidAspd: 'bond:raidShip:aspd', empty: 'bond:emptyShip', emptyDmg: 'bond:emptyShip:dmg',
solo: 'bond:soloShip', sunt: 'bond:suntShip', arcane: 'bond:arcaneShip',
});
/** Bonds whose battle values depend on layers (recomputed on in-battle gains). */
const LAYERED = new Set([ID.preci, ID.swift, ID.skillful, ID.arcane, ID.stead, ID.deput, ID.raid, ID.indom]);
const STEAD_CD = 'bond:steadShip:cd';
const AURA_POLL = 0.25;
const RAID_POLL = 0.25;
const RAID_SEARCH = 2; // landing tiles within this Chebyshev distance of the target enemy
/** `env_gbuff_new` blackboard of a bond (numbers only). */
export function bondBb(bondId) {
return buffParams(bondRecord(bondId), 'env_gbuff_new') ?? {};
}
/** 迅捷 / 不屈 proc chance p = min(1, base_prob + prob_per_stack·L). */
export const procChance = (bb, L) => Math.max(0, Math.min(1, num(bb.base_prob) + num(bb.prob_per_stack) * L));
const prob = procChance;
// =====================================================================================================================
// per-player state
function makeState(battle, p) {
const pid = p.playerId;
const ops = playerOps(battle, pid);
const st = { pid, ops, tiers: {}, bb: {}, members: {} };
for (const id of Object.values(ID)) {
const t = bondTier(battle, pid, id);
if (t <= 0) continue;
st.tiers[id] = t;
st.bb[id] = bondBb(id);
st.members[id] = new Set(ops.filter((u) => isMember(battle, u, id)));
}
return st;
}
const L = (battle, st, id) => bondLayers(battle, st.pid, id);
// =====================================================================================================================
// match-long passives (re-applied on layer changes; passiveBuff replaces by key → idempotent)
function applyPassives(battle, st) {
const t = st.tiers;
if (t[ID.preci]) {
const bb = st.bb[ID.preci];
const wide = t[ID.preci] >= 2;
const mods = { atkMul: 1 + num(bb.base_atk) + num(bb.atk_per_stack) * L(battle, st, ID.preci) };
if (wide) { mods.defIgnorePct = num(bb.power_def_penetrate); mods.resIgnorePct = num(bb.power_magic_resist_penetrate); }
for (const u of st.ops) {
if (st.members[ID.preci].has(u) || (wide && u.def?.position === 'RANGED')) passiveBuff(battle, u, KEY.preci, mods);
}
}
if (t[ID.stead]) {
const bb = st.bb[ID.stead];
const mods = { hpMul: 1 + num(bb.base_max_hp) + num(bb.max_hp_per_stack) * L(battle, st, ID.stead) };
for (const u of st.ops) passiveBuff(battle, u, KEY.stead, mods);
}
if (t[ID.deput]) {
const bb = st.bb[ID.deput];
const mods = { defMul: 1 + num(bb.base_def) + num(bb.def_per_stack) * L(battle, st, ID.deput), redeployMul: Math.max(0, 1 + num(bb.respawn_time)) };
for (const u of st.ops) passiveBuff(battle, u, KEY.deput, mods);
}
if (t[ID.raid]) {
const bb = st.bb[ID.raid];
const lv = L(battle, st, ID.raid);
const milestone = num(bb.power_bond_stack_cnt, Infinity);
if (lv >= milestone) {
const mods = { aspd: num(bb.power_attack_speed) };
for (const u of st.ops) passiveBuff(battle, u, KEY.raidAspd, mods);
if (!st.raidMilestone) { st.raidMilestone = true; for (const u of st.members[ID.raid]) if (onField(u)) { fxOn(battle, 'bondMilestone', u, KEY.raid, 'aspd'); break; } }
}
st.raidMods = raidMods(bb, lv);
for (const u of st.members[ID.raid]) {
const b = u.findBuff(KEY.raid);
if (b) { b.mods = st.raidMods; u.markDirty(); }
}
}
if (t[ID.arcane]) {
const bb = st.bb[ID.arcane];
const m = num(bb.base_damage_scale, 1) + num(bb.damage_scale_per_stack) * L(battle, st, ID.arcane);
st.arcaneMods = { artsTakenMul: Math.max(0, m) };
st.arcaneLowMods = { artsTakenMul: Math.max(0, m * num(bb.power_weak_scale, 1)) };
}
if (t[ID.skillful]) {
const bb = st.bb[ID.skillful];
const lv = L(battle, st, ID.skillful);
const v = num(bb.base_attack_speed) + num(bb.attack_speed_per_stack) * lv;
if (!st.auraMods || st.auraMods.aspd !== v) st.auraMods = { aspd: v };
st.auraWide = lv >= num(bb.power_bond_stack_cnt, Infinity);
}
}
function raidMods(bb, lv) {
const mods = {
atkMul: 1 + num(bb.base_atk) + num(bb.atk_per_stack) * lv,
hpMul: 1 + num(bb.base_max_hp) + num(bb.max_hp_per_stack) * lv,
};
const aspd = num(bb.base_attack_speed) + num(bb.attack_speed_per_stack) * lv;
if (aspd) mods.aspd = aspd;
return mods;
}
/** Layer-independent passives (协防 / 独行 / 绝技), applied once. */
function applyStatic(battle, st) {
const t = st.tiers;
if (t[ID.empty]) {
const bb = st.bb[ID.empty];
const taken = Math.max(0, 1 - num(bb.damage_resistance));
for (const u of st.ops) {
passiveBuff(battle, u, KEY.empty, { physTakenMul: taken, artsTakenMul: taken });
if (st.members[ID.empty].has(u)) {
passiveBuff(battle, u, KEY.emptyDmg, { dmgDealtMul: num(isElite(u) ? bb.damage_scale_extra : bb.damage_scale_normal, 1) });
}
}
}
if (t[ID.solo]) {
const bb = st.bb[ID.solo];
const mods = { atkMul: 1 + num(bb.atk), hpMul: 1 + num(bb.max_hp) };
for (const u of st.members[ID.solo]) passiveBuff(battle, u, KEY.solo, mods);
}
if (t[ID.sunt]) {
const bb = st.bb[ID.sunt];
const mods = { atkMul: 1 + num(bb.power_atk) };
for (const u of st.ops) {
if (!isElite(u)) continue;
passiveBuff(battle, u, KEY.sunt, mods);
if (t[ID.sunt] >= 2) suntSpCost(u, bb);
}
}
}
function suntSpCost(u, bb) {
const sk = u && u.skill;
if (!sk || sk.noSkill || u.mem[KEY.sunt]) return;
u.mem[KEY.sunt] = true;
sk.spCostMul = sk.spCostMul * num(bb.sp_ratio, 1);
}
// =====================================================================================================================
// 灵巧 aura
function updateAura(battle, st) {
const mods = st.auraMods;
if (!mods) return;
const next = st.auraNext ?? (st.auraNext = new Set());
next.clear();
const offs = st.auraWide ? N8 : N4;
for (const m of st.members[ID.skillful]) {
if (!onField(m)) continue;
next.add(m);
for (const [dr, dc] of offs) {
const a = battle.unitAt(m.tileR + dr, m.tileC + dc);
if (a && a.kind === 'op' && a.ownerId === st.pid && onField(a)) next.add(a);
}
}
const cur = st.aura ?? (st.aura = new Set());
for (const u of cur) if (!next.has(u)) battle.removeBuff(u, KEY.skillful);
for (const u of next) {
const b = u.findBuff(KEY.skillful);
if (!b) battle.addBuff(u, { key: KEY.skillful, mods });
else if (b.mods !== mods) { b.mods = mods; u.markDirty(); }
}
st.aura = next;
st.auraNext = cur;
}
// =====================================================================================================================
// 突袭 relocation
function raidTile(battle, u, e) {
const er = Math.round(e.y), ec = Math.round(e.x);
const ek = er * COLS + ec;
const ranged = u.def?.position === 'RANGED';
const grid = u.rangeGrid || [[0, 0]];
const ext = num(u.s.rangeExtend);
let best = null, bs = null;
for (let dr = -RAID_SEARCH; dr <= RAID_SEARCH; dr++) {
for (let dc = -RAID_SEARCH; dc <= RAID_SEARCH; dc++) {
const r = er + dr, c = ec + dc;
if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c)) continue;
if (!battle.grid.canStand(r, c, { ranged })) continue;
const covers = absoluteRangeKeys(grid, r, c, u.dir, ext).includes(ek) ? 0 : 1;
const d = Math.max(Math.abs(dr), Math.abs(dc)) + 0.01 * (Math.abs(dr) + Math.abs(dc));
// last tie-break: the offset in the unit's facing-RIGHT frame (sim/dir.js localOrder; for a RIGHT-facing unit the
// plain tile-key order), so the landing tile turns with its direction
const s0 = covers, s1 = d, s2 = localOrder(dr, dc, u.dir);
if (!bs || s0 < bs[0] || (s0 === bs[0] && (s1 < bs[1] - 1e-9 || (Math.abs(s1 - bs[1]) <= 1e-9 && localBefore(s2, bs[2]))))) { best = [r, c, covers === 0]; bs = [s0, s1, s2]; }
}
}
return best; // [row, col, targetInRange]
}
function raidTargets(battle, u, pid) {
const own = [], other = [];
for (const e of battle.enemies) {
if (e.isFlying || !canTargetEnemy(u, e, { canHitFly: false })) continue;
(e.ownerId === pid ? own : other).push(e);
}
const list = own.length ? own : other;
const dist = new Map(list.map((e) => [e, num(battle.remainingDistance ? battle.remainingDistance(e) : 0)]));
list.sort((a, b) => dist.get(a) - dist.get(b) || a.id - b.id);
return list;
}
function raidPoll(battle, st) {
const bb = st.bb[ID.raid];
const idle = num(bb.no_attack_duration, 10);
for (const u of st.members[ID.raid]) {
if (!onField(u) || !u.canAct) continue;
const since = Math.max(u.lastAttackAt ?? -Infinity, u.deployedAt ?? -Infinity, u.mem[KEY.raid] ?? -Infinity);
const ready = !!(u.skill && u.skill.ready && !(u.skill.active && u.skill.isTimed));
const idleOk = battle.time - since >= idle - 1e-9;
if (!(ready || idleOk)) continue;
if (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length) continue;
const targets = raidTargets(battle, u, st.pid);
for (let i = 0; i < targets.length && i < 8; i++) {
const tile = raidTile(battle, u, targets[i]);
if (!tile) continue;
// the "skill ready" path has no idle gate: only jump where the target is in range, else a ready skill that never
// finds a target would redeploy (and fire every 部署时 effect) every poll
if (!idleOk && !tile[2]) continue;
const res = raidRedeploy(battle, u, tile[0], tile[1]);
if (!res) continue;
u.mem[KEY.raid] = battle.time;
if (res === 'raid') {
battle.addBuff(u, { key: KEY.raid, mods: st.raidMods });
fxOn(battle, 'blink', u, KEY.raid, 'relocate', { target: targets[i].id });
}
break;
}
}
}
/**
* "保留技力立即再部署": retreat the unit and redeploy it for free on (r, c) with its SP / charges kept (engine redeploy
* `tile` + `keepSp`: restored before the `deploy` handlers run; the board tile stays the home of later redeploys).
* Should the landing fail, the unit goes straight back to its home tile (never stranded off the field).
* Returns 'raid' | 'home' | false.
*/
function raidRedeploy(battle, u, r, c) {
battle.retreat(u, { reason: 'raid' });
if (u.alive) return false;
if (battle.redeploy(u, { free: true, tile: [r, c], keepSp: true })) return 'raid';
return battle.redeploy(u, { free: true, keepSp: true }) ? 'home' : false;
}
// =====================================================================================================================
// install
export function install(battle) {
if (!battle || !Array.isArray(battle.players) || !battle.players.length) return;
const states = [];
for (const p of battle.players) {
const st = makeState(battle, p);
if (Object.keys(st.tiers).length) states.push(st);
}
if (!states.length) return;
const byPid = Object.create(null);
for (const st of states) { byPid[st.pid] = st; applyPassives(battle, st); applyStatic(battle, st); }
const has = (id, tier = 1) => states.some((st) => (st.tiers[id] ?? 0) >= tier);
// live layers
if (states.some((st) => Object.keys(st.tiers).some((id) => LAYERED.has(id)))) {
battle.on('layerGain', (c) => {
const st = byPid[c.playerId];
if (!st || !st.tiers[c.bondId] || !LAYERED.has(c.bondId) || st.pending) return;
st.pending = true;
battle.after(0, () => {
st.pending = false;
applyPassives(battle, st);
if (st.tiers[ID.skillful]) updateAura(battle, st);
});
});
}
// 灵巧 aura (deploy / death immediately, relocations through a cheap poll)
if (has(ID.skillful)) {
const aura = states.filter((st) => st.tiers[ID.skillful]);
const refresh = (pid) => { for (const st of aura) if (pid == null || st.pid === pid) updateAura(battle, st); };
battle.on('battleStart', () => refresh(null));
battle.on('deploy', (c) => { if (c.unit && c.unit.kind === 'op' && byPid[c.unit.ownerId]?.tiers[ID.skillful]) refresh(c.unit.ownerId); });
battle.on('death', (c) => { if (c.unit && c.unit.kind === 'op' && byPid[c.unit.ownerId]?.tiers[ID.skillful]) refresh(c.unit.ownerId); });
battle.every(AURA_POLL, () => refresh(null));
}
// 突袭
if (has(ID.raid)) {
const raid = states.filter((st) => st.tiers[ID.raid] && st.members[ID.raid].size);
if (raid.length) {
battle.every(RAID_POLL, () => { for (const st of raid) raidPoll(battle, st); });
}
}
// 迅捷
if (has(ID.swift)) {
battle.on('skillEnd', (c) => {
const u = c.unit;
if (!u || u.kind !== 'op' || c.reason === 'death' || !u.alive || !u.skill) return;
const st = byPid[u.ownerId];
if (!st || !st.tiers[ID.swift]) return;
const bb = st.bb[ID.swift];
const lv = L(battle, st, ID.swift);
const p = prob(bb, lv);
let gained = 0;
if (st.members[ID.swift].has(u) && battle.rng() < p) gained += u.skill.gainSp(num(bb.normal_sp), 'bond');
if (lv >= num(bb.power_bond_stack_cnt, Infinity) && battle.rng() < p) gained += u.skill.gainSp(num(bb.power_sp), 'bond');
if (gained > 0) fxOn(battle, 'sp', u, 'bond:swiftShip', 'proc', { n: gained });
});
}
// 独行 (SP on every deploy) / 绝技 (summons of elites)
const soloOrSunt = has(ID.solo) || has(ID.sunt, 2);
if (soloOrSunt) {
battle.on('deploy', (c) => {
const u = c.unit;
if (!u || (u.kind !== 'op' && u.kind !== 'token')) return;
const st = byPid[u.ownerId];
if (!st) return;
if (u.kind === 'op' && st.tiers[ID.solo] && st.members[ID.solo].has(u) && u.skill) {
const n = num(st.bb[ID.solo].sp);
if (n > 0) u.skill.gainSp(n, 'init');
}
if (u.kind === 'token' && (st.tiers[ID.sunt] ?? 0) >= 2 && isElite(u.ownerUnit)) suntSpCost(u, st.bb[ID.sunt]);
});
}
// 不屈
if (has(ID.indom)) {
battle.on('death', (c) => {
const u = c.unit;
if (c.reason !== 'killed' || !isGroundOp(u)) return;
const st = byPid[u.ownerId];
if (!st || !st.tiers[ID.indom]) return;
const bb = st.bb[ID.indom];
if (st.tiers[ID.indom] >= 2) {
const sp = num(bb.sp);
if (sp > 0) for (const o of st.ops) if (onField(o) && o.skill) o.skill.gainSp(sp, 'bond');
}
if (u.alive || u.removed || u.mem[ID.indom] === battle.time) return;
if (battle.rng() < prob(bb, L(battle, st, ID.indom))) {
u.mem[ID.indom] = battle.time;
if (battle.redeploy(u, { free: true })) fxOn(battle, 'revive', u, 'bond:indomShip', 'redeploy');
}
}, { priority: 10 });
}
// 坚守 (tier 2) redirect + thorns, 奥术 vulnerability
const stead = has(ID.stead, 2);
const arcane = has(ID.arcane);
if (stead) {
battle.on('hit', (c) => {
const t = c.target, dmg = c.dmg;
if (!t || t.side !== 'ally' || t.kind !== 'op' || !dmg || dmg.cancel || dmg.steadShare || dmg.steadCut || dmg.type === 'element') return;
const st = byPid[t.ownerId];
if (!st || (st.tiers[ID.stead] ?? 0) < 2 || st.members[ID.stead].has(t)) return;
let any = false;
for (const m of st.members[ID.stead]) if (onField(m)) { any = true; break; }
if (!any) return;
const ratio = Math.max(0, Math.min(1, num(st.bb[ID.stead].damage_resistance)));
if (!(ratio > 0) || ratio >= 1) return;
dmg.mul *= 1 - ratio;
dmg.steadCut = ratio;
}, { priority: -20 });
}
if (stead || arcane) {
battle.on('damaged', (c) => {
const t = c.target, dmg = c.dmg;
if (!t || !dmg) return;
if (t.side === 'ally') {
if (!stead || t.kind !== 'op') return;
const st = byPid[t.ownerId];
if (!st || (st.tiers[ID.stead] ?? 0) < 2) return;
if (dmg.steadCut) {
const ratio = dmg.steadCut;
dmg.steadCut = 0;
const share = (num(c.amount) / (1 - ratio)) * ratio;
if (!(share > 0)) return;
const on = [];
for (const m of st.members[ID.stead]) if (onField(m)) on.push(m);
// the share is already mitigated and already carries the attacker's damage multipliers: deal it sourceless
// (the member's own damage-taken modifiers and shields still apply), never re-scaled by the attacker
for (const m of on) {
const d = battle.makeDamage({ amount: share / on.length, type: 'true', canDodge: false, tags: ['bond:stead:share'] });
d.steadShare = true;
battle.dealDamage(null, m, d);
}
return;
}
if (dmg.steadShare || dmg.steadThorn || c.type === 'element' || !st.members[ID.stead].has(t)) return;
const bb = st.bb[ID.stead];
const last = t.mem[STEAD_CD] ?? -Infinity;
if (battle.time - last < num(bb.cd_duration) - 1e-9) return;
t.mem[STEAD_CD] = battle.time;
const src = c.source;
if (!src || src.side !== 'enemy' || !src.alive) return;
const d = battle.makeDamage({ amount: num(bb.base_damage_value) + num(bb.damage_value_per_stack) * L(battle, st, ID.stead), type: 'arts', canDodge: false });
d.steadThorn = true;
battle.dealDamage(null, src, d);
if (src.alive) battle.applyStatus(src, 'fragile', { duration: num(bb['weak[limit]'], 5), value: num(bb.damage_scale, 1) - 1, source: t });
return;
}
if (!arcane || c.type !== 'arts' || t.side !== 'enemy' || !t.alive) return;
const src = c.source;
if (!src || src.kind !== 'op') return;
const st = byPid[src.ownerId];
if (!st || !st.tiers[ID.arcane] || !st.members[ID.arcane].has(src)) return;
const bb = st.bb[ID.arcane];
const low = st.tiers[ID.arcane] >= 2 && t.hpRatio < num(bb.hp_ratio);
const mods = low ? st.arcaneLowMods : st.arcaneMods;
const key = st.arcaneKey ?? (st.arcaneKey = `${KEY.arcane}:${st.pid}`);
const dur = num(bb.weak_duration, 3);
const b = t.findBuff(key);
if (b) {
b.timeLeft = dur;
b.duration = dur;
if (b.mods !== mods) { b.mods = mods; t.markDirty(); }
} else {
battle.addBuff(t, { key, duration: dur, mods });
}
});
}
}
+135
View File
@@ -0,0 +1,135 @@
// server/sim/content/bonds/addon/meta.js — prep side of the add-on bonds (research 02 §3.14–§3.17, §3.20).
//
// 助力 deputShip onPrepEnd: every currently active bond +`layer` (tier 2 = 3 助力 by name or elite state:
// +`more_layer`) — buff bond_activated_add_layer
// 远见 visiShip every `layer` layers +`count` funds (bond_layer_gain_coin); first time at `layer1`: 远见 chess cost
// −`discount`; first time at `layer2`: every chess costs −`discount` instead (latched, permanent)
// 奇迹 miraShip manual refresh with no free refresh pending → p = min(1, baseprob + prob·L) the next refresh is free;
// every 100 layers +20 funds (bond_layer_gain_coin). `probk` has no known meaning and is unused.
// 投资人 ×2 / ×3 "获得时" garrisons: EffectDispatcher.investRepeat (server/match/effectsMeta.js).
// 调和 +1 member count for core bonds: server/match/bondsMeta.js.
//
// Milestones read the persistent layers and pay only while the bond is active; payouts owed for layers gained while it
// was inactive ("无需激活" sources) are caught up the next time a hook sees it active (research 02 §8 Q7 [ASSUMED]).
// Funds earned after the prep phase ended (助力 / 寒檀 / 号角 … prep-end layers — the match wipes leftover funds right
// after onPrepEnd) are paid as pending funds, credited at the next round start, so "获得…资金" is never silently lost
// [ASSUMED]; `global:bondaddon_prepend` (first in every dispatch) marks the round whose prep has ended.
// 远见 discounts never push a price below 1 (research 02 §3.15 [ASSUMED]; a price already below 1 is left alone).
// Latched state lives in player counters (prefix `bondaddon:`); onPrice only reads them (pure).
const C_VISI_PAID = 'bondaddon:visi:paid';
const C_VISI_DISC = 'bondaddon:visi:disc';
const C_MIRA_PAID = 'bondaddon:mira:paid';
const C_PREP_ENDED = 'bondaddon:prepEndedRound';
/** Hooks on which owed milestones are caught up (besides the bond's own onLayers). */
const SETTLE_HOOKS = Object.freeze(['onRoundStart', 'onPrepStart', 'onPrepEnd', 'onBuy', 'onGain', 'onSold', 'onMerge']);
const n = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
/** `{ ...bb, ...bbStr }` of a bond buff from the match data (ctx.data), or {}. */
function buff(ctx, bondId, key) {
const rec = ctx.data && ctx.data.bonds ? ctx.data.bonds[bondId] : null;
for (const b of (rec && Array.isArray(rec.buffs) ? rec.buffs : [])) {
if (b && b.key === key) return { ...(b.bb || {}), ...(b.bbStr || {}) };
}
return {};
}
/** Pay "every `layer` layers +`count` funds" up to the current layers (only while active). */
function settleCoins(ctx, bondId, counterKey, label) {
if (!ctx.bondActive(bondId)) return 0;
const p = buff(ctx, bondId, 'bond_layer_gain_coin');
const step = n(p.layer), count = n(p.count);
if (!(step > 0) || !(count > 0)) return 0;
const due = Math.floor(ctx.layers(bondId) / step);
const paid = ctx.counter(counterKey);
if (due <= paid) return 0;
ctx.setCounter(counterKey, due);
const gain = (due - paid) * count;
if (prepEnded(ctx)) {
ctx.addPendingFunds(gain);
ctx.toast(`【${label}】层数达成,下回合开始时获得${gain}资金`, 'info');
} else {
ctx.addFunds(gain, `bond:${bondId}`);
ctx.toast(`【${label}】层数达成,获得${gain}资金`, 'info');
}
return gain;
}
/** True between this round's onPrepEnd and the end of its PREP phase (leftover funds are about to be wiped). */
const prepEnded = (ctx) => ctx.phase === 'PREP' && ctx.counter(C_PREP_ENDED) === ctx.round;
function latchVisiDiscount(ctx) {
if (!ctx.bondActive('visiShip')) return;
const p = buff(ctx, 'visiShip', 'bond_multi_layer_char_goods_price_bond_discount');
const L = ctx.layers('visiShip');
const cur = ctx.counter(C_VISI_DISC);
const t = L >= n(p.layer2, Infinity) ? 2 : L >= n(p.layer1, Infinity) ? 1 : 0;
if (t > cur) {
ctx.setCounter(C_VISI_DISC, t);
ctx.toast(t >= 2 ? '【远见】所有干员购买价格永久降低' : '【远见】远见干员购买价格永久降低', 'info');
}
}
function visiSettle(ctx) {
settleCoins(ctx, 'visiShip', C_VISI_PAID, '远见');
latchVisiDiscount(ctx);
}
const miraSettle = (ctx) => { settleCoins(ctx, 'miraShip', C_MIRA_PAID, '奇迹'); };
/** A handler object running `fn(ctx)` on every catch-up hook and on the bond's own layer gains. */
function settler(bondId, fn) {
const h = { onLayers(ctx, ev) { if (ev && ev.bondId === bondId) fn(ctx); } };
for (const hook of SETTLE_HOOKS) h[hook] = (ctx) => fn(ctx);
return h;
}
export function registerMeta(registry) {
registry.global('bondaddon_prepend', {
onPrepEnd(ctx) { ctx.setCounter(C_PREP_ENDED, ctx.round); },
});
registry.bond('deputShip', {
onPrepEnd(ctx) {
const me = ctx.bond('deputShip');
if (!me || !me.active) return;
const p = buff(ctx, 'deputShip', 'bond_activated_add_layer');
const add = me.tier >= 2 ? n(p.more_layer, n(p.layer)) : n(p.layer);
if (!(add > 0)) return;
const active = Object.entries(ctx.bonds()).filter(([, b]) => b && b.active).map(([id]) => id);
for (const id of active) ctx.addLayers(id, add, { requireActive: true, reason: 'bond:deputShip' });
},
});
registry.bond('visiShip', {
...settler('visiShip', visiSettle),
onPrice(ctx, ev) {
if (!ev || ev.kind !== 'chess') return;
const t = ctx.counter(C_VISI_DISC);
if (t <= 0) return;
const p = buff(ctx, 'visiShip', 'bond_multi_layer_char_goods_price_bond_discount');
const disc = n(p.discount);
if (!(disc > 0)) return;
if (t < 2) {
const rec = ctx.chessRecord(ev.id);
if (!rec || !Array.isArray(rec.bonds) || !rec.bonds.includes(p.bond || 'visiShip')) return;
}
const price = n(ev.price);
if (price > 1) ctx.modifyPrice(-Math.min(disc, price - 1));
},
});
registry.bond('miraShip', {
...settler('miraShip', miraSettle),
onRefresh(ctx, ev) {
if (!ev || ev.trigger || !ctx.bondActive('miraShip')) return;
if (ctx.grantFreeRefresh(0) > 0) return;
const p = buff(ctx, 'miraShip', 'bond_refresh_shop_next_free');
const chance = Math.max(0, Math.min(1, n(p.baseprob) + n(p.prob) * ctx.layers('miraShip')));
if (ctx.rng() < chance) {
ctx.grantFreeRefresh(1);
ctx.toast('【奇迹】下次刷新不消耗资金', 'info');
}
},
});
}
+577
View File
@@ -0,0 +1,577 @@
// server/sim/content/bonds/core.js — the 8 core bonds (核心盟约, research 02 §3.1–3.8), battle side + prep side.
//
// 炎 yanShip members ATK ×(1 + base_atk + atk_per_stack·L); 6: one “炎佑” (30 % of the 炎 ATK / max HP sums
// at battle start); 9: two 炎佑, ATK ×atk, damage taken ×(1 − damage_resistance)
// 萨尔贡 sargonShip member skill start → every member on the field gets an independent stack (ASPD +base_attack_speed
// for base_time + time_per_stack·L s, ≤ max_buff_stack_cnt); 6: each stack also ATK +base_atk
// (additive, one atkMul); 6 + band_narant: instead lends the caster's equipment (tier ≤
// filter_item_level) to the 8 surrounding operators (items.js lendItemEffects)
// 维多利亚 victoriaShip members carrying equipment deal ×(base_damage_scale + damage_scale_per_stack·L);
// 6: ATK +atk_normal_equip per item (+atk_golden_equip more per golden item);
// prep: every `layer` layers → `count` item(s) from `pool` (bond_layer_added_reward_equip)
// 谢拉格 kjeragShip members deal ×base_damage_scale, vs cold / frozen enemies ×(base_ex_damage_scale +
// ex_damage_scale_per_stack·L) instead; 6: cold wind (devices.js kjeragColdWind)
// 拉特兰 lateranoShip member ammo skills start with floor(ammo × (1 + base_ammo_percent + ammo_percent_per_stack·L));
// 6: every ammo used by a member → all members ATK +atk_per_consume (≤ max_atk_for_consume)
// 阿戈尔 egirShip members max HP ×(1 + base_max_hp + max_hp_per_stack·L); battle start devour (see devour());
// 5: the first max_free_respawn_cnt members knocked out for the first time redeploy at once (free)
// 叙拉古 siracusaShip every member deployment: ASPD +(base + per·L) for (base_duration + per·L) s; 6: 隐匿 for the same
// time, and while hidden / end_duration s after, attacks proc (PRD, nominal `prob`) base_damage +
// damage_per_stack·L true damage + fear `fear` s
// 卡西米尔 kazimierzShip every deployment of one of the player's operators (initial ones included): members ATK
// +atk_when_born, total ≤ base_max_atk_when_born + max_atk_when_born_per_stack·L; 6: blocking
// members pulse damage_atk_scale×ATK true damage + stun around them every damage_interval s,
// non-blocking members' attacks add pure_atk_scale×ATK true damage
//
// Numbers: data/bonds.json buffs (support.buffParams by bbStr.key); 炎佑's 30 % is parsed from the effect text.
// Tiers: a bond's upper tier is reached when count ≥ the blackboard threshold (power_bond_char_cnt / ex_bond_char_cnt)
// or the bond's tier index says so. Members = support.isMember (own bonds, 变形同构体, 调和 enjoying core bonds).
// Live layers: effects that read L at a moment (stack duration, ammo, deploy buffs, proc damage, cold multiplier) read
// the live value; standing buffs (炎 ATK, 维多利亚, 阿戈尔 HP, 卡西米尔 cap) are refreshed one tick after a `layerGain`.
// Two players in one field: everything is per player (own members, own deployments); the 谢拉格 wind and 炎佑 act on
// every enemy of the field (debuffs on shared enemies help both, research 06 §8.6).
// Hook priorities: 拉特兰 skillStart −10 (after kits' own ammo changes), 阿戈尔 death 11 (5-tier revive, before 不屈's
// death 10), layerGain −100 (after 魔王-style modifiers).
import * as S from '../support/index.js';
import { spawnYanyou } from '../tokens.js';
import { kjeragColdWind } from '../devices.js';
import * as items from '../items.js';
const num = S.num;
/** Injection point for tests (lendItemEffects stub); null ⇒ items.js export. */
export const deps = { lendItemEffects: null };
const BUFF_KEYS = Object.freeze({
yanShip: 'act1autochess_bond_eff_yan',
sargonShip: 'act1autochess_bond_eff_sargon',
victoriaShip: 'act1autochess_bond_eff_victoria',
kjeragShip: 'act1autochess_bond_eff_kjerag',
lateranoShip: 'act1autochess_bond_eff_laterano',
egirShip: 'act1autochess_bond_eff_egir',
siracusaShip: 'act2autochess_bond_eff_siracusa',
kazimierzShip: 'act2autochess_bond_eff_kazimierz',
});
const SARGON_SHARE_KEY = 'act2autochess_bond_eff_sargon[share]';
const VICTORIA_REWARD_KEY = 'bond_layer_added_reward_equip';
const VICTORIA_COUNTER = 'bond:victoria:hammers';
/** Bond effect blackboard `{ ...bb, ...bbStr }` of a core bond (null when the data record is missing). */
export function bondBb(bondId, key = BUFF_KEYS[bondId]) {
return S.buffParams(S.bondRecord(bondId), key);
}
/** Has player `pid` reached the tier of `bondId` that needs `cnt` members? (active + count / tier index) */
export function reached(battle, pid, bondId, cnt) {
const st = S.bondState(battle, pid, bondId);
if (!st || !st.active) return false;
const need = num(cnt, 0);
if (!(need > 0)) return true;
if (num(st.count, 0) >= need) return true;
const th = S.bondRecord(bondId)?.thresholds;
const idx = Array.isArray(th) ? th.indexOf(need) : -1;
return idx >= 0 && S.bondTier(battle, pid, bondId) >= idx + 1;
}
/** "总和的30%" → 0.3 (炎佑 stat share, text only). */
export function yanyouShare() {
const text = String(S.bondRecord('yanShip')?.effectDesc ?? S.bondRecord('yanShip')?.desc ?? '');
const m = /总和的\s*(\d+(?:\.\d+)?)\s*%/.exec(text);
const v = m ? +m[1] / 100 : NaN;
return v > 0 && v <= 10 ? v : 0.3;
}
const PRD = new Map();
/**
* Pseudo-random-distribution constant C for a nominal probability p: attempt n since the last proc succeeds with
* min(1, C·n), and the long-run proc rate equals p (3 % → C ≈ 0.00139, research 02 §3.7).
*/
export function prdConstant(p) {
if (!(p > 0)) return 0;
if (p >= 1) return 1;
if (PRD.has(p)) return PRD.get(p);
const rate = (c) => {
let exp = 0, notYet = 1;
const nMax = Math.ceil(1 / c) + 1;
for (let n = 1; n <= nMax && notYet > 0; n++) {
const pn = Math.min(1, n * c);
exp += n * notYet * pn;
notYet *= 1 - pn;
}
return exp > 0 ? 1 / exp : 1;
};
let lo = 0, hi = p;
for (let i = 0; i < 50; i++) {
const mid = (lo + hi) / 2;
if (rate(mid) < p) lo = mid; else hi = mid;
}
const c = (lo + hi) / 2;
PRD.set(p, c);
return c;
}
// =====================================================================================================================
// per-battle plumbing
/** Shared per-battle state: layer refreshers, one hit / damaged / skillStart dispatch per battle. */
function core(battle) {
return S.battleStore(battle, 'bonds:core', () => ({
refreshers: new Map(), // `${pid}|${bondId}` → [fn]
pending: new Set(),
hitFns: new Map(), // source unit → [fn(ctx)]
dmgFns: new Map(), // source unit → [fn(ctx)]
hooked: new Set(),
}));
}
function onLayers(battle, pid, bondId, fn) {
const st = core(battle);
const k = `${pid}|${bondId}`;
if (!st.refreshers.has(k)) st.refreshers.set(k, []);
st.refreshers.get(k).push(fn);
if (st.hooked.has('layerGain')) return;
st.hooked.add('layerGain');
battle.on('layerGain', (c) => {
const key = `${c.playerId}|${c.bondId}`;
const list = st.refreshers.get(key);
if (!list || st.pending.has(key)) return;
st.pending.add(key);
// the gain is recorded after the hook: re-read the live layers next tick
battle.after(0, () => { st.pending.delete(key); for (const f of list) f(); });
}, { priority: -100 });
}
function onUnitHit(battle, unit, fn) {
const st = core(battle);
if (!st.hitFns.has(unit)) st.hitFns.set(unit, []);
st.hitFns.get(unit).push(fn);
if (st.hooked.has('hit')) return;
st.hooked.add('hit');
battle.on('hit', (c) => {
const list = c.source ? st.hitFns.get(c.source) : undefined;
if (list) for (let i = 0; i < list.length; i++) list[i](c);
});
}
function onUnitDamaged(battle, unit, fn) {
const st = core(battle);
if (!st.dmgFns.has(unit)) st.dmgFns.set(unit, []);
st.dmgFns.get(unit).push(fn);
if (st.hooked.has('damaged')) return;
st.hooked.add('damaged');
battle.on('damaged', (c) => {
const list = c.source ? st.dmgFns.get(c.source) : undefined;
if (list) for (let i = 0; i < list.length; i++) list[i](c);
});
}
const isEnemyTarget = (t) => !!t && t.side === 'enemy' && t.alive;
const ownTag = (dmg, tag) => !!dmg && Array.isArray(dmg.tags) && dmg.tags.includes(tag);
// =====================================================================================================================
// 炎
function installYan(battle, pid, bb, members) {
const L = () => S.bondLayers(battle, pid, 'yanShip');
const apply = () => {
const mul = 1 + num(bb.base_atk) + num(bb.atk_per_stack) * L();
for (const u of members) S.passiveBuff(battle, u, 'bond:yan', { atkMul: mul });
};
apply();
onLayers(battle, pid, 'yanShip', apply);
if (!reached(battle, pid, 'yanShip', bb.power_bond_char_cnt)) return;
const ex = reached(battle, pid, 'yanShip', bb.ex_bond_char_cnt);
battle.on('battleStart', () => {
// PRTS: the sums count real 炎 operators only (调和 operators' stats are not collected unless they are 炎 too)
let atk = 0, hp = 0;
for (const u of S.playerOps(battle, pid, { fieldOnly: true })) {
if (!S.unitBonds(u).includes('yanShip')) continue;
atk += num(u.s.atk, 0);
hp += num(u.s.maxHp, 0);
}
if (!(hp > 0)) return;
const share = yanyouShare();
const out = spawnYanyou(battle, pid, {
atk: atk * share, hp: hp * share, count: ex ? 2 : 1,
atkMul: ex ? num(bb.atk, 1) : 1,
dmgTakenMul: ex ? Math.max(0, 1 - num(bb.damage_resistance, 0)) : 1,
});
for (const y of out) y.mem.bondYan = true;
}, { once: true });
}
// =====================================================================================================================
// 萨尔贡
function installSargon(battle, pid, bb, members) {
const memberSet = new Set(members);
const ps = S.player(battle, pid);
const share = S.buffParams(S.bondRecord('sargonShip'), SARGON_SHARE_KEY);
const band = ps?.bandId ?? null;
const narant = !!(share && band && share.valid_in_band === band);
const main = narant ? share : bb;
const six = reached(battle, pid, 'sargonShip', num(main.power_bond_char_cnt, 6));
const atkStacks = six && !narant && !(bb.invalid_in_band && bb.invalid_in_band === band);
const maxStacks = Math.max(1, Math.floor(num(main.max_buff_stack_cnt, 25)));
const perAtk = num(bb.base_atk, 0);
const syncAtk = (u) => {
if (!atkStacks) return;
let n = 0;
for (const b of u.buffs) if (b.key === 'bond:sargon') n++;
if (n > 0 && u.alive) battle.addBuff(u, { key: 'bond:sargon:atk', mods: { atkMul: 1 + perAtk * n } });
else battle.removeBuff(u, 'bond:sargon:atk');
};
battle.on('skillStart', ({ unit }) => {
if (!memberSet.has(unit)) return;
const L = S.bondLayers(battle, pid, 'sargonShip');
const dur = num(main.base_time, 0) + num(main.time_per_stack, 0) * L;
if (dur > 0) {
for (const m of members) {
if (!S.onField(m)) continue;
battle.addBuff(m, {
key: 'bond:sargon', duration: dur, refresh: 'independent', maxStacks,
mods: { aspd: num(main.base_attack_speed, 0) },
onExpire: ({ unit: u }) => syncAtk(u), onRemove: ({ unit: u }) => syncAtk(u),
});
syncAtk(m);
}
}
if (narant && six) lendAround(battle, unit, share, S.bondLayers(battle, pid, 'sargonShip'));
});
}
function lendAround(battle, from, share, L) {
if (!S.itemsOf(from).length) return;
const lend = typeof deps.lendItemEffects === 'function' ? deps.lendItemEffects
: typeof items.lendItemEffects === 'function' ? items.lendItemEffects : null;
if (!lend) return;
const duration = num(share.base_power_time, 0) + num(share.power_time_per_stack, 0) * L;
if (!(duration > 0)) return;
const maxTier = num(share.filter_item_level, 5);
let n = 0;
for (const a of S.alliesAround(battle, from, S.N8)) {
if (!S.isOp(a)) continue;
lend(battle, from, a, { maxTier, duration });
n++;
}
if (n) S.fxOn(battle, 'bondShare', from, 'bond:sargonShip', 'narant', { n, duration });
}
// =====================================================================================================================
// 维多利亚
function installVictoria(battle, pid, bb, members) {
const apply = () => {
const mul = num(bb.base_damage_scale, 1) + num(bb.damage_scale_per_stack, 0) * S.bondLayers(battle, pid, 'victoriaShip');
for (const u of members) {
if (S.itemsOf(u).length >= 1) S.passiveBuff(battle, u, 'bond:victoria', { dmgDealtMul: mul });
}
};
apply();
onLayers(battle, pid, 'victoriaShip', apply);
if (!reached(battle, pid, 'victoriaShip', bb.power_bond_char_cnt)) return;
const normal = num(bb.atk_normal_equip, 0), golden = normal + num(bb.atk_golden_equip, 0);
for (const u of members) {
const ids = S.itemsOf(u);
if (!ids.length) continue;
let add = 0;
for (const id of ids) add += S.isGoldenId(id) ? golden : normal;
if (add > 0) S.passiveBuff(battle, u, 'bond:victoria:atk', { atkMul: 1 + add });
}
}
// =====================================================================================================================
// 谢拉格
function installKjerag(battle, pid, bb, members) {
const base = num(bb.base_damage_scale, 1);
for (const u of members) S.passiveBuff(battle, u, 'bond:kjerag', { dmgDealtMul: base });
const ex = num(bb.base_ex_damage_scale, base), per = num(bb.ex_damage_scale_per_stack, 0);
const onHit = (c) => {
const t = c.target;
if (!isEnemyTarget(t) || !c.dmg) return;
const f = t.s.flags;
if (!f.cold && !f.freeze) return;
const target = ex + per * S.bondLayers(battle, pid, 'kjeragShip');
if (base > 0 && target > 0) c.dmg.mul *= target / base;
};
for (const u of members) onUnitHit(battle, u, onHit);
if (reached(battle, pid, 'kjeragShip', bb.power_bond_char_cnt)) kjeragColdWind(battle, pid, bb);
}
// =====================================================================================================================
// 拉特兰
function installLaterano(battle, pid, bb, members) {
const memberSet = new Set(members);
battle.on('skillStart', ({ unit, skill }) => {
if (!memberSet.has(unit) || !skill || skill.kind !== 'ammo' || !(skill.ammoLeft > 0)) return;
const mul = 1 + num(bb.base_ammo_percent, 0) + num(bb.ammo_percent_per_stack, 0) * S.bondLayers(battle, pid, 'lateranoShip');
const n = Math.floor(skill.ammoLeft * mul + 1e-9);
if (Number.isFinite(n) && n > skill.ammoLeft) skill.ammoLeft = n;
}, { priority: -10 });
if (!reached(battle, pid, 'lateranoShip', bb.power_bond_char_cnt)) return;
const per = num(bb.atk_per_consume, 0), cap = num(bb.max_atk_for_consume, Infinity);
if (!(per > 0)) return;
const st = { used: 0, bonus: 0 };
battle.on('ammoUsed', ({ unit }) => {
if (!memberSet.has(unit) || st.bonus >= cap) return;
st.used++;
st.bonus = Math.min(cap, st.used * per);
for (const m of members) S.passiveBuff(battle, m, 'bond:laterano:ammo', { atkMul: 1 + st.bonus });
});
}
// =====================================================================================================================
// 阿戈尔
/**
* Battle-start devour (research 02 §3.6 algorithm): members in order (further left on the player's own board — the
* Final Assault right side is mirrored, so its board-left is the field's right — then higher on the board first) mark
* the operator on the tile in front of them (one step along each member's own direction `dir`), and through marked
* members the tiles in front of
* those (chain); never themselves, a unit already marked by them or a unit that marked them. The marker gains the base
* ATK (atkFlat) and block count of everything it marked; then each mark makes its target lose damage_value HP as a
* 流失 (battle.loseHp: no DEF, shields, dodge or damage multipliers — research "5000-point physical 流失"; the kill is
* credited to the marker) in marking order. A unit that died cancels its remaining marks (as target and as marker).
* Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields).
* Tokens / devices / empty tiles are never devoured.
*/
function devour(battle, pid, bb, members) {
const memberSet = new Set(members);
// "更靠左和靠上": left first (on the player's own board: mirrored players count from the field's right), then top
// first — row 0 is the BOTTOM row (DESIGN §3), so the top of the board is the highest row index. The order is a
// board position, independent of the members' directions (only "身前" follows each member's `dir`).
const boardCol = (u) => (u.player && u.player.mirror ? -u.tileC : u.tileC);
const order = members.filter(S.onField).sort((a, b) =>
boardCol(a) - boardCol(b) || b.tileR - a.tileR || a.id - b.id);
const opAt = (u) => {
const [r, c] = S.frontTile(u);
const a = S.allyAt(battle, r, c, pid);
return a && S.isOp(a) && a.alive ? a : null;
};
const markedBy = new Map(); // marker → [targets]
const marks = [];
for (const m of order) {
const mine = [];
const seen = new Set([m]);
const queue = [m];
for (let guard = 0; queue.length && guard < 64; guard++) {
const x = queue.shift();
const t = opAt(x);
if (!t || seen.has(t)) continue;
if ((markedBy.get(t) ?? []).includes(m)) continue;
seen.add(t);
mine.push(t);
if (memberSet.has(t)) queue.push(t);
}
markedBy.set(m, mine);
if (!mine.length) continue;
let atk = 0, block = 0;
for (const t of mine) { atk += num(t.base.atk, 0); block += num(t.base.blockCnt, 0); marks.push([m, t]); }
const mods = {};
if (atk > 0) mods.atkFlat = atk;
if (block > 0) mods.blockCnt = block;
if (Object.keys(mods).length) S.passiveBuff(battle, m, 'bond:egir:devour', mods);
}
const amount = num(bb.damage_value, 0);
const layered = new Set();
for (const [m, t] of marks) {
if (!t.alive || !m.alive) continue;
S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id });
if (amount > 0) battle.loseHp(t, amount, { source: m, tags: ['bond:egir:devour'] });
if (!layered.has(t)) {
layered.add(t);
S.gainLayers(battle, { playerId: pid, bonds: 'egirShip', n: S.tierOf(t), source: m, reason: 'bond' });
}
}
}
function installEgir(battle, pid, bb, members) {
const apply = () => {
const mul = 1 + num(bb.base_max_hp, 0) + num(bb.max_hp_per_stack, 0) * S.bondLayers(battle, pid, 'egirShip');
for (const u of members) S.passiveBuff(battle, u, 'bond:egir', { hpMul: mul });
};
apply();
onLayers(battle, pid, 'egirShip', apply);
battle.on('battleStart', () => devour(battle, pid, bb, members), { once: true });
if (!reached(battle, pid, 'egirShip', bb.power_bond_char_cnt)) return;
// 5: "前3名【阿戈尔】干员首次被击倒时立刻复活" — PRTS: the knocked-out unit's next deployment has 0 redeploy time and
// 0 cost, i.e. it IS knocked out (被击倒 triggers, 克莱门莎, 幽灵鲨 … fire) and redeploys at once on the tile it was
// knocked out on (engine redeploy `tile`: a raid-relocated member comes back where it fell, later redeploys use its
// board tile) with full HP, SP reset and `deploy` effects (卡西米尔 / 叙拉古). Death priority 11: before 不屈 (10),
// whose redeploy "also consumes a 复活 charge" — with this order the charge is always the one used, same outcome.
const memberSet = new Set(members);
const max = Math.max(0, Math.floor(num(bb.max_free_respawn_cnt, 0)));
const st = { knocked: new Set(), revives: 0 };
battle.on('death', (c) => {
const u = c.unit;
if (c.reason !== 'killed' || !memberSet.has(u) || st.knocked.has(u)) return;
st.knocked.add(u);
if (st.revives >= max || u.alive || u.removed) return;
if (!battle.redeploy(u, { free: true, tile: [u.tileR, u.tileC] }) && !battle.redeploy(u, { free: true })) return;
st.revives++;
S.fxOn(battle, 'revive', u, 'bond:egirShip', 'respawn', { n: st.revives });
}, { priority: 11 });
}
// =====================================================================================================================
// 叙拉古
function installSiracusa(battle, pid, bb, members) {
const memberSet = new Set(members);
const six = reached(battle, pid, 'siracusaShip', bb.power_bond_char_cnt);
const end = num(bb.end_duration, 0);
battle.on('deploy', ({ unit }) => {
if (!memberSet.has(unit)) return;
const L = S.bondLayers(battle, pid, 'siracusaShip');
const dur = num(bb.base_duration, 0) + num(bb.duration_per_stack, 0) * L;
if (!(dur > 0)) return;
battle.addBuff(unit, { key: 'bond:siracusa', duration: dur, mods: { aspd: num(bb.base_attack_speed, 0) + num(bb.attack_speed_per_stack, 0) * L } });
if (!six) return;
unit.mem.siraStealthEnd = Infinity;
const off = ({ unit: u }) => { u.mem.siraStealthEnd = battle.time; };
battle.addBuff(unit, { key: 'bond:siracusa:stealth', duration: dur, flags: { stealth: true }, status: 'stealth', onExpire: off, onRemove: off });
});
if (!six) return;
const c = prdConstant(num(bb.prob, 0));
if (!(c > 0)) return;
const st = { n: 0 };
const onDmg = (ctx) => {
const u = ctx.source, t = ctx.target, dmg = ctx.dmg;
if (!dmg || !dmg.isAttack || dmg.isSplash || !isEnemyTarget(t) || ownTag(dmg, 'bond:siracusa')) return;
const hidden = u.s.flags.stealth || battle.time <= num(u.mem.siraStealthEnd, -Infinity) + end + 1e-9;
if (!hidden) return;
st.n++;
if (!(battle.rng() < Math.min(1, c * st.n))) return;
st.n = 0;
const amount = num(bb.base_damage, 0) + num(bb.damage_per_stack, 0) * S.bondLayers(battle, pid, 'siracusaShip');
S.fxOn(battle, 'bondProc', t, 'bond:siracusaShip', 'assassin', { from: u.id, amount });
if (amount > 0) battle.dealDamage(u, t, { amount, type: 'true', canDodge: false, tags: ['bond:siracusa'] });
if (t.alive && num(bb.fear, 0) > 0) battle.applyStatus(t, 'fear', { duration: num(bb.fear, 0), source: u });
};
for (const u of members) onUnitDamaged(battle, u, onDmg);
}
// =====================================================================================================================
// 卡西米尔
function installKazimierz(battle, pid, bb, members) {
const st = { deploys: 0 };
const apply = () => {
const cap = num(bb.base_max_atk_when_born, 0) + num(bb.max_atk_when_born_per_stack, 0) * S.bondLayers(battle, pid, 'kazimierzShip');
const bonus = Math.max(0, Math.min(cap, st.deploys * num(bb.atk_when_born, 0)));
if (!(bonus > 0)) return;
for (const u of members) S.passiveBuff(battle, u, 'bond:kazimierz', { atkMul: 1 + bonus });
};
battle.on('deploy', ({ unit }) => {
if (!S.isOp(unit) || unit.ownerId !== pid) return;
st.deploys++;
apply();
});
onLayers(battle, pid, 'kazimierzShip', apply);
if (!reached(battle, pid, 'kazimierzShip', bb.power_bond_char_cnt)) return;
const iv = num(bb.damage_interval, 0), radius = num(bb.range_radius, 0), scale = num(bb.damage_atk_scale, 0), stun = num(bb.stun, 0);
if (iv > 0 && scale > 0) {
battle.every(iv, () => {
for (const m of members) {
if (!S.onField(m) || !m.blocking || !m.blocking.length) continue;
const hits = battle.enemiesInRadius(m.x, m.y, radius);
if (!hits.length) continue;
const amount = scale * m.s.atk;
for (const e of hits) {
if (!e.alive) continue;
battle.dealDamage(m, e, { amount, type: 'true', canDodge: false, isSplash: true, tags: ['bond:kazimierz'] });
if (e.alive && stun > 0) battle.applyStatus(e, 'stun', { duration: stun, source: m });
}
S.fxOn(battle, 'aoe', m, 'bond:kazimierzShip', 'pulse', { radius, dmgType: 'true', n: hits.length });
}
});
}
const pure = num(bb.pure_atk_scale, 0);
if (!(pure > 0)) return;
const onDmg = (ctx) => {
const u = ctx.source, t = ctx.target, dmg = ctx.dmg;
if (!dmg || !dmg.isAttack || !isEnemyTarget(t) || ownTag(dmg, 'bond:kazimierz')) return;
if (u.blocking && u.blocking.length) return;
battle.dealDamage(u, t, { amount: pure * u.s.atk, type: 'true', canDodge: false, tags: ['bond:kazimierz'] });
};
for (const u of members) onUnitDamaged(battle, u, onDmg);
}
// =====================================================================================================================
// entry points
const INSTALLERS = Object.freeze({
yanShip: installYan,
sargonShip: installSargon,
victoriaShip: installVictoria,
kjeragShip: installKjerag,
lateranoShip: installLaterano,
egirShip: installEgir,
siracusaShip: installSiracusa,
kazimierzShip: installKazimierz,
});
export function install(battle) {
if (!battle || !Array.isArray(battle.players)) return;
for (const p of battle.players) {
const pid = p.playerId;
if (!p.bonds || typeof p.bonds !== 'object') continue;
for (const [bondId, fn] of Object.entries(INSTALLERS)) {
if (!S.bondActive(battle, pid, bondId)) continue;
const bb = bondBb(bondId);
if (!bb) continue;
const members = S.bondMembers(battle, pid, bondId);
if (!members.length) continue;
fn(battle, pid, bb, members);
}
}
}
/**
* 维多利亚 hammer milestones: every `layer` layers pay `count` item(s) of `pool` once (counter = milestones paid).
* A bond effect, so it pays only while 维多利亚 is active (official tip "叠加的盟约…只有在盟约被激活时才会发挥作用");
* milestones crossed while it was inactive ("无需激活" sources such as 烛煌) are paid the next time a hook sees it active
* (research 02 §3.3 / §8 Q7: the payout counts layers added while inactive) — same rule as 远见 / 奇迹 (addon/meta.js).
*/
export function payHammers(ctx) {
if (!ctx.bondActive('victoriaShip')) return 0;
const p = S.buffParams(ctx.data?.bonds?.victoriaShip ?? S.bondRecord('victoriaShip'), VICTORIA_REWARD_KEY);
const step = Math.floor(num(p?.layer, 0));
if (!p || !(step > 0)) return 0;
const due = Math.floor(ctx.layers('victoriaShip') / step);
const count = Math.max(0, Math.floor(num(p.count, 1)));
let granted = 0;
// re-read the counter every milestone: grantItem dispatches onGain, which re-enters this function (nested payments
// must not be paid twice by the outer loop)
for (let guard = 0; guard < 100; guard++) {
const paid = ctx.counter(VICTORIA_COUNTER);
if (paid >= due) break;
ctx.setCounter(VICTORIA_COUNTER, paid + 1);
for (let i = 0; i < count; i++) {
const id = ctx.rollItem({ pool: p.pool });
if (id && ctx.grantItem(id, { source: 'bond:victoriaShip' })) granted++;
}
}
if (granted && typeof ctx.toast === 'function') ctx.toast(`【维多利亚】获得${granted}件维式重锤`, 'info');
return granted;
}
/** Hooks on which owed hammers are (caught up and) paid; onLayers only for 维多利亚's own gains. */
const HAMMER_HOOKS = Object.freeze(['onLayers', 'onRoundStart', 'onPrepStart', 'onPrepEnd', 'onBuy', 'onGain', 'onSold', 'onMerge']);
export function registerMeta(registry) {
const prev = typeof registry.get === 'function' ? registry.get('bond:victoriaShip') : null;
const handler = { ...(prev || {}) };
for (const hook of HAMMER_HOOKS) {
const before = prev && typeof prev[hook] === 'function' ? prev[hook] : null;
handler[hook] = function hammers(ctx, ev) {
if (before) before.call(this, ctx, ev);
if (hook === 'onLayers' && !(ev && ev.bondId === 'victoriaShip')) return;
payHammers(ctx);
};
}
registry.bond('victoriaShip', handler);
}
+990
View File
@@ -0,0 +1,990 @@
// server/sim/content/bosses.js — scripted leaders (boss_1…boss_10) and their parts (DESIGN §7, research 05 §5, 06 §10.1).
//
// install(battle) ensures the enemy dispatch hooks (enemies.js) and attaches a boss kit to every spawned leader / part
// listed in BOSS_KITS. Damage dealt TO a leader routes to the shared boss pool through the engine (tag 'boss' +
// sharedBoss); parts that "传递伤害" push a share of the damage they take into the pool with battle.loseHp(leader, …)
// credited to the attacker. Numbers come from the (template-overridden) talent / skill blackboards; the rest are named
// constants ([ASSUMED] or PRTS-sourced). Boss HP rules ("<20 %", "≤50 %") read the leader's HP ratio, which the engine
// syncs from the shared pool, so both mirrored copies switch phase together.
//
// Wave template: branches / extraRoutes / devices / patrol steps are read from the template the battle was built
// from — `opts.waveTemplate|template|templateId|waveId` when the match passes it, else inferred from data/waves.json
// (the template whose leader spawn matches and whose route list equals battle.routes). Without a template the
// fallbacks spawn summons at the leader and route them to the nearest goal. Branch spawns keep their literal key
// unless the match passes the round's placeholder replacements as `opts.slotKeys` ({ NF: key, N: key, … }).
// · patrol: a leader / part walking `patrol` steps loops them (never reaches the goal: 卢西恩 lpr 30, 铳, 碎铳之簧).
// · 'boss_battle_multi_player' (铳, 卢西恩): with ≥2 players on the field the mirrored copy is spawned from the branch
// (skipped if the match already spawned a second copy, or opts.bossMirror === false).
//
// Leaders (see each kit for details):
// boss_1/8 假想敌:胄 random arts beam (range 8); 灭顶之灾 刺胄之弹 at the highest-ATK operator (8-hit flying shell,
// 3×3 stun + phys DoT on arrival); <20 %: damage ×0.5 + 2 shells; 死亡集群 drones — a drone
// killed by an operator costs the leader 2 % max HP. boss_8 adds 斩胄之剑 / 破胄之锤.
// 斩胄之剑 / 破胄之锤 hover invulnerable next to 胄, AoE attacks; dive at the lowest-ATK operator (3×3 stun + DoT);
// 15 hits during the dive shoot it down: grounded 20 s, damage ×1.3, PART_TRANSFER to 胄.
// boss_2/9 假想敌:铳 unblockable; highest-DEF target in range; erosion; ASPD ramp (+80 × 5) on the same target
// (floor 20); 最终之罚 charge at the highest-DEF ground unit (disabled by the h07_02 override).
// boss_9: damage ×0.2 while springs live, 盲信之誓 links (100 phys/s on the lines), 末日布道 dash.
// 碎铳之簧 a/b/c arts barrier (absorbs arts after RES; phys ×0.1, counter phys + erosion) / element shield
// (phys+arts ×0.1, broken by its own element burst; every (spCost+1)-th attack a bouncing erosion
// shot) / 5-hit shield (every (spCost+1)-th attack a ten-hit combo); unblockable while shielded;
// shield back 25 s after breaking; PART_TRANSFER of damage to 铳 and, split, to the other springs.
// boss_3/10 假想敌:管 summons 余音 (dark) every 40 s (20 s < 50 %); normal attacks strike dark 余音 (their pulse hurts
// operators); 裂管之奏 3 strikes on every dark 余音 (AoE arts + apoptosis). boss_10 + 假想敌:弦
// (invulnerable, gold 余音, 断弦之奏 AoE + flips them dark; leaves with 管).
// 余音 10-hit unit, block ≥ 2 only; gold (ASPD +50) / dark (ATK +50 %, slower) forms that swap after
// 10 strikes; pulses on every hit taken; 合奏 every 5 s.
// boss_4 盐风主教昆图斯 2 highest-DEF targets (+ neural); 3 growth stages (33 % HP lost or 75 s / 200 s): 大潮 global
// arts + neural, 崩坍 delayed strikes on 2/4/8 highest-DEF units, 断裂生殖 tentacles (stun),
// 物种爆发 LP loss (stage 3).
// boss_5 卢西恩 evade 40 % while unblocked, DEF pen 40 %, neural on hit; AoE skill (radius 2); blinks past its
// blocker leaving a 不祥幻影 (same kit minus blink). Template overrides (0.2 / 0.12) apply.
// boss_6 阿利斯泰尔 王权号令 (SP 1/s, plus-shaped 200 % strike + random equipment drop); 莫非王土 animates the
// latest equipment on the field (2 s after it appears, then once per 王权号令 cycle) (sword strike / directional vest + barrier / wand disarm); ≤50 %: DEF +100 %,
// RES +45, animates 2; 斥退 at 999 s.
// boss_7 “萨米的意志” 冰凌 (its normal attack) hits a whole column; 自然涌动 stun + arts DoT; <50 %: damage taken
// ×(1−0.6), 2 targets; Doom at 600 s (LP −30). Targets via fairOrder (two players alternate).
// Every leader (tag boss) ignores 侵蚀 gauge damage ("最终攻势中,敌方领袖不会受到侵蚀损伤").
// LP effects ('lpLoss' hook + result.lpLoss) must be applied by the match (see the report of this module's owner).
// fx kinds: 'beam' 'shell' 'explode' 'telegraph' 'charge' 'link' 'dash' 'column' 'tide' 'rockfall' 'tentacle' 'equip'
// 'sword' 'vest' 'blink' 'summon' 'grow' 'phase' 'lpLoss' (x, y + extra {id, r, tiles, kind, tx, ty …}).
import { MOVE_SCALE } from '../constants.js';
import { canTargetAlly } from '../targeting.js';
import { compileRoute } from '../ai.js';
import { normalizeRoute } from '../simdata.js';
import {
ensureInstalled, abOf, attach, T, elem, hurt, alliesInTiles, targetsNear, allTargets, byPriority,
remainingRoute, stayRoute, stepToward, setHits, hitCount, lpLoss, blinkForward, canCast, absorbArts, nthOf,
} from './enemies.js';
// ---------------------------------------------------------------------------------------------------------------
// constants
/** Share of the damage a grounded 剑/锤 or a 碎铳之簧 passes to its leader ("以一定比例") [ASSUMED]. */
export const PART_TRANSFER = 0.5;
/** 卢西恩 / 不祥幻影 AoE radius (PRTS "半径2"). */
const LUCIEN_AOE_RADIUS = 2;
/** 余音 on-hit pulse radius (PRTS 1.6) and 合奏 radii per form (PRTS gold 0.8 / dark 1.6). */
const ECHO_PULSE_RADIUS = 1.6;
const ECHO_ENSEMBLE_RADIUS = Object.freeze({ gold: 0.8, dark: 1.6 });
/** "只能被阻挡数大于等于2的单位阻挡". */
const ECHO_BLOCK_WEIGHT = 2;
/** 裂管之奏 strike spacing (s). */
const PIPE_STRIKE_GAP = 0.4;
/** 断裂生殖 tentacle: stun duration and reach around its device tile [ASSUMED]. */
const TENTACLE_STUN = 5, TENTACLE_RADIUS = 1;
/** 崩坍 delay (PRTS 1 s), targets per growth stage (PRTS 2/4/8) and 物种爆发 delay (PRTS 4 s). */
const ROCKFALL_DELAY = 1, ROCKFALL_TARGETS = [2, 4, 8], DOOM_DELAY = 4;
/** 铳: minimum attack speed (PRTS "最低20") and charge hit radius (PRTS 0.35). */
const ASPD_FLOOR = 20, CHARGE_RADIUS = 0.35;
/** 盲信之誓 link half-width (PRTS 0.5). */
const LINK_WIDTH = 0.5;
/** 碎铳之簧 bullet bounce falloff / reach [ASSUMED], combo = 十连击; tag of the damage share passed between springs. */
const SPRING_BOUNCE_FALLOFF = 0.85, SPRING_BOUNCE_RANGE = 2, SPRING_COMBO_HITS = 10, SPRING_SHARE_TAG = 'springShare';
/** 阿利斯泰尔: enemy SP per second [ASSUMED] and 莫非王土 delay after each 王权号令 [ASSUMED]. */
const ENEMY_SP_PER_SEC = 1, ANIMATE_DELAY = 2;
const EQUIP_KEYS = Object.freeze(['enemy_10028_vtswd', 'enemy_10029_vtshld', 'enemy_10030_vtwand']);
const SHELL_KEY = 'enemy_9016_acstmr', ECHO_KEY = 'enemy_9023_acdums', PHANTOM_KEY = 'enemy_2017_csphts';
// ---------------------------------------------------------------------------------------------------------------
// per-battle state, template resolution
const STATE = new WeakMap();
function stOf(b) {
let st = STATE.get(b);
if (!st) { st = { tpl: undefined, mirrored: new Set() }; STATE.set(b, st); }
return st;
}
const pairEq = (a, b) => Array.isArray(a) && Array.isArray(b) && Math.abs(a[0] - b[0]) < 1e-6 && Math.abs(a[1] - b[1]) < 1e-6;
/** The wave template this battle was built from (or null). */
export function templateOf(b) {
const st = stOf(b);
if (st.tpl !== undefined) return st.tpl;
const o = b.opts || {};
for (const cand of [o.waveTemplate, o.template, o.templateId, o.waveId]) {
if (!cand) continue;
const t = typeof cand === 'string' ? b.data.getWave(cand) : cand;
if (t && typeof t === 'object') { st.tpl = t; return t; }
}
let best = null, bestScore = 0;
const ids = typeof b.data.waveIds === 'function' ? b.data.waveIds() : [];
for (const id of ids) {
const w = b.data.getWave(id);
if (!w || !Array.isArray(w.spawns) || !Array.isArray(w.routes)) continue;
if (!w.spawns.some((s) => s && s.tag === 'boss' && BOSS_KITS[s.key ?? s.enemyKey])) continue;
if (w.routes.length !== b.routes.length) continue;
let score = 1;
if (w.routes.every((r, i) => { const n = normalizeRoute(r); const m = b.routes[i]; return m && pairEq(n.start, m.start) && pairEq(n.end, m.end); })) score += 8;
if (/_s$/.test(id) === (b.players.length <= 1)) score += 1;
if (score > bestScore) { best = w; bestScore = score; }
}
st.tpl = bestScore >= 9 ? best : null;
return st.tpl;
}
const legSig = (legs) => legs.map((l) => `${l.t}:${l.r ?? ''},${l.c ?? ''},${l.time ?? ''}`).join('|');
/** Raw template route an enemy is walking (matched on compiled legs). */
function rawRouteOf(b, e, tpl) {
if (!tpl || !e.route || !Array.isArray(e.route.legs)) return null;
const sig = legSig(e.route.legs);
for (const r of [...(tpl.routes || []), ...(tpl.extraRoutes || [])]) {
if (!r) continue;
try { if (legSig(compileRoute(normalizeRoute(r), b.rect)) === sig) return r; } catch { /* skip */ }
}
return null;
}
/** Fallback route from a position to the nearest goal. */
function toGoal(b, x, y, motion = 'WALK') {
const ends = b.grid.specialTiles('end');
let best = ends[0] ?? [Math.round(y), Math.round(x)], bd = Infinity;
for (const p of ends) { const d = Math.hypot(p[0] - y, p[1] - x); if (d < bd) { bd = d; best = p; } }
return { motion, start: [y, x], end: best, checkpoints: [] };
}
/** Spawn one phase of a template branch. Returns the spawned enemies. */
function branchSpawn(b, tpl, name, phaseIdx, { fallback = null, mods = null, at = null } = {}) {
const br = tpl && tpl.branches && tpl.branches[name];
if (!Array.isArray(br) || !br.length) return fallback ? fallback() : [];
const phase = br[Math.max(0, Math.min(phaseIdx, br.length - 1))] || [];
const out = [];
for (const s of phase) {
if (!s || (s.action && String(s.action).toUpperCase() !== 'SPAWN')) continue;
// placeholder slots (NF/N…) of branch spawns: the match may pass the round's replacements as opts.slotKeys
const key = (s.slot && b.opts && b.opts.slotKeys && b.opts.slotKeys[s.slot]) || s.key || s.enemyKey;
if (!key || !String(key).startsWith('enemy_')) continue;
const route = tpl.extraRoutes && tpl.extraRoutes[s.routeIndex];
for (let i = 0; i < Math.max(1, s.count ?? 1); i++) {
const u = b.spawnEnemy(key, { route: route || (at ? toGoal(b, at.x, at.y) : undefined), pos: route ? undefined : at ? [at.y, at.x] : undefined, tag: s.tag ?? null, countInTotal: s.unharmful ? false : undefined, mods });
if (u) out.push(u);
}
}
return out;
}
// ---------------------------------------------------------------------------------------------------------------
// install
export function install(battle) {
ensureInstalled(battle);
battle.on('enemySpawn', ({ enemy }) => onSpawn(battle, enemy), { priority: 90 });
// "最终攻势中,敌方领袖不会受到侵蚀损伤" (research 06 §10.1): leaders ignore erosion gauge damage
battle.on('elementHit', (c) => { if (c.target && c.target.isBoss && c.dmg.element === 'erosion') c.dmg.cancel = true; }, { priority: 100 });
}
export function registerMeta() {}
function onSpawn(b, e) {
const kit = BOSS_KITS[e.defId];
if (typeof kit !== 'function') return;
const tpl = templateOf(b);
const ab = abOf(b, e);
let list = [];
try { list = kit(ab, e, b, tpl) || []; } catch (err) { b._handlerError('content:bosses', e, err); }
attach(b, e, list);
// patrol loop for leaders / parts
const raw = rawRouteOf(b, e, tpl);
if (raw && Array.isArray(raw.steps) && raw.steps.some((s) => s && s.t === 'patrol')) attach(b, e, [patrolLoop()]);
// mirrored leader for 2 players (boss_battle_multi_player)
if (e.isBoss || e.tag === 'boss') mirror(b, e, tpl);
}
function patrolLoop() {
return {
tick(b, e) {
const R = e.route;
if (!R || !Array.isArray(R.legs) || R.legs.length < 2) return;
if (R.legIdx >= R.legs.length - 1) { R.legIdx = 0; R.pts = null; R.waitLeft = null; }
},
};
}
function mirror(b, e, tpl) {
const st = stOf(b);
const br = tpl && tpl.branches && tpl.branches.boss_battle_multi_player;
if (!Array.isArray(br) || !br.length || st.mirrored.has(e.defId) || (b.opts && b.opts.bossMirror === false)) return;
if (b.players.length < 2 || e.mem.mirror) return;
st.mirrored.add(e.defId);
for (const s of br[0] || []) {
const key = s.key ?? s.enemyKey;
if (key !== e.defId) continue;
b.after(Math.max(0, (s.time ?? 0) - b.time), () => {
if (b.aliveEnemies().filter((o) => o.defId === key && o.isBoss).length >= 2) return; // the match already did it
const route = tpl.extraRoutes && tpl.extraRoutes[s.routeIndex];
const u = b.spawnEnemy(key, { route: route || undefined, tag: 'boss', mods: e.mods });
if (u) u.mem.mirror = true;
});
}
}
// ---------------------------------------------------------------------------------------------------------------
// shared bits
const opsOnly = (list) => list.filter((u) => u.kind === 'op');
const nearestOf = (b, e, pred) => {
let best = null, bd = Infinity;
for (const o of b.enemies) { if (!o.alive || o === e || !pred(o)) continue; const d = Math.hypot(o.x - e.x, o.y - e.y); if (d < bd) { bd = d; best = o; } }
return best;
};
/** Apply a floor to an enemy whose data attack speed is 0 (skill-driven leaders: 铳/管/弦). */
function aspdFloor(b, e, extra = 0) {
const rawAspd = e.def.raw && e.def.raw.stats ? Number(e.def.raw.stats.aspd) : NaN;
if (!(rawAspd === 0)) return;
b.addBuff(e, { key: 'boss:aspd', persist: true, mods: { aspd: Math.max(ASPD_FLOOR, rawAspd + extra) - e.base.aspd } });
}
/**
* Engine target priority (blocker → taunt → latest deployed), made fair for two players on one boss field: within the top
* priority tier the picks alternate between the players' units, starting with the other player each call. Both
* players deploy at t = 0, so "latest deployed" alone would always single out the second player [ASSUMED fairness
* rule]. `P.turn` keeps the rotation.
*/
export function fairOrder(b, e, list, P) {
const l = byPriority(e, list);
if (b.players.length < 2 || l.length < 2) return l;
const key = (u) => `${e.blockedBy === u ? 0 : 1}|${u.s.taunt || 0}`;
const top = key(l[0]);
const tier = l.filter((u) => key(u) === top), rest = l.filter((u) => key(u) !== top);
const ids = b.players.map((p) => p.playerId);
P.turn = ((P.turn ?? -1) + 1) % ids.length;
const queues = ids.map((pid) => tier.filter((u) => u.ownerId === pid));
const out = [];
for (let k = 0; out.length < tier.length && k < tier.length * ids.length + ids.length; k++) {
const q = queues[(P.turn + k) % ids.length];
if (q.length) out.push(q.shift());
}
for (const u of tier) if (!out.includes(u)) out.push(u);
return out.concat(rest);
}
/** Stun + phys DoT on the 3×3 around (r, c). */
function stunBlast(b, src, r, c, stun, dot, dur, kind) {
b.fx('explode', { x: c, y: r, r: 1.5, kind, tiles: 'box' });
for (const u of alliesInTiles(b, r, c, 'box', 1)) {
if (stun > 0) b.applyStatus(u, 'stun', { duration: stun, source: src });
if (dot > 0 && dur > 0) b.addBuff(u, { key: `boss:${kind}Dot`, duration: dur, refresh: 'replace', interval: 1, visible: true,
onTick: ({ battle, unit }) => battle.dealDamage(src, unit, { amount: dot, type: 'phys', canDodge: false, tags: ['enemyAbility', kind] }) });
}
}
/** Distance from point p to segment a–b. */
function segDist(px, py, ax, ay, bx, by) {
const dx = bx - ax, dy = by - ay, L = dx * dx + dy * dy;
const t = L > 0 ? Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / L)) : 0;
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
// ---------------------------------------------------------------------------------------------------------------
// 假想敌:胄 (boss_1 / boss_8) + 刺胄之弹 + 斩胄之剑 / 破胄之锤
function kitHelm(ab, e, b, tpl) {
const s1 = ab.sk['1'], s2 = ab.sk['2'];
const lowRatio = T(ab, '1.hp_ratio') ?? 0, lowScale = T(ab, '1.damage_scale') ?? 1;
const hidden = /_2$/.test(e.defId);
const P = { low: false };
const range = () => e.base.rangeRadius || 8;
const shellTargets = (b2) => opsOnly(hidden ? targetsNear(b2, e, range()) : allTargets(b2, e));
return [
{
spawn(b2, e2, a, ab2) { ab2.atkType = 'arts'; },
before(c, b2, e2) { const l = targetsNear(b2, e2, range()); if (l.length) c.targets = [b2.rng.pick(l)]; }, // 随机选择目标
attack(c, b2, e2) { const t = c.targets[0]; if (t) b2.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'helmRay' }); },
iv: 0.25,
tick(b2, e2) {
if (P.low || !(e2.hpRatio < lowRatio)) return;
P.low = true; // <20 %: damage taken ×0.5, extra shell
b2.addBuff(e2, { key: 'boss:helmGuard', persist: true, visible: true, mods: { physTakenMul: lowScale, artsTakenMul: lowScale } }); // 物理与法术伤害降低
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'helmLow' });
},
},
s1 && {
cd: s1.cd, icd: s1.icd, cond: (b2) => shellTargets(b2).length > 0,
fire(b2, e2) { // 【灭顶之灾】
const n = P.low ? 2 : 1;
const ts = shellTargets(b2).sort((p, q) => q.s.atk - p.s.atk || q.deploySeq - p.deploySeq).slice(0, n);
for (const t of ts) fireShell(b2, e2, t);
},
},
s2 && {
cd: s2.cd, icd: s2.icd,
fire(b2, e2) { // 【死亡集群】: drones; one killed by an operator costs the leader hp_ratio × max HP
const hpMul = s2.bb['summon.hp_ratio'] > 0 ? s2.bb['summon.hp_ratio'] : null;
const key = s2.bs.enemy_key ?? 'enemy_1005_yokai';
const drones = branchSpawn(b2, tpl, s2.bs.branch_id ?? 'boss_summon_enemy', 0, {
mods: hpMul ? { hpMul } : null, at: { x: e2.x, y: e2.y },
fallback: () => [b2.spawnEnemy(key, { pos: [e2.y, e2.x], route: toGoal(b2, e2.x, e2.y, 'FLY'), mods: hpMul ? { hpMul } : null })].filter(Boolean),
});
b2.fx('summon', { x: e2.x, y: e2.y, id: e2.id, key, n: drones.length });
const ratio = s2.bb.hp_ratio ?? 0;
for (const d of drones) attach(b2, d, [{
death(c, b3) {
if (c.reason !== 'killed' || !c.killer || c.killer.side !== 'ally' || !(ratio > 0)) return;
const boss = e2.alive ? e2 : b3.aliveEnemies().find((o) => o.isBoss && o.defId === e2.defId);
if (boss) { b3.loseHp(boss, boss.s.maxHp * ratio, { source: c.killer }); b3.fx('beam', { x: d.x, y: d.y, from: d.id, to: boss.id, kind: 'droneLink' }); }
},
}]);
},
},
];
}
/** Launch a 刺胄之弹 from `boss` at `target`'s tile. */
function fireShell(b, boss, target) {
const tr = target.tileR, tc = target.tileC;
const sh = b.spawnEnemy(SHELL_KEY, { pos: [boss.y, boss.x], route: { motion: 'FLY', start: [boss.y, boss.x], end: [tr, tc], steps: [{ t: 'wait', s: 99999 }] }, tag: 'part', countInTotal: false, ownerPlayerId: boss.ownerId });
if (!sh) return null;
sh.mem.ab.shell = { tr, tc, boss };
b.fx('shell', { x: boss.x, y: boss.y, id: sh.id, tx: tc, ty: tr, r: 1.5, kind: 'helmShell' });
return sh;
}
function kitShell(ab, e) {
const stun = T(ab, 'killed.duration') ?? 0, dot = T(ab, 'killed.value') ?? 0;
return [{
spawn(b, e2) { setHits(e2, e2.def.maxHp); hitCount(b, e2, true); }, // 需要数次攻击击倒
tick(b, e2, a, dt) {
const s = e2.mem.ab.shell;
if (!s || a.done) return;
if (!stepToward(e2, s.tc, s.tr, e2.s.moveSpeed * MOVE_SCALE * dt)) return;
a.done = true; // arrival: 3×3 long stun + phys DoT
stunBlast(b, s.boss && s.boss.alive ? s.boss : e2, s.tr, s.tc, stun, dot, stun, 'helmShell');
b.kill(e2, null);
},
}];
}
function kitBlade(ab, e, b, tpl) {
const s = ab.sk['1'];
const bb = s ? s.bb : {};
const P = { state: 'hover', home: { x: e.x, y: e.y }, hits: 0, target: null };
const leader = (b2) => nearestOf(b2, e, (o) => o.isBoss && /enemy_9013_acstmk/.test(o.defId));
const hover = (b2, on) => {
if (on) b2.addBuff(e, { key: 'boss:hover', persist: true, visible: true, flags: { invulnerable: true, untargetable: true } });
else b2.removeBuff(e, 'boss:hover');
};
return [
{
spawn(b2, e2) { hover(b2, true); b2.addBuff(e2, { key: 'boss:anchor', persist: true, flags: { noMove: true } }); },
before(c, b2, e2) { const l = targetsNear(b2, e2, e2.base.rangeRadius || 1.6, { ranged: false }); if (l.length) c.targets = l; }, // 范围物理伤害
taken(c, b2, e2) {
if (P.state === 'dive' && ++P.hits >= (bb.max_hit_cnt ?? Infinity)) {
// shot down: ground unit, damage ×damage_scale, stunned special_stun_duration, then flies home
P.state = 'down';
e2.motion = 'WALK';
b2.addBuff(e2, { key: 'boss:downed', duration: bb.special_stun_duration ?? 0, visible: true, mods: { dmgTakenMul: bb.damage_scale ?? 1 },
onExpire: () => { if (e2.alive) { e2.motion = 'FLY'; P.state = 'return'; } } });
b2.applyStatus(e2, 'stun', { duration: bb.special_stun_duration ?? 0, source: null, force: true });
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'shotDown' });
return;
}
if (P.state === 'down' && c.source && c.source.side === 'ally' && c.amount > 0) {
const L = leader(b2);
if (L) b2.loseHp(L, c.amount * PART_TRANSFER, { source: c.source, from: c.dmg }); // 被击落时受到伤害以一定比例传递给假想敌:胄
}
},
tick(b2, e2, a, dt) {
const sp = e2.s.moveSpeed * MOVE_SCALE * dt;
if (P.state === 'dive') {
const t = P.target;
if (!stepToward(e2, t.c, t.r, sp)) return;
stunBlast(b2, e2, t.r, t.c, bb.stun ?? 0, bb.dot_damage ?? 0, bb.dot_duration ?? 0, 'bladeDive');
P.state = 'return';
} else if (P.state === 'return') {
if (stepToward(e2, P.home.x, P.home.y, sp)) { P.state = 'hover'; hover(b2, true); }
}
},
},
s && {
cd: s.cd, icd: s.icd, cond: (b2) => P.state === 'hover' && opsOnly(allTargets(b2, e)).length > 0,
fire(b2, e2) { // fly at the lowest-ATK operator
const t = opsOnly(allTargets(b2, e2)).sort((p, q) => p.s.atk - q.s.atk || p.deploySeq - q.deploySeq)[0];
if (!t) return;
P.state = 'dive'; P.hits = 0; P.target = { r: t.tileR, c: t.tileC };
hover(b2, false);
b2.fx('charge', { x: e2.x, y: e2.y, id: e2.id, tx: t.tileC, ty: t.tileR, kind: 'bladeDive' });
},
},
];
}
// ---------------------------------------------------------------------------------------------------------------
// 假想敌:铳 (boss_2 / boss_9) + 碎铳之簧
const isGun = (o) => /enemy_9017_achunt/.test(o.defId);
const isSpring = (o) => /enemy_90(18|19|20)_actrp/.test(o.defId);
function kitGun(ab, e, b) {
const hidden = /_2$/.test(e.defId);
const step = T(ab, '2.attack_speed') ?? 0, maxN = T(ab, '2.max_stack_cnt') ?? 0;
const P = { last: null, n: 0, charge: null };
const range = () => e.base.rangeRadius || 2;
const setAspd = (b2) => aspdFloor(b2, e, step * P.n);
const s1 = ab.sk['1'], s2 = ab.sk['2'], s3 = ab.sk['3'];
const groundTargets = (b2) => allTargets(b2, e).filter((u) => u.ground);
const list = [
{
spawn(b2, e2) { b2.addBuff(e2, { key: 'boss:unblockable', persist: true, flags: { unblockable: true } }); setAspd(b2); },
before(c, b2, e2) { const l = targetsNear(b2, e2, range()); if (l.length) c.targets = [l.sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq)[0]]; }, // 优先攻击防御力最高的单位
attack(c, b2) {
const t = c.targets[0];
if (t === P.last) P.n = Math.min(maxN, P.n + 1); else { P.last = t; P.n = 0; }
setAspd(b2);
},
dealt(c, b2, e2) { elem(b2, e2, c.target, 'erosion', e2.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0)); }, // 攻击时附带侵蚀损伤
tick(b2, e2, a, dt) {
const ch = P.charge;
if (!ch) return;
const t = ch.target;
const sp = (e2.s.moveSpeed + (T(ab, '3.move_speed') ?? 0)) * MOVE_SCALE * dt;
const nx = e2.x, ny = e2.y;
const arrived = stepToward(e2, t.x, t.y, sp);
if (!b2.grid.groundPassable(Math.round(e2.y), Math.round(e2.x))) { e2.x = nx; e2.y = ny; }
for (const u of b2.alliesInRadius(e2.x, e2.y, CHARGE_RADIUS)) if (!ch.hit.has(u)) { ch.hit.add(u); hurt(b2, e2, u, e2.s.atk * (T(ab, '3.atk_scale') ?? 1), 'phys'); }
if (arrived || b2.time >= ch.until || (e2.x === nx && e2.y === ny)) {
P.charge = null;
b2.removeBuff(e2, 'boss:charge');
if (e2.route) e2.route.pts = null;
}
},
},
s1 && {
cd: s1.cd, icd: s1.icd, cond: (b2) => groundTargets(b2).length > 0 && !P.charge,
fire(b2, e2) { // 【最终之罚】 charge at the highest-DEF ground unit
const t = groundTargets(b2).sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq)[0];
if (!t) return;
const dur = T(ab, '3.duration') ?? 0;
P.charge = { target: { x: t.x, y: t.y }, until: b2.time + dur, hit: new Set() };
b2.addBuff(e2, { key: 'boss:charge', duration: dur, visible: true, flags: { noMove: true, disarm: true } });
b2.fx('charge', { x: e2.x, y: e2.y, id: e2.id, tx: t.x, ty: t.y, kind: 'finalPenance' });
},
},
];
if (hidden) {
list.push({
iv: 0.25,
tick(b2, e2) { // 【未尽的告解】 physical / arts damage taken ×4.damage_scale while the 碎铳之簧 it came with remain
const springs = b2.enemies.some((o) => o.alive && isSpring(o));
if (springs) { const m = T(ab, '4.damage_scale') ?? 1; b2.addBuff(e2, { key: 'boss:confession', duration: 0.35, refresh: 'replace', mods: { physTakenMul: m, artsTakenMul: m } }); }
},
});
if (s3) list.push({
iv: s3.bb.interval ?? 1,
tick(b2, e2) { // 【盲信之誓】 links: phys per second on operators standing on a line
for (const sp of b2.enemies) {
if (!sp.alive || !isSpring(sp)) continue;
b2.fx('link', { x: e2.x, y: e2.y, from: e2.id, to: sp.id, kind: 'faithLink', dur: s3.bb.interval ?? 1 });
for (const u of b2.allies()) if (segDist(u.x, u.y, e2.x, e2.y, sp.x, sp.y) <= LINK_WIDTH) hurt(b2, e2, u, s3.bb.value ?? 0, 'phys');
}
},
});
if (s2) list.push({
cd: s2.cd, icd: s2.icd, cond: (b2) => b2.enemies.some((o) => o.alive && isSpring(o)),
fire(b2, e2) { // 【末日布道】 springs dash to 铳, invulnerable, trampling operators
for (const sp of b2.enemies) {
if (!sp.alive || !isSpring(sp) || !sp.mem.ab) continue;
sp.mem.ab.dash = { until: b2.time + (s2.bb.dog_duration ?? 0), mul: 1 + (s2.bb.move_speed ?? 0), gun: e2, hit: new Set() };
b2.addBuff(sp, { key: 'boss:dash', duration: s2.bb.dog_duration ?? 0, visible: true, flags: { invulnerable: true, noMove: true, unblockable: true } });
b2.fx('dash', { x: sp.x, y: sp.y, id: sp.id, tx: e2.x, ty: e2.y });
}
},
});
}
return list;
}
function kitSpring(ab, e) {
const kind = /9018/.test(e.defId) ? 'arts' : /9019/.test(e.defId) ? 'element' : 'hits';
const scale = T(ab, '1.damage_scale') ?? 1, regen = T(ab, '1.regenerate_duration') ?? 0;
const P = { up: false, barrier: 0, hits: 0, downAt: null };
const gun = (b) => nearestOf(b, e, (o) => isGun(o));
const raise = (b) => {
P.up = true;
if (kind === 'arts') P.barrier = e.s.maxHp * (T(ab, '1.shield_ratio') ?? 0);
if (kind === 'hits') P.hits = T(ab, '1.block_damage_max_times') ?? 0;
b.addBuff(e, { key: 'boss:springShield', persist: true, visible: true, flags: { unblockable: true } }); // 护盾生效时,自身无法被阻挡
b.fx('phase', { x: e.x, y: e.y, id: e.id, kind: `spring_${kind}` });
};
const drop = (b) => { P.up = false; P.downAt = b.time; b.removeBuff(e, 'boss:springShield'); b.fx('shieldBreak', { x: e.x, y: e.y, id: e.id }); };
const sk = ab.sk['1'];
return [
{
spawn(b) { raise(b); },
hitIn(c, b, e2) {
if (!P.up) return;
const ty = c.dmg.type;
if (kind === 'arts') { // 法术屏障: absorbs arts (after RES, like a shield); phys ×0.1 + counter
if (ty === 'arts') {
P.barrier -= absorbArts(c, P.barrier);
if (P.barrier <= 1e-6) { P.barrier = 0; drop(b); }
} else if (ty === 'phys') {
c.dmg.mul *= scale;
const s = c.source;
if (s && s.side === 'ally' && s.alive) { hurt(b, e2, s, e2.s.atk * (T(ab, '1.atk_scale') ?? 0), 'phys'); elem(b, e2, s, 'erosion', e2.s.atk * (T(ab, '1.ep_damage_ratio') ?? 0)); }
}
} else if (kind === 'element') { // 元素护盾: phys/arts heavily reduced
if (ty === 'phys' || ty === 'arts') c.dmg.mul *= scale;
} else if (ty === 'phys' || ty === 'arts' || ty === 'true') { // 频次护盾: negates N hits
c.dmg.cancel = true;
if (--P.hits <= 0) drop(b);
}
},
burst(c, b) { if (kind === 'element' && P.up) drop(b); }, // 自身元素损伤爆发时,护盾消失
taken(c, b, e2) {
if (!c.source || c.source.side !== 'ally' || !(c.amount > 0)) return;
if (c.dmg.tags && c.dmg.tags.includes(SPRING_SHARE_TAG)) return; // a share never passes on again
// 【盲信之誓】 受到伤害时以一定比例传递给假想敌:铳 (and, split, to the other springs — 隐秘核心 text)
const g = gun(b);
if (g) b.loseHp(g, c.amount * PART_TRANSFER, { source: c.source, from: c.dmg });
const others = b.enemies.filter((o) => o.alive && o !== e2 && isSpring(o));
for (const o of others) b.loseHp(o, (c.amount * PART_TRANSFER) / others.length, { source: c.source, from: c.dmg, tags: [SPRING_SHARE_TAG] });
},
tick(b, e2, a, dt) {
if (!P.up && P.downAt != null && b.time - P.downAt >= regen) raise(b);
const d = e2.mem.ab.dash;
if (!d) return;
const g = d.gun && d.gun.alive ? d.gun : gun(b);
const arrived = !g || stepToward(e2, g.x, g.y, e2.s.moveSpeed * d.mul * MOVE_SCALE * dt) || Math.hypot(g.x - e2.x, g.y - e2.y) < 1;
for (const u of b.alliesInRadius(e2.x, e2.y, 0.5)) if (!d.hit.has(u)) { d.hit.add(u); hurt(b, e2, u, e2.s.atk, 'phys'); }
if (arrived || b.time >= d.until) { e2.mem.ab.dash = null; b.removeBuff(e2, 'boss:dash'); if (e2.route) e2.route.pts = null; }
},
},
// while shielded, every (spCost+1)-th attack (enemy SP +1 per attack, as 粉碎攻坚手's official text) adds the shield's
// special attack on that attack's target
(kind === 'element' || kind === 'hits') && sk && {
attack(c, b, e2, a) {
if (!P.up) return;
a.n = (a.n ?? 0) + 1;
if (a.n % nthOf(sk)) return;
const t0 = c.targets.find((u) => u && u.alive);
if (!t0) return;
if (kind === 'element') { // bouncing element bullet: `times` targets, weaker after each bounce
const times = sk.bb.times || 1, ratio = sk.bb.ep_damage_ratio || 0;
let t = t0;
const hit = new Set();
for (let k = 0; k < times && t; k++) {
hit.add(t);
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'springBullet' });
elem(b, e2, t, 'erosion', e2.s.atk * ratio * Math.pow(SPRING_BOUNCE_FALLOFF, k));
const prev = t;
t = b.alliesInRadius(prev.x, prev.y, SPRING_BOUNCE_RANGE).filter((u) => !hit.has(u)).sort((p, q) => Math.hypot(p.x - prev.x, p.y - prev.y) - Math.hypot(q.x - prev.x, q.y - prev.y) || q.deploySeq - p.deploySeq)[0];
}
} else { // 十连击
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t0.id, kind: 'springCombo' });
for (let i = 0; i < SPRING_COMBO_HITS && t0.alive; i++) hurt(b, e2, t0, e2.s.atk * (sk.bb.atk_scale || 0), 'phys');
}
},
},
];
}
// ---------------------------------------------------------------------------------------------------------------
// 假想敌:管 (boss_3 / boss_10) + 假想敌:弦 + “余音”
const echoesOf = (b, form) => b.enemies.filter((o) => o.alive && o.defId === ECHO_KEY && o.mem.ab && o.mem.ab.form === form);
/** Switch an echo's form. */
export function setEchoForm(b, echo, form) {
const ab = echo.mem.ab;
if (!ab) return;
ab.form = form;
ab.strikes = 0;
const mods = form === 'gold' ? { aspd: T(ab, '1.attack_speed') ?? 0 } : { atkPct: T(ab, '2.atk') ?? 0, moveMul: 1 - (T(ab, '2.move_speed') ?? 0) };
b.addBuff(echo, { key: 'boss:echoForm', persist: true, visible: true, mods });
b.fx('phase', { x: echo.x, y: echo.y, id: echo.id, kind: `echo_${form}` });
}
/** An echo takes a strike/hit: pulse around it and count towards the form switch. */
export function echoHit(b, echo) {
const ab = echo.mem.ab;
if (!ab || !echo.alive) return;
const atk = echo.s.atk;
b.fx('explode', { x: echo.x, y: echo.y, r: ECHO_PULSE_RADIUS, kind: 'echoPulse' });
for (const u of b.alliesInRadius(echo.x, echo.y, ECHO_PULSE_RADIUS)) {
hurt(b, echo, u, atk * (T(ab, '3.atk_scale') ?? 0), 'arts');
elem(b, echo, u, 'apoptosis', atk * (T(ab, '3.ep_damage_ratio') ?? 0));
}
const need = ab.form === 'gold' ? T(ab, '1.hit_times_to_switch') : T(ab, '2.hit_times_to_switch');
ab.strikes = (ab.strikes ?? 0) + 1;
if (need > 0 && ab.strikes >= need) setEchoForm(b, echo, ab.form === 'gold' ? 'dark' : 'gold');
}
function kitEcho(ab, e) {
const s = ab.sk.Skill;
return [
{
spawn(b, e2) { setHits(e2, e2.def.maxHp); hitCount(b, e2, true); e2.blockWeight = ECHO_BLOCK_WEIGHT; setEchoForm(b, e2, 'dark'); },
taken(c, b, e2) { if (c.source && c.source.side === 'ally') echoHit(b, e2); }, // 受到伤害时以自身为中心造成一次范围伤害
},
s && {
cd: s.cd, icd: s.icd,
fire(b, e2) { // 合奏
const gold = e2.mem.ab.form === 'gold';
const r = gold ? ECHO_ENSEMBLE_RADIUS.gold : ECHO_ENSEMBLE_RADIUS.dark;
const ratio = gold ? T(ab, '4.ep_damage_ratio_passion') ?? 0 : T(ab, '4.ep_damage_ratio_depassion') ?? 0;
b.fx('explode', { x: e2.x, y: e2.y, r, kind: gold ? 'ensembleGold' : 'ensembleDark' });
for (const u of b.alliesInRadius(e2.x, e2.y, r)) { hurt(b, e2, u, e2.s.atk, 'arts'); elem(b, e2, u, 'apoptosis', e2.s.atk * ratio); }
},
},
];
}
/** 管 / 弦 core: summon loop, strikes on its own form of 余音, skill. */
function pipeCore(ab, e, b, tpl, { form, prefix }) {
const hi = T(ab, `1.${prefix}_t_1[high_hp].interval`) ?? 40, lo = T(ab, `1.${prefix}_t_1[low_hp].interval`) ?? hi, thr = T(ab, '1.hp_ratio') ?? 0;
const key = ab.tS['1.enemy_key'] ?? ECHO_KEY;
const P = { acc: 0, atk: Infinity }; // first strike as soon as an echo of its form exists (engine attack rule)
const summon = (b2, e2) => {
const got = branchSpawn(b2, tpl, 'summon_enemy', 0, { at: { x: e2.x, y: e2.y }, fallback: () => [b2.spawnEnemy(key, { pos: [e2.y, e2.x], route: toGoal(b2, e2.x, e2.y) })].filter(Boolean) });
for (const u of got) if (u.defId === ECHO_KEY && form === 'gold') setEchoForm(b2, u, 'gold');
b2.fx('summon', { x: e2.x, y: e2.y, id: e2.id, key, n: got.length });
};
return {
spawn(b2, e2) { e2.profile.noAttack = true; aspdFloor(b2, e2); },
tick(b2, e2, a, dt) {
P.acc += dt;
if (P.acc >= (e2.hpRatio < thr ? lo : hi)) { P.acc = 0; summon(b2, e2); }
if (!canCast(e2, false)) return;
P.atk += dt;
if (P.atk < e2.s.interval) return;
const t = b2.rng.pick(echoesOf(b2, form)); // 优先攻击…形态余音
if (!t) return;
P.atk = 0;
e2.lastAttackAt = b2.time;
e2.stats.attacks++;
b2.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: `${prefix}Strike` });
echoHit(b2, t);
},
};
}
function kitPipe(ab, e, b, tpl) {
const s = ab.sk['1'];
return [
pipeCore(ab, e, b, tpl, { form: 'dark', prefix: 'acduml' }),
{
death(c, b2) { for (const o of b2.aliveEnemies()) if (o.defId === 'enemy_9022_acdumm') b2.kill(o, null); }, // 弦 leaves with 管
},
s && {
cd: s.cd, icd: s.icd, cond: (b2) => echoesOf(b2, 'dark').length > 0,
fire(b2, e2) { // 【裂管之奏】 three strikes on every dark echo
const r = s.bb.range_radius ?? 0, ep = s.bb.ep_damage_ratio ?? 0;
for (const echo of echoesOf(b2, 'dark')) for (let k = 0; k < 3; k++) b2.after(k * PIPE_STRIKE_GAP, () => {
if (!e2.alive || !echo.alive) return;
b2.fx('explode', { x: echo.x, y: echo.y, r, kind: 'pipeStrike' });
for (const u of b2.alliesInRadius(echo.x, echo.y, r)) { hurt(b2, e2, u, e2.s.atk, 'arts'); elem(b2, e2, u, 'apoptosis', e2.s.atk * ep); }
echoHit(b2, echo);
}, { owner: e2 });
},
},
];
}
function kitString(ab, e, b, tpl) {
const s = ab.sk['1'];
return [
pipeCore(ab, e, b, tpl, { form: 'gold', prefix: 'acdumm' }),
{ spawn(b2, e2) { b2.addBuff(e2, { key: 'boss:eternal', persist: true, flags: { invulnerable: true, untargetable: true } }); } }, // 永久无敌
s && {
cd: s.cd, icd: s.icd, cond: (b2) => echoesOf(b2, 'gold').length > 0,
fire(b2, e2) { // 【断弦之奏】 AoE on every gold echo, which then turns dark
const r = s.bb.range_radius ?? 0, ep = s.bb.ep_damage_ratio ?? 0;
for (const echo of echoesOf(b2, 'gold')) {
b2.fx('explode', { x: echo.x, y: echo.y, r, kind: 'stringStrike' });
for (const u of b2.alliesInRadius(echo.x, echo.y, r)) { hurt(b2, e2, u, e2.s.atk, 'arts'); elem(b2, e2, u, 'apoptosis', e2.s.atk * ep); }
setEchoForm(b2, echo, 'dark');
}
},
},
];
}
// ---------------------------------------------------------------------------------------------------------------
// 盐风主教昆图斯 (boss_4)
function kitQuintus(ab, e, b, tpl) {
const P = { stage: 0, t0: null, stageAt: null, tent: 0 };
const stageSkills = [['Tidewater', 'Rockfall', 'SummonTentac'], ['TidewaterG1', 'RockfallG1', 'SummonTentacG1'], ['TidewaterG2', 'RockfallG2', 'SummonTentacG2', 'Doom']];
const skills = [];
const atkRatio = T(ab, 'epdamage.attack@ep_damage_ratio') ?? 0;
const mk = (name, stage) => {
const s = ab.sk[name];
if (!s) return null;
const kind = name.replace(/G\d$/, '');
const a = { cd: s.cd, icd: s.icd, stage, cond: () => P.stage === stage, fire: (b2, e2) => QUINTUS[kind](b2, e2, s, P, tpl) };
skills.push(a);
return a;
};
const list = [{
spawn(b2, e2) { P.t0 = b2.time; P.stageAt = b2.time; e2.profile.maxTargets = 2; },
before(c, b2, e2) { // 同时攻击防御最高的两个单位
const l = targetsNear(b2, e2, e2.base.rangeRadius || 99).sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq);
if (l.length) c.targets = l.slice(0, 2);
},
dealt(c, b2, e2) { elem(b2, e2, c.target, 'neural', e2.s.atk * atkRatio); },
iv: 0.25,
tick(b2, e2) {
const lost = 1 - e2.hpRatio;
if (P.stage === 0 && (lost >= (T(ab, 'growup1.hp_ratio') ?? 1) || b2.time - P.t0 >= (T(ab, 'growup1.interval') ?? Infinity))) grow(b2, e2, 1);
else if (P.stage === 1 && (lost >= 2 * (T(ab, 'growup2.hp_ratio') ?? 1) || b2.time - P.stageAt >= (T(ab, 'growup2.interval') ?? Infinity))) grow(b2, e2, 2);
},
}];
const grow = (b2, e2, n) => {
P.stage = n; P.stageAt = b2.time;
b2.addBuff(e2, { key: 'boss:grow', persist: true, visible: true, mods: { atkPct: T(ab, `atkup${n}.atk`) ?? 0 } });
for (const a of skills) if (a.stage === n) a.left = a.icd; // the new form's skills start from their initial cooldown
b2.fx('grow', { x: e2.x, y: e2.y, id: e2.id, stage: n });
};
stageSkills.forEach((names, i) => names.forEach((n) => list.push(mk(n, i))));
return list;
}
const QUINTUS = {
Tidewater(b, e, s) { // 【大潮】 every operator: arts + neural
b.fx('tide', { x: e.x, y: e.y, id: e.id });
for (const u of b.allies()) { hurt(b, e, u, e.s.atk * (s.bb.atk_scale ?? 0), 'arts'); elem(b, e, u, 'neural', e.s.atk * (s.bb.ep_damage_ratio ?? 0)); }
},
Rockfall(b, e, s, P) { // 【崩坍】 delayed strikes on the N highest-DEF units
const n = ROCKFALL_TARGETS[Math.min(P.stage, ROCKFALL_TARGETS.length - 1)];
const ts = allTargets(b, e).sort((p, q) => q.s.def - p.s.def || q.deploySeq - p.deploySeq).slice(0, n);
for (const t of ts) {
b.fx('rockfall', { x: t.x, y: t.y, id: t.id, dur: ROCKFALL_DELAY });
b.after(ROCKFALL_DELAY, () => { if (e.alive && t.alive) hurt(b, e, t, e.s.atk * (s.bb.atk_scale ?? 0), 'phys'); }, { owner: e });
}
},
SummonTentac(b, e, s, P, tpl) { // 【断裂生殖】 tentacles at the template devices (phase i on the i-th cast)
const br = tpl && tpl.branches && tpl.branches[s.bs.branch_id ?? 'dslily_dstnta'];
let spots = [];
if (Array.isArray(br) && br.length) {
const phase = br[Math.min(P.tent, br.length - 1)] || [];
const devs = (tpl.devices || []);
for (const a of phase) { const d = devs.find((x) => x.alias === a.key); if (d && d.pos) spots.push(d.pos); }
}
if (!spots.length) { const t = b.rng.pick(allTargets(b, e)); if (t) spots = [[t.tileR, t.tileC]]; }
P.tent++;
for (const [r, c] of spots) {
b.fx('tentacle', { x: c, y: r, r: TENTACLE_RADIUS, dur: TENTACLE_STUN });
for (const u of b.alliesInRadius(c, r, TENTACLE_RADIUS)) b.applyStatus(u, 'stun', { duration: TENTACLE_STUN, source: e });
}
},
Doom(b, e, s) { // 【物种爆发】
b.fx('telegraph', { x: e.x, y: e.y, r: 99, dur: DOOM_DELAY, kind: 'speciesBurst', id: e.id });
b.after(DOOM_DELAY, () => { if (e.alive) lpLoss(b, Math.abs(s.bb.value ?? 0), 'speciesBurst', e); }, { owner: e });
},
};
// ---------------------------------------------------------------------------------------------------------------
// 卢西恩,“猩红血钻” (boss_5) + 不祥幻影
function lucienCore(ab, e) {
const pen = T(ab, 'penetrate.def_penetrate') ?? 0, prob = T(ab, 'evade.prob') ?? 0, epr = T(ab, 'combat.attack@ep_damage_ratio') ?? 0;
const s = ab.sk.aoe;
return [
{
spawn(b, e2) { if (pen > 0) b.addBuff(e2, { key: 'boss:pierce', persist: true, mods: { defIgnorePct: pen } }); },
tick(b, e2, a) { // 未被阻挡时有几率闪避物理与法术攻击
const on = !e2.blockedBy && prob > 0;
if (on === a.on) return;
a.on = on;
if (on) b.addBuff(e2, { key: 'boss:evade', persist: true, mods: { dodgePhys: prob, dodgeArts: prob } });
else b.removeBuff(e2, 'boss:evade');
},
dealt(c, b, e2) { elem(b, e2, c.target, 'neural', e2.s.atk * epr); },
},
s && {
cd: s.cd, icd: s.icd, cond: (b) => b.alliesInRadius(e.x, e.y, LUCIEN_AOE_RADIUS).length > 0,
fire(b, e2) {
b.fx('explode', { x: e2.x, y: e2.y, r: LUCIEN_AOE_RADIUS, kind: 'crimsonAoe' });
for (const u of b.alliesInRadius(e2.x, e2.y, LUCIEN_AOE_RADIUS)) { hurt(b, e2, u, e2.s.atk * (s.bb.atk_scale ?? 0), 'phys'); elem(b, e2, u, 'neural', e2.s.atk * (s.bb.ep_damage_ratio ?? 0)); }
},
},
];
}
function kitLucien(ab, e) {
const s = ab.sk.blink;
return [...lucienCore(ab, e), s && {
cd: s.cd, icd: s.icd, cond: () => !!e.blockedBy,
fire(b, e2) { // blink past the blocker, leaving a 不祥幻影 on its path
const route = remainingRoute(e2);
const from = blinkForward(b, e2, s.bb.dist ?? 1.5);
if (!from) return;
const ph = b.spawnEnemy(PHANTOM_KEY, { pos: [from.y, from.x], route, ownerPlayerId: e2.ownerId, sourcePlayerId: e2.sourcePlayerId });
if (ph) b.fx('summon', { x: from.x, y: from.y, id: e2.id, key: PHANTOM_KEY, n: 1 });
},
}];
}
// ---------------------------------------------------------------------------------------------------------------
// 阿利斯泰尔,帝国余晖 (boss_6)
function kitLion(ab, e, b) {
const eq = ab.sk.equip, eqA = ab.sk.equipA, st = ab.sk.store, stA = ab.sk.storeA, su = ab.sk.Suicide;
const P = { sp: 0, adv: false, nextAnim: null, wand: false };
const wandDef = b.data.getEnemy('enemy_10030_vtwand');
const disarm = wandDef && wandDef.talent ? wandDef.talent['1.disarmed_duration'] ?? 0 : 0;
const range = () => e.base.rangeRadius || 3.4;
const cost = eq ? (eq.sp > 0 ? eq.sp : 25) : Infinity;
/** Equipment lying on this field, oldest first. */
const equipment = (b2) => b2.enemies.filter((q) => q.alive && EQUIP_KEYS.includes(q.defId)).sort((p, q) => p.spawnSeq - q.spawnSeq);
const decree = (b2, e2) => { // 【王权号令】 plus-shaped strike + random equipment
const sk = P.adv && eqA ? eqA : eq;
const t = b2.rng.pick(targetsNear(b2, e2, range()));
if (!t) return false;
const r0 = t.tileR, c0 = t.tileC;
const tiles = [[r0, c0], [r0 + 1, c0], [r0 - 1, c0], [r0, c0 + 1], [r0, c0 - 1]];
b2.fx('telegraph', { x: c0, y: r0, r: 1, kind: 'royalDecree', tiles: 'plus', id: e2.id });
for (const u of alliesInTiles(b2, r0, c0, 'plus', 1)) hurt(b2, e2, u, e2.s.atk * ((sk && sk.bb.atk_scale) || 0), 'phys');
const taken = equipment(b2);
const free = tiles.filter(([r, c]) => b2.grid.inRect(r, c) && b2.grid.isLow(r, c) && b2.grid.groundPassable(r, c) && !b2.unitAt(r, c)
&& !taken.some((q) => Math.round(q.y) === r && Math.round(q.x) === c));
const spot = b2.rng.pick(free);
if (spot) {
const key = b2.rng.pick(EQUIP_KEYS);
const q = b2.spawnEnemy(key, { pos: spot, route: { motion: 'FLY', start: spot, end: spot, steps: [{ t: 'wait', s: 99999 }] }, tag: 'part', countInTotal: false, ownerPlayerId: e2.ownerId });
if (q) b2.fx('equip', { x: spot[1], y: spot[0], id: q.id, key });
}
return true;
};
const animate = (b2, e2) => { // 【莫非王土】 the latest equipment (2 at ≤50 %)
const n = P.adv && stA ? stA.bb.max_target ?? 2 : 1;
const sk = P.adv && stA ? stA : st;
for (const q of equipment(b2).slice(-n)) {
if (q.defId === 'enemy_10028_vtswd') {
const t = b2.rng.pick(targetsNear(b2, e2, range()));
if (t) { b2.fx('sword', { x: q.x, y: q.y, from: q.id, to: t.id }); hurt(b2, e2, t, e2.s.atk * ((sk && sk.bb.atk_scale) || 0), 'phys'); }
} else if (q.defId === 'enemy_10029_vtshld') {
const side = Math.sign(q.x - e2.x) || -1;
b2.addBuff(e2, { key: 'boss:vest', shield: (sk && sk.bb.dynamic) || 0, persist: true, visible: true, data: { side, cut: (sk && sk.bb.damage_resistance) || 0 } });
b2.fx('vest', { x: e2.x, y: e2.y, id: e2.id, side });
} else if (q.defId === 'enemy_10030_vtwand') {
P.wand = true; // [substitute for 黄金回响]: next attack becomes arts and disarms its target
b2.fx('equip', { x: q.x, y: q.y, id: q.id, key: q.defId, kind: 'wand' });
}
b2.kill(q, null);
}
};
return [
{
tick(b2, e2, a, dt) {
if (!P.adv && e2.hpRatio <= (T(ab, 'advance.hp_ratio') ?? 0)) { // ≤50 %: DEF +100 %, RES +45, animate two
P.adv = true;
b2.addBuff(e2, { key: 'boss:advance', persist: true, visible: true, mods: { defPct: T(ab, 'advance.def') ?? 0, resFlat: T(ab, 'advance.magic_resistance') ?? 0 } });
b2.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'lionAdvance' });
}
if (!canCast(e2, false)) return;
P.sp += dt * ENEMY_SP_PER_SEC;
if (P.sp >= cost && decree(b2, e2)) P.sp = 0;
// 【莫非王土】: ANIMATE_DELAY s after equipment first lies on the field, then once per 王权号令 cycle
const has = equipment(b2).length > 0;
if (!has) { P.nextAnim = null; return; }
if (P.nextAnim == null) P.nextAnim = b2.time + ANIMATE_DELAY;
if (b2.time >= P.nextAnim) { animate(b2, e2); P.nextAnim = b2.time + (Number.isFinite(cost) ? cost : ANIMATE_DELAY); }
},
hitIn(c, b2, e2) { // vest: damage from its side ×(1 − damage_resistance) while the barrier holds
const v = e2.findBuff('boss:vest');
if (v && v.shield > 0 && c.source && Math.sign(c.source.x - e2.x) === v.data.side) c.dmg.mul *= 1 - v.data.cut;
},
hitOut(c, b2, e2) { if (P.wand && c.dmg.isAttack) c.dmg.type = 'arts'; },
dealt(c, b2, e2) { if (!P.wand) return; P.wand = false; if (disarm > 0) b2.applyStatus(c.target, 'disarm', { duration: disarm, source: e2 }); },
},
su && {
cd: su.cd, icd: su.icd,
fire(b2, e2) { // 【斥退】
b2.fx('telegraph', { x: e2.x, y: e2.y, r: 99, kind: 'repel', id: e2.id });
for (const u of b2.allies()) hurt(b2, e2, u, e2.s.atk * (su.bb.atk_scale ?? 0), 'arts');
lpLoss(b2, Math.abs(su.bb.value ?? 0), 'repel', e2);
},
},
];
}
function kitEquip() {
return [{ spawn(b, e) { b.addBuff(e, { key: 'boss:equip', persist: true, flags: { invulnerable: true, untargetable: true, noMove: true } }); } }];
}
// ---------------------------------------------------------------------------------------------------------------
// “萨米的意志” (boss_7)
function kitDeer(ab, e) {
const dr = T(ab, 'Madness.damage_resistance') ?? 0;
const atkN = T(ab, 'Madness.enemy_smdeer_mad[attack].max_cnt') ?? 1, skN = T(ab, 'Madness.enemy_smdeer_mad[skill].max_target') ?? 1;
const lasso = ab.sk.Lasso, doom = ab.sk.Doom;
const P = { low: false, acc: Infinity }; // like engine attacks, the first 冰凌 comes as soon as it has a target
return [
{
spawn(b, e2) { e2.profile.noAttack = true; },
tick(b, e2, a, dt) {
if (!P.low && e2.hpRatio < 0.5) { // <50 %: damage taken reduced, extra targets
P.low = true;
b.addBuff(e2, { key: 'boss:madness', persist: true, visible: true, mods: { physTakenMul: 1 - dr, artsTakenMul: 1 - dr } }); // 物理和法术伤害降低
b.fx('phase', { x: e2.x, y: e2.y, id: e2.id, kind: 'deerMadness' });
}
if (!canCast(e2, false)) return;
P.acc += dt;
if (P.acc < e2.s.interval) return;
const cands = fairOrder(b, e2, allTargets(b, e2), P);
if (!cands.length) return;
P.acc = 0;
const cols = [];
for (const t of cands) { if (!cols.includes(t.tileC)) cols.push(t.tileC); if (cols.length >= (P.low ? atkN : 1)) break; }
for (const c of cols) { // 【冰凌】 its normal attack: the whole column
const hit = b.allies().filter((u) => u.tileC === c);
b.fx('column', { x: c, y: e2.y, c, id: e2.id });
for (const u of hit) b.dealDamage(e2, u, { amount: e2.s.atk, type: 'phys', isAttack: true });
}
e2.lastAttackAt = b.time;
e2.stats.attacks++;
},
},
lasso && {
cd: lasso.cd, icd: lasso.icd, cond: (b) => allTargets(b, e).length > 0,
fire(b, e2) { // 【自然涌动】 stun + arts per second
const n = P.low ? skN : (lasso.bb.max_target ?? 1);
const dur = lasso.bb.projectile_life_time ?? 0;
for (const t of fairOrder(b, e2, allTargets(b, e2), P).slice(0, n)) {
b.fx('beam', { x: e2.x, y: e2.y, from: e2.id, to: t.id, kind: 'naturalSurge', dur });
b.applyStatus(t, 'stun', { duration: dur, source: e2 });
b.addBuff(t, { key: 'boss:surge', duration: dur, interval: 1, visible: true,
onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: e2.s.atk * (lasso.bb.atk_scale ?? 0), type: 'arts', canDodge: false, tags: ['enemyAbility'] }) });
}
},
},
doom && {
cd: doom.cd, icd: doom.icd,
fire(b, e2) {
b.fx('telegraph', { x: e2.x, y: e2.y, r: 99, kind: 'samiDoom', id: e2.id });
for (const u of b.allies()) hurt(b, e2, u, e2.s.atk * (doom.bb.atk_scale ?? 0), 'arts');
lpLoss(b, Math.abs(doom.bb.value ?? 0), 'samiDoom', e2);
},
},
];
}
// ---------------------------------------------------------------------------------------------------------------
export const BOSS_KITS = Object.freeze({
enemy_9013_acstmk: kitHelm, // boss_1 假想敌:胄
enemy_9013_acstmk_2: kitHelm, // boss_8 假想敌:胄 (隐秘核心)
enemy_9014_acstma: kitBlade, // “斩胄之剑”
enemy_9015_acstmb: kitBlade, // “破胄之锤”
enemy_9016_acstmr: kitShell, // 刺胄之弹
enemy_9017_achunt: kitGun, // boss_2 假想敌:铳
enemy_9017_achunt_2: kitGun, // boss_9 假想敌:铳 (隐秘核心)
enemy_9018_actrpa: kitSpring, // “碎铳之簧” 法术护盾
enemy_9019_actrpb: kitSpring, // “碎铳之簧” 元素护盾
enemy_9020_actrpc: kitSpring, // “碎铳之簧” 频次护盾
enemy_9021_acduml: kitPipe, // boss_3 假想敌:管
enemy_9021_acduml_2: kitPipe, // boss_10 假想敌:管 (隐秘核心)
enemy_9022_acdumm: kitString, // 假想敌:弦
enemy_9023_acdums: kitEcho, // “余音”
enemy_1521_dslily: kitQuintus, // boss_4 盐风主教昆图斯
enemy_2016_csphtm: kitLucien, // boss_5 卢西恩,“猩红血钻”
enemy_2017_csphts: lucienCore, // 不祥幻影
enemy_9032_aclionk: kitLion, // boss_6 阿利斯泰尔,帝国余晖
enemy_10028_vtswd: kitEquip, // 未装配刀片 (inert until animated)
enemy_10029_vtshld: kitEquip, // 防护背心
enemy_10030_vtwand: kitEquip, // 冲击式施术单元
enemy_9033_acdeer: kitDeer, // boss_7 “萨米的意志”
});
export const BOSS_KEYS = Object.freeze(Object.keys(BOSS_KITS));
+495
View File
@@ -0,0 +1,495 @@
// server/sim/content/choices.js — 机变 (SP draft) card content (DESIGN §6.1 / §7; research 01 §12 + Addendum A4,
// 04 Addendum, 05 §3.2 step 8, 06 §4.4 / §8 / §11.5). Card GENERATION (family per SP round, 6 shared / 3 solo cards,
// pick order, timers) lives in server/match/choices.js; this module owns what a picked card DOES.
//
// Prep side — registerMeta(registry) registers one `choice:<id>` handler per card id the draft can produce
// (docs/META.md §2.6; the handler replaces the match's family default):
// 悬赏决策 (129 ENEMY_GAIN effects, data/effects.json): the effect's own bounty buff is read —
// add_enemy_kill_gain_coin {count, coin, round, enemy_id} kill bounty: `count` enemies on the picker's
// next `round` battles (99 = all remaining); the
// KILLER gets `coin` (a 联防 helper too)
// add_enemy_selfbattle_win_gain_coin / next_battle_add_enemy_win_gain_coin 战术特训: `coin` only when the picker's
// own 各自行动阶段 is perfect (settlement, match)
// enemyeffect_1 lists both perfect keys with different counts (4 / 3): the add_enemy_* buff wins (its text says
// "添加3只"). The bounty goes to the picker only; spawns, kill payout, perfect payout and the solo ×0.7 of
// 战术特训 enemies are applied by the match (waves.js bountySpawns / Match settlement / Battle kill payout).
// 道具补给 / 机密商店 (item cards; id = the normal EQUIP item id): the item goes to the picker's hand (temp on
// overflow; merges with an identical normal copy) — free ("无需消耗资金").
// 战术决策 (43 BUFF_GAIN effects), by buff key (numbers from the blackboard):
// global_special_choice_gain_equip {count, pool} 列装 `count` items rolled from the server pool
// global_special_choice_bond_addlayer {count, bond_list} …的盟誓 layers +count on every listed bond (无需激活)
// global_special_choice_gain_coin {count} 财富 funds now
// global_special_choice_refresh_free {count} 补给 free refreshes (stack)
// single_special_choice_gloden_equip_chess {count} 整备 next `count` purchased items → 进阶 (EffectRef
// effect:builtin_next_buy_golden_item)
// single_special_choice_gloden_char_chess {count} 升华 next `count` purchased operators → 精锐 (EffectRef
// effect:builtin_next_buy_elite)
// single_special_choice_gain_bond_chess {count, bond} …驰援 `count` random chess of the bond from the shared
// pool (tier ≤ shop level, else any tier) [ASSUMED tier rule]
// auto_chess_change_map {alias: 0|1} 模拟战场演变 the picker's own board: prep legality
// (setDeviceActive) + battle devices (devices.js
// reads the EffectRef params)
// everything else → a battle EffectRef `{ key: 'choice:<id>', params: { …, prepOk }, data: { effectId } }` read by
// install() below. Prep conditions are evaluated by this handler on onPrepEnd (the ref's own dispatch):
// global_special_choice_prep_finish_bench_at_least {count} 火力 "休整期结束时若手牌区至少有N个单位"
// global_special_choice_prep_finish_bench_at_most {count} 锐利 "休整期结束时若手牌区为空"
// [ASSUMED] both count every card of the 手牌区 (10 regular + 5 temporary slots: operators, items, summon
// stacks) — the two cards share one bench count and 锐利 reads "为空".
// Cards whose text says "若存在其他队友则他们也获得" (team: true, icon_team_buff) reach every alive teammate
// (server/match/choices.js applyCard runs this handler once per recipient). Re-picking a card stacks.
//
// Battle side — install(battle) reads each player's playerEffects (key 'choice:<effectId>'):
// global_special_choice_all_activated always on
// …prep_finish_bench_at_least / _at_most on when params.prepOk (fallback: contentInfo.handUnits)
// global_special_choice_prep_finish_same_row_at_least 征召: on when, at battle start, some row holds ≥ count of
// the player's deployed operators
// env_gbuff_new_with_verify act1autochess_debuff_3 {value} 自愈: every damage instance an own unit (operator or
// summon) takes heals it `value` (not HP loss 流失, not gauge
// fills, not while a 禁疗 status is on)
// env_gbuff_new_with_verify act1autochess_debuff_9 {atk, def} 无瑕: operators at full HP get ATK/DEF ×(1 + x)
// env_gbuff_new halfIdle_magic_resist_penetration {magic_resist_penetrate_fixed} 火力: operators ignore N RES
// env_gbuff_new halfIdle_def_penetration {def_penetrate} 锐利: operators ignore x DEF
// char_respawntime_mul {scale} 征召: operators' redeploy time × scale
// enemy_attribute_mul / enemy_attribute_add {atk|def|magic_resistance|max_hp|attack_speed|move_speed}
// (+ bbStr enemy_level_type ELITE / BOSS) 排斥/责罚/裁决: enemies of the player's half (ownerId)
// of that rank get the debuff at spawn (all ranks without a type)
// Attribute bonuses are 直接乘算 (support/index.js convention): ×(1 + x) multipliers; flat values stay flat.
import { gameData, num, buffsOf, passiveBuff, effectRecord } from './support/index.js';
// =====================================================================================================================
// data helpers
const BOUNTY_KEYS = Object.freeze(['add_enemy_kill_gain_coin', 'add_enemy_selfbattle_win_gain_coin', 'next_battle_add_enemy_win_gain_coin']);
const KEY = Object.freeze({
gainEquip: 'global_special_choice_gain_equip',
addLayer: 'global_special_choice_bond_addlayer',
gainCoin: 'global_special_choice_gain_coin',
freeRefresh: 'global_special_choice_refresh_free',
goldenItem: 'single_special_choice_gloden_equip_chess',
eliteChess: 'single_special_choice_gloden_char_chess',
bondChess: 'single_special_choice_gain_bond_chess',
map: 'auto_chess_change_map',
always: 'global_special_choice_all_activated',
benchAtLeast: 'global_special_choice_prep_finish_bench_at_least',
benchAtMost: 'global_special_choice_prep_finish_bench_at_most',
sameRow: 'global_special_choice_prep_finish_same_row_at_least',
});
/** Buff keys resolved on the prep side (no battle part). */
const PREP_KEYS = new Set([KEY.gainEquip, KEY.addLayer, KEY.gainCoin, KEY.freeRefresh, KEY.goldenItem, KEY.eliteChess, KEY.bondChess]);
/** Condition (gate) keys of battle cards. */
const GATE_KEYS = new Set([KEY.always, KEY.benchAtLeast, KEY.benchAtMost, KEY.sameRow]);
export const BUILTIN_REFS = Object.freeze({ goldenItem: 'effect:builtin_next_buy_golden_item', eliteChess: 'effect:builtin_next_buy_elite' });
const int = (v, d = 0) => { const n = num(v, NaN); return Number.isFinite(n) ? Math.trunc(n) : d; };
const own = (o, k) => (o && typeof o === 'object' && typeof k === 'string' && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : null);
/** choices.json cards.<family> entry of an effect id (display name, tier, team flag, tactic kind), or null. */
function cardRecord(data, family, id) {
const list = data && data.choices && data.choices.cards && Array.isArray(data.choices.cards[family]) ? data.choices.cards[family] : [];
return list.find((c) => c && c.effectId === id) ?? null;
}
/**
* The bounty a 悬赏 effect adds (from its own buff blackboard), in the choices.json card shape used by the match
* (`m.addBounty`), or null when the effect has no bounty buff.
* @param {object} effect data/effects.json ENEMY_GAIN record
* @param {object|null} [card] choices.json cards.bounty entry (tier)
*/
export function bountyOf(effect, card = null) {
if (!effect || !Array.isArray(effect.buffs)) return null;
const list = effect.buffs.filter((b) => b && BOUNTY_KEYS.includes(b.key) && b.bbStr && typeof b.bbStr.enemy_id === 'string');
if (!list.length) return null;
list.sort((a, b) => BOUNTY_KEYS.indexOf(a.key) - BOUNTY_KEYS.indexOf(b.key));
const b = list[0];
const bb = b.bb || {};
const coin = Math.max(0, int(bb.coin, int(effect.enemyPrice, 0)));
const rounds = b.key === 'next_battle_add_enemy_win_gain_coin' ? 1 : Math.max(1, Math.min(99, int(bb.round, 1)));
const tier = card && Number.isInteger(card.tier) ? card.tier : Math.max(1, Math.min(3, coin || 1));
return {
effectId: effect.effectId ?? null,
name: effect.name ?? card?.name ?? '悬赏',
desc: effect.desc ?? card?.desc ?? '',
tier,
coin,
payout: b.key === 'add_enemy_kill_gain_coin' ? 'kill' : 'perfect',
rounds,
multiRound: rounds > 1,
enemyKey: b.bbStr.enemy_id,
count: Math.max(1, int(bb.count, 1)),
};
}
/** Device aliases (`trap_…#nnn` → on/off) of every auto_chess_change_map buff of an effect. */
export function mapAliases(effect) {
const out = {};
for (const b of buffsOf(effect)) {
if (b.key !== KEY.map) continue;
for (const [k, v] of Object.entries(b.p)) if (k.includes('#')) out[k] = num(v, 0) !== 0 ? 1 : 0;
}
return out;
}
/** Gate of a battle card: { kind: 'always'|'benchAtLeast'|'benchAtMost'|'sameRow', count }. */
export function gateOf(effect) {
for (const b of buffsOf(effect)) {
if (b.key === KEY.benchAtLeast) return { kind: 'benchAtLeast', count: Math.max(0, int(b.p.count, 0)) };
if (b.key === KEY.benchAtMost) return { kind: 'benchAtMost', count: Math.max(0, int(b.p.count, 0)) };
if (b.key === KEY.sameRow) return { kind: 'sameRow', count: Math.max(1, int(b.p.count, 3)) };
}
return { kind: 'always', count: 0 };
}
/** Whether a prep bench gate holds for `n` cards in the 手牌区. */
export function benchGate(gate, n) {
if (!gate) return true;
if (gate.kind === 'benchAtLeast') return n >= gate.count;
if (gate.kind === 'benchAtMost') return n <= gate.count;
return true;
}
/** Does a 战术决策 effect have a battle part (anything besides the prep-side keys)? */
export function hasBattlePart(effect) {
return buffsOf(effect).some((b) => b.key && !PREP_KEYS.has(b.key) && !GATE_KEYS.has(b.key));
}
/** Ids of every 机变 card the draft can produce (bounty / tactic effects, supply & shop items) — the coverage target. */
export function choiceCardIds(data = gameData()) {
const bounty = [];
const tactic = [];
for (const [id, e] of Object.entries((data && data.effects) || {})) {
if (!e || typeof e !== 'object') continue;
if (e.effectType === 'ENEMY_GAIN') bounty.push(id);
else if (e.effectType === 'BUFF_GAIN') tactic.push(id);
}
const cards = (data && data.choices && data.choices.cards) || {};
for (const c of Array.isArray(cards.bounty) ? cards.bounty : []) if (c && c.effectId && !bounty.includes(c.effectId)) bounty.push(c.effectId);
for (const c of Array.isArray(cards.tactic) ? cards.tactic : []) if (c && c.effectId && !tactic.includes(c.effectId)) tactic.push(c.effectId);
const items = [];
for (const [id, it] of Object.entries((data && data.items) || {})) {
if (!it || it.isGolden || it.hideInShop || it.itemType !== 'EQUIP' || !Number.isInteger(it.tier)) continue;
items.push(id);
}
return { bounty: bounty.sort(), tactic: tactic.sort(), items: items.sort() };
}
// =====================================================================================================================
// prep side
/** Cards currently in the 手牌区 (regular + temporary slots; operators, items, summon stacks). */
function benchCount(ctx) {
let n = 0;
for (const p of ctx.hand()) if (p) n++;
for (const p of ctx.temp()) if (p) n++;
return n;
}
const effectOf = (ctx, id) => own(ctx.data && ctx.data.effects, id) ?? effectRecord(id);
/** 悬赏决策: the picker's bounty (personal: never given to teammates). */
const BOUNTY = {
onChoicePick(ctx, ev) {
if (ctx.source.kind !== 'choice' || !ev || !ev.card || ev.forTeammate) return;
const id = ev.card.id;
const spec = bountyOf(effectOf(ctx, id), cardRecord(ctx.data, 'bounty', id)) ?? cardRecord(ctx.data, 'bounty', id);
if (!spec || !ctx.gd.enemy(spec.enemyKey)) return;
ctx.addBounty(spec);
},
};
/** 道具补给 / 机密商店: the item, free. */
const ITEM = {
onChoicePick(ctx, ev) {
if (ctx.source.kind !== 'choice' || !ev || !ev.card || ev.forTeammate) return;
if (!ctx.gd.item(ev.card.id)) return;
ctx.grantItem(ev.card.id, { source: 'choice' });
},
};
function refIdFor(ctx, effectId) {
const n = ctx.incCounter('choices:refSeq', 1);
return `choice:${effectId}#${n}`;
}
function applyTactic(ctx, ev) {
const id = ev.card.id;
const eff = effectOf(ctx, id);
if (!eff || !Array.isArray(eff.buffs)) return;
const card = cardRecord(ctx.data, 'tactic', id);
const team = !!(card ? card.team : ev.card.team);
const name = eff.name ?? ev.card.name ?? id;
const desc = eff.desc ?? ev.card.desc ?? '';
const iconKind = team ? 'team' : 'choice';
const iconId = eff.decoIconId || id;
const data = { effectId: id, tacticKind: card ? card.kind ?? null : ev.card.tacticKind ?? null, team, round: ctx.round };
for (const b of buffsOf(eff)) {
const p = b.p;
const count = Math.max(0, int(p.count, 1));
switch (b.key) {
case KEY.gainEquip: {
for (let i = 0; i < count; i++) {
const r = typeof p.pool === 'string' ? ctx.rollPool(p.pool) : null;
const itemId = r && r.kind === 'item' ? r.id : ctx.rollItem({ tier: 1 });
if (itemId) ctx.grantItem(itemId, { source: 'choice' });
}
break;
}
case KEY.addLayer: {
const bonds = String(p.bond_list ?? '').split(',').map((s) => s.trim()).filter(Boolean);
for (const bondId of bonds) ctx.addLayers(bondId, count, { requireActive: false, reason: 'choice' });
break;
}
case KEY.gainCoin:
ctx.addFunds(count, 'choice');
break;
case KEY.freeRefresh:
ctx.grantFreeRefresh(count);
break;
case KEY.goldenItem:
if (count > 0) ctx.addEffect({ id: refIdFor(ctx, id), key: BUILTIN_REFS.goldenItem, name, desc, iconKind, iconId, counter: count, battle: false, params: { count }, data });
break;
case KEY.eliteChess:
if (count > 0) ctx.addEffect({ id: refIdFor(ctx, id), key: BUILTIN_REFS.eliteChess, name, desc, iconKind, iconId, counter: count, battle: false, params: { count }, data });
break;
case KEY.bondChess: {
const bond = typeof p.bond === 'string' ? p.bond : null;
if (!bond) break;
for (let i = 0; i < count; i++) {
const chessId = ctx.rollChess({ bond, maxTier: Math.max(1, ctx.shopLevel()) }) ?? ctx.rollChess({ bond });
if (chessId) ctx.grantChess(chessId, { source: 'choice' });
}
break;
}
default:
break;
}
}
const aliases = mapAliases(eff);
const aliasList = Object.entries(aliases);
for (const [alias, on] of aliasList) ctx.setDeviceActive(alias, on !== 0);
if (aliasList.length || hasBattlePart(eff)) {
const gate = gateOf(eff);
const params = { ...aliases };
if (gate.kind === 'benchAtLeast' || gate.kind === 'benchAtMost') params.prepOk = benchGate(gate, benchCount(ctx));
ctx.addEffect({ id: refIdFor(ctx, id), key: `choice:${id}`, name, desc, iconKind, iconId, battle: true, params, data });
}
}
/** 战术决策 handler: application on pick; bench conditions re-evaluated at every prep end (the ref's own dispatch). */
const TACTIC = {
onChoicePick(ctx, ev) {
if (ctx.source.kind !== 'choice' || !ev || !ev.card) return;
applyTactic(ctx, ev);
},
onPrepEnd(ctx) {
if (ctx.source.kind !== 'effect' || !ctx.source.ref) return;
const ref = ctx.source.ref;
const effectId = ref.data && typeof ref.data.effectId === 'string' ? ref.data.effectId : String(ref.key || '').replace(/^choice:/, '');
const gate = gateOf(effectOf(ctx, effectId));
if (gate.kind !== 'benchAtLeast' && gate.kind !== 'benchAtMost') return;
const n = benchCount(ctx);
ctx.addEffect({ id: ref.id, params: { ...(ref.params || {}), prepOk: benchGate(gate, n), bench: n } });
},
};
export function registerMeta(registry) {
const ids = choiceCardIds();
for (const id of ids.bounty) registry.choice(id, BOUNTY);
for (const id of ids.tactic) registry.choice(id, TACTIC);
for (const id of ids.items) registry.choice(id, ITEM);
}
// =====================================================================================================================
// battle side
const RUNE = Object.freeze({ heal: 'act1autochess_debuff_3', flawless: 'act1autochess_debuff_9' });
/** Operator mods of an env_gbuff blackboard (numbers only; 直接乘算 for attributes). */
function opModsOf(p) {
const mods = {};
const add = (k, v) => { if (Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 0) + v; };
const mul = (k, v) => { if (Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 1) * (1 + v); };
for (const [k, raw] of Object.entries(p || {})) {
const v = num(raw, NaN);
if (!Number.isFinite(v)) continue;
switch (k) {
case 'magic_resist_penetrate_fixed': add('resIgnoreFlat', v); break;
case 'magic_resist_penetrate': add('resIgnorePct', v); break;
case 'def_penetrate': add('defIgnorePct', v); break;
case 'def_penetrate_fixed': add('defIgnoreFlat', v); break;
case 'atk': mul('atkMul', v); break;
case 'def': mul('defMul', v); break;
case 'max_hp': mul('hpMul', v); break;
case 'attack_speed': add('aspd', Math.abs(v) < 1 ? v * 100 : v); break;
case 'damage_scale': mul('dmgDealtMul', v); break;
default: break;
}
}
return mods;
}
/** Enemy mods of an enemy_attribute_mul / _add blackboard. */
function enemyModsOf(p, isMul) {
const mods = {};
const MUL = { atk: 'atkMul', def: 'defMul', max_hp: 'hpMul', magic_resistance: 'resMul', move_speed: 'moveMul' };
const ADD = { atk: 'atkFlat', def: 'defFlat', max_hp: 'hpFlat', magic_resistance: 'resFlat', move_speed: 'moveFlat', attack_speed: 'aspd' };
for (const [k, raw] of Object.entries(p || {})) {
const v = num(raw, NaN);
if (!Number.isFinite(v)) continue;
if (isMul) {
if (k === 'attack_speed') { if (v !== 1) mods.aspd = (mods.aspd ?? 0) + (v - 1) * 100; continue; }
if (MUL[k] && v > 0 && v !== 1) mods[MUL[k]] = (mods[MUL[k]] ?? 1) * v;
} else if (ADD[k] && v !== 0) {
mods[ADD[k]] = (mods[ADD[k]] ?? 0) + v;
}
}
return mods;
}
/**
* Battle plan of one card EffectRef: { effectId, key, gate, heal, flawless, opMods, enemy: [{ rank, mods }] } or null.
* @param {object} ref playerEffects entry
*/
export function battlePlanOf(ref) {
const effectId = ref && ref.data && typeof ref.data.effectId === 'string' ? ref.data.effectId
: ref && typeof ref.key === 'string' && ref.key.startsWith('choice:') ? ref.key.slice(7) : null;
if (!effectId) return null;
const eff = effectRecord(effectId);
if (!eff || !Array.isArray(eff.buffs)) return null;
// buff key: the ref id (`choice:<effectId>#<n>`, unique per pick) — one buff per picked card, so re-picks stack
const key = typeof ref.id === 'string' && ref.id.startsWith(`choice:${effectId}#`) ? ref.id : `choice:${effectId}#${ref.id ?? ''}`;
const plan = { effectId, key, gate: gateOf(eff), heal: 0, flawless: null, opMods: null, enemy: [] };
const mergeOp = (mods) => {
if (!mods || !Object.keys(mods).length) return;
plan.opMods = plan.opMods ?? {};
for (const [k, v] of Object.entries(mods)) plan.opMods[k] = k.endsWith('Mul') ? (plan.opMods[k] ?? 1) * v : (plan.opMods[k] ?? 0) + v;
};
for (const b of buffsOf(eff)) {
if (!b.key || PREP_KEYS.has(b.key) || GATE_KEYS.has(b.key) || b.key === KEY.map) continue;
if (b.key === 'env_gbuff_new' || b.key === 'env_gbuff_new_with_verify') {
if (b.bbKey === RUNE.heal) plan.heal += Math.max(0, num(b.p.value, 0));
else if (b.bbKey === RUNE.flawless) plan.flawless = opModsOf({ atk: b.p.atk, def: b.p.def });
else mergeOp(opModsOf(b.p));
} else if (b.key === 'char_respawntime_mul') {
const s = num(b.p.scale, NaN);
if (Number.isFinite(s) && s >= 0) mergeOp({ redeployMul: s });
} else if (b.key === 'enemy_attribute_mul' || b.key === 'enemy_attribute_add') {
const mods = enemyModsOf(b.p, b.key === 'enemy_attribute_mul');
const rank = typeof b.p.enemy_level_type === 'string' && b.p.enemy_level_type ? b.p.enemy_level_type.toUpperCase() : null;
if (Object.keys(mods).length) plan.enemy.push({ rank, mods });
}
}
return plan.heal > 0 || plan.flawless || plan.opMods || plan.enemy.length ? plan : null;
}
const rankOf = (e) => String(e?.def?.rank ?? e?.def?.raw?.rank ?? 'NORMAL').toUpperCase();
const isOwnOp = (u, pid) => !!u && u.kind === 'op' && u.side === 'ally' && u.ownerId === pid;
/** Whether a prep-gated card is on in this battle (params.prepOk from the match; contentInfo as a fallback). */
function prepGateOn(battle, ps, ref, gate) {
if (gate.kind !== 'benchAtLeast' && gate.kind !== 'benchAtMost') return true;
const p = ref && ref.params;
if (p && typeof p.prepOk === 'boolean') return p.prepOk;
const ci = ps && ps.input && ps.input.contentInfo;
const n = ci && Number.isFinite(ci.handUnits) ? ci.handUnits : null;
return n == null ? false : benchGate(gate, n);
}
/** Largest number of the player's deployed operators sharing one row. */
function maxRowOps(battle, pid) {
const rows = new Map();
for (const u of battle.allyUnits) if (isOwnOp(u, pid) && u.alive && u.deployed) rows.set(u.tileR, (rows.get(u.tileR) ?? 0) + 1);
let best = 0;
for (const n of rows.values()) best = Math.max(best, n);
return best;
}
export function install(battle) {
const cards = [];
for (const ps of battle.players || []) {
for (const ref of ps.playerEffects || []) {
if (!ref || typeof ref.key !== 'string' || !ref.key.startsWith('choice:')) continue;
const plan = battlePlanOf(ref);
if (!plan || !prepGateOn(battle, ps, ref, plan.gate)) continue;
cards.push({ pid: ps.playerId, ref, plan });
}
}
if (!cards.length) return;
const heal = new Map(); // pid → HP per damage instance (自愈)
const flawless = new Map(); // pid → [{ key, mods }] (无瑕)
const enemy = new Map(); // pid → [{ key, rank, mods }] (排斥 / 责罚 / 裁决)
const opCards = []; // cards with operator mods, applied at battle start
const enable = (c) => {
const { pid, plan } = c;
if (plan.heal > 0) heal.set(pid, (heal.get(pid) ?? 0) + plan.heal);
if (plan.flawless && Object.keys(plan.flawless).length) {
if (!flawless.has(pid)) flawless.set(pid, []);
flawless.get(pid).push({ key: plan.key, mods: plan.flawless });
}
if (plan.enemy.length) {
if (!enemy.has(pid)) enemy.set(pid, []);
for (const e of plan.enemy) enemy.get(pid).push({ key: plan.key, rank: e.rank, mods: e.mods });
}
if (plan.opMods) opCards.push(c);
};
// 征召's row check needs the deployed board (battle start); every other card is on from the first tick
const late = cards.filter((c) => c.plan.gate.kind === 'sameRow');
for (const c of cards) if (c.plan.gate.kind !== 'sameRow') enable(c);
const wantsHeal = cards.some((c) => c.plan.heal > 0);
const wantsFlawless = cards.some((c) => c.plan.flawless);
battle.on('battleStart', () => {
for (const c of late) {
c.rowOk = maxRowOps(battle, c.pid) >= c.plan.gate.count;
if (c.rowOk) enable(c);
}
for (const c of opCards) for (const u of battle.allyUnits) if (isOwnOp(u, c.pid)) passiveBuff(battle, u, c.plan.key, { ...c.plan.opMods });
if (flawless.size) for (const u of battle.allyUnits) syncFlawless(u);
}, { priority: -10 });
// 无瑕: operators at full HP (checked on damage, and every tick for heals / redeploys / max-HP changes)
function syncFlawless(u) {
if (!u || u.kind !== 'op' || u.side !== 'ally') return;
const list = flawless.get(u.ownerId);
if (!list) return;
const full = u.alive && u.deployed && u.hp >= u.s.maxHp - 1e-6;
for (const f of list) {
const has = !!u.findBuff(f.key);
if (full && !has) battle.addBuff(u, { key: f.key, mods: { ...f.mods } });
else if (!full && has) battle.removeBuff(u, f.key);
}
}
if (wantsHeal || wantsFlawless) {
battle.on('damaged', (ctx) => {
const t = ctx && ctx.target;
if (!t || t.side !== 'ally' || (t.kind !== 'op' && t.kind !== 'token')) return;
// 自愈: every damage instance an own unit takes (not HP loss 流失, not element gauge fills, not under 禁疗)
const amt = heal.get(t.ownerId);
if (amt > 0 && ctx.amount > 0 && ctx.type !== 'element' && !(ctx.dmg && Array.isArray(ctx.dmg.tags) && ctx.dmg.tags.includes('hpLoss'))
&& t.alive && t.deployed && t.hp > 0 && !t.s.flags.noHeal) {
battle.heal(null, t, amt, { self: true });
}
if (flawless.size && t.kind === 'op') syncFlawless(t);
});
}
if (wantsFlawless) {
battle.on('tick', () => {
if (!flawless.size) return;
for (const u of battle.allyUnits) if (u.kind === 'op' && flawless.has(u.ownerId)) syncFlawless(u);
});
}
// enemy debuffs: enemies of the player's half (ownerId) with the card's rank, at spawn
if (cards.some((c) => c.plan.enemy.length)) {
battle.on('enemySpawn', (ctx) => {
const e = ctx && ctx.enemy;
if (!e || !e.alive || !enemy.size) return;
const pid = e.ownerId ?? (battle.players.length === 1 ? battle.players[0].playerId : null);
const list = pid != null ? enemy.get(pid) : null;
if (!list) return;
const rank = rankOf(e);
for (const d of list) if (!d.rank || d.rank === rank) battle.addBuff(e, { key: d.key, mods: { ...d.mods }, persist: true });
});
}
}
+522
View File
@@ -0,0 +1,522 @@
// server/sim/content/devices.js — stage devices & special terrain (data/stages.json, research 05 §2.3–2.4).
//
// The engine already spawns active 阻隔工事 crates (obstacle devices, 100 HP) and marks active 射击台/mounds as
// obstacles that elevate operators. This module adds, per battle:
// 阻隔工事 an enemy blocked by a crate destroys it (after its attack interval even if it cannot attack: harmless
// walkers no longer stall on a crate); map cards (`deviceOverrides`) remove crates / add or remove
// platforms (obstacle + elevation) / activate hidden devices
// 气流 act2 m01 blowers (`trap_013_blower`, rangeTiles along `dir`): operators whose direction (`u.dir`, UP|
// RIGHT|DOWN|LEFT) equals the blower's dir / is its opposite / is perpendicular get ATK +
// blower_s_character[equal|opposite|vertical].atk (the m01 blowers blow DOWN); enemies moving with /
// against the flow get move speed × (1 + blower_s_enemy[equal|opposite].move_speed)
// 沼泽 m +1 stack on entering and every intervalSec s on the tile (max maxStacks): ASPD aspdPerStack (fractions
// are ×100 ASPD), move speed × (1 + moveMulPerStack × stacks); cleared on leaving (allies and ground enemies)
// 烟雾 g operators on it cannot be targeted by enemy ranged attacks (stealth flag: blocked enemies still hit them)
// 深水 d ground enemies on it: sea_drown[enemy].damage true dmg/s, ASPD attack_speed (×100), move × move_speed
// 活性源石 i units on it (allies and ground enemies): damage true dmg/s, ATK + atk, ASPD + attack_speed, for
// `duration` s of battle time
// “双眼皮” turrets (`trap_1104_aclasert`, band 机械援助 / `deviceOverrides` alias on): ranged arts shooter on its data
// range; ASPD + attack_speed_per_stack × L (≤ max_attack_speed), hits apply fragile 1 + damage_scale_per_stack
// × L (≤ max_damage_scale) for the text's duration; L = the owner's highest bond layers (live)
// 盟约寒风 `startColdWind` / `kjeragColdWind` helpers for the 谢拉格 bond (every interval s all enemies cold)
// Device overrides: a player's battle input `deviceOverrides` ({ alias: bool }) and `alias` keys in its playerEffects
// params (auto_chess_change_map effects: band 机械援助, 机变 map cards) switch the devices on that player's half of
// the field only (unite / boss fields hold both halves); Battle opts.deviceOverrides switch every half.
// `deviceOverridesOf(battle)` is the union (informational).
// Terrain numbers come from `stage.special` (fallback: the device's skill blackboard, then research 05 values).
// Not implemented: the devices of act1 m05–m07 (canoe platforms, sandstorm + mounds, bushes) — weight 0 this season.
import { COLS, ROWS } from '../constants.js';
import { performAttack } from '../ai.js';
import { sortEnemyTargets } from '../targeting.js';
import { DIR_VEC, normDir, oppositeDir } from '../dir.js';
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
/** ASPD blackboard values: a fraction (|v| < 1, e.g. −0.6 / −0.05) is ×100 ASPD, otherwise flat (+20). */
const aspdOf = (v) => (Math.abs(v) < 1 ? v * 100 : v);
export const TERRAIN = Object.freeze({ mire: 1, smog: 2, deepsea: 3, infection: 4 });
const TERRAIN_NAME = ['', 'mire', 'smog', 'deepsea', 'infection'];
const BUFF = { 1: 'terrain:mire', 2: 'terrain:smog', 3: 'terrain:deepsea', 4: 'terrain:infection' };
const AIRFLOW = 'terrain:airflow';
const CRATE_KEY = 'trap_1105_accrate';
const TURRET_KEY = 'trap_1104_aclasert';
/** Research 05 §2.3 values, used only when a stage has no `special` block / device blackboard. */
const RESEARCH = Object.freeze({
mire: { intervalSec: 3, aspdPerStack: -0.05, moveMulPerStack: -0.05, maxStacks: 10 },
deepsea: { damage: 40, attack_speed: -0.6, move_speed: 0.6 },
infection: { damage: 70, atk: 0.2, attack_speed: 20, duration: 300 },
});
const STATE = new WeakMap();
const stateOf = (battle) => STATE.get(battle) ?? null;
// ---------------------------------------------------------------------------------------------------------------
// device overrides
const truthy = (v) => (typeof v === 'number' ? v !== 0 : typeof v === 'string' ? v !== '0' && v !== '' && v !== 'false' : !!v);
function addOverrides(m, obj) {
if (!obj || typeof obj !== 'object') return m;
for (const [k, v] of Object.entries(obj)) if (k.includes('#')) m.set(k, truthy(v));
return m;
}
/** Device overrides of one player (battle input `deviceOverrides` + playerEffects params): Map alias → active. */
export function playerDeviceOverrides(ps) {
const m = new Map();
if (!ps) return m;
addOverrides(m, ps.input?.deviceOverrides);
for (const e of ps.playerEffects || []) addOverrides(m, e?.params);
return m;
}
/** Union of device overrides for this battle: Map alias → active. */
export function deviceOverridesOf(battle) {
const m = new Map();
for (const ps of battle.players || []) for (const [k, v] of playerDeviceOverrides(ps)) m.set(k, v);
addOverrides(m, battle.opts?.deviceOverrides);
return m;
}
const aliasOf = (d) => d?.raw?.alias ?? d?.alias ?? null;
function dataActive(d) {
return d.raw && typeof d.raw.active === 'boolean' ? d.raw.active : !d.hidden && d.active !== false;
}
/**
* Override of a device (true / false, undefined = none). A device belongs to the player whose half of the field
* holds it (the stage has one alias group per half: #0xx own board, #1xx partner half, #2xx/#3xx boss halves); map
* cards (“移除场地上全部阻隔工事”) and band 机械援助 (“所有场地中出现1个”) change the owner's own half only — a
* partner's card never removes this player's crates, a partner's band never adds a turret scaling with this player's
* layers. Battle-level overrides (opts.deviceOverrides) apply to every half.
*/
function overrideOf(battle, st, d) {
const a = aliasOf(d);
if (a == null) return undefined;
const owner = playerForCol(battle, d.col);
const mine = owner != null ? st.byPlayer.get(owner) : null;
if (mine && mine.has(a)) return mine.get(a);
if (st.global.has(a)) return st.global.get(a);
return undefined;
}
function isActive(battle, st, d) {
const o = overrideOf(battle, st, d);
return o === undefined ? dataActive(d) : o;
}
// ---------------------------------------------------------------------------------------------------------------
// cold wind (谢拉格 bond)
/**
* Every `interval` s (first gust after `first` s, default = interval) all enemies on the field become cold for
* `duration` s (a number or `() => number`, evaluated per gust — layers can change mid-battle). A cold enemy that
* gets cold again freezes (engine rule). `playerId` only tags the fx; `ownerOnly` limits the gust to enemies
* attributed to that player. Returns { cancel() } or null for bad numbers.
*/
export function startColdWind(battle, { playerId = null, interval, duration, first, ownerOnly = false } = {}) {
const iv = num(interval, 0);
if (!(iv > 0)) return null;
const durOf = () => num(typeof duration === 'function' ? duration() : duration, 0);
const handle = { cancelled: false, gusts: 0, timer: null, cancel() { this.cancelled = true; this.timer?.cancel(); } };
const gust = () => {
if (handle.cancelled || battle.finished) return;
const dur = durOf();
if (!(dur > 0)) return;
let n = 0;
for (const e of battle.enemies) {
if (!e.alive || e.hidden) continue;
if (ownerOnly && playerId != null && e.ownerId !== playerId) continue;
if (battle.applyStatus(e, 'cold', { duration: dur, source: null })) n++;
}
handle.gusts++;
const R = battle.rect;
battle.fx('coldWind', { x: (R.c0 + R.c1) / 2, y: (R.r0 + R.r1) / 2, playerId, n, duration: dur });
};
const firstDelay = Math.max(0, num(first, iv));
handle.timer = battle.after(firstDelay, () => {
if (handle.cancelled) return;
gust();
handle.timer = battle.every(iv, gust);
});
return handle;
}
/**
* 谢拉格 6-member wind from the bond effect blackboard (effects.json bondeffect_kjerag `env_gbuff_new` bb):
* interval `bond_eff_kjerag[storm].interval`, duration `base_time + time_per_stack × layers` (live 谢拉格 layers).
*/
export function kjeragColdWind(battle, playerId, bb = {}, { bondId = 'kjeragShip' } = {}) {
const interval = num(bb['bond_eff_kjerag[storm].interval'], 0);
const base = num(bb['bond_eff_kjerag[storm].base_time'], 0);
const per = num(bb['bond_eff_kjerag[storm].time_per_stack'], 0);
const layers = () => num(battle.getPlayer(playerId)?.bonds?.[bondId]?.layers, 0);
return startColdWind(battle, { playerId, interval, duration: () => base + per * layers() });
}
// ---------------------------------------------------------------------------------------------------------------
// turrets (“双眼皮”)
/** Highest bond layer count of a player (live copy: in-battle gains included). */
function topLayers(battle, playerId) {
const bonds = battle.getPlayer(playerId)?.bonds;
let L = 0;
if (bonds && typeof bonds === 'object') for (const b of Object.values(bonds)) L = Math.max(L, num(b?.layers, 0));
return L;
}
function playerForCol(battle, c) {
if (!battle.players.length) return null;
if (battle.players.length === 1) return battle.players[0].playerId;
const half = c >= 11 ? 'R' : 'L';
return (battle.players.find((p) => p.half === half) ?? battle.players[0]).playerId;
}
/**
* Tile for a turret: its own position when it is inside the field and free, else the nearest free in-rect tile
* nobody can deploy on — off the enemy paths first (void before a lane / gate: act2 m01's turrets stand on row 8,
* outside the normal field, and would otherwise land on the red gate).
*/
function deviceTile(battle, r0, c0) {
const R = battle.rect;
let best = null, bs = null;
for (let r = R.r0; r <= R.r1; r++) {
for (let c = R.c0; c <= R.c1; c++) {
if (battle.isReservedTile(r, c)) continue;
const exact = r === r0 && c === c0;
if (!exact && battle.grid.tile(r, c).build !== 'NONE') continue;
const walk = !exact && battle.grid.groundPassable(r, c, true) ? 1 : 0;
const s = [exact ? 0 : 1, walk, Math.hypot(r - r0, c - c0), r * COLS + c];
let less = !bs;
if (!less) for (let i = 0; i < s.length; 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;
}
function spawnTurret(battle, st, d) {
const alias = aliasOf(d) ?? `${d.key}@${d.row},${d.col}`;
if (st.turrets.has(alias)) return st.turrets.get(alias);
const tiles = (d.raw?.rangeTiles ?? []).filter((p) => Array.isArray(p) && battle.grid.inRect(p[0], p[1]));
if (!tiles.length) return null; // shoots at nothing on this field
const tile = deviceTile(battle, d.row, d.col);
if (!tile) return null;
const stats = d.raw?.stats ?? d.stats ?? {};
const u = battle.spawnDevice(d.key, tile[0], tile[1], {
hp: num(stats.maxHp, 100), blockCnt: 0, name: d.name ?? d.raw?.name ?? d.key,
atk: num(stats.atk, 0), def: num(stats.def, 0), res: num(stats.res, 0), bat: num(stats.bat, 1), aspd: num(stats.aspd, 100),
});
if (!u) return null;
const skill = d.raw?.skill ?? {};
const bb = skill.bb ?? {};
const durM = String(skill.desc ?? skill.description ?? '').match(/持续(\d+(?:\.\d+)?)秒/);
u.ownerId = playerForCol(battle, d.col);
u.dir = u.ownerId != null ? (battle.getPlayer(u.ownerId)?.dir ?? 'RIGHT') : 'RIGHT';
u.mem.turret = {
alias,
keys: tiles.map(([r, c]) => r * COLS + c),
baseAspd: u.base.aspd,
aspdPer: num(bb.attack_speed_per_stack, 0),
aspdMax: num(bb.max_attack_speed, Infinity),
fragPer: num(bb.damage_scale_per_stack, 0),
fragMax: num(bb.max_damage_scale, Infinity),
fragDur: durM ? +durM[1] : 0,
cd: 0,
};
st.turrets.set(alias, u);
battle.fx('turretOnline', { x: u.x, y: u.y, id: u.id, alias });
return u;
}
const TURRET_PROFILE = Object.freeze({ attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true, maxTargets: 1, atkScale: 1 });
function tickTurret(battle, u, dt) {
const T = u.mem.turret;
if (!u.alive || !T) return;
T.cd -= dt;
if (T.cd > 0 || u.s.flags.stun) return;
const L = u.ownerId != null ? topLayers(battle, u.ownerId) : 0;
const bonus = Math.min(L * T.aspdPer, T.aspdMax);
if (Math.abs(u.base.aspd - (T.baseAspd + bonus)) > 1e-9) { u.base.aspd = T.baseAspd + bonus; u.markDirty(); }
const cands = battle.enemiesInKeys(T.keys, u, TURRET_PROFILE);
if (!cands.length) { T.cd = 0; return; }
sortEnemyTargets(battle, u, cands, null);
const frag = Math.min(1 + L * T.fragPer, T.fragMax) - 1;
const prof = {
...TURRET_PROFILE,
afterHit: (b, src, target) => {
if (frag > 0 && T.fragDur > 0 && target && target.alive) b.applyStatus(target, 'fragile', { duration: T.fragDur, value: frag, source: src });
},
};
performAttack(battle, u, prof, [cands[0]]);
T.cd = u.s.interval;
}
/** Activate “双眼皮” turrets on this field (all known aliases by default; idempotent). Returns the turret units. */
export function activateTurrets(battle, { aliases = null } = {}) {
const st = stateOf(battle);
if (!st) return [];
const out = [];
for (const d of battle.stage?.devices || []) {
if (d.key !== TURRET_KEY && d.role !== 'turret') continue;
if (aliases && !aliases.includes(aliasOf(d))) continue;
const u = spawnTurret(battle, st, d);
if (u) out.push(u);
}
ensureTick(battle, st);
return out;
}
// ---------------------------------------------------------------------------------------------------------------
// terrain
function buildTerrain(battle, st) {
const R = battle.rect;
let any = false;
for (let r = R.r0; r <= R.r1; r++) {
for (let c = R.c0; c <= R.c1; c++) {
const code = TERRAIN[battle.grid.tile(r, c).terrain] ?? 0;
if (code) { st.terrain[r * COLS + c] = code; any = true; }
}
}
st.hasTerrain = any;
const sp = battle.stage?.special || {};
const devSkill = (role) => (battle.stage?.devices || []).find((d) => d.role === role)?.raw?.skill?.bb ?? null;
// mire
const m = sp.mire || {};
const mdev = devSkill('mireController') || {};
st.mire = {
interval: num(m.intervalSec ?? mdev.value, RESEARCH.mire.intervalSec),
aspdPer: aspdOf(num(m.aspdPerStack ?? mdev.attack_speed, RESEARCH.mire.aspdPerStack)),
movePer: num(m.moveMulPerStack ?? mdev.move_speed, RESEARCH.mire.moveMulPerStack),
max: Math.max(1, Math.floor(num(m.maxStacks ?? mdev.max_stack_cnt, RESEARCH.mire.maxStacks))),
};
// deep sea
const ds = sp.deepsea?.bb || devSkill('tideController') || {};
st.deepsea = {
damage: num(ds['sea_drown[enemy].damage'], RESEARCH.deepsea.damage),
aspd: aspdOf(num(ds['sea_drown[enemy].attack_speed'], RESEARCH.deepsea.attack_speed)),
moveMul: num(ds['sea_drown[enemy].move_speed'], RESEARCH.deepsea.move_speed),
};
// active originium
const inf = sp.infection?.bb || {};
st.infection = {
damage: num(inf.damage, RESEARCH.infection.damage),
atk: num(inf.atk, RESEARCH.infection.atk),
aspd: aspdOf(num(inf.attack_speed, RESEARCH.infection.attack_speed)),
until: num(inf.duration, RESEARCH.infection.duration),
};
// blowers
for (const d of battle.stage?.devices || []) {
if (d.role !== 'blower' || !isActive(battle, st, d)) continue;
const raw = String(d.dir ?? d.raw?.dir ?? 'UP').toUpperCase();
const f = DIR_VEC[raw];
if (!f) continue;
const dir = normDir(raw);
const bb = d.raw?.skill?.bb ?? sp.blower?.bb ?? {};
const tiles = d.raw?.rangeTiles ?? [];
for (const p of tiles) {
if (!Array.isArray(p) || !battle.grid.inRect(p[0], p[1])) continue;
// world vector: x = col, y = row
st.flow.set(p[0] * COLS + p[1], { fx: f[1], fy: f[0], dir, bb });
}
}
}
const terrainDamage = (battle, amount) => (ctx) => {
if (amount > 0 && ctx.unit.alive) battle.dealDamage(null, ctx.unit, { amount, type: 'true', canDodge: false, tags: ['terrain'] });
};
function enterTerrain(battle, st, u, code) {
const m = u.mem;
if (m.terrain === code) return;
if (m.terrain) battle.removeBuff(u, BUFF[m.terrain]);
m.terrain = code;
m.terrainSince = battle.time;
m.mireStacks = 0;
if (!code) return;
if (code === TERRAIN.smog) battle.addBuff(u, { key: BUFF[code], flags: { stealth: true } });
else if (code === TERRAIN.deepsea) {
const D = st.deepsea;
battle.addBuff(u, { key: BUFF[code], mods: { aspd: D.aspd, moveMul: D.moveMul }, interval: 1, onTick: terrainDamage(battle, D.damage) });
} else if (code === TERRAIN.infection) {
const I = st.infection;
battle.addBuff(u, { key: BUFF[code], mods: { atkPct: I.atk, aspd: I.aspd }, interval: 1, onTick: terrainDamage(battle, I.damage) });
}
// mire stacks are applied by tickMire
}
function tickMire(battle, st, u) {
const M = st.mire;
const n = Math.min(M.max, 1 + Math.floor((battle.time - u.mem.terrainSince + 1e-9) / M.interval));
if (n === u.mem.mireStacks) return;
u.mem.mireStacks = n;
battle.addBuff(u, { key: BUFF[TERRAIN.mire], refresh: 'replace', mods: { aspd: M.aspdPer * n, moveMul: Math.max(0, 1 + M.movePer * n) } });
}
/** Terrain code under a unit (0 = none). Flyers and hidden units ignore ground terrain; smog only helps allies. */
function terrainFor(battle, st, u) {
if (u.hidden || u.motion === 'FLY') return 0;
let k;
if (u.side === 'ally') k = u.tileR * COLS + u.tileC;
else {
const r = Math.round(u.y), c = Math.round(u.x);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return 0;
k = r * COLS + c;
}
let code = st.terrain[k];
if (code === TERRAIN.smog && u.side !== 'ally') code = 0;
if (code === TERRAIN.deepsea && u.side === 'ally') code = 0;
if (code === TERRAIN.infection && battle.time >= st.infection.until) code = 0;
return code;
}
function airflowRelation(dot) { return dot > 0.7 ? 'equal' : dot < -0.7 ? 'opposite' : 'vertical'; }
/** Operator ↔ blower: the same direction ⇒ 'equal', the reverse ⇒ 'opposite', perpendicular ⇒ 'vertical'. */
export function airflowRelationOf(unitDir, flowDir) {
const u = normDir(unitDir), f = normDir(flowDir);
return u === f ? 'equal' : u === oppositeDir(f) ? 'opposite' : 'vertical';
}
function tickAirflowAlly(battle, st, u) {
const f = st.flow.get(u.tileR * COLS + u.tileC);
let atk = 0;
if (f && u.motion !== 'FLY') atk = num(f.bb[`blower_s_character[${airflowRelationOf(u.dir, f.dir)}].atk`], 0);
if (atk === (u.mem.airAtk ?? 0)) return;
u.mem.airAtk = atk;
if (atk) battle.addBuff(u, { key: AIRFLOW, refresh: 'replace', mods: { atkPct: atk } });
else battle.removeBuff(u, AIRFLOW);
}
function tickAirflowEnemy(battle, st, e) {
const px = e.mem.airPx, py = e.mem.airPy;
e.mem.airPx = e.x; e.mem.airPy = e.y;
let mul = 1;
if (!e.hidden && px != null) {
const r = Math.round(e.y), c = Math.round(e.x);
const f = r >= 0 && r < ROWS && c >= 0 && c < COLS ? st.flow.get(r * COLS + c) : null;
const vx = e.x - px, vy = e.y - py;
const v = Math.hypot(vx, vy);
if (f && v > 1e-6) {
const rel = airflowRelation((vx * f.fx + vy * f.fy) / v);
if (rel !== 'vertical') mul = 1 + num(f.bb[`blower_s_enemy[${rel}].move_speed`], 0);
} else if (f && v <= 1e-6) mul = e.mem.airMul ?? 1; // paused this tick (attack pause / blocked): keep the last push
}
if (!(mul >= 0)) mul = 0;
if (mul === (e.mem.airMul ?? 1)) return;
e.mem.airMul = mul;
if (mul !== 1) battle.addBuff(e, { key: AIRFLOW, refresh: 'replace', mods: { moveMul: mul } });
else battle.removeBuff(e, AIRFLOW);
}
/** Apply terrain / airflow state to one unit now (deploy, spawn, every tick). */
function refreshUnit(battle, st, u) {
if (!u.alive || !u.deployed || u.kind === 'device') return;
if (st.hasTerrain) {
enterTerrain(battle, st, u, terrainFor(battle, st, u));
if (u.mem.terrain === TERRAIN.mire) tickMire(battle, st, u);
}
if (st.flow.size) { if (u.side === 'ally') tickAirflowAlly(battle, st, u); else tickAirflowEnemy(battle, st, u); }
}
function ensureTick(battle, st) {
if (st.ticking) return;
if (!st.hasTerrain && !st.flow.size && !st.turrets.size) return;
st.ticking = true;
battle.on('tick', ({ dt }) => {
if (st.hasTerrain || st.flow.size) {
for (const u of battle.allyUnits) refreshUnit(battle, st, u);
for (const e of battle.enemies) refreshUnit(battle, st, e);
}
for (const u of st.turrets.values()) tickTurret(battle, u, dt);
});
// a unit that (re)deploys / spawns starts fresh (death drops its buffs) and gets its tile's terrain at once: smog
// must already hide an operator from the first enemy shot of the battle
battle.on('deploy', (ctx) => {
const u = ctx.unit;
if (!u || !u.mem || u.kind === 'device') return;
u.mem.terrain = 0; u.mem.airAtk = 0; u.mem.airMul = 1; u.mem.airPx = null;
refreshUnit(battle, st, u);
}, { priority: 90 });
}
/** Terrain name under (r, c) for other content ('mire' | 'smog' | 'deepsea' | 'infection' | null). */
export function terrainAt(battle, r, c) {
const st = stateOf(battle);
const code = st && r >= 0 && r < ROWS && c >= 0 && c < COLS ? st.terrain[r * COLS + c] : 0;
return TERRAIN_NAME[code] || null;
}
// ---------------------------------------------------------------------------------------------------------------
// install
export function install(battle) {
const st = {
byPlayer: new Map((battle.players || []).map((ps) => [ps.playerId, playerDeviceOverrides(ps)])),
global: addOverrides(new Map(), battle.opts?.deviceOverrides),
terrain: new Uint8Array(ROWS * COLS),
hasTerrain: false,
flow: new Map(),
turrets: new Map(),
platformOn: new Set(),
platformOff: new Set(),
crateOff: new Set(),
ticking: false,
};
STATE.set(battle, st);
const devices = battle.stage?.devices || [];
buildTerrain(battle, st); // grid, stage `special` and overrides are known at construction
ensureTick(battle, st);
// overrides that differ from the data (per half owner): crates removed, platforms added/removed
for (const d of devices) {
if (!battle.grid.inRect(d.row, d.col)) continue;
const on = overrideOf(battle, st, d);
if (on === undefined || on === dataActive(d)) continue;
const k = d.row * COLS + d.col;
if (d.role === 'crate' && !on) st.crateOff.add(k);
if (d.role === 'platform' || d.role === 'mound') (on ? st.platformOn : st.platformOff).add(k);
}
battle.on('deploy', (ctx) => {
const u = ctx.unit;
if (!u || u.side !== 'ally') return;
const k = u.tileR * COLS + u.tileC;
if (u.kind === 'device') {
// stage crates switched off by a map card vanish before anybody deploys on their tile
if (u.defId === CRATE_KEY && st.crateOff.has(k) && !st.started) { u.mem.removedByOverride = true; battle.retreat(u, { reason: 'removed', permanent: true }); }
return;
}
if (st.platformOn.has(k)) u.ground = false;
else if (st.platformOff.has(k)) u.ground = battle.grid.isLow(u.tileR, u.tileC);
}, { priority: 100 });
// an enemy stopped by a crate breaks it (the engine lets attackers chop it; this also covers harmless walkers)
battle.on('blocked', ({ blocker, enemy }) => {
if (!blocker || blocker.kind !== 'device' || blocker.defId !== CRATE_KEY) return;
const delay = Math.max(0.1, Math.min(10, num(enemy.s.interval, 1)));
battle.after(delay, () => {
if (blocker.alive && enemy.alive && enemy.blockedBy === blocker) {
battle.fx('crateBreak', { x: blocker.x, y: blocker.y, id: blocker.id });
battle.kill(blocker, enemy);
}
});
});
battle.on('battleStart', () => {
st.started = true;
// platforms switched by map cards (the engine applied the data state)
for (const k of st.platformOn) battle.setObstacle((k / COLS) | 0, k % COLS, true);
for (const k of st.platformOff) battle.setObstacle((k / COLS) | 0, k % COLS, false);
// hidden crates switched on by an override
for (const d of devices) {
if (d.role !== 'crate' || dataActive(d) || !isActive(battle, st, d) || !battle.grid.inRect(d.row, d.col) || battle.unitAt(d.row, d.col)) continue;
battle.spawnDevice(d.key, d.row, d.col, { hp: num(d.stats?.maxHp ?? d.raw?.stats?.maxHp, 100), obstacle: true, name: d.name });
}
// “双眼皮” turrets switched on (band 机械援助 / overrides)
for (const d of devices) if ((d.key === TURRET_KEY || d.role === 'turret') && isActive(battle, st, d)) spawnTurret(battle, st, d);
ensureTick(battle, st);
}, { priority: 50 });
}
export function registerMeta(registry) {} // map cards / band 机械援助 reach the battle through deviceOverrides
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
// server/sim/content/garrisons.js — 特质 (garrisons), battle side and prep side (docs/DATA.md §4, docs/META.md §2.3).
//
// install(battle) IN_BATTLE garrisons — garrisons/battle.js
// registerMeta(registry) SERVER_* garrisons — garrisons/meta.js (garrison:<effectKey>)
// triggerGainEffects(ctx, p) re-run the 获得时 garrisons of an owned chess piece (×投资人); returns the effects run
export { install } from './garrisons/battle.js';
export { registerMeta, triggerGainEffects } from './garrisons/meta.js';
+497
View File
@@ -0,0 +1,497 @@
// server/sim/content/garrisons/battle.js — IN_BATTLE 特质 (garrisons) of the operators on the field.
//
// Each operator carries `unit.def.raw.garrisonIds` (elite chess carry the `_b` garrisons); the IN_BATTLE ones are
// dispatched here by their effectKey (bbStr.key for battle runes, else effectType — docs/DATA.md §4):
//
// ADD_BOND grants `give_garrison_id` to other operators before the first deployment (targets parsed from
// the text: 身前一格 / 身前一格【X】/ 自身和身前一格 / 同一行最右边 / 所有【X】). A unit owns a garrison id
// at most once (耀骑士临光 already carries the 144/159 it grants to itself). The 华法琳-granted trait
// (garrison_95) is capped 12 / 24 per battle instead of the data's 7 / 14 (research 02 Addendum 1).
// layer events act1autochess_gar_event_useskill (skillStart) · _selfkillenemy (kill, every check_cnt) ·
// _selfdead (death 'killed'; texts with 替身 also on every substitute ⇄ body swap, read from the
// dollkeeper flag unit.trait.doll) · _consume_ammo (ammoUsed; range_id 0-1 self, 1-1 front tile,
// x-5 self + 4 adjacent, pooled counter) · _enemy_abflag_inrange (an enemy in range ENTERS
// 'freeze', × prob) · act2autochess_gar_event_onstart (every deploy) ·
// act2autochess_gar_event_allyenemy_sleepstun_inrange (an enemy or operator in range ENTERS
// sleep / stun). "进入…时": re-applying a running status (a refresh) is not a new entry
// (engine statusApplied ctx.entered). Targets: bond_by_id / bond_self (own active bonds) / bond_actived_maxstack;
// amounts: by_count / by_charcount_samerow / by_charlevel; conditions character_same_row /
// character_same_col (≥ check_count incl. self). Gains go through support.gainLayers with
// reason 'garrison', source = the trait's owner, cap = max_add_count_per_battle per (instance, bond).
// act1autochess_gar_event_addition_cnt (魔王) layerGain: a 'garrison' gain whose source stands on the tile in front
// of 魔王 (range_id 1-1) — or was knocked out there this instant (幽灵鲨 "被击倒时"; engine ctx.tile)
// — gets +extra_cnt per bond (the extra does not count toward the source's per-battle cap).
// stat traits GAIN_BUFF (atkMul / hpMul), attr_common_global_buff (spRecoveryFlat), attrByBond,
// respawnTimeByBond, attrByBond_add_onstart, ab_damageScaleByBond — layer readers use
// floor(Σ layers of the listed ACTIVE bonds / divide_num) and are recomputed after every layer
// gain (research 02 §5 [ASSUMED] only active bonds count).
// hit modifiers act1autochess_gar_eff_attack_enemy (dmg.amount × atk vs check_tag enemies),
// ab_damageScaleByBond (bound / sluggish targets), _chaos (弱点伤害, research 04 glossary:
// phys/arts → whichever deals MORE after the engine's mitigation incl. the attacker's DEF/RES
// ignore and dealt/taken multipliers; a tie keeps the type).
// NONE 风丸 "2 copies merge" — prep rule (chess.upgradeNum 2), nothing in battle.
//
// Hooks: priority 0, except 弱点伤害 (hit, WEAKNESS_PRIORITY −10: decided on the final amount after the ATK
// multipliers). install() registers nothing when no operator carries an IN_BATTLE garrison.
import * as S from '../support/index.js';
import { mitigate } from '../../damage.js';
import { frontOf } from '../../dir.js';
/** 华法琳's granted trait: per-battle cap override (research 02 Addendum 1: PRTS 3/27 "初始7/精锐14 → 初始12/精锐24"). */
export const GRANTED_CAP_OVERRIDE = Object.freeze({ garrison_95_a: 12, garrison_95_b: 24 });
const ids = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const EMPTY = Object.freeze([]);
/** Status keys for check_ab_flag values used by the data (16 = 冻结). */
const AB_FLAG_STATUS = Object.freeze({ 16: 'freeze' });
function garrisonIdsOf(u) {
const list = u?.def?.raw?.garrisonIds ?? u?.def?.garrisonIds;
return Array.isArray(list) ? list : EMPTY;
}
// ---------------------------------------------------------------------------------------------------------------------
// install
export function install(battle) {
const all = [];
const owned = new Map(); // unit → Set(garrisonId)
const add = (unit, g, grantedBy = null) => {
let set = owned.get(unit);
if (!set) { set = new Set(); owned.set(unit, set); }
if (set.has(g.garrisonId)) return null;
set.add(g.garrisonId);
const bb = g.bb || {};
const override = grantedBy ? GRANTED_CAP_OVERRIDE[g.garrisonId] : undefined;
const cap = override ?? (S.num(bb.max_add_count_per_battle, 0) > 0 ? S.num(bb.max_add_count_per_battle) : Infinity);
const it = {
unit, g, gid: g.garrisonId, key: g.effectKey, bb, bbStr: g.bbStr || {}, grantedBy,
cap, capKey: `gar:${g.garrisonId}:${unit.id}`, cnt: 0, k: -1,
};
all.push(it);
return it;
};
for (const u of battle.allyUnits) {
if (!S.isOp(u)) continue;
for (const gid of garrisonIdsOf(u)) {
const g = S.garrisonRecord(gid);
if (g && g.eventType === 'IN_BATTLE') add(u, g);
}
}
if (!all.length) return;
const grants = [];
for (const it of all.slice()) {
if (it.key !== 'ADD_BOND') continue;
const g2 = S.garrisonRecord(it.bbStr.give_garrison_id);
if (!g2 || g2.eventType !== 'IN_BATTLE') continue;
for (const t of grantTargets(battle, it)) {
const got = add(t, g2, it);
if (got) grants.push(got);
}
}
const by = new Map();
for (const it of all) {
let arr = by.get(it.key);
if (!arr) { arr = []; by.set(it.key, arr); }
arr.push(it);
}
for (const [key, list] of by) {
const inst = INSTALLERS[key];
if (inst) inst(battle, list);
}
installHitMods(battle, by);
installReaders(battle, by);
if (grants.length) {
battle.on('battleStart', () => {
for (const it of grants) S.fxOn(battle, 'garrisonGrant', it.unit, `gar:${it.grantedBy.gid}`, it.gid, { from: it.grantedBy.unit.id });
});
}
}
// ---------------------------------------------------------------------------------------------------------------------
// ADD_BOND targets (before deployment: home tiles)
function opsOf(battle, pid) {
const out = [];
for (const u of battle.allyUnits) if (S.isOp(u) && u.ownerId === pid && !u.removed) out.push(u);
return out;
}
function opAtHome(battle, pid, r, c) {
for (const u of battle.allyUnits) if (S.isOp(u) && u.ownerId === pid && !u.removed && u.homeR === r && u.homeC === c) return u;
return null;
}
function grantTargets(battle, it) {
const u = it.unit;
const desc = it.g.desc || '';
const need = it.bbStr.check_bond_id || null;
const ok = (t) => !!t && (!need || S.isMember(battle, t, need));
const front = () => { const [fr, fc] = frontOf(u.homeR, u.homeC, u.dir); return opAtHome(battle, u.ownerId, fr, fc); };
if (desc.includes('所有')) return opsOf(battle, u.ownerId).filter(ok);
if (desc.includes('同一行最右边')) {
let best = null;
for (const t of opsOf(battle, u.ownerId)) {
if (t.homeR !== u.homeR) continue;
// a board position ("最右边" of the player's own board: the mirrored Final Assault right side counts from the left)
const col = (x) => (x.player && x.player.mirror ? -x.homeC : x.homeC);
if (!best || col(t) > col(best)) best = t;
}
return ok(best) ? [best] : [];
}
const out = [];
if (desc.includes('自身和身前') && ok(u)) out.push(u);
const f = front();
if (ok(f)) out.push(f);
return out;
}
// ---------------------------------------------------------------------------------------------------------------------
// layer gains
function targetBonds(battle, it) {
const u = it.unit;
switch (it.bbStr.bond_type) {
case 'bond_self': return S.unitBonds(u).filter((b) => S.bondActive(battle, u.ownerId, b));
case 'bond_actived_maxstack': { const b = S.topActiveBond(battle, u.ownerId); return b ? [b] : EMPTY; }
default: return ids(it.bbStr.bond_id);
}
}
function amountOf(battle, it) {
const bb = it.bb;
switch (it.bbStr.bond_add_type) {
case 'by_charcount_samerow': return S.rowMates(battle, it.unit).length * S.num(bb.bond_add_count_multi, S.num(bb.bond_add_count, 1));
case 'by_charlevel': return S.tierOf(it.unit) * S.num(bb.bond_add_count, 1);
default: return S.num(bb.bond_add_count, 0);
}
}
function conditionMet(battle, it) {
const ck = it.bbStr.conditionkey;
const need = S.num(it.bb.check_count, 0);
if (ck === 'character_same_row') return S.rowMates(battle, it.unit).length >= need;
if (ck === 'character_same_col') {
let n = 0;
for (const a of battle.allyUnits) if (S.isOp(a) && a.ownerId === it.unit.ownerId && S.onField(a) && a.tileC === it.unit.tileC) n++;
return n >= need;
}
return true;
}
/** Fire a layer-gain garrison instance. Returns the layers added. */
export function fireGain(battle, it) {
if (!conditionMet(battle, it)) return 0;
const bonds = targetBonds(battle, it);
if (!bonds.length) return 0;
const n = amountOf(battle, it);
const added = S.gainLayers(battle, {
playerId: it.unit.ownerId, bonds, n, requireActive: true, source: it.unit, reason: 'garrison', cap: it.cap, capKey: it.capKey,
});
if (added > 0) S.fxOn(battle, 'garrison', it.unit, `gar:${it.gid}`, it.key, { n: added });
return added;
}
const byUnit = (list) => {
const m = new Map();
for (const it of list) {
let a = m.get(it.unit);
if (!a) { a = []; m.set(it.unit, a); }
a.push(it);
}
return m;
};
function isFrontOf(holder, u) {
if (!u || u === holder || u.ownerId !== holder.ownerId || !S.onField(u)) return false;
const [fr, fc] = frontOf(holder.tileR, holder.tileC, holder.dir);
return u.tileR === fr && u.tileC === fc;
}
/**
* 魔王: the gain's source (an operator of the holder's player) stands in front of it. `tile` = the engine's layerGain
* ctx.tile: where the source stands, or where it was knocked out this very instant (幽灵鲨 "被击倒时" gains fire on death).
*/
function sourceInFront(holder, u, tile) {
if (!u || u === holder || u.ownerId !== holder.ownerId || !S.isOp(u) || !Array.isArray(tile)) return false;
const [fr, fc] = frontOf(holder.tileR, holder.tileC, holder.dir);
return tile[0] === fr && tile[1] === fc;
}
function ammoScope(it, u) {
const h = it.unit;
switch (it.bbStr.range_id) {
case '1-1': return S.onField(h) && isFrontOf(h, u);
case 'x-5': return u === h || (S.onField(h) && S.onField(u) && u.ownerId === h.ownerId && S.isOp(u) && Math.abs(u.tileR - h.tileR) + Math.abs(u.tileC - h.tileC) === 1);
default: return u === h;
}
}
const INSTALLERS = {
act1autochess_gar_event_useskill(battle, list) {
const m = byUnit(list);
battle.on('skillStart', ({ unit }) => {
const arr = m.get(unit);
if (arr) for (const it of arr) fireGain(battle, it);
});
},
act1autochess_gar_event_selfkillenemy(battle, list) {
const m = byUnit(list);
for (const it of list) it.allies = /我方干员/.test(it.g.desc || '');
battle.on('kill', ({ killer, victim }) => {
const arr = killer ? m.get(killer) : null;
if (!arr || !victim) return;
for (const it of arr) {
if (!(victim.side === 'enemy' || (it.allies && victim.side === 'ally' && victim.kind === 'op'))) continue;
it.cnt++;
if (it.cnt % Math.max(1, S.num(it.bb.check_cnt, 1)) === 0) fireGain(battle, it);
}
});
},
act1autochess_gar_event_selfdead(battle, list) {
const m = byUnit(list);
battle.on('death', ({ unit, reason }) => {
if (reason !== 'killed') return;
const arr = m.get(unit);
if (arr) for (const it of arr) fireGain(battle, it);
});
const swaps = list.filter((it) => /替身/.test(it.g.desc || ''));
if (!swaps.length) return;
for (const it of swaps) it.doll = false;
battle.on('tick', () => {
for (let i = 0; i < swaps.length; i++) {
const it = swaps[i];
const u = it.unit;
const doll = !!(u.trait && u.trait.doll);
if (doll === it.doll) continue;
it.doll = doll;
if (u.alive && u.deployed) fireGain(battle, it);
}
});
},
act1autochess_gar_event_consume_ammo(battle, list) {
battle.on('ammoUsed', ({ unit }) => {
if (!unit) return;
for (let i = 0; i < list.length; i++) {
const it = list[i];
if (!ammoScope(it, unit)) continue;
it.cnt++;
if (it.cnt % Math.max(1, S.num(it.bb.consume_count, 1)) === 0) fireGain(battle, it);
}
});
},
act1autochess_gar_event_enemy_abflag_inrange(battle, list) {
const watched = new Set(list.map((it) => AB_FLAG_STATUS[S.num(it.bb.check_ab_flag, 16)] ?? 'freeze'));
// "进入冻结时": only a new entry (engine ctx.entered — a refresh of a running freeze is not one)
battle.on('statusApplied', ({ target, status, entered }) => {
if (!target || !watched.has(status) || !entered || target.side !== 'enemy') return;
for (const it of list) {
if (status !== (AB_FLAG_STATUS[S.num(it.bb.check_ab_flag, 16)] ?? 'freeze')) continue;
if (!S.onField(it.unit) || !S.inRange(it.unit, target)) continue;
if (battle.rng() < S.num(it.bb.prob, 1)) fireGain(battle, it);
}
});
},
act2autochess_gar_event_onstart(battle, list) {
const m = byUnit(list);
battle.on('deploy', ({ unit }) => {
const arr = m.get(unit);
if (arr) for (const it of arr) fireGain(battle, it);
});
},
act2autochess_gar_event_allyenemy_sleepstun_inrange(battle, list) {
battle.on('statusApplied', ({ target, status, entered }) => { // "进入沉睡/晕眩时": new entries only (ctx.entered)
if (!target || (status !== 'sleep' && status !== 'stun') || !entered) return;
if (!(target.side === 'enemy' || (target.side === 'ally' && target.kind === 'op'))) return;
for (const it of list) if (S.onField(it.unit) && S.inRange(it.unit, target)) fireGain(battle, it);
});
},
act1autochess_gar_event_addition_cnt(battle, list) {
battle.on('layerGain', (ctx) => {
if (ctx.reason !== 'garrison' || !ctx.source) return;
for (const it of list) {
const h = it.unit;
if (h.ownerId !== ctx.playerId || !S.onField(h)) continue;
if (it.bbStr.range_id && it.bbStr.range_id !== '1-1') continue;
if (sourceInFront(h, ctx.source, ctx.tile)) ctx.n += S.num(it.bb.extra_cnt, 0);
}
});
},
GAIN_BUFF(battle, list) {
for (const it of list) {
const mods = {};
if (S.num(it.bb.atk, 0) > 0) mods.atkMul = S.num(it.bb.atk);
if (S.num(it.bb.max_hp, 0) > 0) mods.hpMul = S.num(it.bb.max_hp);
if (S.num(it.bb.def, 0) > 0) mods.defMul = S.num(it.bb.def);
S.passiveBuff(battle, it.unit, `gar:${it.gid}`, mods);
}
},
attr_common_global_buff(battle, list) {
for (const it of list) {
const mods = {};
if (S.num(it.bb.sp_recovery_per_sec, 0)) mods.spRecoveryFlat = S.num(it.bb.sp_recovery_per_sec);
if (S.num(it.bb.atk, 0)) mods.atkMul = 1 + S.num(it.bb.atk);
if (S.num(it.bb.max_hp, 0)) mods.hpMul = 1 + S.num(it.bb.max_hp);
if (S.num(it.bb.attack_speed, 0)) mods.aspd = S.num(it.bb.attack_speed);
S.passiveBuff(battle, it.unit, `gar:${it.gid}`, mods);
}
},
// grants are resolved in install(); 风丸's rule lives in the prep economy
ADD_BOND() {},
NONE() {},
};
// ---------------------------------------------------------------------------------------------------------------------
// layer readers (每叠加N层)
const READERS = new Set([
'act1autochess_gar_eff_attrByBond', 'act1autochess_gar_eff_respawnTimeByBond',
'act2autochess_gar_eff_attrByBond_add_onstart', 'act2autochess_gar_eff_ab_damageScaleByBond',
]);
/** floor(Σ layers of the listed active bonds / divide_num). */
export function stepsOf(battle, it) {
const div = Math.max(1, S.num(it.bb.divide_num, 1));
let sum = 0;
for (const b of it.bonds ?? (it.bonds = ids(it.bbStr.bond_id))) if (S.bondActive(battle, it.unit.ownerId, b)) sum += S.bondLayers(battle, it.unit.ownerId, b);
return Math.floor(sum / div);
}
function attrMods(bb, k) {
const m = {};
if (S.num(bb.atk, 0)) m.atkMul = 1 + S.num(bb.atk) * k;
if (S.num(bb.max_hp, 0)) m.hpMul = 1 + S.num(bb.max_hp) * k;
if (S.num(bb.def, 0)) m.defMul = 1 + S.num(bb.def) * k;
if (S.num(bb.attack_speed, 0)) m.aspd = S.num(bb.attack_speed) * k;
if (S.num(bb.hp_recovery_per_sec, 0)) m.hpRegen = S.num(bb.hp_recovery_per_sec) * k;
if (S.num(bb.sp_recovery_per_sec, 0)) m.spRecoveryFlat = S.num(bb.sp_recovery_per_sec) * k;
return m;
}
function applyReader(battle, it) {
const k = stepsOf(battle, it);
if (k === it.k) return;
it.k = k;
const u = it.unit;
const key = `gar:${it.gid}`;
switch (it.key) {
case 'act1autochess_gar_eff_attrByBond':
if (k > 0) S.passiveBuff(battle, u, key, attrMods(it.bb, k));
else battle.removeBuff(u, key);
break;
case 'act1autochess_gar_eff_respawnTimeByBond':
if (k > 0) S.passiveBuff(battle, u, key, { redeployMul: Math.max(0.05, 1 + S.num(it.bb.respawn_time, 0) * k) });
else battle.removeBuff(u, key);
break;
case 'act2autochess_gar_eff_attrByBond_add_onstart': {
const b = u.findBuff(key);
if (b && b.timeLeft > 0) onstartBuff(battle, it, b.timeLeft);
break;
}
default: break; // damageScale reads it.k in the hit hook
}
}
function onstartBuff(battle, it, duration) {
const k = Math.max(0, it.k);
const key = `gar:${it.gid}`;
if (!(k > 0) || !(duration > 0)) { battle.removeBuff(it.unit, key); return; }
battle.addBuff(it.unit, { key, duration, refresh: 'replace', mods: { atkFlat: S.num(it.bb.atk, 0) * k, hpFlat: S.num(it.bb.max_hp, 0) * k } });
}
function installReaders(battle, by) {
const list = [];
for (const key of READERS) for (const it of by.get(key) ?? EMPTY) list.push(it);
if (!list.length) return;
const recompute = () => { for (const it of list) applyReader(battle, it); };
recompute();
let pending = false;
battle.on('layerGain', () => {
if (pending) return;
pending = true;
battle.after(0, () => { pending = false; recompute(); });
});
const onstart = by.get('act2autochess_gar_eff_attrByBond_add_onstart');
if (onstart) {
const m = byUnit(onstart);
battle.on('deploy', ({ unit }) => {
const arr = m.get(unit);
if (!arr) return;
for (const it of arr) { it.k = stepsOf(battle, it); onstartBuff(battle, it, S.num(it.bb.duration, 0)); }
});
}
}
// ---------------------------------------------------------------------------------------------------------------------
// hit modifiers
/**
* 弱点伤害 (research 04 §glossary: "each hit is dealt as whichever of physical/arts yields MORE damage against that
* target's DEF/RES"): the engine's own mitigation formula incl. the attacker's DEF/RES ignore stats; a tie keeps the
* original type.
*/
export function weaknessType(source, target, dmg) {
const ss = source && source.s ? source.s : null;
const ign = {
defIgnorePct: S.num(dmg.defIgnorePct, 0) + (ss ? S.num(ss.defIgnorePct, 0) : 0),
defIgnoreFlat: S.num(dmg.defIgnoreFlat, 0) + (ss ? S.num(ss.defIgnoreFlat, 0) : 0),
resIgnorePct: S.num(dmg.resIgnorePct, 0) + (ss ? S.num(ss.resIgnorePct, 0) : 0),
resIgnoreFlat: S.num(dmg.resIgnoreFlat, 0) + (ss ? S.num(ss.resIgnoreFlat, 0) : 0),
};
const ph = mitigate(dmg.amount, 'phys', target.s, ign) * (ss ? S.num(ss.physDealtMul, 1) : 1) * S.num(target.s.physTakenMul, 1);
const ar = mitigate(dmg.amount, 'arts', target.s, ign) * (ss ? S.num(ss.artsDealtMul, 1) : 1) * S.num(target.s.artsTakenMul, 1);
if (ar > ph + 1e-9) return 'arts';
if (ph > ar + 1e-9) return 'phys';
return dmg.type;
}
const bound = (t) => !!(t.s && t.s.flags && t.s.flags.bind) || !!t.findBuff('bind');
/** Priority of the 弱点伤害 conversion: after every default-priority hit modifier, so the final amount decides. */
export const WEAKNESS_PRIORITY = -10;
function installHitMods(battle, by) {
const m = new Map();
const push = (it, kind) => {
let a = m.get(it.unit);
if (!a) { a = []; m.set(it.unit, a); }
a.push({ it, kind });
};
for (const it of by.get('act1autochess_gar_eff_attack_enemy') ?? EMPTY) push(it, 'tag');
for (const it of by.get('act2autochess_gar_eff_ab_damageScaleByBond') ?? EMPTY) push(it, 'ab');
if (m.size) {
battle.on('hit', ({ source, target, dmg }) => {
const arr = source ? m.get(source) : null;
if (!arr || !target || target.side !== 'enemy' || !dmg || dmg.type === 'element') return;
for (let i = 0; i < arr.length; i++) {
const { it, kind } = arr[i];
if (kind === 'tag') {
// an ATK multiplier: fixed-value DoT ticks are not ATK-based
if (Array.isArray(dmg.tags) && dmg.tags.includes('dot')) continue;
const tags = target.def && target.def.tags;
if (Array.isArray(tags) && tags.includes(it.bbStr.check_tag)) dmg.amount *= S.num(it.bb.atk, 1);
} else if (it.k > 0) {
const hit = (S.num(it.bb.check_ab_flag, 0) && bound(target)) || (S.num(it.bb.check_sluggish, 0) && !!target.findBuff('sluggish'));
if (hit) dmg.mul = S.num(dmg.mul, 1) * (1 + S.num(it.bb.damage_scale_per_stack, 0) * it.k);
}
}
});
}
const chaos = new Set((by.get('act1autochess_gar_eff_chaos') ?? EMPTY).map((it) => it.unit));
if (chaos.size) {
battle.on('hit', ({ source, target, dmg }) => {
if (!source || !chaos.has(source) || !target || target.side !== 'enemy' || !dmg) return;
if (dmg.type === 'phys' || dmg.type === 'arts') dmg.type = weaknessType(source, target, dmg);
}, { priority: WEAKNESS_PRIORITY });
}
}
+412
View File
@@ -0,0 +1,412 @@
// server/sim/content/garrisons/meta.js — prep-side ("SERVER_*") 特质 (docs/META.md §2.3).
//
// One registry handler per effectKey (`garrison:<effectKey>`); the dispatcher calls it on the garrison's eventType hook
// with ctx.source = { piece, garrisonId, garrison, bb, bbStr, where }. Numbers come from the garrison's own bb / bbStr
// (normal `_a` and elite `_b` records). Layer wording: "(无需激活盟约)" → requireActive false; "已激活" / "激活且" →
// requireActive true. The dispatcher already repeats onGain garrisons ×2 / ×3 under 投资人 and skips hand pieces for
// conditionkey character_target_inboard.
//
// Re-triggers (铃兰 SERVER_TRIGGER_*, 瑰盐, 塞雷娅 / 白面鸮 "特质与其相同") go through the engine's
// ctx.triggerGarrisons (same handlers, 投资人 repeat, per-handler isolation, shared depth cap).
// triggerGainEffects(ctx, piece) is exported for 铃兰-style effects of other modules (bands.js 铃兰 御守之力).
//
// Decisions (documented, see research 02 §4 / 03 §5.5):
// * "同一行有3名干员" / "同一行每有1名干员" count the trait's owner (board chess of that row, self included).
// * POSITION "使自身及身后/身前一格干员的已激活盟约分别各层数+N": per target operator (a shared bond gets +N twice).
// * 瑰盐 "优先…更靠上的和更靠右的": highest row first (DESIGN §3.1: row 0 is the bottom), then highest column.
// * 购买价格 (SERVER_CHESS_PRICE) replaces the base price; earlier price deltas (远见 discount …) are kept.
// * 至简 costs bb.price = 2 (its text says 1; blackboards are authoritative).
// * [ASSUMED] 余 SERVER_MOST_BOND: ties between most-member bonds are shuffled; the chess is a copy-weighted pool roll
// of any tier (the text gives no tier cap); a bond without an available chess falls through to the next tied one.
// * [ASSUMED] 松果: the "免费特殊招募" is a free pick-one offer of `rewardOffer.count` (3) chess of the pool's tier.
import { metaBonds, frontPiece, behindPiece, distinctTiers } from '../support/meta.js';
const ids = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d);
/** "(无需激活盟约)" → false; "使已激活的…" / "当前激活且…" / "自身已激活的盟约" → true. */
export function requireActiveOf(g) {
const d = (g && g.desc) || '';
if (d.includes('无需激活')) return false;
return /已激活|激活且/.test(d);
}
const bondOrder = (ctx) => (Array.isArray(ctx.gd && ctx.gd.bondIds) ? ctx.gd.bondIds : Object.keys(ctx.data.bonds || {}));
/** Active bond with the most layers (ties → bond data order), or null. */
export function topActiveBondMeta(ctx) {
let best = null, bl = -1;
for (const id of bondOrder(ctx)) {
if (!ctx.bondActive(id)) continue;
const l = ctx.layers(id);
if (l > bl) { bl = l; best = id; }
}
return best;
}
/** Fresh view of the source piece (with row/col on the board). */
function selfView(ctx) {
const p = ctx.source && ctx.source.piece;
if (!p) return null;
const v = Number.isInteger(p.uid) && p.uid > 0 ? ctx.piece(p.uid) : null;
return v || p;
}
const onBoard = (v) => !!v && v.area === 'board' && Number.isInteger(v.row);
const chessOnBoard = (ctx) => ctx.board().filter((p) => p && p.kind === 'chess');
function addAll(ctx, bonds, n, requireActive) {
let total = 0;
if (!(n > 0)) return 0;
for (const b of bonds) if (b) total += ctx.addLayers(b, n, { requireActive });
return total;
}
function sameRowCount(ctx, me) {
if (!onBoard(me)) return 0;
return chessOnBoard(ctx).filter((p) => p.row === me.row).length;
}
function rowCondition(ctx, me) {
const { bb, bbStr } = ctx.source;
if (bbStr.conditionkey === 'character_same_row') return sameRowCount(ctx, me) >= num(bb.check_count, 0);
return true;
}
// ---------------------------------------------------------------------------------------------------------------------
// handlers
const H = {};
H.SERVER_ADD_BOND = {
run(ctx) {
const { bb, bbStr, garrison, piece } = ctx.source;
const bonds = ids(bbStr.bond);
// 录武官 (hidden 4_15) carries garrison_157 (【奇迹】+3) next to garrison_156 (【炎】+6、【奇迹】+3) with the same text:
// the text grants 奇迹 once, so a bond already covered by the piece's SERVER_ADD_MULTIPLE_BOND is skipped.
const rec = piece ? ctx.chessRecord(piece.id) : null;
const covered = new Set();
for (const gid of (rec && Array.isArray(rec.garrisonIds) ? rec.garrisonIds : [])) {
const g = ctx.gd.garrison(gid);
if (g && g !== garrison && g.effectType === 'SERVER_ADD_MULTIPLE_BOND' && g.eventType === garrison.eventType && g.desc === garrison.desc) for (const b of ids(g.bbStr && g.bbStr.bond)) covered.add(b);
}
addAll(ctx, bonds.filter((b) => !covered.has(b)), num(bb.count), requireActiveOf(garrison));
},
};
H.SERVER_ADD_BOND_CHESS_ALL = {
run(ctx) {
const { bb, garrison } = ctx.source;
addAll(ctx, metaBonds(ctx, ctx.source.piece), num(bb.count), requireActiveOf(garrison));
},
};
H.SERVER_ADD_BOND_METHOD = {
run(ctx) {
const { bb, bbStr, garrison } = ctx.source;
const multi = num(bb.multi, 1);
const bonds = ids(bbStr.bond);
const ra = requireActiveOf(garrison);
const me = selfView(ctx);
switch (bbStr.add_method) {
case 'shoplv': addAll(ctx, bonds, ctx.shopLevel() * multi, ra); break;
case 'round_gain_char': addAll(ctx, bonds, (ctx.roundStats().gainedChess | 0) * multi, ra); break;
case 'hand_count': addAll(ctx, bonds, ctx.hand().filter((p) => p && p.kind === 'chess').length * multi, ra); break;
case 'same_row': addAll(ctx, bonds, sameRowCount(ctx, me) * multi, ra); break;
case 'same_bond_diff_lv':
for (const b of bonds) addAll(ctx, [b], distinctTiers(ctx, (p) => metaBonds(ctx, p).includes(b)).size * multi, ra);
break;
default: break;
}
},
};
H.SERVER_ADD_MULTIPLE_BOND = {
run(ctx) {
const { bbStr, garrison } = ctx.source;
const bonds = ids(bbStr.bond);
const counts = ids(bbStr.count).map((x) => num(x));
bonds.forEach((b, i) => addAll(ctx, [b], counts[i] ?? counts[counts.length - 1] ?? 0, requireActiveOf(garrison)));
},
};
H.SERVER_ADD_BOND_ACTIVATED_MOST_LAYER = {
run(ctx) {
const b = topActiveBondMeta(ctx);
if (b) ctx.addLayers(b, num(ctx.source.bb.count), { requireActive: true });
},
};
H.SERVER_ADD_ACT_BOND_DIFF_LV_MOST_LAYER = {
run(ctx) {
const b = topActiveBondMeta(ctx);
if (!b) return;
const n = distinctTiers(ctx, (p) => metaBonds(ctx, p).includes(b)).size;
ctx.addLayers(b, n * num(ctx.source.bb.multi, 1), { requireActive: true });
},
};
H.SERVER_ADD_BOND_IN_HAND = {
run(ctx) {
const n = num(ctx.source.bb.count);
for (const p of ctx.hand()) {
if (!p || p.kind !== 'chess') continue;
for (const b of metaBonds(ctx, p)) if (ctx.bondActive(b)) ctx.addLayers(b, n, { requireActive: true });
}
},
};
// "使自身及身后/身前一格干员的已激活盟约分别各层数+N": the trait's 付与对象 are two operators (PRTS 下半 更新记录 on
// 断崖: "自身外的付与对象从 身前一格干员 调整至 身后一格干员"); each target's active bonds get +N, so a bond both
// operators share gets +N twice (same per-operator reading as 流明 "每名干员所在的已激活盟约分别层数+N").
H.SERVER_ADD_BOND_POSITION = {
run(ctx) {
const { bb, bbStr } = ctx.source;
const me = selfView(ctx);
if (!onBoard(me)) return;
const other = bbStr.dir === 'behind' ? behindPiece(ctx, me) : frontPiece(ctx, me);
const targets = [me];
if (other && other.kind === 'chess' && other.uid !== me.uid) targets.push(other);
for (const t of targets) for (const b of metaBonds(ctx, t)) if (ctx.bondActive(b)) ctx.addLayers(b, num(bb.count), { requireActive: true });
},
};
H.SERVER_ADD_BOND_ROUND_COIN_COST = {
run(ctx) {
const { bb, bbStr, garrison } = ctx.source;
const per = Math.max(1, num(bb.count, 1));
addAll(ctx, ids(bbStr.bond), Math.floor((ctx.roundStats().spent | 0) / per) * num(bb.layer), requireActiveOf(garrison));
},
};
H.SERVER_ADD_REFRESH_CNT_MULTIPLIER_BOND_LAYER = {
run(ctx) {
const { bb, bbStr, garrison } = ctx.source;
let n = (ctx.roundStats().refreshes | 0) * num(bb.multiplier);
if (num(bb.max_layer, 0) > 0) n = Math.min(n, num(bb.max_layer));
addAll(ctx, ids(bbStr.bond), n, requireActiveOf(garrison));
},
};
H.SERVER_GAIN_BOND_LAYER_BY_REFRESH_CNT = {
onRefresh(ctx, ev) {
if (ev && ev.trigger) return;
const { bb, bbStr, garrison } = ctx.source;
if ((ctx.roundStats().refreshes | 0) !== num(bb.refresh_cnt, 1)) return;
addAll(ctx, ids(bbStr.bond), num(bb.layer), requireActiveOf(garrison));
},
};
// "购买价格为N": the trait replaces the chess's base price (bb.price — 至简's data says 2 although its text says 1;
// blackboards are authoritative). Modifiers that ran earlier in the dispatch (bonds such as 远见's discount) keep their
// delta relative to the base price instead of being overwritten.
H.SERVER_CHESS_PRICE = {
onPrice(ctx, ev) {
const p = ctx.source.bb.price;
if (!Number.isFinite(p) || !ev) return;
const base = ev.slot && Number.isFinite(ev.slot.basePrice) ? ev.slot.basePrice : null;
const cur = Number.isFinite(Number(ev.price)) ? Number(ev.price) : base;
ctx.setPrice(base == null || cur == null ? p : p + (cur - base));
},
};
H.SERVER_GAIN_EQUIP = {
run(ctx) {
const { bb, bbStr } = ctx.source;
if (!ctx.gd.item(bbStr.chess)) return;
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) ctx.grantItem(bbStr.chess);
},
};
H.SERVER_GAIN_FREE_REFRESH_COUNT = {
run(ctx) { ctx.grantFreeRefresh(num(ctx.source.bb.count, 1)); },
};
H.SERVER_GAIN_RANDOM_EQUIP_CHESS_IN_POOL = {
run(ctx) {
const { bb, bbStr } = ctx.source;
const rounds = ids(bbStr.round_list).map((x) => num(x, -1));
if (rounds.length && !rounds.includes(ctx.round)) return;
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) {
const r = ctx.rollPool(bbStr.pool);
const id = r && r.kind === 'item' ? r.id : ctx.rollItem({ maxTier: ctx.shopLevel() });
if (id) ctx.grantItem(id);
}
},
};
/** Weighted item roll of an equip pool; elite garrisons use the pool's `goldenWeights` when present. */
function rollEquip(ctx, poolId, golden) {
const pool = ctx.data.choices && ctx.data.choices.pools ? ctx.data.choices.pools[poolId] : null;
if (golden && pool && Array.isArray(pool.weighted) && Array.isArray(pool.goldenWeights) && pool.goldenWeights.length === pool.weighted.length) {
const pairs = pool.weighted.map((x, i) => [x[0], Math.max(0, num(pool.goldenWeights[i]))]).filter(([id, w]) => w > 0 && ctx.gd.item(id));
let total = 0;
for (const [, w] of pairs) total += w;
if (total > 0) {
let r = ctx.rng() * total;
for (const [id, w] of pairs) { r -= w; if (r < 0) return id; }
return pairs[pairs.length - 1][0];
}
}
const r = ctx.rollPool(poolId);
return r && r.kind === 'item' ? r.id : null;
}
H.SERVER_POOL_EQUIP = {
run(ctx) {
const { bb, bbStr, garrisonId } = ctx.source;
const golden = /_b$/.test(garrisonId);
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) {
const id = rollEquip(ctx, bbStr.pool, golden);
if (id) ctx.grantItem(id);
}
},
};
H.SERVER_POOL_CHAR = {
run(ctx) {
const { bb, bbStr } = ctx.source;
if (!rowCondition(ctx, selfView(ctx))) return;
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) {
const r = ctx.rollPool(bbStr.pool);
if (r && r.kind === 'chess') ctx.grantChess(r.id, { golden: !!r.golden });
}
},
};
H.SERVER_MOST_BOND = {
run(ctx) {
if (!rowCondition(ctx, selfView(ctx))) return;
let best = [], bc = 0;
for (const id of bondOrder(ctx)) {
const c = ctx.bondCount(id);
if (c > bc) { bc = c; best = [id]; } else if (c === bc && c > 0) best.push(id);
}
const order = best.length > 1 ? ctx.rng.shuffle(best.slice()) : best;
for (const bond of order) {
const id = ctx.rollChess({ bond, maxTier: 6 });
if (id && ctx.grantChess(id)) return;
}
},
};
H.SERVER_ONCE_GOLD = {
run(ctx) { ctx.addPendingFunds(num(ctx.source.bb.count)); },
};
H.SERVER_ONCE_GOLD_WITH_BOND_CONDITION = {
run(ctx) {
const { bb, bbStr, where } = ctx.source;
if (where === 'hand' && !ids(bbStr.bond).some((b) => ctx.bondActive(b))) return;
ctx.addPendingFunds(num(bb.count));
},
};
H.SERVER_SELL_CHESS_GAIN_SPECIAL_GOODS = {
onSold(ctx) {
const { bbStr } = ctx.source;
const poolId = bbStr[`pool${ctx.shopLevel()}`] || bbStr.max_pool;
const pool = ctx.data.choices && ctx.data.choices.pools ? ctx.data.choices.pools[poolId] : null;
const tier = pool && Number.isInteger(pool.tier) ? pool.tier : null;
const count = Math.max(1, num(ctx.gd.rewardOffer ? ctx.gd.rewardOffer().count : 3, 3));
const list = [];
for (let i = 0; i < count * 4 && list.length < count; i++) {
const r = ctx.rollPool(poolId);
if (r && r.kind === 'chess' && !list.includes(r.id)) list.push(r.id);
}
if (list.length) ctx.offerChess(list, { tier });
else if (tier) ctx.offerChess(null, { tier });
},
};
// ---------------------------------------------------------------------------------------------------------------------
// re-triggers — through the engine's ctx.triggerGarrisons (dispatcher: same handlers, 投资人 repeat for SERVER_GAIN,
// per-handler error isolation, shared depth cap)
const garrisonsOfPiece = (ctx, piece) => {
const rec = piece ? ctx.chessRecord(piece.id) : null;
return (rec && Array.isArray(rec.garrisonIds) ? rec.garrisonIds : []).map((gid) => ctx.gd.garrison(gid)).filter(Boolean);
};
const hasEvent = (ctx, piece, eventType) => garrisonsOfPiece(ctx, piece).some((g) => g.eventType === eventType);
/**
* Run the garrisons of the owned chess `piece` whose eventType is `eventType` once more (ctx.triggerGarrisons).
* `asPiece` runs them as that piece's own trait (塞雷娅 / 白面鸮 "特质与其相同"). SERVER_GAIN repeats ×投资人.
* Returns the number of garrison effects run.
*/
export function runGarrisonsOf(ctx, piece, eventType, { asPiece = null } = {}) {
if (!ctx || typeof ctx.triggerGarrisons !== 'function' || !piece || piece.kind !== 'chess' || !Number.isInteger(piece.uid)) return 0;
const opts = asPiece && Number.isInteger(asPiece.uid) ? { asUid: asPiece.uid } : {};
return ctx.triggerGarrisons(piece.uid, eventType, opts) | 0;
}
/** Run the 获得时 (SERVER_GAIN) garrisons of an owned chess piece once more (×投资人). Returns the effects run. */
export function triggerGainEffects(ctx, piece) {
const p = piece && Number.isInteger(piece.uid) && piece.uid > 0 ? (ctx.piece(piece.uid) || piece) : piece;
if (!p || p.kind !== 'chess') return 0;
return runGarrisonsOf(ctx, p, 'SERVER_GAIN');
}
// 铃兰 60_a: "<进入休整期时>触发身前一格的其他干员的“获得时”类效果" (scope front)
// 瑰盐 82: "<售出时>触发场上一名拥有“休整期结束时”的干员的特质(优先触发部署位置更靠上的和更靠右的)" (scope farright).
// DESIGN §3.1: row 0 is the bottom, so "更靠上" = the highest row; then the highest column ("更靠右").
H.SERVER_TRIGGER_ANOTHER = {
run(ctx) {
const { bbStr } = ctx.source;
const event = bbStr.event || 'SERVER_GAIN';
const self = selfView(ctx);
if (bbStr.scope === 'front') {
if (!onBoard(self)) return;
const f = frontPiece(ctx, self);
if (f && f.kind === 'chess' && f.uid !== self.uid) runGarrisonsOf(ctx, f, event);
return;
}
const cands = chessOnBoard(ctx).filter((p) => (!self || p.uid !== self.uid) && hasEvent(ctx, p, event));
cands.sort((a, b) => (b.row - a.row) || (b.col - a.col));
if (cands.length) runGarrisonsOf(ctx, cands[0], event);
},
};
// 铃兰 60_b: "<进入休整期时>触发身前两格的其他干员的“获得时”类效果" (the two tiles in front, nearest first)
H.SERVER_TRIGGER_FRONT_COUNT = {
run(ctx) {
const { bb, bbStr } = ctx.source;
const self = selfView(ctx);
if (!onBoard(self)) return;
for (let k = 1; k <= Math.min(8, num(bb.count, 1)); k++) {
const f = frontPiece(ctx, self, k);
if (f && f.kind === 'chess' && f.uid !== self.uid) runGarrisonsOf(ctx, f, bbStr.event || 'SERVER_GAIN');
}
},
};
// 塞雷娅 / 白面鸮: "身前一格干员若为“X”特质,本干员的特质与其相同" — the front operator's X garrisons run as the copier's
// own (self-relative effects use the copier's tile / bonds). A copier of the same kind in front copies what that one
// copies (the chain is followed, bounded).
function copyFront(ctx, eventType, selfKey) {
const self = selfView(ctx);
if (!onBoard(self)) return;
let target = frontPiece(ctx, self);
for (let i = 0; i < 8 && target && target.kind === 'chess'; i++) {
const gs = garrisonsOfPiece(ctx, target).filter((g) => g.eventType === eventType);
if (!gs.length) return;
if (!gs.every((g) => g.effectKey === selfKey)) break;
target = frontPiece(ctx, target);
}
if (!target || target.kind !== 'chess' || target.uid === self.uid) return;
runGarrisonsOf(ctx, target, eventType, { asPiece: self });
}
H.SERVER_FRONT_SAME_EFFECT_PREP_FIN = {
run(ctx) { copyFront(ctx, 'SERVER_PREP_FIN', 'SERVER_FRONT_SAME_EFFECT_PREP_FIN'); },
};
H.SERVER_FRONT_SAME_EFFECT_PREP_START = {
run(ctx) { copyFront(ctx, 'SERVER_PREP_START', 'SERVER_FRONT_SAME_EFFECT_PREP_START'); },
};
/** effectKey → handler (the registered objects). */
export const HANDLERS = Object.freeze(H);
export function registerMeta(registry) {
for (const [key, h] of Object.entries(H)) registry.garrison(key, h);
}
+386
View File
@@ -0,0 +1,386 @@
// server/sim/content/generic.js — generic kit derived from a chess's skill blackboard + skill metadata.
//
// Used for every chess without a hand-authored kit (and for tokens with skills), so every unit fights sensibly.
// Kind: PASSIVE (or a free skill without duration/ammo) ⇒ passive · durationType AMMO ⇒ ammo · duration > 0 ⇒
// duration · duration < 0 ⇒ ammo when an ammo key exists, toggle only for an explicitly endless skill
// ("持续时间无限", 史尔特尔) — the official data also uses −1 for instant/charge skills ("立即…", "下一次攻击…",
// "可充能N次": 夕, 塑心, 妮芙, 莱恩哈特, 流星 …) · otherwise instant (charges when maxCharges > 1).
// Blackboard keys → SkillSpec (stat keys prefer the plain key, attack/targeting keys prefer the `attack@` key —
// e.g. 薄绿 `attack@atk_scale` 1.1 per hit vs `atk_scale` 2.1 at skill end):
// atk→mods.atkPct · def→defPct · max_hp→hpPct (values > 5 are flat: 史尔特尔 "生命上限+5000") · attack_speed→aspd
// base_attack_time→batPct · magic_resistance→resMul (|v| < 1, "法术抗性+60%") or resFlat · damage_scale→dmgDealtMul
// block_cnt→blockCnt · taunt_level→taunt · damage_resistance→dmgTakenMul (1−v) · hp_recovery_per_sec(_by_max_hp_ratio)
// →hpRegen(Ratio) · sp_recovery_per_sec→spRecoveryFlat · magic_resist_penetrate_fixed→resIgnoreFlat ·
// def_penetrate_fixed→defIgnoreFlat · ability_range_forward_extend→targeting.rangeExtend · max_target→targeting.maxTargets
// atk_scale→attack.atkScale · heal_scale→attack.healScale · times→attack.hits · attack@range_radius→attack.splashRadius
// trigger_time / ammo→ammo.
// Negative atk/def/magic_resistance described on enemies ("攻击范围内所有敌人防御力-40%", "命中目标的防御力-30%") debuff
// the targets (aura while the skill runs, or on hit for bb.duration s) instead of the operator.
// Statuses stun/cold/sleep/fear/sluggish/root/unmovable (× prob): `attack@` keys → on hit; plain keys → on hit for
// instant/charge skills, once at skill start on the enemies in range (≤ max_target) for timed skills (小满 sleeps 3
// enemies once, not on every attack); a plain `stun` described "…结束后…晕眩" (幽灵鲨, 雷蛇) stuns the operator itself
// when the skill ends.
// Bursts: a timed skill whose text says "立即…造成…" deals its plain atk_scale once at start (乌尔比安's anchor); "技能结束时
// …造成…" deals it at the end (薄绿). Those plain scales never become the per-attack scale.
// Text rules: "停止攻击…" (duration skills; not "…之后…") ⇒ no attacks while active (泡泡, 蛇屠箱, 凯瑟琳, 铃兰, 菲莱);
// "受到攻击时…造成…攻击力/防御力N%的X伤害" ⇒ counter-damage to the attacker (or around the operator when the text says
// 周围) while the skill is active (星熊, 泡泡, 菲莱; aoe_cd = cooldown), never a per-attack scale;
// "附带…凋亡/灼燃/神经损伤" + ep_damage_ratio ⇒ element damage on hit (× damage dealt when the text says "伤害N%的…损伤",
// else × ATK); "屏障" + shield_max_hp_ratio / hp_ratio ⇒ self shield at start decaying over its duration (砾, 新约能天使);
// "立即流失N%当前生命" + hp_ratio ⇒ self HP loss at start (宴, 风丸); hp_ratio + "恢复/回复…生命" ⇒ self heal at start.
// Passive skills only apply stat mods (for bb.duration s when the text says "N秒内": 宴) and the self/counter effects
// above — their scales describe procs (bombs, sword rain, counters) that need a hand-authored kit.
// force→onHit displacement (pull when the text says 拖拽 or for hookmasters, push otherwise; 0.5+0.5·force tiles).
import { normalizeSkill } from '../simdata.js';
import { sortEnemyTargets } from '../targeting.js';
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v !== '' && Number.isFinite(+v) ? +v : undefined));
/** Plain key first, then `attack@` / `skill@` (stat modifiers). */
function getter(bb) {
return (k) => {
for (const key of [k, 'attack@' + k, 'skill@' + k]) {
const v = num(bb[key]);
if (v !== undefined) return v;
}
return undefined;
};
}
/** `attack@` key first (per-attack values), then plain / `skill@`. */
function attackGetter(bb) {
return (k) => {
for (const key of ['attack@' + k, k, 'skill@' + k]) {
const v = num(bb[key]);
if (v !== undefined) return v;
}
return undefined;
};
}
const ENDLESS = /持续时间无限|无限持续/;
const STOP_ATTACK = /(^|[,;。,;])停止(主动)?攻击(敌人)?([,;。,;]|$)/;
const ELEMENTS = [[/凋亡损伤/, 'apoptosis'], [/灼燃损伤/, 'burn'], [/神经损伤/, 'neural']];
const STATUS_KEYS = [['stun', 'stun'], ['cold', 'cold'], ['sleep', 'sleep'], ['fear', 'fear'], ['sluggish', 'sluggish'], ['root', 'bind'], ['unmovable', 'bind']];
/** Decide the skill kind from metadata. */
export function genericKind(sk, bb = {}) {
if (!sk) return null;
const g = getter(bb);
if (sk.kind) return sk.kind;
if (sk.skillType === 'PASSIVE') return 'passive';
// a skill that costs no SP and has no duration/ammo is effectively always on (e.g. 迷迭香's shield token)
if (!(sk.spCost > 0) && sk.durationType !== 'AMMO' && !(sk.duration > 0)) return 'passive';
if (sk.durationType === 'AMMO') return 'ammo';
if (sk.duration > 0) return 'duration';
if (sk.duration < 0) {
if (g('trigger_time') !== undefined || g('ammo') !== undefined) return 'ammo';
if (ENDLESS.test(String(sk.description || ''))) return 'toggle';
}
return sk.maxCharges > 1 ? 'charges' : 'instant';
}
function dmgTypeFromText(text, fallback) {
if (/法术伤害/.test(text)) return 'arts';
if (/物理伤害/.test(text)) return 'phys';
if (/真实伤害/.test(text)) return 'true';
return fallback === 'heal' || fallback === 'none' || !fallback ? 'phys' : fallback;
}
/** Enemies currently in the unit's range, best targets first (at most `n`). */
function enemiesInRange(battle, unit, n) {
const list = battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true });
sortEnemyTargets(battle, unit, list, unit.profile?.priority ?? null);
return n > 0 && list.length > n ? list.slice(0, n) : list;
}
/** Build a SkillSpec from a normalised skill def and its blackboard. `def` (optional) = normalised unit def. */
export function genericSkillSpec(sk, bb = sk?.bb ?? {}, def = null) {
if (!sk) return null;
const g = getter(bb);
const ga = attackGetter(bb);
const kind = genericKind(sk, bb);
const desc = String(sk.description || '');
const passive = kind === 'passive';
const timed = kind === 'duration' || kind === 'ammo' || kind === 'toggle';
// "受到攻击时…造成…" numbers belong to a counter effect (the operator's own, or an ally's: 刺玫 "该角色受到攻击时")
const counterCtx = /受到(敌人的)?攻击时/.test(desc);
const counterText = counterCtx && !/该(角色|干员|单位)受到攻击时/.test(desc);
// ---- stat mods (self) and target debuffs
const mods = {};
const debuff = {};
const set = (k, v) => { if (v !== undefined && Number.isFinite(v) && v !== 0) mods[k] = (mods[k] ?? 0) + v; };
const onEnemies = (stat) => new RegExp(`(敌人|目标|敌方)[^。;]*${stat}-`).test(desc);
const pctOrFlat = (v, pctKey, flatKey, target) => { if (v === undefined || v === 0) return; if (Math.abs(v) > 5) target[flatKey] = (target[flatKey] ?? 0) + v; else target[pctKey] = (target[pctKey] ?? 0) + v; };
const atk = g('atk');
if (atk !== undefined && atk < 0 && onEnemies('攻击力')) pctOrFlat(atk, 'atkPct', 'atkFlat', debuff); else pctOrFlat(atk, 'atkPct', 'atkFlat', mods);
const defv = g('def');
if (defv !== undefined && defv < 0 && onEnemies('防御力')) pctOrFlat(defv, 'defPct', 'defFlat', debuff); else pctOrFlat(defv, 'defPct', 'defFlat', mods);
pctOrFlat(g('max_hp'), 'hpPct', 'hpFlat', mods);
set('aspd', g('attack_speed'));
const bat = g('base_attack_time');
if (bat !== undefined && bat !== 0) mods.batPct = Math.max(-0.9, bat);
const mr = g('magic_resistance');
if (mr !== undefined && mr !== 0) {
const tgt = mr < 0 && onEnemies('法术抗性') ? debuff : mods;
if (Math.abs(mr) < 1) tgt.resMul = (tgt.resMul ?? 1) * Math.max(0, 1 + mr);
else tgt.resFlat = (tgt.resFlat ?? 0) + mr;
}
const ds = g('damage_scale');
if (ds !== undefined && ds > 0) mods.dmgDealtMul = ds;
set('blockCnt', g('block_cnt'));
set('taunt', g('taunt_level'));
const dr = g('damage_resistance');
if (dr !== undefined && dr > 0 && dr < 1) mods.dmgTakenMul = 1 - dr;
// negative regen keys describe enemies ("受到的治疗和回复效果降低50%", 引星棘刺): never a self drain
const hrs = g('hp_recovery_per_sec'), hrr = g('hp_recovery_per_sec_by_max_hp_ratio');
if (hrs > 0) set('hpRegen', hrs);
if (hrr > 0) set('hpRegenRatio', hrr);
set('spRecoveryFlat', g('sp_recovery_per_sec'));
set('resIgnoreFlat', g('magic_resist_penetrate_fixed'));
set('defIgnoreFlat', g('def_penetrate_fixed'));
// ---- targeting / attack override (never for passives: their scales describe procs)
const targeting = {};
const attack = {};
const mt = ga('max_target');
if (!passive && mt !== undefined && mt > 0) targeting.maxTargets = Math.floor(mt);
const ext = g('ability_range_forward_extend');
if (!passive && ext !== undefined && ext > 0) targeting.rangeExtend = Math.round(ext);
if (!passive && sk.rangeGrid && sk.rangeGrid.length) targeting.rangeGrid = sk.rangeGrid;
const atkAt = num(bb['attack@atk_scale']);
const atkPlain = num(bb.atk_scale) ?? num(bb['skill@atk_scale']);
const startBurst = timed && atkPlain !== undefined && /立即[^。]*造成/.test(desc) ? atkPlain : undefined;
const endBurst = timed && atkPlain !== undefined && /技能结束时[^。]*造成[^。]*攻击力/.test(desc) ? atkPlain : undefined;
const counterScale = counterText && atkPlain !== undefined && atkAt === undefined ? atkPlain : undefined;
// "额外造成攻击力N%的…伤害" is bonus damage on top of the attack (烛煌), not the attack's own scale
const isExtra = (v) => v !== undefined && [...desc.matchAll(/额外(造成|附带)[^。;]*?攻击力(\d+(\.\d+)?)%/g)].some((m) => Math.abs(+m[2] - v * 100) < 1e-6);
// a non-healer whose skill "恢复…一名(其他)友方…生命" (古米, 瑕光): its hits heal the most injured ally nearby
const healerUnit = !!def && (def.dmgType === 'heal' || def.profession === 'MEDIC');
const allyHealScale = ga('heal_scale');
const allyHeal = !passive && !!def && !healerUnit && allyHealScale > 0 && /(恢复|治疗)[^。]*友(方|军)[^。]*生命/.test(desc);
const allyHealOthersOnly = /其他友(方|军)/.test(desc);
const allyHealAll = /所有友(方|军)/.test(desc); // 塞雷娅: heals every injured ally in the skill range
if (!passive) {
let as = isExtra(atkAt) ? undefined : atkAt;
if (as === undefined && atkAt === undefined && atkPlain !== undefined && !counterCtx && !isExtra(atkPlain) && startBurst === undefined && endBurst === undefined) as = atkPlain;
if (as !== undefined && as > 0) attack.atkScale = as;
const hs = ga('heal_scale');
if (hs !== undefined && hs > 0 && !allyHeal) attack.healScale = hs;
const times = ga('times');
if (times !== undefined && times > 1) attack.hits = Math.min(10, Math.floor(times));
const rr = num(bb['attack@range_radius']);
if (rr !== undefined && rr > 0 && rr < 5) attack.splashRadius = rr;
if (kind === 'duration' && STOP_ATTACK.test(desc) && !/之后/.test(desc)) attack.noAttack = true;
}
// ---- statuses
const hitStatuses = [];
const startStatuses = [];
let selfStunOnEnd = 0;
if (!passive) {
for (const [key, status] of STATUS_KEYS) {
const onAtk = num(bb['attack@' + key]);
const plain = num(bb[key]) ?? num(bb['skill@' + key]);
if (onAtk !== undefined && onAtk > 0) hitStatuses.push({ key: status, duration: onAtk });
if (plain !== undefined && plain > 0 && !(onAtk > 0 && plain === onAtk)) {
if (key === 'stun' && /结束后[^,。;]*晕眩/.test(desc)) selfStunOnEnd = plain;
else if (timed) startStatuses.push({ key: status, duration: plain });
else if (!(onAtk > 0)) hitStatuses.push({ key: status, duration: plain });
}
}
}
const prob = g('prob');
const rollProb = (battle) => !(prob !== undefined && prob > 0 && prob < 1) || battle.rng.chance(prob);
// ---- element damage attached to hits / counters
let element = null;
const epr = g('ep_damage_ratio');
if (epr !== undefined && epr > 0 && /附带/.test(desc) && !/其他友方/.test(desc)) {
const el = ELEMENTS.find(([re]) => re.test(desc));
if (el) element = { el: el[1], ratio: epr, ofDamage: /伤害\d+(\.\d+)?%的[^。,;]{0,3}损伤/.test(desc) };
}
const applyElement = (battle, unit, target, dealt) => {
if (!element || !target || !target.alive) return;
const amount = element.ofDamage ? (dealt || 0) * element.ratio : unit.s.atk * element.ratio;
if (amount > 0) battle.dealDamage(unit, target, { type: 'element', element: element.el, amount, tags: ['skill'] });
};
// ---- displacement
const forceRaw = g('force');
const hasForce = !passive && forceRaw !== undefined;
const force = Math.max(0, forceRaw ?? 0);
const pull = /拖拽/.test(desc) || (!/推开|击退/.test(desc) && def && def.subProf === 'hookmaster');
const hitElement = element && !counterText;
const healAlly = (battle, unit) => {
if (allyHealAll) {
for (const a of battle.injuredAlliesInKeys(unit.rangeKeys, unit)) if (!(allyHealOthersOnly && a === unit)) battle.heal(unit, a, unit.s.atk * allyHealScale);
return;
}
let best = null;
for (const a of battle.injuredAlliesInKeys(unit.rangeKeys, unit)) if (!(allyHealOthersOnly && a === unit)) { best = a; break; }
if (!best) best = battle.alliesInRadius(unit.x, unit.y, 1.5, null).filter((a) => a.hp < a.s.maxHp && !(allyHealOthersOnly && a === unit)).sort((a, b) => a.hpRatio - b.hpRatio)[0] ?? null;
if (best) battle.heal(unit, best, unit.s.atk * allyHealScale);
};
const onHit = hitStatuses.length || hasForce || hitElement || allyHeal
? (ctx) => {
if (allyHeal) healAlly(ctx.battle, ctx.unit);
if (!ctx.target || !ctx.target.alive) return;
if (hitElement) applyElement(ctx.battle, ctx.unit, ctx.target, ctx.dealt);
if (hitStatuses.length && rollProb(ctx.battle)) {
for (const s of hitStatuses) ctx.battle.applyStatus(ctx.target, s.key, { duration: s.duration, source: ctx.unit });
}
if (hasForce && ctx.target.alive && ctx.target.side === 'enemy') {
const dx = ctx.target.x - ctx.unit.x, dy = ctx.target.y - ctx.unit.y;
const len = Math.hypot(dx, dy) || 1;
const dir = pull ? { x: -dx / len, y: -dy / len } : { x: dx / len, y: dy / len };
const dist = pull ? Math.max(0, Math.min(len - 0.6, 0.5 + 0.5 * force)) : 0.5 + 0.5 * force;
if (dist > 0) ctx.battle.displace(ctx.target, dir, dist, { force: force + 1 });
}
}
: null;
// target debuffs: aura over the range while a timed skill runs, else on hit for bb.duration (default 5 s)
const hasDebuff = Object.keys(debuff).length > 0 && !passive;
const debuffKey = `generic:debuff:${sk.id ?? 'skill'}`;
const aura = hasDebuff && timed && /攻击范围内[^。;]*敌人/.test(desc);
const debuffDur = timed ? 5 : (num(bb.duration) > 0 ? num(bb.duration) : 5);
let onHitFinal = onHit;
if (hasDebuff && !aura) {
const base = onHit;
onHitFinal = (ctx) => {
if (base) base(ctx);
if (ctx.target && ctx.target.alive) ctx.battle.addBuff(ctx.target, { key: debuffKey, duration: debuffDur, mods: { ...debuff }, refresh: 'extend', source: ctx.unit });
};
}
if (onHitFinal) attack.onHit = onHitFinal;
let ammo;
if (kind === 'ammo') ammo = Math.max(1, Math.floor(g('trigger_time') ?? g('ammo') ?? g('cnt') ?? 8));
const spec = {
id: sk.id,
name: sk.name,
kind,
duration: kind === 'duration' ? sk.duration : (kind === 'ammo' && sk.duration > 0 ? sk.duration : undefined),
ammo,
};
// passive stat buffs limited in time ("部署后…在14秒内攻击力+65%", 宴) become a timed buff at each deployment
const passiveTimed = passive && Object.keys(mods).length && num(bb.duration) > 0 && /\d+(\.\d+)?秒内/.test(desc) ? num(bb.duration) : 0;
if (Object.keys(mods).length && !passiveTimed) spec.mods = mods;
if (Object.keys(targeting).length) spec.targeting = targeting;
// instant/charges skills act on the next attack: mods/targeting without an explicit attack still need one
if (!Object.keys(attack).length && (kind === 'instant' || kind === 'charges') && (spec.mods || spec.targeting)) spec.attack = {};
if (Object.keys(attack).length) spec.attack = attack;
// ---- start / end effects
const starts = [];
const ends = [];
if (passiveTimed) {
const m = { ...mods };
starts.push(({ battle, unit }) => battle.addBuff(unit, { key: `generic:passive:${sk.id ?? 'skill'}`, duration: passiveTimed, mods: m, tags: ['skill'] }));
}
const hr = g('hp_ratio');
if (hr !== undefined && hr > 0 && hr <= 1 && /立即流失\d+(\.\d+)?%(的)?当前生命/.test(desc)) {
starts.push(({ battle, unit }) => { const loss = unit.hp * hr; if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit }); });
} else if (hr !== undefined && hr > 0 && hr <= 1 && /(恢复|回复)[^。,]*生命/.test(desc) && !/流失/.test(desc)) {
starts.push(({ battle, unit }) => battle.heal(unit, unit, unit.s.maxHp * hr, { self: true }));
}
const shieldRatio = /屏障/.test(desc) ? (g('shield_max_hp_ratio') ?? (/生命(上限|值)?\d+(\.\d+)?%的屏障/.test(desc) ? hr : undefined)) : undefined;
if (shieldRatio !== undefined && shieldRatio > 0) {
const dur = g('shield_max_duration') ?? num(bb.duration) ?? (sk.duration > 0 ? sk.duration : 0);
const decays = /衰减/.test(desc) && dur > 0;
starts.push(({ battle, unit }) => {
const total = unit.s.maxHp * shieldRatio;
battle.addBuff(unit, {
key: `generic:shield:${sk.id ?? 'skill'}`, shield: total, duration: dur > 0 ? dur : Infinity, visible: true, interval: decays ? 0.5 : 0,
onTick: decays ? ({ unit: u, buff }) => { buff.shield = Math.max(0, buff.shield - total * 0.5 / dur); u.markDirty(); } : null,
});
});
}
const burst = (scale) => ({ battle, unit }) => {
const type = dmgTypeFromText(desc, unit.profile?.dmgType ?? 'phys');
for (const e of enemiesInRange(battle, unit, 0)) {
const dealt = battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type, isSkill: true, tags: ['skill', 'burst'] });
applyElement(battle, unit, e, dealt);
}
};
if (startBurst !== undefined && startBurst > 0) starts.push(burst(startBurst));
if (startStatuses.length) {
const n = mt !== undefined && mt > 0 ? Math.floor(mt) : 0;
starts.push(({ battle, unit }) => {
for (const e of enemiesInRange(battle, unit, n)) for (const s of startStatuses) battle.applyStatus(e, s.key, { duration: s.duration, source: unit });
});
}
if (endBurst !== undefined && endBurst > 0) {
const b = burst(endBurst);
ends.push((ctx) => { if (ctx.reason !== 'death' && ctx.unit.alive) b(ctx); });
}
if (selfStunOnEnd > 0) {
ends.push((ctx) => { if (ctx.reason !== 'death' && ctx.unit.alive) ctx.battle.applyStatus(ctx.unit, 'stun', { duration: selfStunOnEnd, source: ctx.unit }); });
}
if (starts.length) spec.onStart = (ctx) => { for (const f of starts) f(ctx); };
if (ends.length) spec.onEnd = (ctx) => { for (const f of ends) f(ctx); };
if (aura) {
const m = { ...debuff };
spec.onTick = ({ battle, unit }) => {
for (const e of battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true })) battle.addBuff(e, { key: debuffKey, duration: 0.5, mods: m, refresh: 'extend', source: unit });
};
}
// ---- counter-damage while the skill is active ("受到攻击时对目标造成…攻击力/防御力N%的X伤害")
if (counterText && (counterScale !== undefined || element)) {
const m = desc.match(/受到(敌人的)?攻击时[^。]*?(攻击力|防御力)(\d+(\.\d+)?)%的(物理|法术|真实)伤害/);
spec.counter = {
scale: counterScale ?? 0,
stat: m && m[2] === '防御力' ? 'def' : 'atk',
type: m ? dmgTypeFromText(m[5] + '伤害', 'phys') : 'phys',
around: /受到(敌人的)?攻击时对周围/.test(desc),
groundOnly: /周围的地面敌人/.test(desc),
cooldown: num(bb.aoe_cd) ?? 0,
applyElement: element ? applyElement : null,
};
}
return spec;
}
/** Per-unit hooks for the generic spec (counter-damage). */
function installGeneric(spec) {
const c = spec && spec.counter;
if (!c) return null;
return (battle, unit) => {
let readyAt = -Infinity;
battle.on('damaged', (ctx) => {
if (ctx.target !== unit || !unit.alive || !unit.skill?.active || battle.time < readyAt) return;
const src = ctx.source;
if (!src || src.side !== 'enemy' || !ctx.dmg || !ctx.dmg.isAttack) return;
readyAt = battle.time + c.cooldown;
const amount = (c.stat === 'def' ? unit.s.def : unit.s.atk) * c.scale;
const victims = c.around ? battle.enemiesInRadius(unit.x, unit.y, 1.5).filter((e) => !(c.groundOnly && e.isFlying)) : (src.alive ? [src] : []);
for (const e of victims) {
const dealt = amount > 0 ? battle.dealDamage(unit, e, { amount, type: c.type, canDodge: false, isSkill: true, tags: ['counter'] }) : 0;
if (c.applyElement) c.applyElement(battle, unit, e, dealt);
}
}, { owner: unit });
};
}
/**
* Generic kit: `(bb, chess, def?) => Kit`. `chess` is the data record; `def` the normalised def when available.
*/
export function genericKit(bb, chess, def = null) {
const sk = def?.skill ?? normalizeSkill(chess);
if (!sk) return { skill: null, talents: [], generic: true };
const spec = genericSkillSpec(sk, bb && Object.keys(bb).length ? bb : sk.bb, def);
const kit = { skill: spec, talents: [], generic: true };
const inst = installGeneric(spec);
if (inst) kit.install = inst;
return kit;
}
export default genericKit;
+218
View File
@@ -0,0 +1,218 @@
// server/sim/content/index.js — installs all content into a Battle (DESIGN §7).
//
// installContent(battle, { mode }) — applies the per-battle loadout data view (simdata withUnitLoadouts; DESIGN §16),
// then mode 'full' (default): hand-authored kits (kits/tier1..6) with generic
// fallback + every domain module's install(battle); 'generic': generic kits only, no domain modules;
// 'none': no kits (units never cast skills) and no modules. Every module install runs inside try/catch:
// a faulty content module is logged and skipped, never crashing the battle.
// setupUnitKit(battle, unit, mode) — resolves a unit's Kit: kits[baseChessId]?.(bb, chess, def) ?? generic.
// Battle opts.kits ({ [baseChessId]: kitFn }) take precedence (tests / sandboxes).
// Operator loadouts (DESIGN §16): the def is resolved for the unit's selected skill / module (simdata getChess), so
// the kit receives `chess.skill` = the SELECTED skill record and `bb` = its blackboard (talents / trait / module of
// the selected module). Kit contract (backward compatible): `{ skill?, skills?: { [skillId]: SkillSpec }, talents,
// trait, install? }` — the skill spec is `skills[selectedSkillId]` when the kit authors it, else `skill` only when
// the selected skill is the chess's default one, else the GENERIC spec of the selected skill (its generic install,
// e.g. counter damage, is chained after the kit's). Talents / trait / install of the kit always apply.
// syncUnitLoadout (called first, before the battle starts): a unit whose def lacks the loadout of its own input entry
// (id-only data lookups in a multi-player field; an entry without loadout fields = the default) is given its own
// def; summon pieces follow their owner (an inline `def` of a token entry is kept). Summons spawned AFTER the start
// are resolved by Battle (getToken(id, owner.defId, owner.def.loadout)), not here.
// registerAllMeta(registry) — calls each domain module's registerMeta(registry) (prep side, server boot).
import { genericKit } from './generic.js';
import { withUnitLoadouts } from '../simdata.js';
// Content files are loaded with guarded dynamic imports: a module that fails to load (syntax error, throwing
// top-level code, missing file) is logged and replaced by an empty module instead of breaking the server.
async function safeImport(path) {
try {
return await import(path);
} catch (e) {
console.error(`[content] failed to load ${path}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`);
return {};
}
}
const TIERS = await Promise.all([1, 2, 3, 4, 5, 6].map((t) => safeImport(`./kits/tier${t}.js`)));
const DOMAIN_NAMES = ['tokens', 'devices', 'enemies', 'bosses', 'bonds', 'garrisons', 'items', 'bands', 'choices'];
const DOMAINS = await Promise.all(DOMAIN_NAMES.map((n) => safeImport(`./${n}.js`)));
const tokens = DOMAINS[0];
/** Merged kit registry: baseChessId → (bb, chess, def) => Kit */
export const KITS = Object.freeze(Object.assign({}, ...TIERS.map((m) => (m && m.default && typeof m.default === 'object' ? m.default : {}))));
/** Domain modules in install order: tokens, devices, enemies, bosses, bonds, garrisons, items, bands, choices. */
export const MODULES = Object.freeze(DOMAIN_NAMES.map((n, i) => [n, DOMAINS[i]]));
/** Resolve the Kit of an ally unit. Never throws (falls back to the generic kit). */
export function setupUnitKit(battle, unit, mode = 'full') {
try { syncUnitLoadout(battle, unit); } catch (e) { battle._handlerError?.('loadout', unit, e); }
if (mode === 'none') return {};
const def = unit.def || {};
const raw = def.raw ?? def;
const bb = def.skill?.bb ?? {};
if (unit.kind === 'token') {
const tk = mode === 'full' ? (tokens.kits?.[def.id] ?? tokens.default?.[def.id]) : null;
if (typeof tk === 'function') {
try { const k = tk(bb, raw, def); if (k) return k; } catch (e) { battle._handlerError(`tokenKit:${def.id}`, unit, e); }
}
return def.skill ? genericKit(bb, raw, def) : {};
}
if (unit.kind !== 'op') return {};
const injected = battle.opts && battle.opts.kits;
if (mode === 'full' || injected) {
// DESIGN §5.6's example keys kits by the suffix-less id (`chess_char_1_01`), data/SIM.md by baseId (`…_a`): accept both
const bare = String(def.baseId ?? def.id ?? '').replace(/_[ab]$/, '');
const pick = (reg) => reg?.[def.baseId] ?? reg?.[def.id] ?? reg?.[bare];
const f = pick(injected) ?? (mode === 'full' ? pick(KITS) : undefined);
if (typeof f === 'function') {
try {
const k = f(bb, raw, def);
if (k) return selectSkillSpec(k, bb, raw, def);
} catch (e) {
battle._handlerError(`kit:${def.baseId}`, unit, e);
}
}
}
return genericKit(bb, raw, def);
}
/** A unit's own PlayerBattleInput entry (operators: kind ≠ 'token'; tokens: kind 'token'), or null. */
function inputEntry(unit) {
const list = unit && unit.player && unit.player.input && Array.isArray(unit.player.input.units) ? unit.player.input.units : null;
if (!list || unit.uid == null) return null;
const tok = unit.kind === 'token';
return list.find((v) => v && v.uid === unit.uid && (v.kind === 'token') === tok) ?? null;
}
/**
* The loadout `{ skillIndex, moduleId }` a unit's PlayerBattleInput entry gives; an entry without loadout fields means
* the DEFAULT loadout (`{}`), never "whatever the data view maps this chess id to" (another player's choice in a
* multi-player field). Null when the unit has no input entry.
*/
function inputLoadout(unit) {
const x = inputEntry(unit);
if (!x) return null;
if (x.skillIndex == null && x.moduleId == null) return {};
return { skillIndex: x.skillIndex ?? null, moduleId: x.moduleId ?? null };
}
/** Put a def on a not-yet-deployed ally (the fields Battle._makeAlly takes from the def). */
function swapDef(unit, def) {
const st = def.stats;
unit.def = def;
unit.defId = def.id;
unit.name = def.name;
unit.rangeGrid = def.rangeGrid;
Object.assign(unit.base, {
maxHp: st.maxHp, atk: st.atk, def: st.def, res: st.res, aspd: st.aspd, bat: st.bat, blockCnt: st.blockCnt,
spRecovery: st.spRecovery, tauntLevel: st.tauntLevel, massLevel: st.massLevel, hpRecoveryPerSec: st.hpRecoveryPerSec,
cost: st.cost, respawnTime: st.respawnTime,
});
unit.markDirty?.();
unit.hp = unit.s ? unit.s.maxHp : st.maxHp;
}
/**
* Loadout exactness (DESIGN §16) before the battle starts: an operator whose def does not carry the loadout of its own
* PlayerBattleInput entry (the per-battle data view resolves id-only lookups per chess id — two players of one field
* may give the same chess different loadouts) gets the def of its own loadout — an entry without loadout fields means
* the default — and a summon piece follows its owner's (unless its entry carries an inline `def`). A no-op when the def
* already matches (Battle passing the unit's loadout to getChess / getToken).
*/
export function syncUnitLoadout(battle, unit) {
if (!battle || battle.started || !unit || unit.deployed || !unit.def || !battle.data) return;
if (unit.kind === 'op') {
const lo = inputLoadout(unit);
if (!lo || typeof battle.data.getChess !== 'function') return;
// an entry without loadout fields only needs a look when the def carries a non-default loadout
if (!Object.keys(lo).length && !(unit.def.loadout && unit.def.loadout.isDefault === false)) return;
const want = battle.data.getChess(unit.def.id, lo);
if (want && want !== unit.def && want.id === unit.def.id) swapDef(unit, want);
} else if (unit.kind === 'token' && unit.ownerUnit && unit.ownerUnit.kind === 'op' && typeof battle.data.getToken === 'function') {
// an explicit inline def of the input entry (Battle `_tokenDef(id, owner, inp.def)`) is never replaced
if (inputEntry(unit)?.def) return;
const owner = unit.ownerUnit;
syncUnitLoadout(battle, owner);
if (!owner.def || !owner.def.loadout) return;
const want = battle.data.getToken(unit.def.id, owner.def.id, owner.def.loadout);
if (want && want !== unit.def && want.id === unit.def.id) swapDef(unit, want);
}
}
/** Id of the selected skill of an operator def (null without a skill). */
const selectedSkillId = (def) => def?.skill?.id ?? def?.raw?.skill?.skillId ?? null;
/** True when the def's selected skill is the chess's default skill (no loadout info ⇒ default). */
const skillIsDefault = (def) => !def?.loadout || def.loadout.skillIsDefault !== false;
/**
* The kit with the skill spec of the SELECTED skill (see header): `kit.skills[id]` → `kit.skill` (default skill only)
* → the generic spec. Returns the kit itself when nothing changes; `kit.skillSource` tells which one was used
* ('skills' | 'kit' | 'generic').
* @param {object} kit hand-authored kit
*/
export function selectSkillSpec(kit, bb, raw, def) {
const id = selectedSkillId(def);
const map = kit && kit.skills && typeof kit.skills === 'object' ? kit.skills : null;
if (id && map && Object.prototype.hasOwnProperty.call(map, id)) return { ...kit, skill: map[id] ?? null, skillSource: 'skills' };
if (skillIsDefault(def)) return kit;
const g = genericKit(bb, raw, def);
const own = typeof kit.install === 'function' ? kit.install : null;
const gen = typeof g.install === 'function' ? g.install : null;
const out = { ...kit, skill: g.skill ?? null, skillSource: 'generic' };
if (own && gen) out.install = (battle, unit) => { own(battle, unit); gen(battle, unit); };
else if (gen) out.install = gen;
return out;
}
/**
* Whether the kit of `def` has a hand-authored spec for its selected skill (tools/kit-coverage.mjs, tests): `skills`
* entry, or the kit's `skill` for the default skill when the kit is not the generic fallback. Never throws.
* @returns {'skills'|'kit'|'generic'|'none'} where the spec comes from ('none' = no kit function at all)
*/
export function skillSpecSource(def, kits = KITS) {
const bare = String(def?.baseId ?? def?.id ?? '').replace(/_[ab]$/, '');
const f = kits?.[def?.baseId] ?? kits?.[def?.id] ?? kits?.[bare];
if (typeof f !== 'function') return 'none';
let k = null;
try { k = f(def.skill?.bb ?? {}, def.raw ?? def, def); } catch { return 'generic'; }
if (!k || k.generic) return 'generic';
const id = selectedSkillId(def);
if (id && k.skills && Object.prototype.hasOwnProperty.call(k.skills, id)) return 'skills';
return skillIsDefault(def) && k.skill !== undefined ? 'kit' : 'generic';
}
/**
* Install kits for every ally unit and (mode 'full') every domain module. First, a Battle constructed without the spec
* path (server-run fields, bot rehearsals, tests) gets the same per-battle loadout data view createBattleFromSpec uses
* (simdata withUnitLoadouts over its players' inputs), so summons spawned during the battle (`getToken(id, owner)`)
* and every other id-only lookup resolve the owner's selected skill / module there too (DESIGN §16).
*/
export function installContent(battle, { mode = 'full', extra = null } = {}) {
try {
const view = withUnitLoadouts(battle.data, (battle.players || []).map((p) => p && p.input));
if (view && view !== battle.data) battle.data = view;
} catch (e) { battle._handlerError?.('loadoutView', null, e); }
for (const u of battle.allyUnits) {
if (u.kit) continue;
try { battle._setupUnit(u); } catch (e) { battle._handlerError('setupUnit', u, e); }
}
if (mode === 'full') {
for (const [name, mod] of MODULES) {
if (typeof mod.install !== 'function') continue;
try { mod.install(battle); } catch (e) { battle._handlerError(`content:${name}`, null, e); }
}
}
for (const m of extra || []) {
try { (typeof m === 'function' ? m : m.install)?.(battle); } catch (e) { battle._handlerError('content:extra', null, e); }
}
}
/** Prep-side registration of every domain module (server boot). */
export function registerAllMeta(registry) {
for (const [name, mod] of MODULES) {
if (typeof mod.registerMeta !== 'function') continue;
try { mod.registerMeta(registry); } catch (e) { console.error(`[content] ${name}.registerMeta failed:`, e); }
}
}
+6
View File
@@ -0,0 +1,6 @@
// server/sim/content/items.js — equipment & Arts content (research 04, docs/DATA.md §5).
// Battle side: install(battle) is called once per Battle by content/index.js (inside try/catch).
// Prep side: registerMeta(registry) is called once at server boot by match/effectsMeta.js.
// lendItemEffects(battle, fromUnit, toUnit, { maxTier, duration }) is used by 萨尔贡 × 娜仁图亚 (bands).
export { install, lendItemEffects } from './items/battle.js';
export { registerMeta } from './items/meta.js';
+862
View File
@@ -0,0 +1,862 @@
// server/sim/content/items/battle.js — battle side of the 56 equipment items (research 04 §4, docs/DATA.md §5).
//
// Every operator's `unit.items` (equipped ids; merged items are golden `_b` ids with their own numbers) becomes a set of
// item grants. A grant = one concrete item record installed on one unit through `installItem(battle, unit, itemId)`:
// its behaviour registers hooks / timers / buffs through a Scope, and `grant.dispose()` removes all of it again (used
// by lendItemEffects: 萨尔贡 × 娜仁图亚 lends a carrier's items to its neighbours for 60 s).
//
// Numbers always come from the concrete record's buffs (support.buffsOf: `{ ...bb, ...bbStr }` per buff, looked up by
// the buff's bbStr.key — the flattened `params` of data/items.json lose duplicate keys such as 蒸汽之心 attack_speed).
//
// Stat rule (research 02 §2.1, PRTS "直接乘算"; research 04 §2 assumed additive %): every equipment percentage is its own
// multiplier on the final stat — atk/def/max_hp → atkMul/defMul/hpMul = 1 + x; attack_speed → aspd (additive);
// magic_resistance → resFlat; respawn_time → redeployMul 1 + x; sp_recovery_per_sec → spRecoveryFlat;
// magic_resist_penetrate → resIgnorePct; taunt_level → taunt. Two different items on one unit both apply, and a
// normal + golden copy of the same item may coexist (different ids → different buff keys) and both apply.
//
// Gating: "若携带者为【X】盟约干员" = memberOf (support.isMember: own bonds + 变形同构体 grants; 调和 members only while the
// core bond is active). Combo partners (bbStr.equip_chess_id / other_equip) are item keys without the _a/_b suffix:
// either quality satisfies them; lent items count as carried. Both are evaluated live, at the moment of use.
//
// Hook priorities (non-default): 'fatal' — consumable death savers (坚固维式重锤's first lethal hit, M3茧甲 revives)
// run LAST (PRIO_REVIVE −100: "被击倒时" = nothing else prevented the knock-down, so a skill's / talent's own undying
// (kits use 10 … −60) never wastes a charge); 骑士戒律's free in-skill undying runs early (20). Flat damage reduction
// 'hit' −10 (after the other damage modifiers). Proc damage dealt by items carries the tag 'item' and never
// re-triggers item procs.
//
// 蒸汽之心 (Victoria carrier) — LIVE model: the hammer types (灼燃/坚固/加速/战栗维式重锤) carried by the owner's operators
// that are on the field right now (cached ≤ 0.5 s, dropped whenever an operator deploys or leaves) are granted to the
// carrier; a hammer the carrier carries itself counts twice ("效果变为2倍"). Implemented as one shared hammer runtime
// per unit whose multiplier
// m = own copies of that hammer + (1 if 蒸汽之心 applies and the type is on the field): burn ×m of arts damage,
// undying duration ×m, ASPD ×m, tremble chance ×m. Granted hammers without an own copy use 蒸汽之心's blackboard.
import {
num, itemRecord, itemKeyOf, buffsOf, isOp, onField, unitBonds, isMember, bondActive, isGroundOp, frontTile,
alliesAround, passiveBuff, fxOn, battleStore, contentInfo, itemsOf,
} from '../support/index.js';
import { mitigate } from '../../damage.js';
// =====================================================================================================================
// data helpers
/** Buff params `{ ...bb, ...bbStr }` of the record's buff whose bbStr.key is `bbKey` (null when absent). */
function bp(rec, bbKey) {
for (const b of buffsOf(rec)) if (b.bbKey === bbKey) return b.p;
return null;
}
/** Partner key of a combo buff (equip_chess_id / other_equip), without suffix. */
function partnerOf(p) {
if (!p) return null;
if (typeof p.equip_chess_id === 'string' && p.equip_chess_id) return itemKeyOf(p.equip_chess_id);
if (typeof p.other_equip === 'string' && p.other_equip) return itemKeyOf(p.other_equip.split(',')[0].trim());
return null;
}
const isProc = (dmg) => !!(dmg && dmg.tags && dmg.tags.indexOf('item') >= 0);
const procTags = (k) => ['item', `item:${k}`];
const chance = (battle, p) => p > 0 && (p >= 1 || battle.rng() < p);
/** 'fatal' priorities (see header). */
export const PRIO_REVIVE = -100;
const PRIO_FREE_UNDYING = 20;
/** Stat keys of a stat buff → mods (直接乘算, see header). */
const STAT_BUFFS = new Set([
'attr_common_global_buff', 'act1autochess_equip_acarm056_global_buff', 'act1autochess_equip_acarm050_global_buff',
'magic_penetrate_global_buff', 'act1autochess_equip_acarm054_global_buff',
]);
function statMods(p) {
const m = {};
let any = false;
const set = (k, v) => { m[k] = v; any = true; };
if (num(p.atk)) set('atkMul', 1 + num(p.atk));
if (num(p.def)) set('defMul', 1 + num(p.def));
if (num(p.max_hp)) set('hpMul', Math.max(0.01, 1 + num(p.max_hp)));
if (num(p.attack_speed)) set('aspd', num(p.attack_speed));
if (num(p.magic_resistance)) set('resFlat', num(p.magic_resistance));
if (num(p.respawn_time)) set('redeployMul', Math.max(0, 1 + num(p.respawn_time)));
if (num(p.sp_recovery_per_sec)) set('spRecoveryFlat', num(p.sp_recovery_per_sec));
if (num(p.magic_resist_penetrate)) set('resIgnorePct', num(p.magic_resist_penetrate));
if (num(p.taunt_level)) set('taunt', num(p.taunt_level));
return any ? m : null;
}
// ---- hammers (蒸汽之心) — derived from data: hammer records carry bbStr.big_hammer on their special buff
const STEAM_KEY = 'chess_item_6_05_e';
const STEAM_BUFF = 'act1autochess_equip_acarm079_global_buff';
function hammerTypeOf(p) {
if (!p) return null;
if (p.damage_scale != null) return 'burn';
if (p.undeadable_duration != null) return 'undying';
if (p.disarmed_duration != null) return 'tremble';
if (p.attack_speed != null) return 'aspd';
return null;
}
/** hammer item key → { type, bbKey } */
const HAMMERS = new Map();
function hammerInfo(key) {
if (!HAMMERS.size) {
const steam = itemRecord(`${STEAM_KEY}_a`);
const sp = steam ? bp(steam, STEAM_BUFF) : null;
const keys = sp ? Object.keys(sp).filter((k) => /^hammer_\d+$/.test(k)).map((k) => itemKeyOf(sp[k])) : [];
for (const k of keys) {
const rec = itemRecord(`${k}_a`);
const b = rec ? buffsOf(rec).find((x) => x.p.big_hammer != null) : null;
const type = b ? hammerTypeOf(b.p) : null;
if (type) HAMMERS.set(k, { type, bbKey: b.bbKey });
}
if (!HAMMERS.size) HAMMERS.set('__none__', null);
}
return HAMMERS.get(key) ?? null;
}
// =====================================================================================================================
// membership / carrying
/** "若携带者为【X】盟约干员": own bonds (incl. 变形同构体 grants), or 调和 while the core bond X is active. */
export function memberOf(battle, u, bondId) {
if (!isOp(u) || !bondId) return false;
if (unitBonds(u).includes(bondId)) return true;
return isMember(battle, u, bondId) && bondActive(battle, u.ownerId, bondId);
}
function runtime(battle) {
return battleStore(battle, 'items:rt', () => {
const rt = { grants: new Map(), hammers: new Map(), field: new Map(), lends: new Map() };
// the per-player "hammers on the field" cache (蒸汽之心) is dropped whenever an operator enters or leaves the field:
// a cache filled before the initial deployment must not hide the field's hammers for the first 0.5 s
const drop = (c) => { if (c.unit && c.unit.kind === 'op') rt.field.clear(); };
battle.on('deploy', drop);
battle.on('death', drop);
return rt;
});
}
function grantsOf(rt, u) {
let m = rt.grants.get(u);
if (!m) { m = new Map(); rt.grants.set(u, m); }
return m;
}
/** Does `u` carry an item of this key (own equipment or a lent grant)? */
export function carries(battle, u, key) {
if (!key || !u) return false;
for (const id of itemsOf(u)) if (itemKeyOf(id) === key) return true;
const g = runtime(battle).grants.get(u);
if (g) for (const x of g.values()) if (x.lent && x.key === key) return true;
return false;
}
// =====================================================================================================================
// Scope: everything an item behaviour registers, removable at once
class Scope {
constructor(battle, unit, tag) {
this.b = battle;
this.u = unit;
this.tag = tag;
this.hooks = [];
this.scheds = [];
this.buffs = [];
this.fns = [];
this.disposed = false;
}
key(part) { return `${this.tag}:${part}`; }
on(name, fn, priority = 0) {
const h = this.b.on(name, fn, { owner: this.u, priority });
if (h) this.hooks.push(h);
return h;
}
every(sec, fn, opts = {}) { const s = this.b.every(sec, fn, { owner: this.u, ...opts }); this.scheds.push(s); return s; }
after(sec, fn) { const s = this.b.after(sec, fn, { owner: this.u }); this.scheds.push(s); return s; }
/** Match-long stat buff on the carrier (persist, survives death/redeploy). */
stat(part, mods, extra = {}) {
const k = this.key(part);
const r = passiveBuff(this.b, this.u, k, mods, extra);
if (r) this.track(this.u, k);
return r;
}
/** Any buff on any unit, removed at dispose. */
buff(target, b) {
const r = this.b.addBuff(target, b);
if (r) this.track(target, b.key);
return r;
}
track(target, key) { if (!this.buffs.some(([t, k]) => t === target && k === key)) this.buffs.push([target, key]); }
onDispose(fn) { this.fns.push(fn); }
dispose() {
if (this.disposed) return;
this.disposed = true;
for (const h of this.hooks) this.b.off(h);
for (const s of this.scheds) s.cancel();
for (const [t, k] of this.buffs) this.b.removeBuff(t, k);
for (const f of this.fns) { try { f(); } catch { /* keep disposing */ } }
this.hooks = []; this.scheds = []; this.buffs = []; this.fns = [];
}
}
// =====================================================================================================================
// hammer runtime (shared per unit: own hammers + 蒸汽之心)
function fieldHammers(battle, rt, pid) {
let c = rt.field.get(pid);
if (!c) { c = { t: -Infinity, set: new Set() }; rt.field.set(pid, c); }
if (battle.time - c.t >= 0.5 - 1e-9) {
c.t = battle.time;
c.set.clear();
for (const a of battle.allyUnits) {
if (a.kind !== 'op' || a.ownerId !== pid || !onField(a)) continue;
for (const id of itemsOf(a)) { const h = hammerInfo(itemKeyOf(id)); if (h) c.set.add(h.type); }
}
}
return c.set;
}
function hammerState(battle, rt, u) {
let hs = rt.hammers.get(u);
if (!hs) {
hs = { own: { burn: 0, undying: 0, aspd: 0, tremble: 0 }, params: {}, steam: 0, steamP: null, refs: 0, scope: null, aspdCur: 0, undyingUsed: false, undyingUntil: -Infinity };
rt.hammers.set(u, hs);
}
return hs;
}
/** Effective multiplier of a hammer type on `u` and its params (null when the type does not apply). */
function hammerMul(battle, rt, u, hs, type) {
const own = hs.own[type];
const steam = hs.steam > 0 && memberOf(battle, u, 'victoriaShip') && fieldHammers(battle, rt, u.ownerId).has(type) ? 1 : 0;
return own + steam;
}
const hammerParams = (hs, type) => hs.params[type] ?? hs.steamP ?? null;
function hammerRefresh(battle, rt, u, hs) {
if (!hs.scope) return;
const p = hammerParams(hs, 'aspd');
const v = p ? num(p.attack_speed) * hammerMul(battle, rt, u, hs, 'aspd') : 0;
if (v === hs.aspdCur) return;
hs.aspdCur = v;
const k = hs.scope.key('aspd');
if (v) hs.scope.stat('aspd', { aspd: v });
else battle.removeBuff(u, k);
}
function hammerAcquire(battle, rt, u) {
const hs = hammerState(battle, rt, u);
hs.refs++;
if (hs.scope) return hs;
const S = new Scope(battle, u, `item:hammer#${u.id}`);
hs.scope = S;
hs.aspdCur = 0;
S.every(0.5, () => hammerRefresh(battle, rt, u, hs), { immediate: true });
// 灼燃: arts damage also deals burn (元素损伤) = damage_scale × damage × m
S.on('damaged', (c) => {
if (c.source !== u || c.type !== 'arts' || !(c.amount > 0) || isProc(c.dmg) || !c.target || c.target.side !== 'enemy' || !c.target.alive) return;
const p = hammerParams(hs, 'burn');
if (!p) return;
const m = hammerMul(battle, rt, u, hs, 'burn');
if (m > 0) battle.dealDamage(u, c.target, { type: 'element', element: 'burn', amount: c.amount * num(p.damage_scale) * m, tags: procTags('hammer_burn') });
});
// 战栗: ground carrier, on attack prob × m ⇒ 战栗 disarmed_duration s
S.on('attack', (c) => {
if (c.attacker !== u || !isGroundOp(u)) return;
const p = hammerParams(hs, 'tremble');
if (!p) return;
const m = hammerMul(battle, rt, u, hs, 'tremble');
if (!(m > 0)) return;
const pr = Math.min(1, num(p.prob) * m);
for (const t of c.targets) {
if (t && t.side === 'enemy' && t.alive && chance(battle, pr)) battle.applyStatus(t, 'tremble', { duration: num(p.disarmed_duration, 2), source: u });
}
});
// 坚固: first lethal hit per battle ⇒ HP never below 1 for undeadable_duration × m s
S.on('fatal', (c) => {
if (c.unit !== u || c.prevented) return;
if (battle.time < hs.undyingUntil) { c.prevented = true; return; }
if (hs.undyingUsed) return;
const p = hammerParams(hs, 'undying');
if (!p) return;
const m = hammerMul(battle, rt, u, hs, 'undying');
if (!(m > 0)) return;
hs.undyingUsed = true;
const dur = num(p.undeadable_duration, 8) * m;
hs.undyingUntil = battle.time + dur;
c.prevented = true;
fxOn(battle, 'undying', u, 'item:hammer', 'chess_item_3_09_e', { duration: dur });
}, PRIO_REVIVE);
return hs;
}
function hammerRelease(battle, rt, u, hs) {
hs.refs = Math.max(0, hs.refs - 1);
if (hs.refs > 0 || !hs.scope) { hammerRefresh(battle, rt, u, hs); return; }
hs.scope.dispose();
hs.scope = null;
hs.aspdCur = 0;
}
// =====================================================================================================================
// item behaviours: key → (battle, u, rec, S, ctx) — register through S (disposed with the grant)
/** Damage mitigation estimate for flat DR / weakness damage (same formula as damage.js). */
function mitigated(amount, type, target, source, dmg) {
const ss = source && source.s ? source.s : null;
return mitigate(amount, type, target.s, {
defIgnorePct: (dmg.defIgnorePct || 0) + (ss ? ss.defIgnorePct : 0),
defIgnoreFlat: (dmg.defIgnoreFlat || 0) + (ss ? ss.defIgnoreFlat : 0),
resIgnorePct: (dmg.resIgnorePct || 0) + (ss ? ss.resIgnorePct : 0),
resIgnoreFlat: (dmg.resIgnoreFlat || 0) + (ss ? ss.resIgnoreFlat : 0),
});
}
/** Per enemy target of an attack by `u` (heals excluded). */
function onAttackEnemies(S, u, fn) {
S.on('attack', (c) => {
if (c.attacker !== u) return;
const ts = c.targets;
for (let i = 0; i < ts.length; i++) { const t = ts[i]; if (t && t.side === 'enemy' && t.alive) fn(t, c); }
});
}
/** Generic buff-key behaviours (shared by several items). */
const BY_BUFF = {
// 源石溶剂: 每秒流失 damage 点生命值 (HP loss, may kill)
periodic_damage(battle, u, p, S) {
const d = num(p.damage);
if (!(d > 0)) return;
S.stat('drain', null, { interval: 1, onTick: ({ unit }) => { if (unit.deployed) battle.loseHp(unit, d, { source: unit }); } });
},
// 奥术法阵: 攻击使目标失去特殊能力 silence s
silence_attachment(battle, u, p, S) {
const d = num(p.silence);
if (d > 0) onAttackEnemies(S, u, (t) => battle.applyStatus(t, 'silence', { duration: d, source: u }));
},
// 海沟实验体: 伤害减免 value per damage instance (after DEF/RES)
halfidle_block_fixed_damage(battle, u, p, S) {
const v = num(p.value);
if (!(v > 0)) return;
S.on('hit', (c) => {
if (c.target !== u) return;
const d = c.dmg;
if (d.type === 'element') return;
const base = mitigated(d.amount * d.mul, d.type, u, c.source, d);
if (!(base > 0)) return;
d.mul *= Math.max(0, base - v) / base;
}, -10);
},
// 叙拉古正装: allies on the two tiles beside the carrier ("左右两格", perpendicular to its direction: the facing-RIGHT
// offsets (±1, 0) rotated by its dir) get ASPD +attack_speed
act2autochess_equip_acarm121_ability(battle, u, p, S) {
const v = num(p.attack_speed);
if (!v) return;
const key = S.key('side');
const mods = { aspd: v };
const SIDES = [[1, 0], [-1, 0]];
S.every(0.5, () => {
if (!onField(u)) return;
for (const a of alliesAround(battle, u, SIDES)) if (a.kind === 'op') S.buff(a, { key, duration: 0.6, mods, refresh: 'replace' });
}, { immediate: true });
},
};
/** Item-key behaviours. */
const BY_ITEM = {
// 精准狙击镜: damage ×damage_scale against targets ≥ radius tiles away
chess_item_3_02_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm063_global_buff');
if (!p) return;
const r = num(p.radius, 3), sc = num(p.damage_scale, 1);
S.on('hit', (c) => {
if (c.source !== u || !c.target || c.target.side !== 'enemy' || c.dmg.type === 'element') return;
if (Math.hypot(c.target.x - u.x, c.target.y - u.y) >= r - 1e-6) c.dmg.mul *= sc;
});
},
// 炎国短刀: each skill activation +atk (≤ atk_buff_cnt stacks) — one multiplier 1 + atk × stacks
chess_item_3_04_e(battle, u, rec, S) {
const p = bp(rec, 'act1vautochess_equip_acarm024_global_buff');
if (!p) return;
let n = 0;
const cap = Math.max(1, Math.floor(num(p.atk_buff_cnt, 10)));
S.on('skillStart', (c) => {
if (c.unit !== u || n >= cap || !chance(battle, num(p.prob, 1))) return;
n++;
S.stat('stacks', { atkMul: 1 + num(p.atk) * n });
});
},
// 迅捷作战粮: on deploy SP += sp_each_person × (1 + other operators sharing a bond)
chess_item_3_05_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm051_global_buff');
const each = p ? num(p.sp_each_person) : 0;
if (!(each > 0)) return;
S.on('deploy', (c) => {
if (c.unit !== u || !u.skill) return;
const mine = unitBonds(u);
let n = 0;
if (mine.length) {
for (const a of battle.allyUnits) {
if (a === u || a.kind !== 'op' || a.ownerId !== u.ownerId || a.removed) continue;
if (!(c.initial ? !a.removed : onField(a))) continue;
if (unitBonds(a).some((b) => mine.includes(b))) n++;
}
}
u.skill.gainSp(each * (1 + n), 'item');
});
},
// 歌利亚头盔: HP +init_max_hp; deploy with no operator on the tile in front ⇒ HP +ex_max_hp (until it leaves)
chess_item_3_06_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm049_global_buff');
if (!p) return;
if (num(p.init_max_hp)) S.stat('hp', { hpMul: 1 + num(p.init_max_hp) });
const ex = num(p.ex_max_hp);
if (!ex) return;
const key = S.key('front');
const check = (initial) => {
const [fr, fc] = frontTile(u);
let occupied = false;
for (const a of battle.allyUnits) {
if (a === u || a.kind !== 'op' || a.removed) continue;
const here = initial ? a.homeR === fr && a.homeC === fc : onField(a) && a.tileR === fr && a.tileC === fc;
if (here) { occupied = true; break; }
}
if (!occupied) S.buff(u, { key, mods: { hpMul: 1 + ex }, refresh: 'replace' });
else battle.removeBuff(u, key);
};
S.on('deploy', (c) => { if (c.unit === u) check(!!c.initial); });
if (onField(u)) check(false);
},
// 突袭手雷: within `duration` s after each deploy, attacks stun the target `stun` s
chess_item_3_11_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm064_global_buff');
if (!p) return;
const win = num(p.duration), st = num(p.stun);
if (!(win > 0 && st > 0)) return;
onAttackEnemies(S, u, (t) => { if (battle.time - u.deployedAt <= win + 1e-9) battle.applyStatus(t, 'stun', { duration: st, source: u }); });
},
// 有限加速器: every attack or heal ⇒ ASPD +attack_speed (≤ max_buff_cnt stacks, whole battle)
chess_item_4_03_e(battle, u, rec, S) {
const p = bp(rec, 'act1vautochess_equip_acarm037_global_buff');
if (!p || !num(p.attack_speed)) return;
const key = S.key('stacks');
const cap = Math.max(1, Math.floor(num(p.max_buff_cnt, 60)));
const mods = { aspd: num(p.attack_speed) };
S.on('attack', (c) => {
if (c.attacker !== u) return;
S.buff(u, { key, mods, refresh: 'stack', stacks: 1, maxStacks: cap, persist: true, allowDead: true });
});
},
// 伪装服: first damage taken in the battle ⇒ 隐匿 `duration` s
chess_item_4_04_e(battle, u, rec, S) {
const p = bp(rec, 'act2autochess_equip_acarm055_global_buff');
const d = p ? num(p.duration) : 0;
if (!(d > 0)) return;
let used = false;
S.on('damaged', (c) => {
if (used || c.target !== u || !(c.amount > 0) || !u.alive) return;
used = true;
battle.applyStatus(u, 'stealth', { duration: d, source: u });
fxOn(battle, 'camouflage', u, 'item:chess_item_4_04_e', rec.id, { duration: d });
});
},
// 防暴盾: while blocking, damage from units it does not block ×damage_scale
chess_item_4_05_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm060_global_buff');
if (!p) return;
const sc = num(p.damage_scale, 1);
S.on('hit', (c) => {
// "受到来自非自身阻挡单位的伤害": only damage whose source is a unit the carrier does not block (terrain / sourceless
// bursts and its own HP loss are not "from a unit")
const src = c.source;
if (c.target !== u || !u.blocking.length || c.dmg.type === 'element' || !src || src === u) return;
if (u.blocking.indexOf(src) >= 0) return;
c.dmg.mul *= sc;
});
},
// 休眠子裔: each attacked target ⇒ heal self hp_ratio × max HP
chess_item_4_06_e(battle, u, rec, S) {
const p = bp(rec, 'act1vautochess_equip_acarm025_global_buff');
const r = p ? num(p.hp_ratio) : 0;
if (r > 0) onAttackEnemies(S, u, () => battle.heal(u, u, u.s.maxHp * r, { self: true }));
},
// 卡西米尔竞技旗: damage ×damage_scale for `interval` s after each deploy, then −damage_scale_minus every ex_interval s
chess_item_4_07_e(battle, u, rec, S) {
const p = bp(rec, 'act2autochess_equip_acarm120_global_buff');
if (!p) return;
const sc = num(p.damage_scale, 1), win = num(p.interval, 15), step = Math.max(0.1, num(p.ex_interval, 0.5)), minus = num(p.damage_scale_minus);
S.on('hit', (c) => {
if (c.source !== u || c.dmg.type === 'element') return;
const t = battle.time - u.deployedAt;
let m = sc;
if (t > win + 1e-9) m = minus < 0 ? Math.max(1, sc + minus * Math.ceil((t - win) / step - 1e-9)) : 1;
if (m !== 1) c.dmg.mul *= m;
});
},
// 拉特兰桥夹: ammo skill down to 1 bullet ⇒ prob: +ceil(ammo_percent × ammo) bullets (≤ max_trigger_cnt per deploy)
chess_item_4_08_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm057_global_buff');
if (!p) return;
const cap = Math.floor(num(p.max_trigger_cnt, 3));
let seq = -1, n = 0;
S.on('ammoUsed', (c) => {
if (c.unit !== u || c.left !== 1 || !c.skill) return;
if (u.deploySeq !== seq) { seq = u.deploySeq; n = 0; }
if (n >= cap || !chance(battle, num(p.prob))) return;
n++;
c.skill.addAmmo(Math.max(1, Math.ceil(num(p.ammo_percent) * c.skill.ammo - 1e-9)));
fxOn(battle, 'buff', u, 'item:chess_item_4_08_e', rec.id);
});
},
// 浓缩嗅盐: while HP ratio > hp_ratio, immune to 晕眩/冻结 … special states
chess_item_4_10_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm059_global_buff');
if (!p) return;
const thr = num(p.hp_ratio, 1);
S.on('beforeStatus', (c) => { if (c.target === u && CC.has(c.status) && u.hpRatio > thr) c.cancel = true; });
},
// 护盾无人机: heals ⇒ prob: target gets 1 shield layer (≤ max_stack_cnt)
chess_item_4_11_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm061_global_buff');
if (!p) return;
const pr = num(p.prob), cap = Math.max(1, Math.floor(num(p.max_stack_cnt, 1)));
S.on('heal', (c) => {
if (c.source !== u || !c.target || (c.opts && (c.opts.regen || c.opts.self)) || !(c.amount > 0)) return;
const cur = c.target.findBuff(SHIELD_KEY);
if (cur && cur.shieldHits >= cap) return;
if (!chance(battle, pr)) return;
if (addShieldLayer(battle, c.target, SHIELD_KEY, cap)) fxOn(battle, 'shield', c.target, 'item:chess_item_4_11_e', rec.id);
});
},
// M3茧甲: knocked down in battle ⇒ revive at full HP (max_respawn_cnt per battle)
chess_item_4_12_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm068_global_buff');
const max = p ? Math.floor(num(p.max_respawn_cnt, 1)) : 0;
if (!(max > 0)) return;
let used = 0;
S.on('fatal', (c) => {
if (c.unit !== u || c.prevented || used >= max) return;
used++;
c.prevented = true;
u.hp = u.s.maxHp;
fxOn(battle, 'revive', u, 'item:chess_item_4_12_e', rec.id, { left: max - used });
}, PRIO_REVIVE);
},
// 催泪瓦斯: on attack prob ⇒ 1 麻痹 stack
chess_item_5_01_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm058_global_buff');
const pr = p ? num(p.prob) : 0;
if (pr > 0) onAttackEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'palsy', { value: 1, source: u }); });
},
// 谢拉格不融冰: on attack prob ⇒ 寒冷 `cold` s
chess_item_5_02_e(battle, u, rec, S) {
const p = bp(rec, 'act1vautochess_equip_acarm003_global_buff');
if (!p) return;
const pr = num(p.prob), d = num(p.cold);
if (pr > 0 && d > 0) onAttackEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'cold', { duration: d, source: u }); });
},
// 双模机械臂: physical/arts damage becomes 弱点伤害 (whichever type the target resists less)
chess_item_5_03_e(battle, u, rec, S) {
S.on('hit', (c) => { if (c.source === u) weaknessRetype(c.dmg, u, c.target); }, 5);
},
// 天马之盔: with 天马之枪 ⇒ regen hp_recovery_per_sec_by_max_hp_ratio × max HP per second
chess_item_5_09_e(battle, u, rec, S) {
const p = bp(rec, 'act2autochess_equip_acarm118_global_buff');
const partner = partnerOf(p);
const r = p ? num(p.hp_recovery_per_sec_by_max_hp_ratio) : 0;
if (!partner || !(r > 0)) return;
S.stat('set', null, { interval: 1, onTick: ({ unit }) => { if (unit.deployed && carries(battle, unit, partner)) battle.heal(unit, unit, unit.s.maxHp * r, { self: true }); } });
},
// 天马之枪: with 天马之盔 ⇒ every damage instance also deals atk_scale × ATK true damage
chess_item_6_01_e(battle, u, rec, S) {
const p = bp(rec, 'act2autochess_equip_acarm117_global_buff');
const partner = partnerOf(p);
const sc = p ? num(p.atk_scale) : 0;
if (!partner || !(sc > 0)) return;
S.on('damaged', (c) => {
if (c.source !== u || isProc(c.dmg) || c.type === 'element' || !c.target || c.target.side !== 'enemy' || !c.target.alive) return;
if (!carries(battle, u, partner)) return;
battle.dealDamage(u, c.target, { amount: u.s.atk * sc, type: 'true', canDodge: false, tags: procTags('pegasus') });
});
},
// 铳骑之威 (拉特兰): on attack 35 % ⇒ extra bullet at 1 enemy in range, atk_scale_1 (with 拉特兰桥夹 atk_scale_2) × ATK phys
chess_item_6_02_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm076_global_buff');
if (!p) return;
const partner = partnerOf(p);
S.on('attack', (c) => {
if (c.attacker !== u || !c.targets.some((t) => t && t.side === 'enemy') || !memberOf(battle, u, 'lateranoShip')) return;
if (!chance(battle, num(p.prob))) return;
const cands = battle.enemiesInKeys(u.rangeKeys, u, u.profile);
const t = battle.rng.pick(cands);
if (!t) return;
const sc = partner && carries(battle, u, partner) ? num(p.atk_scale_2) : num(p.atk_scale_1);
battle.fx('strike', { x: t.x, y: t.y, id: t.id, src: 'item:chess_item_6_02_e', key: rec.id, from: u.id });
battle.dealDamage(u, t, { amount: u.s.atk * sc, type: 'phys', tags: procTags('gunknight') });
});
},
// 天师古鼎 (炎): ASPD +attack_speed × min(operators gained this round, max_cnt) for the battle
chess_item_6_03_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm077_global_buff');
if (!p || !memberOf(battle, u, 'yanShip')) return;
const k = Math.min(Math.floor(num(p.max_cnt, 3)), Math.max(0, Math.floor(num(contentInfo(battle, u.ownerId).roundStats?.gainedChess))));
if (k > 0) S.stat('yan', { aspd: num(p.attack_speed) * k });
},
// 海沟实验体 (阿戈尔): hit taken ⇒ atk_scale × ATK arts to the source (lock_duration CD); + 阿戈尔重刃 ⇒ one more hit
chess_item_6_04_e(battle, u, rec, S) {
const p = bp(rec, 'act2autochess_equip_acarm078_global_buff');
if (!p) return;
const partner = partnerOf(p);
const cd = num(p.lock_duration, 0.5), sc = num(p.atk_scale);
let ready = -Infinity;
S.on('damaged', (c) => {
const src = c.source;
if (c.target !== u || !src || src.side !== 'enemy' || !src.alive || battle.time < ready || isProc(c.dmg)) return;
if (!u.alive || !memberOf(battle, u, 'egirShip')) return;
ready = battle.time + cd;
const hits = partner && carries(battle, u, partner) ? 2 : 1;
for (let i = 0; i < hits && src.alive; i++) battle.dealDamage(u, src, { amount: u.s.atk * sc, type: 'arts', tags: procTags('trench') });
});
},
// 蒸汽之心 (维多利亚): hammer effects of the field (see header)
chess_item_6_05_e(battle, u, rec, S, g) {
const rt = runtime(battle);
const hs = hammerAcquire(battle, rt, u);
hs.steam++;
if (!hs.steamP) hs.steamP = bp(rec, STEAM_BUFF);
hammerRefresh(battle, rt, u, hs);
S.onDispose(() => { hs.steam = Math.max(0, hs.steam - 1); hammerRelease(battle, rt, u, hs); });
},
// 耶拉冈德之泪 (谢拉格): every interval s, cold/frozen enemies in range take atk_scale (+不融冰: atk_scale_ex) × ATK arts
chess_item_6_06_e(battle, u, rec, S) {
const p = bp(rec, 'act2autochess_equip_acarm102_ability');
if (!p) return;
const partner = partnerOf(p);
S.every(Math.max(0.1, num(p.interval, 1)), () => {
if (!onField(u) || !memberOf(battle, u, 'kjeragShip')) return;
const sc = partner && carries(battle, u, partner) ? num(p.atk_scale_ex) : num(p.atk_scale);
for (const e of battle.enemiesInKeys(u.rangeKeys, u, { canHitFly: true })) {
const f = e.s.flags;
if (f.cold || f.freeze) battle.dealDamage(u, e, { amount: u.s.atk * sc, type: 'arts', canDodge: false, tags: procTags('tears') });
}
});
},
// 黄沙罗盘: 初始技力 +init_sp each deploy; (萨尔贡) first skill end +sp; + 萨尔贡浓茶: each cast all 萨尔贡 +addition_sp
chess_item_6_07_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm103_global_buff');
if (!p) return;
const partner = partnerOf(p);
const init = num(p.init_sp);
if (init > 0) {
S.on('deploy', (c) => { if (c.unit === u && u.skill) u.skill.gainSp(init, 'init'); });
if (onField(u) && u.skill) u.skill.gainSp(init, 'init');
}
let firstDone = false;
S.on('skillEnd', (c) => {
if (c.unit !== u || firstDone || c.reason === 'death' || !memberOf(battle, u, 'sargonShip')) return;
firstDone = true;
if (u.skill && u.alive) u.skill.gainSp(num(p.sp), 'item');
});
S.on('skillStart', (c) => {
if (c.unit !== u || !partner || !memberOf(battle, u, 'sargonShip') || !carries(battle, u, partner)) return;
const n = num(p.addition_sp);
if (!(n > 0)) return;
for (const a of battle.allyUnits) {
if (a.kind === 'op' && a.ownerId === u.ownerId && onField(a) && a.skill && memberOf(battle, a, 'sargonShip')) a.skill.gainSp(n, 'item');
}
});
},
// 骑士戒律 (卡西米尔): skill start ⇒ 20 s aura: enemies in range ASPD ×attack_speed, move ×move_speed;
// + 卡西米尔竞技旗: during the (timed) skill ATK +atk, lethal damage does not retreat it — it retreats when the skill ends
chess_item_6_10_e(battle, u, rec, S) {
const p = bp(rec, 'act2autochess_equip_acarm119_global_buff');
const a = bp(rec, 'act2autochess_equip_acarm119_ability');
if (!p) return;
const partner = partnerOf(p);
const win = num(p.duration, 20);
const aspdMul = a ? num(a.attack_speed, 1) : 1, moveMul = a ? num(a.move_speed, 1) : 1;
const auraKey = S.key('aura'), comboKey = S.key('combo');
let until = -Infinity, combo = false, doomed = false;
S.on('skillStart', (c) => {
if (c.unit !== u || !memberOf(battle, u, 'kazimierzShip')) return;
until = battle.time + win;
if (partner && carries(battle, u, partner) && c.skill && c.skill.isTimed) {
combo = true;
doomed = false;
S.buff(u, { key: comboKey, mods: { atkMul: 1 + num(p.atk) } });
}
});
S.on('skillEnd', (c) => {
if (c.unit !== u || !combo) return;
combo = false;
battle.removeBuff(u, comboKey);
if (doomed && c.reason !== 'death') {
doomed = false;
battle.after(0, () => { if (u.alive) battle.retreat(u, { reason: 'retreat' }); });
}
});
S.on('fatal', (c) => {
if (c.unit !== u || c.prevented || !combo) return;
c.prevented = true;
doomed = true;
}, PRIO_FREE_UNDYING);
S.every(0.5, () => {
if (battle.time >= until || !onField(u)) return;
for (const e of battle.enemiesInKeys(u.rangeKeys, u, { canHitFly: true })) {
S.buff(e, { key: auraKey, duration: 0.6, refresh: 'replace', mods: { aspd: -(1 - aspdMul) * num(e.base.aspd, 100), moveMul } });
}
}, { immediate: true });
},
// 家族徽章 (叙拉古): while 隐匿 ATK grows atk_per_sec/s up to +max_atk until the first damage after 隐匿 ends
// (or leaving the field); + 叙拉古正装 ⇒ that first hit also deals atk_scale × ATK true damage
chess_item_6_11_e(battle, u, rec, S) {
const p = bp(rec, 'act2autochess_equip_acarm122_global_buff');
if (!p) return;
const partner = partnerOf(p);
const key = S.key('stealth');
const per = num(p.atk_per_sec), max = num(p.max_atk, 1);
const st = { bonus: 0, wasStealth: false, armed: false, seq: -1 };
const TICKS = 0.25;
S.every(TICKS, () => {
if (u.deploySeq !== st.seq) { st.seq = u.deploySeq; st.bonus = 0; st.armed = false; st.wasStealth = false; }
if (!onField(u) || !memberOf(battle, u, 'siracusaShip')) return;
const stealth = !!u.s.flags.stealth;
if (stealth && st.bonus < max) {
st.bonus = Math.min(max, st.bonus + per * TICKS);
S.buff(u, { key, mods: { atkMul: 1 + st.bonus }, refresh: 'replace' });
}
if (st.wasStealth && !stealth && st.bonus > 0) st.armed = true;
st.wasStealth = stealth;
});
S.on('damaged', (c) => {
if (c.source !== u || !st.armed || isProc(c.dmg) || !(c.amount > 0)) return;
st.armed = false;
if (partner && carries(battle, u, partner) && c.target && c.target.alive && c.target.side === 'enemy') {
battle.dealDamage(u, c.target, { amount: u.s.atk * num(p.atk_scale), type: 'true', canDodge: false, tags: procTags('crest') });
battle.fx('strike', { x: c.target.x, y: c.target.y, id: c.target.id, src: 'item:chess_item_6_11_e', key: rec.id, from: u.id });
}
st.bonus = 0;
battle.removeBuff(u, key);
});
S.on('death', (c) => { if (c.unit === u) { st.bonus = 0; st.armed = false; } });
},
};
/** 浓缩嗅盐 "免疫晕眩、冻结等特殊状态" — research 04 §4 list (stun/freeze/sleep/levitate/palsy). 寒冷 is a debuff, not a
* control state: it still applies, but the freeze of a second 寒冷 is itself a `freeze` status and is cancelled. */
const CC = new Set(['stun', 'freeze', 'sleep', 'levitate', 'palsy']);
const SHIELD_KEY = 'item:shield_drone';
/**
* 弱点伤害 (双模机械臂, band 陈 以己之长): a physical or arts DamageInfo from `source` becomes whichever of the two types
* `target` resists less (same mitigation formula as damage.js, incl. the source's ignore stats). Mutates `dmg.type`.
*/
export function weaknessRetype(dmg, source, target) {
if (!dmg || !target || !target.s || (dmg.type !== 'phys' && dmg.type !== 'arts')) return;
const ph = mitigated(dmg.amount, 'phys', target, source, dmg);
const ar = mitigated(dmg.amount, 'arts', target, source, dmg);
const best = ar > ph + 1e-9 ? 'arts' : ph > ar + 1e-9 ? 'phys' : dmg.type;
if (best !== dmg.type) dmg.type = best;
}
/**
* "获得1层护盾(最多`cap`层)": one shield layer (negates one damage instance) under buff `key`. Returns true when a
* layer was added (false at the cap).
*/
export function addShieldLayer(battle, target, key, cap = 1) {
if (!target || !target.alive) return false;
const cur = target.findBuff(key);
const have = cur ? cur.shieldHits || 0 : 0;
if (have >= cap) return false;
battle.addBuff(target, { key, shieldHits: have + 1, visible: true, refresh: 'replace' });
return true;
}
/** Does this record have a battle part? (stat buffs, known buff behaviours, hammer specials, item behaviours) */
export function hasBattleEffect(rec) {
if (!rec || rec.itemType !== 'EQUIP') return false;
const key = itemKeyOf(rec.id);
if (BY_ITEM[key] || hammerInfo(key)) return true;
return buffsOf(rec).some((b) => STAT_BUFFS.has(b.bbKey) || BY_BUFF[b.bbKey]);
}
// =====================================================================================================================
// grants
/**
* Install the battle effects of item `itemId` on `u`. `lent` grants use their own buff keys (`@lend`).
* Returns the grant `{ id, key, lent, dispose() }` or null when the item has no battle part.
*/
export function installItem(battle, u, itemId, { lent = false } = {}) {
const rec = itemRecord(itemId);
if (!isOp(u) || !hasBattleEffect(rec)) return null;
const rt = runtime(battle);
const gm = grantsOf(rt, u);
const gid = lent ? `lend:${itemId}` : `own:${itemId}:${gm.size}`;
const S = new Scope(battle, u, `item:${itemId}${lent ? '@lend' : ''}`);
const key = itemKeyOf(itemId);
const grant = { gid, id: itemId, key, lent, scope: S, timer: null, dispose: null };
grant.dispose = () => {
if (S.disposed) return;
S.dispose();
if (grant.timer) grant.timer.cancel();
if (gm.get(gid) === grant) gm.delete(gid);
};
gm.set(gid, grant);
try {
for (const b of buffsOf(rec)) {
if (STAT_BUFFS.has(b.bbKey)) {
const mods = statMods(b.p);
if (mods) S.stat(`stat:${b.bbKey}`, mods);
} else if (BY_BUFF[b.bbKey]) BY_BUFF[b.bbKey](battle, u, b.p, S);
}
const h = hammerInfo(key);
if (h) {
const hs = hammerAcquire(battle, rt, u);
hs.own[h.type]++;
if (!hs.params[h.type]) hs.params[h.type] = bp(rec, h.bbKey);
hammerRefresh(battle, rt, u, hs);
S.onDispose(() => { hs.own[h.type] = Math.max(0, hs.own[h.type] - 1); hammerRelease(battle, rt, u, hs); });
}
if (BY_ITEM[key]) BY_ITEM[key](battle, u, rec, S, grant);
} catch (e) {
battle._handlerError?.(`item:${itemId}`, u, e);
}
return grant;
}
/** Battle install: every operator's equipped items. Nearly free when nobody carries equipment. */
export function install(battle) {
let any = false;
for (const u of battle.allyUnits) if (u.kind === 'op' && itemsOf(u).length) { any = true; break; }
if (!any) return;
for (const u of battle.allyUnits) {
if (u.kind !== 'op') continue;
for (const id of itemsOf(u)) installItem(battle, u, id);
}
}
/**
* 萨尔贡 × 娜仁图亚 (P1): temporarily grant `toUnit` the battle effects of every item `fromUnit` carries with
* tier ≤ maxTier (meta-only / consume items skipped) for `duration` s. Lending the same item id to the same unit again
* refreshes its timer (no stacking, whoever lends it). Returns the number of item effects lent (new + refreshed).
*/
export function lendItemEffects(battle, fromUnit, toUnit, { maxTier = 5, duration = 60 } = {}) {
if (!battle || !isOp(fromUnit) || !isOp(toUnit) || fromUnit === toUnit) return 0;
if (!toUnit.alive || toUnit.removed) return 0;
const dur = num(duration, 60);
if (!(dur > 0)) return 0;
const rt = runtime(battle);
const gm = grantsOf(rt, toUnit);
let n = 0;
for (const id of itemsOf(fromUnit)) {
const rec = itemRecord(id);
if (!rec || num(rec.tier, 99) > maxTier || !hasBattleEffect(rec)) continue;
let g = gm.get(`lend:${id}`);
if (!g) g = installItem(battle, toUnit, id, { lent: true });
if (!g) continue;
if (g.timer) g.timer.cancel();
g.until = battle.time + dur;
g.timer = battle.after(dur, () => g.dispose());
n++;
}
if (n) fxOn(battle, 'buff', toUnit, 'item:lend', 'lend', { from: fromUnit.id, n, duration: dur });
return n;
}
/** Active grants of a unit (tests / UI): [{ id, key, lent, until? }]. */
export function itemGrants(battle, u) {
const g = runtime(battle).grants.get(u);
return g ? [...g.values()].map((x) => ({ id: x.id, key: x.key, lent: x.lent, until: x.until })) : [];
}
+181
View File
@@ -0,0 +1,181 @@
// server/sim/content/items/meta.js — prep side of the items (docs/META.md registry API).
//
// The engine built-ins (server/match/builtinMeta.js) already implement most consume-on-equip items and Arts from the
// concrete item's params; they are reviewed and kept (盟约之币, 骑士储蓄罐, 随身身份牌, 精打细算玩偶, 简易通讯机 (tier ≤
// shop level), 见钱眼开玩偶, 人事部文档 (cap 9), 博士投影 (golden now / normal at the next round start), 拟态物质, 信标,
// 商业包装方案 (count from the concrete record: 8 / golden 7)). The Arts' rangeGrid (画卷 = placed tile + the tile in
// front) and the per-round limit are engine rules.
// Overridden / added here (a built-in, when present, is wrapped — never re-implemented):
// 画卷 the copy keeps the target's items; a copied normal item that pairs with an owned one merges and the
// golden stays in the hand (the built-in equipped the merged golden on the copy, and its live loop over
// the target's items skipped the item after a merge)
// 紧急调度券 a shop operator leaves its slot only when it was actually granted (built-in cleared the slot first)
// 寻呼模块 the special refresh shows `refresh_cnt` DIFFERENT operators when the pool allows it
// 突变细胞 after the battle the carrier becomes a NORMAL random tier+1 operator; its other equipment goes back to
// the hand (built-in kept it on the new operator)
// 教鞭 / “神秘顾客” trap_create_self_choice {choice_event: hunter_band_1}: the bounty comes from the band bounty
// family `enemyeffect_b_*` (research 04 §7: "offer 3 random enemyeffect_b_* bounties … adds 1 enemy to your
// next battle, killer gets `coin` funds"); the built-in drew any tier ≤ II kill bounty (incl. 2-round and
// faction bounties). [ASSUMED simplification, engine: no PERSONAL_CHOOSE overlay] 3 are drawn and one of
// them is taken at random. Falls back to the built-in when the family is empty.
// “神秘顾客” trap_disney_special: when actively destroyed, +count funds and the Art passes to the next alive player
// (seat order, cyclic)
// 天师古鼎 equip_with_another_gain_coin_when_gain_char: a 【炎】 carrier also holding 炎国短刀 (either quality)
// gains `count` funds every time the player gains an operator, at most `max` times per round per item
import { itemKeyOf, isCoreBond } from '../support/index.js';
import { metaBonds } from '../support/meta.js';
const int = (v, d = 0) => (Number.isFinite(v) ? Math.trunc(v) : d);
/** Params `{ ...bb, ...bbStr }` of the concrete record's buff with official key `key` (null when absent). */
function buffP(ctx, itemId, key) {
const rec = itemId ? ctx.gd.item(itemId) : null;
for (const b of (rec && Array.isArray(rec.buffs) ? rec.buffs : [])) if (b && b.key === key) return { ...(b.bb || {}), ...(b.bbStr || {}) };
return null;
}
/** 【X】盟约干员 on the prep side: own bonds + 变形同构体 grants, or 调和 (maniShip) members while both bonds are active. */
function pieceIsMember(ctx, piece, bondId) {
const bonds = metaBonds(ctx, piece);
if (bonds.includes(bondId)) return true;
return bonds.includes('maniShip') && isCoreBond(bondId) && ctx.bondActive('maniShip') && ctx.bondActive(bondId);
}
/** Offer size of the personal bounty choice (research 04 §7, [ASSUMED]). */
const OFFER_SIZE = 3;
/** Bounty cards of the band bounty family `enemyeffect_b_*` whose enemy can appear in this mode. */
function bandBounties(ctx) {
const all = ctx.data && ctx.data.choices && ctx.data.choices.cards && Array.isArray(ctx.data.choices.cards.bounty) ? ctx.data.choices.cards.bounty : [];
const inactive = ctx.gd.inactiveEnemies instanceof Set ? ctx.gd.inactiveEnemies : new Set();
return all.filter((c) => c && /^enemyeffect_b_\d+$/.test(String(c.effectId)) && ctx.gd.enemy(c.enemyKey) && !inactive.has(c.enemyKey));
}
function wrap(registry, key, hooks) {
const base = registry.get(`item:${key}`) || {};
registry.item(key, { ...base, ...hooks(base) });
}
export function registerMeta(registry) {
// 紧急调度券 — use_equip_reward_random_char_chess_in_shop {count}
wrap(registry, 'chess_item_2_02_e', () => ({
onEquip(ctx, ev) {
const p = buffP(ctx, ev.item && ev.item.id, 'use_equip_reward_random_char_chess_in_shop') || {};
const n = Math.max(1, int(p.count, 1));
const tried = new Set();
for (let k = 0; k < n; k++) {
const slots = ctx.shopSlots();
const idx = slots.map((s, i) => (s && s.kind === 'chess' && !s.sold && !tried.has(i) ? i : -1)).filter((i) => i >= 0);
if (!idx.length) break;
const i = ctx.rng.pick(idx);
const got = ctx.grantChess(slots[i].id);
if (got) ctx.setShopSlot(i, null);
else { tried.add(i); k--; }
}
},
}));
// 寻呼模块 — use_equip_reward_special_goods_char_chess {refresh_cnt, choice_cnt}
wrap(registry, 'chess_item_4_01_e', () => ({
onEquip(ctx, ev) {
const p = buffP(ctx, ev.item && ev.item.id, 'use_equip_reward_special_goods_char_chess') || {};
const n = Math.max(1, int(p.refresh_cnt, 3));
const bonds = new Set(ctx.pieceBonds(ev.target.uid));
const maxTier = ctx.shopLevel();
const shares = (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => bonds.has(b))); };
const ids = [];
for (let k = 0; k < n; k++) {
const id = ctx.rollChess({ maxTier, filter: (x) => shares(x) && !ids.includes(x) }) || ctx.rollChess({ maxTier, filter: shares });
if (id) ids.push(id);
}
if (ids.length) ctx.offerChess(ids, { source: 'item' });
},
}));
// 突变细胞 — char_chess_transformation_equip
wrap(registry, 'chess_item_5_08_e', () => ({
onBattleResult(ctx) {
const { piece, holder } = ctx.source;
if (!piece || !holder) return;
const tier = Math.min(6, ctx.gd.tierOf(holder.id) + 1);
const id = ctx.rollChess({ tier });
if (!id) return;
const others = (ctx.piece(holder.uid)?.items || holder.items || []).filter((it) => it && it.uid !== piece.uid);
ctx.destroyPiece(piece.uid);
for (const it of others) {
if (ctx.destroyPiece(it.uid)) ctx.grantItem(it.id, { source: 'mutation' });
}
ctx.transform(holder.uid, ctx.gd.baseIdOf(id));
},
}));
// 教鞭 / “神秘顾客” — trap_create_self_choice {choice_event}: a band bounty (enemyeffect_b_*) for the next battle
for (const key of ['chess_item_6_03_m', 'chess_item_6_01_m']) {
wrap(registry, key, (base) => ({
onArt(ctx, ev) {
const cards = bandBounties(ctx);
if (!cards.length) { if (typeof base.onArt === 'function') base.onArt.call(base, ctx, ev); return; }
const offer = ctx.rng.shuffle(cards.slice()).slice(0, OFFER_SIZE);
const card = ctx.rng.pick(offer);
if (!card || !ctx.addBounty(card)) { ev.error = 'BAD_TARGET'; ev.detail = 'no bounty available'; }
},
}));
}
// 画卷 — trap_copy_front_char: copy the operator in range (elite status included) with its equipment. A copied
// normal item that completes a pair with an owned one merges at once and the golden stays in the hand (research 04
// addendum "两个同名道具(无论是否被装备)会自动合并…并自动返回整备区"); the built-in equipped that golden on the copy.
wrap(registry, 'chess_item_6_02_m', () => ({
onArt(ctx, ev) {
const target = (ev.targets || []).find((p) => p && p.kind === 'chess');
if (!target) { ev.error = 'BAD_TARGET'; ev.detail = 'no operator in range'; return; }
const copy = ctx.grantChess(target.id, { requirePool: false, source: 'item:chess_item_6_02_m' });
if (!copy) { ev.error = 'HAND_FULL'; return; }
// snapshot first: a merge detaches the original's copy of the item from `target.items` while we iterate
// (the built-in's live loop then skipped the next item)
for (const itemId of (target.items || []).map((it) => it.id)) {
const got = ctx.grantItem(itemId, { source: 'item:chess_item_6_02_m' });
const holder = got ? ctx.piece(copy.uid) : null;
if (got && got.id === itemId && holder && holder.kind === 'chess') ctx.equipDirect(got.uid, holder.uid);
}
},
}));
// “神秘顾客” — trap_create_self_choice (bounty above) + trap_disney_special {count}
wrap(registry, 'chess_item_6_01_m', () => ({
onDestroy(ctx, ev) {
if (!ev || ev.reason !== 'player') return;
const item = ev.item || ctx.source.piece;
const p = buffP(ctx, item && item.id, 'trap_disney_special') || {};
const n = int(p.count, 1);
if (n > 0) ctx.addFunds(n, 'item');
const mates = ctx.teammates();
if (!mates.length || !item) return;
const seat = Number.isInteger(ctx.seat) ? ctx.seat : 0;
const dist = (t) => { const s = Number.isInteger(t.seat) ? t.seat : 0; return ((s - seat) % 64 + 64) % 64 || 64; };
const next = mates.slice().sort((a, b) => dist(a) - dist(b))[0];
if (next) next.grantItem(item.id, { source: 'item' });
},
}));
// 天师古鼎 — equip_with_another_gain_coin_when_gain_char {count, max} {other_equip, bond}
wrap(registry, 'chess_item_6_03_e', (base) => ({
onGain(ctx, ev) {
if (typeof base.onGain === 'function') base.onGain.call(base, ctx, ev);
if (!ev || ev.kind !== 'chess' || !ev.piece) return;
const { piece, holder } = ctx.source;
if (!piece || !holder) return;
const p = buffP(ctx, piece.id, 'equip_with_another_gain_coin_when_gain_char');
if (!p) return;
const bond = typeof p.bond === 'string' && p.bond ? p.bond : 'yanShip';
const others = String(p.other_equip || '').split(',').map((s) => itemKeyOf(s.trim())).filter(Boolean);
const fresh = ctx.piece(holder.uid) || holder;
const carries = (fresh.items || []).some((it) => it && it.uid !== piece.uid && others.includes(itemKeyOf(it.id)));
if (!carries || !pieceIsMember(ctx, fresh, bond)) return;
const k = `cauldron:${piece.uid}:${ctx.round}`;
if (ctx.counter(k) >= Math.max(0, int(p.max, 3))) return;
ctx.incCounter(k);
ctx.addFunds(int(p.count, 2), 'item');
},
}));
}
File diff suppressed because it is too large. Load diff
+887
View File
@@ -0,0 +1,887 @@
// server/sim/content/kits/tier2.js — Tier 2 operator kits.
//
// export default { [baseChessId]: (bb, chess, def) => Kit } (docs/SIM.md §7.2). Shared helpers live in tier1.js (named
// exports). Numbers come from the skill / talent / trait blackboards (elite module upgrades are in the elite record's
// trait.bb and hidden index −1 talents). fx kinds: see the tier1.js header.
//
// Covered (normal + elite): 2_01 送葬人 2_02 赫默 2_03 崖心(H) 2_04 小满 2_05 哈洛德 2_06 莎草 2_07 幽灵鲨 2_08 泡泡
// 2_09 休谟斯 2_10 洛洛 2_11 风丸 2_12 砾 2_13 蒂比 2_14 调香师 2_15 协律(H) 2_16 拉普兰德 2_17 折桠 2_18 灰毫 2_19 锡人.
//
// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skill in `skills`
// ({ [skillId]: SkillSpec }, official Lv4 / Lv7 blackboards — the kit function receives the SELECTED skill's `bb`, so
// every spec is built from `bb`; the trigger rule comes from that skill's data). Talent / install hooks that belong to
// the default skill only (target locks, counters, hit triggers) check onDefaultSkill(chess). Module choices need no
// code here: `chess` is the loadout-resolved record (trait.bb / talents of the selected module, or none).
import { COLS, ELEMENT_GAUGE_MAX } from '../../constants.js';
import { canTargetEnemy } from '../../targeting.js';
import {
num, talentBb, moduleBb, traitBb, up, posKey, cheb, byEnemyAttack, onHitBy, onHitOn, onDamagedOn, enemiesInGrid,
alliesInGridOf, enemyInRange, statBuff, toggleBuff, installAura, spTimeBonus, freeTileAround, summonTileFree, instantKind,
tinmanKit, batMod,
} from './tier1.js';
/**
* The tokens.json variant a summon of `owner` gets — its chess (else the normal `_a` sibling, else the first variant)
* with the owner's selected skill / module merged, as simdata getToken does — for fields normalizeToken drops (the
* withdraw skill's duration). Exact when two players of one field give the same chess different loadouts (DESIGN §16).
*/
function ownTokenVariant(battle, tokId, owner) {
const vs = battle.tokenDef(tokId, owner)?.raw?.variants;
if (!vs || typeof vs !== 'object') return null;
const oid = String(owner?.defId ?? '');
let v = vs[oid] ?? vs[oid.replace(/_b$/, '_a')] ?? Object.values(vs)[0] ?? null;
const lo = owner?.def?.loadout;
if (v && lo && !lo.isDefault) {
if (!lo.skillIsDefault && v.bySkill?.[lo.skillIndex]) v = { ...v, ...v.bySkill[lo.skillIndex] };
if (!lo.moduleIsDefault && v.byModule?.[lo.moduleId]) v = { ...v, ...v.byModule[lo.moduleId] };
}
return v;
}
/** Lifetime (s) of a token `owner` summons: its own withdraw skill duration, else "N秒后自动销毁" in the owner's skill text. */
function tokenLifetime(battle, tokId, owner, desc) {
const raw = battle.tokenDef(tokId, owner)?.raw;
const v = num(ownTokenVariant(battle, tokId, owner)?.skill?.duration, num(raw?.skill?.duration));
if (v > 0) return v;
const m = String(desc || '').match(/(\d+(?:\.\d+)?)秒后自动销毁/);
return m ? +m[1] : 0;
}
/** Largest element gauge of a unit. */
const elemLoad = (a) => Math.max(a.elem.burn, a.elem.neural, a.elem.necrosis, a.elem.apoptosis);
/**
* Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the
* selected SkillRecord, `chess.skills[]` flags the default one). Records without skill choices count as default.
*/
function onDefaultSkill(chess) {
const d = (chess?.skills ?? []).find((s) => s && s.isDefault);
return !d || !chess?.skill || d.skillId === chess.skill.skillId;
}
/** 蒂比 take-off (起飞): airborne — blocks flyers only (flags.blockFly of the skill), releases ground enemies. */
function tippiTakeOff({ battle, unit }) {
unit.mem.tippiGround = unit.ground;
unit.ground = false;
battle.releaseBlocked(unit);
battle.fx('takeoff', { x: unit.x, y: unit.y, id: unit.id });
}
/** 蒂比 landing when the airborne skill ends. */
function tippiLand({ battle, unit }) {
if (unit.mem.tippiGround != null) unit.ground = unit.mem.tippiGround;
unit.mem.tippiGround = null;
battle.releaseBlocked(unit);
}
export default {
// ---------------------------------------------------------------------------------------------------------------
// 2_01 送葬人 最终旅程: normal attacks become double hits, base attack time +base_attack_time s (−0.5 on 2.3 s
// "少量缩短"). 终结改装: ignore def_penetrate_fixed DEF. (Trait: every enemy in range, ×atk_scale on its front row —
// reaperrange profile.)
//
// S1 铳口收束 (alt): ATK +atk; "攻击时对攻击范围内的所有敌人应用特性加成" — every enemy in range takes the trait's
// front-row multiplier (profile frontScale: trait atk_scale 1.5, elite module 1.6, elite without module 1.5).
chess_char_2_01_a: (bb, chess) => ({
skill: { kind: 'duration', mods: { batPct: batMod(bb.base_attack_time, chess) }, attack: { hits: 2 } },
skills: {
skchr_excu_1: { kind: 'duration', mods: { atkPct: num(bb.atk) }, attack: { dmgMul: (battle, u) => num(u.profile?.frontScale, 1.5) } },
},
talents: [{ install(battle, unit) { statBuff(battle, unit, 'talent:excu', { defIgnoreFlat: num(talentBb(chess, 0).def_penetrate_fixed) }); } }],
}),
// ---------------------------------------------------------------------------------------------------------------
// 2_02 赫默 医疗无人机 (AUTO, heal trigger): deploy a medical drone (overrideTokenKey, stats from tokens.json variants)
// next to the most injured ally in range; it heals around itself and self-destructs after its lifetime; at most `cnt`
// drones at once. 强化注射: every 【医疗】 operator on the field ASPD +attack_speed. Elite module (PHY-Y): heals on
// ground units ×heal_scale (module 'none': no bonus).
// S1 治疗强化·γ型 (alt): ATK +atk for its duration (no drone).
chess_char_2_02_a: (bb, chess) => {
const tokId = chess?.skill?.overrideTokenKey ?? (chess?.tokens ?? [])[0] ?? 'token_10000_silent_healrb';
const cnt = Math.max(1, Math.floor(num(bb.cnt, 1)));
const t = talentBb(chess, 0);
const tb = traitBb(chess);
return {
skill: {
kind: 'instant', heal: true,
onStart({ battle, unit }) {
const focus = battle.injuredAlliesInKeys(unit.rangeKeys, unit)[0] ?? unit;
let best = null, bd = Infinity;
for (const k of unit.rangeKeys || []) {
const r = (k / COLS) | 0, c = k % COLS;
if (!summonTileFree(battle, r, c) || !battle.grid.canStand(r, c, { ranged: true })) continue;
const d = Math.max(Math.abs(r - focus.tileR), Math.abs(c - focus.tileC)) + 0.01 * (Math.abs(r - unit.tileR) + Math.abs(c - unit.tileC));
if (d < bd) { bd = d; best = [r, c]; }
}
if (!best) return;
const drones = (unit.mem.drones ?? []).filter((d) => d.alive);
while (drones.length >= cnt) battle.retreat(drones.shift(), { reason: 'expired', permanent: true });
const life = tokenLifetime(battle, tokId, unit, chess?.skill?.desc);
const d = battle.spawnToken(unit, tokId, best[0], best[1], life > 0 ? { duration: life } : {});
if (d) { drones.push(d); battle.fx('summon', { x: d.x, y: d.y, id: d.id, token: tokId }); }
unit.mem.drones = drones;
},
},
skills: { 'skcom_heal_up[3]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } },
talents: [{ install(battle, unit) {
const v = num(t.attack_speed);
if (v) installAura(battle, unit, { key: 'talent:silent', value: v, select: (a) => a.kind === 'op' && a.def.profession === 'MEDIC', mods: { aspd: v } });
if (num(tb.heal_scale, 1) !== 1) {
battle.on('heal', (ctx) => { if (ctx.source === unit && ctx.target.ground) ctx.amount *= num(tb.heal_scale, 1); }, { owner: unit });
}
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_03 崖心 (hidden) 束缚链: up to max_target enemies in the large front grid are dragged (force 1 = 中等) to the tile in front
// of her, take atk_scale × ATK true damage and are stunned `stun` s. 雪境猎手: ATK/DEF +atk/+def while not blocking.
// Elite module (HOK-X, trait value/dist): dragged enemies take `value` arts per `dist` tiles travelled.
chess_char_2_03_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const tb = traitBb(chess);
const force = num(bb.force, 1);
return {
skill: {
kind: 'instant',
onStart({ battle, unit }) {
const foes = enemiesInGrid(battle, unit, def?.skill?.rangeGrid ?? null, { n: Math.max(1, Math.floor(num(bb.max_target, 1))) });
const fx0 = unit.tileC + unit.fwd[1], fy0 = unit.tileR + unit.fwd[0];
for (const e of foes) {
const sx = e.x, sy = e.y;
const dx = fx0 - e.x, dy = fy0 - e.y, dist = Math.hypot(dx, dy);
const moved = dist > 0.05 ? battle.displace(e, { x: dx, y: dy }, dist, { force: force + 1 }) : 0; // same force mapping as generic.js
battle.fx('pull', { x: e.x, y: e.y, id: e.id, fromX: sx, fromY: sy });
if (moved > 0 && num(tb.value) > 0) {
battle.dealDamage(unit, e, { amount: num(tb.value) * moved / Math.max(0.01, num(tb.dist, 1)), type: 'arts', isSkill: true, canDodge: false, tags: ['drag'] });
}
if (!e.alive) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale), type: 'true', isSkill: true, tags: ['skill'] });
if (e.alive) battle.applyStatus(e, 'stun', { duration: num(bb.stun), source: unit });
}
},
},
talents: [{ install(battle, unit) {
if (num(t.atk) || num(t.def)) toggleBuff(battle, unit, 'slchan:hunter', () => unit.blocking.length === 0, { atkPct: num(t.atk), defPct: num(t.def) });
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_04 小满 乡音沉沉: stops attacking and puts max_target enemies in range to sleep `sleep` s; afterwards ASPD
// +attack_speed and attacks max_target enemies. 好好听话: ASPD +attack_speed; vs 【野生动物】 (tag `infection`,
// "野生的被感染生物") the trait 停顿 lasts +sluggish_addition s. Elite module (DEC-X): +sp_recovery_per_sec SP/s with
// an enemy in range.
// S1 竹笛飞声 (alt, 2 charges): the next attack deals atk_scale × ATK arts and hits one more enemy (+1 target).
chess_char_2_04_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const tb = traitBb(chess);
const n = Math.max(1, Math.floor(num(bb.max_target, 1)));
const sleep = num(bb.sleep);
return {
trait: {
onHitStatus: null,
canAttack: (battle, u) => !(u.skill?.active && battle.time < (u.mem.grainQuietUntil ?? -1)),
afterHit(battle, u, target) {
if (!target || !target.alive || target.side !== 'enemy') return;
const beast = (target.def?.tags || []).includes('infection');
battle.applyStatus(target, 'sluggish', { duration: num(tb.sluggish, 0.8) + (beast ? num(t.sluggish_addition) : 0), source: u });
},
},
skill: {
kind: 'duration', mods: { aspd: num(bb.attack_speed) },
targeting: { maxTargets: n, rangeGrid: def?.skill?.rangeGrid ?? null },
onStart({ battle, unit }) {
unit.mem.grainQuietUntil = battle.time + sleep;
for (const e of enemiesInGrid(battle, unit, def?.skill?.rangeGrid ?? null, { n })) {
if (battle.applyStatus(e, 'sleep', { duration: sleep, source: unit })) battle.fx('sleep', { x: e.x, y: e.y, id: e.id });
}
},
},
skills: {
skchr_grabds_1: { kind: instantKind(def), attack: { atkScale: num(bb.atk_scale, 1), dmgType: 'arts' }, mods: { maxTargets: 1 } },
},
talents: [{ install(battle, unit) {
statBuff(battle, unit, 'talent:grabds', { aspd: num(t.attack_speed) });
spTimeBonus(battle, unit, num(moduleBb(chess).sp_recovery_per_sec), () => enemyInRange(battle, unit));
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_05 哈洛德 重症优先: ASPD +attack_speed, heals the ally with the heaviest element damage first; on targets whose
// gauge is over half, the element recovery is trait_scale × (the wandermedic trait: ep_heal_ratio × ATK).
// 我即军营: allies in range whose gauge is over half take −ep_damage_resistance element damage.
// S1 治疗强化·γ型 (alt): ATK +atk; the 重症优先 target order belongs to S2 only.
chess_char_2_05_a: (bb, chess) => {
const t = talentBb(chess, 0);
const s2 = onDefaultSkill(chess);
const half = ELEMENT_GAUGE_MAX / 2;
const ts = num(bb.trait_scale, 1);
return {
skill: {
kind: 'duration', heal: true, mods: { aspd: num(bb.attack_speed) },
onHit({ battle, unit, target }) {
if (!target || !unit.mem.haroldHalf?.has(target.id) || !(ts > 1)) return;
battle.reduceElement(target, unit.s.atk * (unit.profile.heal?.elementHealRatio ?? 0) * (ts - 1));
},
},
skills: { 'skcom_heal_up[3]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } },
talents: [{ install(battle, unit) {
if (s2) battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.skill?.active) return;
const cands = battle.injuredAlliesInKeys(unit.rangeKeys, unit, true);
cands.sort((a, b) => elemLoad(b) - elemLoad(a) || a.hpRatio - b.hpRatio || a.deploySeq - b.deploySeq);
if (cands.length) ctx.targets = cands.slice(0, Math.max(1, ctx.targets.length));
unit.mem.haroldHalf = new Set(ctx.targets.filter((a) => elemLoad(a) > half).map((a) => a.id));
}, { owner: unit });
const r = num(t.ep_damage_resistance);
if (r > 0) {
battle.every(0.2, () => {
if (!up(unit)) return;
for (const a of alliesInGridOf(battle, unit)) {
if (elemLoad(a) > half) battle.addBuff(a, { key: 'harold:camp', duration: 0.3, mods: { elemTakenMul: 1 - r }, source: unit, tags: ['talent'] });
}
}, { owner: unit });
}
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_06 莎草 巧思乍现 (AUTO, 2 charges): the next heal is heal_scale × ATK (chain jumps included) and the talent
// barrier is shield_scale_skill × stronger. 博览古卷: every heal gives the target a barrier of attack@scale × ATK for
// attack@shield_duration s. (Chain falloff incl. the elite module comes from the chainhealer profile.)
// S2 临考发挥 (alt): on start lock the ally in range (not herself, no 禁疗; any profession, summons included) with the
// highest max HP; while it runs that ally is the only main heal target, base attack time +base_attack_time s (−1.1
// on 2.85), ATK +atk, chain jumps +attack@chain.extra_value. PRTS: "无法可选单位时无法开启技能" — with no lockable
// ally the skill never activates (the talent install guards the unit's skill.activate: no skillStart, no
// 萨尔贡 / 特质 "开启技能时" gains, SP kept); the skill stops at once when the locked ally leaves the field.
chess_char_2_06_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const sc = num(t['attack@scale']), sd = num(t['attack@shield_duration'], 8), boost = num(bb.shield_scale_skill, 1);
const extra = Math.max(0, Math.floor(num(bb['attack@chain.extra_value'])));
const lockOf = (battle, unit) => alliesInGridOf(battle, unit)
.filter((a) => a !== unit && !a.s.flags.noHeal && !a.profile?.noHeal)
.sort((a, b) => b.s.maxHp - a.s.maxHp || a.deploySeq - b.deploySeq)[0] ?? null;
const release = (battle, unit) => {
if (unit.mem.papyrsHeal) { unit.profile.heal = unit.mem.papyrsHeal; unit.mem.papyrsHeal = null; }
if (unit.mem.papyrsHook) battle.off(unit.mem.papyrsHook);
unit.mem.papyrsHook = null;
unit.mem.papyrsLock = null;
unit.mem.papyrsAbort = false;
};
return {
skill: { kind: instantKind(def), heal: true, attack: { healScale: num(bb.heal_scale, 1) } },
skills: {
skchr_papyrs_2: {
kind: 'duration', heal: true, mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess) },
onStart({ battle, unit, skill }) {
release(battle, unit);
const lock = lockOf(battle, unit);
// (only reachable when a runtime bypasses the activate guard below, e.g. a copied skill: undo on the next tick)
if (!lock) { unit.mem.papyrsAbort = true; return; }
unit.mem.papyrsLock = lock;
const h = unit.profile.heal;
if (h && extra > 0) { unit.mem.papyrsHeal = h; unit.profile.heal = { ...h, count: Math.max(1, h.count || 3) + extra }; }
unit.mem.papyrsHook = battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !skill.active) return;
const L = unit.mem.papyrsLock;
if (L && up(L) && unit.rangeKeySet?.has(posKey(L))) ctx.targets = [L];
}, { owner: unit, priority: 10 });
battle.fx('buff', { x: lock.x, y: lock.y, id: lock.id, kind: 'lock' });
},
onTick({ unit, skill }) {
if (unit.mem.papyrsAbort) {
// "无法可选单位时无法开启技能": undo the cast, keep the SP
unit.mem.papyrsAbort = false;
skill.end('noTarget');
skill.addCharge(1);
return;
}
const L = unit.mem.papyrsLock;
if (!L || !up(L)) skill.end('target');
},
onEnd({ battle, unit }) { release(battle, unit); },
},
},
talents: [{ install(battle, unit) {
const sk = unit.skill;
if (sk && sk.id === 'skchr_papyrs_2' && !Object.prototype.hasOwnProperty.call(sk, 'activate')) {
// "无法可选单位时无法开启技能": every activation path (DEFAULT heal trigger, carried skill, content) is refused
// while no ally can be locked — the charge stays, nothing starts (non-enumerable: never serialised)
const activate = sk.activate;
Object.defineProperty(sk, 'activate', {
configurable: true, writable: true, enumerable: false,
value(reason, opts) { return lockOf(battle, unit) ? activate.call(this, reason, opts) : false; },
});
}
if (!(sc > 0)) return;
battle.on('heal', (ctx) => {
if (ctx.source !== unit || ctx.target === unit && ctx.opts?.regen || !(ctx.amount > 0)) return;
const bySkill = !!(unit.skill?.active && unit.skill.pending);
battle.addBuff(ctx.target, { key: 'papyrs:shield', shield: unit.s.atk * sc * (bySkill ? boost : 1), duration: sd, source: unit, visible: true, tags: ['talent'] });
}, { owner: unit, priority: -10 });
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_07 幽灵鲨 肉斩骨断: ATK +atk and HP never drops below 1 while active; stunned `stun` s afterwards.
// Talent: max HP +max_hp (elite: + hp_recovery_per_sec_by_max_hp_ratio × max HP per s).
// Elite module (CEN-X, trait atk_scale): vs enemies it blocks ATK ×atk_scale.
// S1 攻击力强化·γ型 (alt): ATK +atk for its duration.
chess_char_2_07_a: (bb, chess) => {
const t = talentBb(chess, 0);
const tb = traitBb(chess);
return {
skill: {
kind: 'duration', mods: { atkPct: num(bb.atk) },
onStart({ battle, unit }) {
if (unit.mem.undying) battle.off(unit.mem.undying);
unit.mem.undying = battle.on('fatal', (c) => { if (c.unit === unit && unit.skill?.active) c.prevented = true; }, { owner: unit, priority: 10 });
},
onEnd({ battle, unit, reason }) {
if (unit.mem.undying) battle.off(unit.mem.undying);
unit.mem.undying = null;
if (reason !== 'death' && unit.alive && num(bb.stun) > 0) battle.applyStatus(unit, 'stun', { duration: num(bb.stun), source: unit });
},
},
skills: { 'skcom_atk_up[3]': { kind: 'duration', mods: { atkPct: num(bb.atk) } } },
talents: [{ install(battle, unit) {
statBuff(battle, unit, 'talent:ghost', { hpPct: num(t.max_hp), hpRegenRatio: num(t.hp_recovery_per_sec_by_max_hp_ratio) });
if (num(tb.atk_scale, 1) !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && target.blockedBy) dmg.amount *= num(tb.atk_scale, 1); });
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_08 泡泡 “挨打”: stops attacking; DEF +def, taunt +taunt_level, each attack received returns atk_scale × DEF phys
// damage to the attacker. 尖刺盾: attackers get ATK −atk for `duration` s. Elite module (PRO-X): DEF +def while blocking.
// S1 防御力强化·β型 (alt, TAKE_DAMAGE from data): DEF +def for its duration; the counter belongs to “挨打” only.
chess_char_2_08_a: (bb, chess) => {
const t = talentBb(chess, 0);
const tb = traitBb(chess);
const s2 = onDefaultSkill(chess);
return {
skill: { kind: 'duration', mods: { defPct: num(bb.def), taunt: num(bb.taunt_level) }, attack: { noAttack: true } },
skills: { 'skcom_def_up[2]': { kind: 'duration', mods: { defPct: num(bb.def) } } },
talents: [{ install(battle, unit) {
onDamagedOn(battle, unit, (ctx) => {
if (!byEnemyAttack(ctx) || !unit.alive) return;
const src = ctx.source;
if (s2 && unit.skill?.active && src.alive) {
battle.dealDamage(unit, src, { amount: unit.s.def * num(bb.atk_scale), type: 'phys', isSkill: true, canDodge: false, tags: ['counter'] });
battle.fx('counter', { x: src.x, y: src.y, id: unit.id });
}
if (num(t.atk) < 0 && src.alive) battle.addBuff(src, { key: 'bubble:spike', duration: num(t.duration, 5), mods: { atkPct: num(t.atk) }, refresh: 'extend', source: unit });
});
if (num(tb.def)) toggleBuff(battle, unit, 'bubble:guard', () => unit.blocking.length > 0, { defPct: num(tb.def) });
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_09 休谟斯 高效处理: block +block_cnt; above peak_1.hp_ratio HP ATK +peak_1.atk (精力充沛), above peak_2.hp_ratio
// +peak_2.atk. 再回收: healing beyond max HP becomes a barrier, at most max_hp_ratio × max HP.
// S1 固废切割 (alt, attack SP): the next attack hits at atk_scale × ATK (every enemy it reaches) and heals her `value`
// HP once (a self heal: her trait bars only healing by others; overflow feeds 再回收).
chess_char_2_09_a: (bb, chess) => {
const t = talentBb(chess, 0);
const peaks = [];
for (const [k, v] of Object.entries(bb)) {
const m = k.match(/\[(peak_\d+)\]\.peak_performance\.(atk|hp_ratio)$/);
if (!m) continue;
let p = peaks.find((x) => x.id === m[1]);
if (!p) peaks.push((p = { id: m[1], atk: 0, hp: 1 }));
if (m[2] === 'atk') p.atk = num(v); else p.hp = num(v, 1);
}
peaks.sort((a, b) => b.hp - a.hp);
const key = 'humus:peak';
const peak = ({ battle, unit }) => {
const lvl = peaks.find((p) => unit.hpRatio > p.hp)?.atk ?? 0;
const cur = unit.findBuff(key);
if ((cur?.data?.v ?? 0) === lvl) return;
if (lvl > 0) battle.addBuff(unit, { key, mods: { atkPct: lvl }, data: { v: lvl }, visible: true, tags: ['skill'] });
else battle.removeBuff(unit, key);
};
return {
skill: {
kind: 'duration', mods: { blockCnt: num(bb.block_cnt) },
onStart: peak, onTick: peak,
onEnd({ battle, unit }) { battle.removeBuff(unit, key); },
},
skills: {
skchr_humus_1: {
kind: 'instant', attack: { atkScale: num(bb.atk_scale, 1) },
onAttack({ battle, unit }) {
if (num(bb.value) > 0 && unit.alive) battle.heal(unit, unit, num(bb.value), { self: true, tags: ['skill'] });
},
},
},
talents: [{ install(battle, unit) {
const ratio = num(t.max_hp_ratio);
if (!(ratio > 0)) return;
battle.on('heal', (ctx) => {
if (ctx.target !== unit || !(ctx.amount > 0)) return;
const over = ctx.amount - (unit.s.maxHp - unit.hp);
if (!(over > 0)) return;
const cur = unit.findBuff('humus:recycle');
const have = cur ? cur.shield : 0;
const val = Math.min(unit.s.maxHp * ratio, have + over);
if (val > have + 1e-6) battle.addBuff(unit, { key: 'humus:recycle', shield: val, source: unit, visible: true, tags: ['talent'] });
}, { owner: unit, priority: -50 });
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_10 洛洛 自负此轭: ASPD +attack_speed; the drones lock on their target until it dies or the skill ends.
// 过载 (term ba.overdrive "技能进行到一半时触发额外效果"): the second half of the duration — ATK +atk and the funnel cap
// ×scale; when the skill ends she is stunned as long as the overload lasted. 立于磐石: ATK +atk per `interval` s on the
// field, up to max_stack_cnt stacks.
// S1 战术咏唱·γ型 (alt): ASPD +attack_speed; the target lock belongs to 自负此轭 only.
chess_char_2_10_a: (bb, chess) => {
const t = talentBb(chess, 0);
const s2 = onDefaultSkill(chess);
const endOverload = (battle, unit) => {
if (unit.mem.rockFunnel) { unit.profile.funnel = unit.mem.rockFunnel; unit.mem.rockFunnel = null; }
battle.removeBuff(unit, 'rockr:overload');
};
return {
skill: {
kind: 'duration', mods: { aspd: num(bb.attack_speed) },
onStart({ battle, unit, skill }) {
unit.mem.rockOverAt = battle.time + skill.duration / 2;
unit.mem.rockOver = false;
unit.mem.rockLock = null;
},
onTick({ battle, unit }) {
if (unit.mem.rockOver || battle.time + 1e-9 < unit.mem.rockOverAt) return;
unit.mem.rockOver = true;
const f = unit.profile.funnel;
if (f) { unit.mem.rockFunnel = f; unit.profile.funnel = { ...f, max: f.max * num(bb.scale, 1) }; }
battle.addBuff(unit, { key: 'rockr:overload', mods: { atkPct: num(bb.atk) }, visible: true, tags: ['skill'] });
battle.fx('overload', { x: unit.x, y: unit.y, id: unit.id });
},
onEnd({ battle, unit, reason }) {
const over = unit.mem.rockOver ? battle.time - unit.mem.rockOverAt : 0;
endOverload(battle, unit);
unit.mem.rockOver = false;
unit.mem.rockLock = null;
if (over > 0 && reason !== 'death' && unit.alive) battle.applyStatus(unit, 'stun', { duration: over, source: unit });
},
},
skills: { 'skcom_magic_rage[3]': { kind: 'duration', mods: { aspd: num(bb.attack_speed) } } },
talents: [{ install(battle, unit) {
if (s2) battle.on('beforeAttack', (ctx) => {
if (ctx.attacker !== unit || !unit.skill?.active) return;
const L = unit.mem.rockLock;
if (L && L.alive && unit.rangeKeySet?.has(posKey(L)) && canTargetEnemy(unit, L, ctx.profile || unit.profile)) ctx.targets = [L];
else unit.mem.rockLock = ctx.targets[0] ?? null;
}, { owner: unit });
const iv = num(t.interval), max = Math.floor(num(t.max_stack_cnt)), v = num(t.atk);
if (!(iv > 0) || !(max > 0) || !v) return;
battle.on('tick', () => {
if (!up(unit)) return;
const n = Math.min(max, Math.floor((battle.time - unit.deployedAt + 1e-9) / iv));
const cur = unit.findBuff('rockr:rock');
if ((cur ? cur.stacks : 0) === n) return;
if (n > 0) battle.addBuff(unit, { key: 'rockr:rock', mods: { atkPct: v }, stacks: n, maxStacks: max, tags: ['talent'] });
else battle.removeBuff(unit, 'rockr:rock');
}, { owner: unit });
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_11 风丸 纸艺·双影: lose hp_ratio of current HP, ATK +atk, summon the <替身> (overrideTokenKey) on a free melee tile
// around her for the skill's duration [ASSUMED lifetime]. 折纸生花: whenever a <替身> appears (the summon, and the
// dollkeeper substitution) enemies on the 8 surrounding tiles take damage_scale × its ATK arts.
// Trait substitution: she fights with the <替身>'s stats (the engine already swaps in its HP; the kit swaps ATK/DEF
// with flat deltas so %-buffs still apply). Elite module (PUM-X, trait atk): while substituted, ATK +atk.
// S1 纸艺·迅击 (alt, attack SP): the next attack hits at atk_scale × ATK and she loses hp_ratio of her MAX HP (PRTS:
// ten casts knock her out — the loss can be fatal and so sets off the <替身> substitution).
chess_char_2_11_a: (bb, chess) => {
const t = talentBb(chess, 0);
const tb = traitBb(chess);
const tokId = chess?.skill?.overrideTokenKey ?? (chess?.tokens ?? []).find((x) => /shadow|doll/.test(String(x))) ?? 'token_10022_kazema_shadow';
const ds = num(t.damage_scale);
const origami = (battle, src, atk) => {
battle.fx('aoe', { x: src.x, y: src.y, radius: 1.5, id: src.id, skill: 'origami' });
if (!(ds > 0)) return;
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.s.flags.untargetable || cheb(src, e) > 1) continue;
battle.dealDamage(src, e, { amount: atk * ds, type: 'arts', isSkill: true, canDodge: false, tags: ['talent'] });
}
};
return {
skill: {
kind: 'duration', mods: { atkPct: num(bb.atk) },
onStart({ battle, unit }) {
const loss = unit.hp * num(bb.hp_ratio);
if (loss > 0 && unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit });
const tile = freeTileAround(battle, unit, (r, c) => battle.grid.canStand(r, c));
if (!tile) return;
const doll = battle.spawnToken(unit, tokId, tile[0], tile[1], {});
if (!doll) return;
unit.mem.doll = doll;
battle.fx('summon', { x: doll.x, y: doll.y, id: doll.id, token: tokId });
origami(battle, doll, doll.s.atk);
},
onEnd({ battle, unit }) {
const d = unit.mem.doll;
if (d && d.alive) battle.retreat(d, { reason: 'expired', permanent: true });
unit.mem.doll = null;
},
},
skills: {
skchr_kazema_1: {
kind: 'instant', attack: { atkScale: num(bb.atk_scale, 1) },
onAttack({ battle, unit }) {
const loss = unit.s.maxHp * num(bb.hp_ratio);
if (loss > 0 && unit.alive) battle.loseHp(unit, loss, { source: unit, tags: ['skill'] });
},
},
},
talents: [{ install(battle, unit) {
const bonus = num(tb.atk);
battle.on('tick', () => {
const sub = up(unit) && !!unit.findBuff('trait:substitute');
if (sub && !unit.mem.kzSub) {
unit.mem.kzSub = true;
const ts = battle.tokenDef(tokId, unit)?.stats ?? null; // the owner's module variant (DESIGN §16)
const mods = {};
if (num(ts?.atk) > 0) mods.atkFlat = num(ts.atk) - unit.base.atk;
if (ts && Number.isFinite(ts.def)) mods.defFlat = ts.def - unit.base.def;
if (bonus > 0) mods.atkPct = bonus;
battle.addBuff(unit, { key: 'kazema:doll', mods, tags: ['trait'] });
origami(battle, unit, unit.s.atk);
} else if (!sub && unit.mem.kzSub) {
unit.mem.kzSub = false;
battle.removeBuff(unit, 'kazema:doll');
}
}, { owner: unit });
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_12 砾 鼠群 (passive): at each deployment a barrier of hp_ratio × max HP that decays to 0 over `duration` s
// (capacity stepped once per second; a damaged barrier shrinks in proportion — PRTS).
// 快速部署: own deployment cost +cost (−1). Elite 小个子支援: every unit whose initial deployment cost ≤ cond.cost
// DEF +def while 砾 is on the field. (Elite module withdraw refund: no manual retreat in battle ⇒ no effect.)
// S1 影袭 (alt, passive): at each deployment DEF +def, decaying linearly to 0 over `duration` s, updated once per
// second (PRTS: "防御力加成每秒更新一次").
chess_char_2_12_a: (bb, chess) => {
const t = talentBb(chess, 0);
return {
skill: {
kind: 'passive',
onStart({ battle, unit }) {
const total = unit.s.maxHp * num(bb.hp_ratio);
const dur = num(bb.duration, 10);
if (!(total > 0) || !(dur > 0)) return;
let k = 0;
battle.addBuff(unit, {
key: 'gravel:rats', shield: total, duration: dur, interval: 1, visible: true, tags: ['skill'],
// PRTS: "屏障最大容量每秒更新一次;受到伤害后,剩余容量根据屏障最大容量等比变化" — the capacity steps down
// once per second and what is left of the barrier scales with it (never a plain cap)
onTick: ({ unit: u, buff }) => {
const prev = Math.max(0, dur - k) / dur;
k++;
const next = Math.max(0, dur - k) / dur;
if (prev > 0 && buff.shield > 0) { buff.shield *= next / prev; u.markDirty(); }
},
});
battle.fx('shield', { x: unit.x, y: unit.y, id: unit.id });
},
},
skills: {
skchr_gravel_1: {
kind: 'passive',
onStart({ battle, unit }) {
const v = num(bb.def), dur = num(bb.duration);
if (!(v > 0) || !(dur > 0)) return;
let k = 0;
battle.addBuff(unit, {
key: 'gravel:shadow', duration: dur, mods: { defPct: v }, interval: 1, visible: true, tags: ['skill'],
onTick: ({ unit: u, buff }) => {
k++;
buff.mods = { defPct: v * Math.max(0, dur - k) / dur };
u.markDirty();
},
});
battle.fx('buff', { x: unit.x, y: unit.y, id: unit.id, kind: 'def' });
},
},
},
talents: [{ install(battle, unit) {
const c = num(t.cost);
if (c) unit.base.cost = Math.max(0, unit.base.cost + c);
const d = num(t.def), lim = t['cond.cost'];
if (d && lim != null) installAura(battle, unit, { key: 'talent:gravel', value: d, select: (a) => num(a.def?.stats?.cost, Infinity) <= num(lim), mods: { defPct: d } });
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_13 蒂比 紧急赶场通知 (AUTO): "受到攻击后触发" — any incoming enemy attack sets it off (the kit is the only
// trigger: the engine rule is disabled so non-attack damage never fires it) and a physical/arts one is dodged; takes
// off for the duration: skill range, ATK +atk, attacks become 3 shots, blocks flying (not ground) enemies. Trait
// "起飞后能够阻挡2个飞行敌人": flying enemies are blocked only while airborne. 片场工作指南: if not attacked for
// stack_time s, the next physical/arts attack is dodged (prob); every attack restarts that timer.
// S1 专业喷绘技巧 (alt, DEFAULT trigger from data): takes off at once for its duration — skill range, ATK +atk, blocks
// flyers only; no triple shot, and incoming attacks never set it off.
chess_char_2_13_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const s2 = onDefaultSkill(chess);
return {
trait: { blockFly: false },
skill: {
kind: 'duration', trigger: { rule: 'CUSTOM_RANGE', grid: [] }, // never by the engine: the hit handler below
mods: { atkPct: num(bb.atk) }, flags: { blockFly: true },
targeting: { rangeGrid: def?.skill?.rangeGrid ?? null },
attack: { hits: 3 },
onStart: tippiTakeOff,
onEnd: tippiLand,
},
skills: {
skchr_tippi_1: {
kind: 'duration', mods: { atkPct: num(bb.atk) }, flags: { blockFly: true },
targeting: { rangeGrid: def?.skill?.rangeGrid ?? null },
onStart: tippiTakeOff,
onEnd: tippiLand,
},
},
talents: [{ install(battle, unit) {
battle.removeBuff(unit, 'trait:skywalker'); // the profession grants blockFly permanently; here only airborne
const st = num(t.stack_time), p = num(t.prob, 1);
let last = -Infinity;
battle.on('deploy', ({ unit: u }) => { if (u === unit) last = battle.time - st; }, { owner: unit });
onHitOn(battle, unit, (ctx) => {
const { dmg } = ctx;
if (!byEnemyAttack(ctx) || dmg.cancel || !up(unit)) return;
const dodgeable = dmg.type === 'phys' || dmg.type === 'arts';
const sk = unit.skill;
let dodged = false;
if (s2 && sk && sk.ready && !sk.active && unit.canAct && !unit.s.flags.silence && sk.activate('TAKE_DAMAGE')) dodged = dodgeable;
else if (dodgeable && st > 0 && battle.time - last + 1e-9 >= st && battle.rng.chance(p)) dodged = true;
last = battle.time;
if (dodged) { dmg.cancel = true; battle.fx('dodge', { x: unit.x, y: unit.y, id: unit.id }); }
}, 5);
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_14 调香师 精调: ASPD +attack_speed (−50), ATK +atk. 熏衣草: every ally on the field recovers
// atk_to_hp_recovery_ratio × ATK HP per second. Elite module (RIN-Y, hidden attack@max_target): heals 4 allies.
// S1 治疗强化·β型 (alt): ATK +atk for its duration.
chess_char_2_14_a: (bb, chess) => {
const t = talentBb(chess, 0);
const cnt = Math.floor(num(moduleBb(chess)['attack@max_target']));
return {
trait: cnt > 0 ? { heal: { mode: 'multi', count: cnt } } : undefined,
skill: { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk), aspd: num(bb.attack_speed) } },
skills: { 'skcom_heal_up[2]': { kind: 'duration', heal: true, mods: { atkPct: num(bb.atk) } } },
talents: [{ install(battle, unit) {
const r = num(t.atk_to_hp_recovery_ratio);
if (!(r > 0)) return;
battle.every(1, () => {
if (!up(unit)) return;
const amt = unit.s.atk * r;
for (const a of battle.allies()) if (a.hp < a.s.maxHp) battle.heal(unit, a, amt, { aura: true, tags: ['talent'] });
}, { owner: unit });
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_15 协律 (hidden) 震爆调谐: ATK +atk; each attack sets off a sonic boom at every other operator in range:
// attack@aoe_atk_scale × ATK arts within attack@range_radius. 律脉同构: ATK +atk with another operator in range.
// Elite module (BLA-X): damage grows with distance, up to +damage_scale at max_dist tiles.
chess_char_2_15_a: (bb, chess) => {
const t = talentBb(chess, 0);
const tb = traitBb(chess);
const rad = num(bb['attack@range_radius'], 0.9), sc = num(bb['attack@aoe_atk_scale']);
return {
skill: {
kind: 'duration', mods: { atkPct: num(bb.atk) },
onAttack({ battle, unit }) {
if (!(sc > 0)) return;
for (const a of alliesInGridOf(battle, unit)) {
if (a === unit || a.kind !== 'op') continue;
battle.fx('sonic', { x: a.x, y: a.y, radius: rad, id: unit.id });
for (const e of battle.enemiesInRadius(a.x, a.y, rad)) {
if (!e.s.flags.untargetable) battle.dealDamage(unit, e, { amount: unit.s.atk * sc, type: 'arts', isSkill: true, isSplash: true, tags: ['sonic'] });
}
}
},
},
talents: [{ install(battle, unit) {
const v = num(t.atk);
if (v) toggleBuff(battle, unit, 'akkord:sync', () => alliesInGridOf(battle, unit).some((a) => a !== unit && a.kind === 'op'), { atkPct: v });
if (tb.damage_scale != null) {
const mn = num(tb.min_dist), mx = num(tb.max_dist, 4), ds = num(tb.damage_scale);
onHitBy(battle, unit, ({ target, dmg }) => {
if (!dmg.isAttack) return;
const d = Math.hypot(target.x - unit.x, target.y - unit.y);
dmg.amount *= 1 + ds * (mx > mn ? Math.max(0, Math.min(1, (d - mn) / (mx - mn))) : 1);
});
}
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_16 拉普兰德 狼魂 (AUTO, attack SP): ATK +atk, arts damage, one extra target, ranged attacks no longer reduced.
// 精神摧毁: attacks disable the target's abilities (silence) for `duration` s. Elite module (LOR-X, trait
// atk_scale_m): attacks add atk_scale_m × ATK arts damage.
// S1 日晷 (alt, attack SP, 持续时间无限 ⇒ toggle until she falls): ATK +atk and a `prob` chance to block (抵挡: the
// damage instance is negated) each physical damage an enemy deals her.
chess_char_2_16_a: (bb, chess) => {
const t = talentBb(chess, 0);
const tb = traitBb(chess);
const trait = {};
if (num(t.duration) > 0) trait.onHitStatus = { key: 'silence', duration: num(t.duration) };
if (num(tb.atk_scale_m) > 0) {
trait.afterHit = (battle, u, target) => {
if (target && target.alive && target.side === 'enemy') battle.dealDamage(u, target, { amount: u.s.atk * num(tb.atk_scale_m), type: 'arts', tags: ['module'] });
};
}
return {
trait,
skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, targeting: { maxTargets: 2 }, attack: { dmgType: 'arts', dmgMul: () => 1 } },
skills: {
skchr_whitew_1: {
kind: 'toggle', mods: { atkPct: num(bb.atk) },
onStart({ battle, unit, skill }) {
if (unit.mem.sundial) battle.off(unit.mem.sundial);
const p = num(bb.prob);
unit.mem.sundial = p > 0 ? onHitOn(battle, unit, ({ source, dmg }) => {
if (!skill.active || dmg.cancel || dmg.type !== 'phys' || !source || source.side !== 'enemy') return;
if (battle.rng.chance(p)) { dmg.cancel = true; battle.fx('block', { x: unit.x, y: unit.y, id: unit.id }); }
}, 5) : null;
},
onEnd({ battle, unit }) {
if (unit.mem.sundial) battle.off(unit.mem.sundial);
unit.mem.sundial = null;
},
},
},
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_17 折桠 生存决心: on start, ground enemies around her (skill grid) 战栗 (tremble: "被阻挡后无法进行普通攻击") for
// not_combat s; ATK +atk, DEF +def, hits every blocked enemy. 简易包扎: heals hp_ratio × max HP when the skill ends.
// Elite module (UNY-X, trait damage_scale): damage from enemies she blocks ×damage_scale.
// S1 绝境抵抗 (alt, TAKE_DAMAGE / hurt SP from data): DEF +def and 抵抗 (−one_minus_status_resistance: control
// statuses last half as long) for its duration; 简易包扎 heals at its end too.
chess_char_2_17_a: (bb, chess, def) => {
const t = talentBb(chess, 0);
const tb = traitBb(chess);
return {
skill: {
kind: 'duration', mods: { atkPct: num(bb.atk), defPct: num(bb.def) }, attack: { hitAllBlocked: true },
onStart({ battle, unit }) {
const grid = def?.skill?.rangeGrid;
const foes = grid ? enemiesInGrid(battle, unit, grid, { canHitFly: false, groundOnly: true }) : battle.enemiesInRadius(unit.x, unit.y, 1.5).filter((e) => !e.isFlying);
battle.fx('aoe', { x: unit.x, y: unit.y, radius: 1.5, id: unit.id, skill: 'resolve' });
for (const e of foes) battle.applyStatus(e, 'tremble', { duration: num(bb.not_combat), source: unit });
},
},
skills: {
skchr_branch_1: {
kind: 'duration', mods: { defPct: num(bb.def) },
onStart({ battle, unit, skill }) {
const v = Math.min(0.95, Math.max(0, -num(bb.one_minus_status_resistance)));
if (v > 0) battle.applyStatus(unit, 'resist', { duration: skill.timeLeft, value: v, source: unit });
},
},
},
talents: [{ install(battle, unit) {
const hr = num(t.hp_ratio);
if (hr > 0) {
battle.on('skillEnd', ({ unit: u, reason }) => {
if (u === unit && reason !== 'death' && unit.alive) battle.heal(unit, unit, unit.s.maxHp * hr, { self: true, tags: ['talent'] });
}, { owner: unit });
}
if (tb.damage_scale != null) onHitOn(battle, unit, ({ source, dmg }) => { if (source && source.blockedBy === unit) dmg.mul *= num(tb.damage_scale, 1); });
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_18 灰毫 攻击力强化·γ型 (TAKE_DAMAGE): ATK +atk. 炮术研习: ATK +atk, or +ashlok_t_1.atk when the `cnt` orthogonal
// tiles around her are all ground (LOW). Elite module (FOR-X, trait atk_scale): vs blocked enemies ATK ×atk_scale.
// S2 专注轰击 (alt): block count 0 (noBlock: releases what she holds), only ranged (splash) attacks, base attack time
// +base_attack_time s (−0.4 / −0.5 on 2.8), ATK +atk.
chess_char_2_18_a: (bb, chess) => {
const t = talentBb(chess, 0);
const tb = traitBb(chess);
return {
skill: { kind: 'duration', mods: { atkPct: num(bb.atk) } },
skills: {
skchr_ashlok_2: {
kind: 'duration', mods: { atkPct: num(bb.atk), batPct: batMod(bb.base_attack_time, chess) }, flags: { noBlock: true },
attack: { fortress: false, _fortressMelee: false },
},
},
talents: [{ install(battle, unit) {
battle.on('deploy', ({ unit: u }) => {
if (u !== unit) return;
const r = unit.tileR, c = unit.tileC;
const low = [[1, 0], [-1, 0], [0, 1], [0, -1]].filter(([dr, dc]) => battle.grid.isLow(r + dr, c + dc)).length;
const ground = low >= num(t.cnt, 4);
const v = ground ? num(t['ashlok_t_1.atk'], num(t.atk)) : num(t.atk);
if (v) battle.addBuff(unit, { key: 'talent:ashlok', mods: { atkPct: v }, data: { ground }, tags: ['talent'] });
}, { owner: unit });
if (num(tb.atk_scale, 1) !== 1) onHitBy(battle, unit, ({ target, dmg }) => { if (dmg.isAttack && target.blockedBy) dmg.amount *= num(tb.atk_scale, 1); });
} }],
};
},
// ---------------------------------------------------------------------------------------------------------------
// 2_19 锡人 “大拉里”: see tinmanKit (tier1.js).
// S1 “老科利” (alt, attack SP, needs a target): throws an alchemy unit at the target (ground first); for
// projectile_delay_time s ground enemies within projectile_range tiles are 虚弱 (weaken −atk) and take atk_scale ×
// ATK arts per second (ATK cached at the cast; damage over time, so the elite 凋敝魂灵 ×skill@damage_scale applies;
// the zone counts for the module's SP bonus and outlives her).
chess_char_2_19_a: (bb, chess, def) => {
const k = tinmanKit(bb, chess, def);
const dur = num(bb.projectile_delay_time, 8), radius = num(bb.projectile_range, 1), dmgScale = num(bb.atk_scale);
const weak = Math.min(1, Math.max(0, -num(bb.atk)));
const wither = num(talentBb(chess, 1)['skill@damage_scale'], 1);
const STEP = 0.25; // weaken refresh cadence (damage every 1 s)
return {
...k,
skills: {
...(k.skills || {}),
skchr_tinman_1: {
kind: instantKind(def),
onStart({ battle, unit }) {
const tgt = enemiesInGrid(battle, unit, null, { n: 1, groundOnly: true })[0] ?? enemiesInGrid(battle, unit, null, { n: 1 })[0];
const x = tgt ? tgt.x : unit.x + unit.fwd[1], y = tgt ? tgt.y : unit.y + unit.fwd[0];
const atk = unit.s.atk;
const n = Math.max(1, Math.round(dur / STEP)), per = Math.max(1, Math.round(1 / STEP));
let i = 0;
unit.mem.tinZones = (unit.mem.tinZones ?? 0) + 1;
battle.fx('zone', { x, y, radius, dur, id: unit.id, skill: 'tinman1' });
battle.every(STEP, (b, sc) => {
const pulse = i % per === 0;
for (const e of b.enemiesInRadius(x, y, radius)) {
if (e.isFlying || e.s.flags.untargetable) continue;
if (weak > 0) b.applyStatus(e, 'weaken', { duration: STEP + 0.05, value: weak, source: unit });
if (wither > 1) b.addBuff(e, { key: 'tinman:wither', duration: STEP + 0.05, data: { mul: wither }, source: unit });
if (pulse && dmgScale > 0) b.dealDamage(unit, e, { amount: atk * dmgScale, type: 'arts', isSkill: true, canDodge: false, tags: ['dot', 'zone'] });
}
if (++i >= n) { sc.cancel(); unit.mem.tinZones = Math.max(0, (unit.mem.tinZones ?? 1) - 1); }
}, { immediate: true });
},
},
},
};
},
};
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+325
View File
@@ -0,0 +1,325 @@
// server/sim/content/support/index.js — shared helpers of content wave B (bonds, garrisons, items, bands, choices).
//
// Battle side and prep side read the same frozen game data (server/data.js). Everything here is pure and deterministic;
// battle helpers only go through the public Battle API (docs/SIM.md §6). Wave-B submodules import from this file and
// never edit it; module-specific helpers live in the submodules.
//
// Conventions shared by every wave-B module (details in docs/CONTENT.md):
// * Attribute bonuses from bonds, bands, equipment and 机变 cards are "直接乘算" (research 02 §2.1): each source is its
// own multiplier on the final stat → `atkMul` / `defMul` / `hpMul` (1 + x), never the skill-style `atkPct`.
// ASPD stays additive (`aspd`), damage bonuses are `dmgDealtMul`, redeploy changes `redeployMul`.
// * Match-long passives use `passiveBuff()` (persist + allowDead + replace): idempotent by key, survive death.
// * Buff keys / fx `src` are namespaced: `bond:<bondId>…`, `item:<itemKey>…`, `gar:<effectKey>…`, `band:<bandId>…`,
// `choice:<effectId>…`.
// * IN_BATTLE layer gains go through `gainLayers()` (requireActive, per-source per-battle caps, 魔王-compatible source).
// * Prep-side facts a battle needs (hand size, operators gained this round, teammates' bands …) arrive in
// `PlayerBattleInput.contentInfo`, written by the `global:contentb_info` meta handler (support/meta.js).
import { getData } from '../../../data.js';
import { COLS } from '../../constants.js';
import { frontOf, offsetTile } from '../../dir.js';
export { COLS };
// =====================================================================================================================
// numbers & data
/** Finite number from a number or numeric string, else `d`. */
export const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
const own = (o, k) => (o && typeof o === 'object' && typeof k === 'string' && Object.prototype.hasOwnProperty.call(o, k) ? o[k] : null);
const QUIET = Object.freeze({ warn() {}, error() {}, info() {} });
let DATA = null;
/** Frozen data/*.json (process singleton of server/data.js). Never mutate. */
export function gameData() {
if (!DATA) {
try { DATA = getData({ log: QUIET }) || {}; } catch { DATA = {}; }
}
return DATA;
}
/** Tests only: swap the data object (null → reload the singleton). Also drops derived caches. */
export function setGameData(d) { DATA = d ?? null; CORE = null; }
export const bondRecord = (id) => own(gameData().bonds, id);
export const itemRecord = (id) => own(gameData().items, id);
export const garrisonRecord = (id) => own(gameData().garrisons, id);
export const bandRecord = (id) => own(gameData().bands, id);
export const effectRecord = (id) => own(gameData().effects, id);
export const chessRecord = (id) => own(gameData().chess, id);
/** Registry key of an item: id without the `_a` / `_b` suffix (Arts have none). */
export const itemKeyOf = (id) => String(id ?? '').replace(/_[ab]$/, '');
/** True for golden (进阶) items / elite chess ids. */
export const isGoldenId = (id) => /_b$/.test(String(id ?? ''));
/**
* Buffs of a data record (bond / item / band / effect / garrison-like) as `{ key, bbKey, p }` where `key` is the
* buff key, `bbKey` the `bbStr.key` (battle rune key, may be null) and `p = { ...bb, ...bbStr }`.
*/
export function buffsOf(rec) {
const out = [];
for (const b of (rec && Array.isArray(rec.buffs) ? rec.buffs : [])) {
if (!b || typeof b !== 'object') continue;
out.push({ key: b.key ?? null, bbKey: b.bbStr && typeof b.bbStr.key === 'string' ? b.bbStr.key : null, p: { ...(b.bb || {}), ...(b.bbStr || {}) } });
}
return out;
}
/** `{ ...bb, ...bbStr }` of the first buff of `rec` whose key or bbStr.key equals `key` (or matches a RegExp), else null. */
export function buffParams(rec, key) {
for (const b of buffsOf(rec)) {
const hit = key instanceof RegExp ? key.test(b.key ?? '') || key.test(b.bbKey ?? '') : b.key === key || b.bbKey === key;
if (hit) return b.p;
}
return null;
}
let CORE = null;
/** Core (核心, isPower) bond ids. */
export function coreBondIds() {
if (!CORE) {
CORE = new Set();
for (const [id, b] of Object.entries(gameData().bonds || {})) if (b && b.isCore) CORE.add(id);
}
return CORE;
}
export const isCoreBond = (id) => coreBondIds().has(id);
// =====================================================================================================================
// battle: players & bonds (live: in-battle layer gains update `battle.getPlayer(pid).bonds[id].layers`)
export const player = (battle, pid) => (battle && pid != null ? battle.getPlayer(pid) : null);
/** Live bond state `{ count, active, tier, layers }` of a player, or null. */
export function bondState(battle, pid, bondId) {
const b = player(battle, pid)?.bonds?.[bondId];
return b && typeof b === 'object' ? b : null;
}
/** Tier reached (0 when inactive). */
export function bondTier(battle, pid, bondId) {
const b = bondState(battle, pid, bondId);
return b && b.active ? Math.max(1, Math.floor(num(b.tier, 1))) : 0;
}
export const bondActive = (battle, pid, bondId, minTier = 1) => bondTier(battle, pid, bondId) >= minTier;
/** Live layers (also while the bond is inactive). */
export const bondLayers = (battle, pid, bondId) => Math.max(0, num(bondState(battle, pid, bondId)?.layers, 0));
/** Ids of the player's active bonds. */
export function activeBondIds(battle, pid) {
const out = [];
const bonds = player(battle, pid)?.bonds;
if (bonds && typeof bonds === 'object') for (const [id, b] of Object.entries(bonds)) if (b && b.active) out.push(id);
return out;
}
/** Active bond with the most layers (ties → data order); null when none is active. */
export function topActiveBond(battle, pid) {
let best = null, bl = -1;
for (const id of Object.keys(gameData().bonds || {})) {
if (!bondActive(battle, pid, id)) continue;
const l = bondLayers(battle, pid, id);
if (l > bl) { bl = l; best = id; }
}
return best;
}
/** Prep-side facts for this player's battle (support/meta.js `global:contentb_info`), or {} when absent (tests). */
export function contentInfo(battle, pid) {
const ci = player(battle, pid)?.input?.contentInfo;
return ci && typeof ci === 'object' ? ci : {};
}
/** Band ids of every alive player of the match (own first); falls back to this battle's players. */
export function matchBands(battle, pid) {
const mb = contentInfo(battle, pid).matchBands;
if (Array.isArray(mb) && mb.length) return mb;
return (battle.players || []).map((p) => p.bandId).filter(Boolean);
}
// =====================================================================================================================
// battle: units, membership, items
const EMPTY = Object.freeze([]);
export const isOp = (u) => !!u && u.kind === 'op';
export const onField = (u) => !!u && u.alive && u.deployed;
/** Elite (精锐) chess. */
export const isElite = (u) => !!(u && u.def && (u.def.golden || isGoldenId(u.def.id)));
/** Shop tier (阶) of an operator (tokens: their summoner's tier, else 1). */
export function tierOf(u) {
if (!u) return 1;
if (u.kind === 'op') return Math.max(1, Math.floor(num(u.def?.tier ?? u.def?.raw?.tier, 1)));
if (u.ownerUnit) return tierOf(u.ownerUnit);
return 1;
}
/** Operator standing on a ground (LOW, not elevated) tile — "地面干员" (engine flag set at deployment). */
export const isGroundOp = (u) => isOp(u) && u.ground === true;
/** Base (normal) chess id of an operator (normal and elite share it). */
export const baseChessId = (u) => String(u?.def?.baseId ?? u?.defId ?? '').replace(/_b$/, '_a');
/** Equipment ids carried by a unit (golden ids when merged). */
export const itemsOf = (u) => (u && Array.isArray(u.items) ? u.items : EMPTY);
export const hasItemKey = (u, key) => itemsOf(u).some((id) => itemKeyOf(id) === key);
export const goldenItemCount = (u) => itemsOf(u).filter(isGoldenId).length;
const BONDS = new WeakMap();
/**
* A unit's own bonds: the chess's data bonds + bonds granted by 变形同构体 (an item with `canGiveBond` worn together
* with an item that has a `giveBondId`; same rule as server/match/bondsMeta.js pieceBonds). Tokens / enemies: [].
*/
export function unitBonds(u) {
if (!isOp(u)) return EMPTY;
const cached = BONDS.get(u);
const items = itemsOf(u);
const key = items.join('|');
if (cached && cached.key === key) return cached.bonds;
const out = [...(Array.isArray(u.def?.bonds) ? u.def.bonds : Array.isArray(u.def?.raw?.bonds) ? u.def.raw.bonds : [])];
if (items.length >= 2) {
const recs = items.map((id) => itemRecord(id)).filter(Boolean);
if (recs.some((r) => r.canGiveBond)) {
for (const r of recs) {
if (r.canGiveBond) continue;
if (typeof r.giveBondId === 'string' && bondRecord(r.giveBondId) && !out.includes(r.giveBondId)) out.push(r.giveBondId);
}
}
}
const bonds = Object.freeze(out);
BONDS.set(u, { key, bonds });
return bonds;
}
/**
* Does `u` receive the effects of `bondId`? Own bonds (incl. 变形同构体 grants), and — 调和 (maniShip) — an operator
* of the 调和 bond enjoys every core bond while 调和 is active (research 02 §2.1; callers still check the core bond is
* active themselves).
*/
export function isMember(battle, u, bondId) {
const own = unitBonds(u);
if (own.includes(bondId)) return true;
return isCoreBond(bondId) && own.includes('maniShip') && bondActive(battle, u.ownerId, 'maniShip');
}
/**
* Operators (optionally tokens) of player `pid` that receive `bondId`'s effects.
* opts: { fieldOnly = false (alive & deployed only), tokens = false }
*/
export function bondMembers(battle, pid, bondId, { fieldOnly = false, tokens = false } = {}) {
const out = [];
for (const u of battle.allyUnits) {
if (u.ownerId !== pid) continue;
if (u.kind !== 'op' && !(tokens && u.kind === 'token')) continue;
if (fieldOnly && !onField(u)) continue;
const who = u.kind === 'token' ? u.ownerUnit : u;
if (who && isMember(battle, who, bondId)) out.push(u);
}
return out;
}
/** Operators of a player (every one created from the board, dead or alive unless fieldOnly). */
export function playerOps(battle, pid, { fieldOnly = false } = {}) {
const out = [];
for (const u of battle.allyUnits) if (u.kind === 'op' && u.ownerId === pid && (!fieldOnly || onField(u))) out.push(u);
return out;
}
// =====================================================================================================================
// battle: geometry (tile relations use the unit's direction `dir` (sim/dir.js): "身前" = one step along its forward
// vector; relative offsets [dRow, dCol] are facing-RIGHT and rotated like range grids)
export const N4 = Object.freeze([[1, 0], [-1, 0], [0, 1], [0, -1]]);
export const N8 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
/** Tile `k` steps in front of (k < 0: behind) a unit's tile, along its direction. */
export const frontTile = (u, k = 1) => frontOf(u.tileR, u.tileC, u.dir, k);
/** The two tiles beside a unit (perpendicular to its direction): [its left-hand tile, its right-hand tile]. */
export const sideTiles = (u) => [offsetTile(u.tileR, u.tileC, 1, 0, u.dir), offsetTile(u.tileR, u.tileC, -1, 0, u.dir)];
/** Deployed ally on (r, c) owned by `pid` (any owner when pid is null), else null. */
export function allyAt(battle, r, c, pid = null) {
if (!Number.isInteger(r) || !Number.isInteger(c)) return null;
const u = battle.unitAt(r, c);
if (!u || u.side !== 'ally' || !u.alive || u.kind === 'device') return null;
return pid == null || u.ownerId === pid ? u : null;
}
/** Deployed allies of the same owner on the given relative offsets ([dRow, dCol] facing RIGHT, rotated by `u.dir`). */
export function alliesAround(battle, u, offsets = N4) {
const out = [];
for (const [dr, dc] of offsets) {
const [r, c] = offsetTile(u.tileR, u.tileC, dr, dc, u.dir);
const a = allyAt(battle, r, c, u.ownerId);
if (a && a !== u) out.push(a);
}
return out;
}
/** Deployed operators of the same owner on the unit's row (the unit included). */
export function rowMates(battle, u) {
const out = [];
for (const a of battle.allyUnits) if (a.kind === 'op' && a.ownerId === u.ownerId && onField(a) && a.tileR === u.tileR) out.push(a);
return out;
}
/** Enemy / ally tile key used by range sets (`unit.rangeKeySet`). */
export const tileKey = (u) => (u.side === 'ally' ? u.tileR * COLS + u.tileC : Math.round(u.y) * COLS + Math.round(u.x));
/** Is `target` inside `u`'s current attack range (grid ranges)? */
export const inRange = (u, target) => !!(u && target && u.rangeKeySet && u.rangeKeySet.has(tileKey(target)));
// =====================================================================================================================
// battle: buffs, fx, per-battle state
/** Match-long passive buff (survives death/redeploy; re-adding the same key replaces it — idempotent). */
export function passiveBuff(battle, u, key, mods, extra = {}) {
return battle.addBuff(u, { key, mods, persist: true, allowDead: true, refresh: 'replace', ...extra });
}
/** Client VFX anchored on a unit: `battle.fx(kind, { x, y, id, src, key, …extra })`. */
export function fxOn(battle, kind, u, src, key, extra = {}) {
if (!u) return;
battle.fx(kind, { x: u.x, y: u.y, id: u.id, src, key, ...extra });
}
const STORES = new WeakMap();
/** Per-battle scratch object for namespace `ns` (created with `init()` on first use). */
export function battleStore(battle, ns, init = () => ({})) {
let m = STORES.get(battle);
if (!m) { m = new Map(); STORES.set(battle, m); }
let s = m.get(ns);
if (s === undefined) { s = init(); m.set(ns, s); }
return s;
}
// =====================================================================================================================
// battle: IN_BATTLE layer gains
/**
* Add IN_BATTLE layers (no-op in 联防 / boss / hidden fields: `battle.flags.layerGainsEnabled`).
* playerId receiving player
* bonds bond id or array of ids (each gets `n`)
* n layers per bond (floored)
* requireActive "使已激活的【X】层数+N" (default true); false = "(无需激活盟约)"
* source unit that caused the gain (garrison owner; 魔王's +1 keys on it through the `layerGain` hook)
* reason 'garrison' for 特质 gains (魔王 only boosts those), else 'bond' / 'item' / 'band' / 'choice'
* cap, capKey per-battle cap per (capKey, bond) — "每场战斗至多N层"; hook extras (魔王) don't count toward it
* Returns the layers actually added (sum over bonds).
*/
export function gainLayers(battle, { playerId, bonds, n, requireActive = true, source = null, reason = 'content', cap = Infinity, capKey = null } = {}) {
if (!battle || !battle.flags || !battle.flags.layerGainsEnabled || playerId == null) return 0;
const k0 = Math.floor(num(n, 0));
if (!(k0 > 0)) return 0;
const list = Array.isArray(bonds) ? bonds : [bonds];
const caps = capKey != null && Number.isFinite(cap) ? battleStore(battle, 'support:layerCaps', () => new Map()) : null;
let total = 0;
for (const b of list) {
if (typeof b !== 'string' || !b) continue;
if (requireActive && !bondActive(battle, playerId, b)) continue;
let k = k0;
let ck = null;
if (caps) {
ck = `${capKey}|${b}`;
const used = caps.get(ck) ?? 0;
k = Math.min(k, Math.max(0, Math.floor(cap) - used));
if (!(k > 0)) continue;
}
const added = battle.addLayers(playerId, b, k, reason, { source });
if (added > 0) {
total += added;
if (ck) caps.set(ck, (caps.get(ck) ?? 0) + k);
}
}
return total;
}
/** Layers already added this battle under (capKey, bond) — for tests / UI. */
export function layersUsed(battle, capKey, bondId) {
return battleStore(battle, 'support:layerCaps', () => new Map()).get(`${capKey}|${bondId}`) ?? 0;
}
+84
View File
@@ -0,0 +1,84 @@
// server/sim/content/support/meta.js — prep-side helpers shared by content wave B (docs/META.md registry API).
//
// registerSupportMeta(registry) is called once from bonds.js → registerMeta. It installs
// global:contentb_info onBattleStart: writes `ev.input.contentInfo` (read in battles with support.contentInfo()):
// { round, kind, shopLevel, funds,
// handUnits pieces in the 整备区 (hand, 10 slots; operators, items and summon stacks),
// handChess operators in the hand,
// roundStats { refreshes, buys, sells, spent, gainedChess, arts } (this round's prep, ps.round),
// matchBands band ids of every alive player (this player first),
// teammates [{ playerId, bandId }] of the other alive players }
// Global handlers run first in every dispatch, so band / bond / garrison / item handlers of the same onBattleStart can
// read or extend `ev.input.contentInfo`.
//
// Meta helpers (pure functions of a handler ctx):
// metaBonds(ctx, pieceView) bonds of a piece incl. 变形同构体 grants
// distinctTiers(ctx, pred) distinct chess tiers on the board among pieces matching pred
// frontPiece / behindPiece board neighbours ("身前一格" = one step along the piece's direction `dir`, default
// RIGHT = col + 1; board piece views carry `dir`, sim/dir.js)
import { frontOf } from '../../dir.js';
export function registerSupportMeta(registry) {
registry.global('contentb_info', {
onBattleStart(ctx, ev) {
if (!ev || !ev.input || typeof ev.input !== 'object') return;
const hand = ctx.hand();
const rs = ctx.roundStats();
const mates = ctx.teammates();
ev.input.contentInfo = {
round: ctx.round,
kind: ev.kind ?? null,
shopLevel: ctx.shopLevel(),
funds: ctx.funds(),
handUnits: hand.filter(Boolean).length,
handChess: hand.filter((p) => p && p.kind === 'chess').length,
roundStats: {
refreshes: rs.refreshes | 0, buys: rs.buys | 0, sells: rs.sells | 0, spent: rs.spent | 0,
gainedChess: rs.gainedChess | 0, arts: rs.arts | 0,
},
matchBands: [ctx.bandId(), ...mates.map((t) => t.bandId())].filter(Boolean),
teammates: mates.map((t) => ({ playerId: t.playerId, bandId: t.bandId() })),
};
},
});
}
/** Bonds of a piece view (own + 变形同构体 grants), as the match counts them. */
export function metaBonds(ctx, piece) {
if (!piece || piece.kind !== 'chess') return [];
const fresh = Number.isInteger(piece.uid) && piece.uid > 0 ? ctx.piece(piece.uid) : null;
const p = fresh && fresh.kind === 'chess' ? fresh : piece;
const rec = ctx.chessRecord(p.id);
return withGrants(ctx, p, rec && Array.isArray(rec.bonds) ? rec.bonds.slice() : []);
}
function withGrants(ctx, piece, bonds) {
const items = Array.isArray(piece.items) ? piece.items : [];
if (items.length < 2) return bonds;
const recs = items.map((it) => ctx.gd.item(it.id)).filter(Boolean);
if (!recs.some((r) => r.canGiveBond)) return bonds;
const out = bonds.slice();
for (const r of recs) if (!r.canGiveBond && typeof r.giveBondId === 'string' && ctx.gd.bond(r.giveBondId) && !out.includes(r.giveBondId)) out.push(r.giveBondId);
return out;
}
/** Distinct tiers of board chess matching `pred(pieceView)`. */
export function distinctTiers(ctx, pred = () => true) {
const tiers = new Set();
for (const p of ctx.board()) if (p && p.kind === 'chess' && pred(p)) tiers.add(p.tier ?? ctx.gd.tierOf(p.id));
return tiers;
}
/** Board piece at (row, col) or null. */
export function boardPieceAt(ctx, row, col) {
for (const p of ctx.board()) if (p.row === row && p.col === col) return p;
return null;
}
/** Board piece `k` tiles in front of a board piece, along its direction (`p.dir`, default RIGHT: col + k). */
export const frontPiece = (ctx, p, k = 1) => {
if (!p || !Number.isInteger(p.row) || !Number.isInteger(p.col)) return null;
const [r, c] = frontOf(p.row, p.col, p.dir, k);
return boardPieceAt(ctx, r, c);
};
export const behindPiece = (ctx, p, k = 1) => frontPiece(ctx, p, -k);
File diff suppressed because it is too large. Load diff
+354
View File
@@ -0,0 +1,354 @@
// server/sim/damage.js — damage & heal pipeline, shields, dodge, element gauges (DESIGN §5.5).
//
// dealDamage order: (element → gauge path) | invulnerable? → 'hit' hook (mutable DamageInfo, may set cancel)
// → dodge (phys/arts, canDodge) → mitigation (phys: DEF, arts: RES, true: none)
// → × source dmgDealtMul (× phys/artsDealtMul) × target dmgTakenMul × type-taken mul × dmg.mul
// → shields (hit-negating barriers first, then HP shields) → HP loss (boss pool routing) → 'damaged' hook
// → SP-on-hurt / TAKE_DAMAGE trigger → fatal/kill.
// Phys: max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 5 %·A); Arts: max(A×(1 − R′/100), 5 %·A) with
// R′ = max(0, R×(1−resIgnorePct) − resIgnoreFlat); True: A.
// Element damage ('element' type + element) fills a gauge instead of HP (1000; enemy leaders 2000). A full gauge
// bursts with the official term-table effects (constants.js ELEMENT), which differ by the side hit:
// operators (enemy damage): burn 1200 arts + RES −20 10 s · neural 1000 true + stun 10 s · apoptosis 15 s: no skill
// activation, −1 SP/s, 100 arts/s · erosion 800 phys + permanent DEF −100.
// enemies (operator damage, "·我方"): burn 7000 元素伤害 + RES −20 (10 s) · neural 6000 元素伤害 + 3 麻痹 (10 s) ·
// apoptosis 15 s: 50 % weaken recovering over the burst + 800 元素伤害/s · erosion 5000 元素伤害 + permanent DEF −120 (8 s).
// necrosis (legacy spare gauge): 12 s of 100 true dmg/s and ATK −20 %.
// The gauge stays locked (full) for the burst duration ("冷却") and resets to 0 when it ends. A burst that is still
// resolving (its `elementBurst` hook runs before the `<el>Burst` lock exists; erosion on operators has no lock at all)
// already counts as locked (`unit.burstPending[el]`): same-element fills of that unit are refused until it has resolved,
// so a hook that spreads the element back (淤困 parasite hosts next to each other) cannot re-burst it recursively.
// 元素伤害 (HP damage of an element) is the DamageInfo type 'elemental' (+ optional `element` for display): no DEF/RES,
// no dodge, × source dmgDealtMul × target dmgTakenMul × elemTakenMul. Sleeping units (沉睡: 无敌) take no damage
// unless the attacker's profile has `hitSleep` or the damage carries `ignoreSleep`.
import { MIN_DAMAGE_RATIO, ELEMENT, PALSY_MAX } from './constants.js';
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
/** Normalise a DamageInfo descriptor. */
export function makeDamageInfo(d = {}) {
const type = d.type ?? 'phys';
return {
_norm: true,
amount: Number.isFinite(+d.amount) ? +d.amount : 0,
type,
element: d.element ?? null,
atkScale: d.atkScale ?? 1,
defIgnoreFlat: d.defIgnoreFlat ?? 0,
defIgnorePct: d.defIgnorePct ?? 0,
resIgnoreFlat: d.resIgnoreFlat ?? 0,
resIgnorePct: d.resIgnorePct ?? 0,
mul: d.mul ?? 1,
canDodge: d.canDodge ?? (type === 'phys' || type === 'arts'),
isSkill: !!d.isSkill,
isSplash: !!d.isSplash,
isAttack: !!d.isAttack,
isProjectile: !!d.isProjectile,
tags: d.tags ?? [],
cancel: false,
noSp: !!d.noSp,
ignoreSleep: !!d.ignoreSleep,
attackId: d.attackId ?? 0,
};
}
/** 沉睡 (ba.sleep "无敌且无法行动"): only attackers whose profile has `hitSleep` (or `ignoreSleep` damage) reach a sleeper. */
function sleepBlocks(target, source, dmg) {
return !!target.s.flags.sleep && !dmg.ignoreSleep && !(source && source.profile && source.profile.hitSleep);
}
/** Pure mitigation formula (exported for tests). */
export function mitigate(amount, type, target, ign = {}) {
if (type === 'phys') {
const D = target.def ?? 0;
const eff = Math.max(0, D * (1 - clamp01(ign.defIgnorePct ?? 0)) - (ign.defIgnoreFlat ?? 0));
return Math.max(amount - eff, MIN_DAMAGE_RATIO * amount);
}
if (type === 'arts') {
const R = target.res ?? 0;
const eff = Math.max(0, R * (1 - clamp01(ign.resIgnorePct ?? 0)) - (ign.resIgnoreFlat ?? 0));
return Math.max(amount * (1 - Math.min(100, eff) / 100), MIN_DAMAGE_RATIO * amount);
}
return amount;
}
/** Absorb damage with shields on `target`. Returns the remaining amount. */
export function absorbShields(battle, target, amount) {
if (amount <= 0) return 0;
let changed = false;
let rest = amount;
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
const b = target.buffs[i];
if (b.shieldHits > 0) {
b.shieldHits--;
rest = 0;
if (b.shieldHits <= 0 && !(b.shield > 0) && !b.mods && !b.flags) { battle._removeBuffAt(target, i); i--; }
changed = true;
break;
}
}
for (let i = 0; i < target.buffs.length && rest > 0; i++) {
const b = target.buffs[i];
if (b.shield > 0) {
const take = Math.min(b.shield, rest);
b.shield -= take;
rest -= take;
changed = true;
if (b.shield <= 1e-9) {
b.shield = 0;
if (!b.mods && !b.flags && !(b.shieldHits > 0)) { battle._removeBuffAt(target, i); i--; }
}
}
}
if (changed) target.markDirty();
return rest;
}
/**
* Full damage pipeline. Returns the HP actually removed (0 when dodged/cancelled/absorbed).
*/
export function dealDamage(battle, source, target, dmgIn) {
if (!target || !target.alive || target.removed || target.hidden || !target.deployed) return 0;
const dmg = dmgIn && dmgIn._norm ? dmgIn : makeDamageInfo(dmgIn);
if (dmg.type === 'element') return applyElement(battle, source, target, dmg);
let ts = target.s;
if (ts.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
if (battle._hooks.hit) {
battle.emit('hit', { source, target, dmg });
if (dmg.cancel || !target.alive || !target.deployed) return 0;
if (dmg.type === 'element') return applyElement(battle, source, target, dmg);
ts = target.s; // handlers may have changed the target's buffs (fragile, invulnerable, dodge…): never use stale stats
if (ts.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
}
const type = dmg.type;
// dodge
if (dmg.canDodge && (type === 'phys' || type === 'arts')) {
const p = type === 'phys' ? ts.dodgePhys : ts.dodgeArts;
if (p > 0 && battle.rng() < p) {
battle.fx('dodge', { x: target.x, y: target.y, id: target.id });
if (battle._hooks.dodge) battle.emit('dodge', { source, target, dmg });
return 0;
}
}
// 频次 units (flags hitCount / hitCountArts): every damage instance removes exactly 1 HP (maxHp = hits needed);
// hitCountArts ignores physical instances. The instance keeps its own DamageInfo, so `damaged` handlers still
// recognise their own (tagged) damage — never re-create such a loss with a fresh loseHp.
if (ts.flags.hitCount || ts.flags.hitCountArts) {
const counts = !(ts.flags.hitCountArts && !ts.flags.hitCount && type === 'phys');
return applyHpLoss(battle, source, target, absorbShields(battle, target, counts ? 1 : 0), dmg);
}
const ss = source && source.s ? source.s : null;
let final = mitigate(dmg.amount, type, ts, {
defIgnorePct: dmg.defIgnorePct + (ss ? ss.defIgnorePct : 0),
defIgnoreFlat: dmg.defIgnoreFlat + (ss ? ss.defIgnoreFlat : 0),
resIgnorePct: dmg.resIgnorePct + (ss ? ss.resIgnorePct : 0),
resIgnoreFlat: dmg.resIgnoreFlat + (ss ? ss.resIgnoreFlat : 0),
});
let mul = dmg.mul * ts.dmgTakenMul;
if (ss) mul *= ss.dmgDealtMul * (type === 'phys' ? ss.physDealtMul : type === 'arts' ? ss.artsDealtMul : 1);
mul *= type === 'phys' ? ts.physTakenMul : type === 'arts' ? ts.artsTakenMul : type === 'elemental' ? ts.elemTakenMul : ts.trueTakenMul;
final *= mul;
if (!(final > 0) || !Number.isFinite(final)) final = 0;
final = absorbShields(battle, target, final);
return applyHpLoss(battle, source, target, final, dmg);
}
/**
* Remove HP (after mitigation) and run damaged/fatal/kill bookkeeping. Also used by `battle.loseHp` (流失).
*/
export function applyHpLoss(battle, source, target, amount, dmg) {
if (!target.alive) return 0;
let dealt = 0;
if (target.bossPool) {
const pool = target.bossPool;
const before = pool.hp;
const pid = source && source.side === 'ally' ? source.ownerId : null;
if (amount > 0) {
try { pool.damage(pid, amount); } catch (e) { battle._internalError('bossPool.damage', e); pool.hp = Math.max(0, pool.hp - amount); }
}
dealt = Math.max(0, before - pool.hp);
if (!Number.isFinite(dealt)) dealt = 0; // a misbehaving pool (NaN hp) must not poison damage stats
if (pid != null) { const pp = battle._pp(pid); if (pp) pp.bossDamage += dealt; }
battle._syncBossHp(target);
} else {
const before = target.hp;
target.hp -= amount;
if (target.hp <= 0) {
target.hp = 0;
if (battle._hooks.fatal) {
const fctx = { unit: target, source, dmg, amount, prevented: false };
battle.emit('fatal', fctx);
if (fctx.prevented && target.alive) { if (target.hp < 1) target.hp = Math.min(1, target.s.maxHp); }
}
}
dealt = Math.max(0, before - Math.max(0, target.hp));
}
if (source) {
source.stats.dmg += dealt;
if (source.side === 'ally' && source.ownerId != null) { const pp = battle._pp(source.ownerId); if (pp) pp.damageDealt += dealt; }
}
target.stats.taken += dealt;
target.lastHitAt = battle.time;
if (dmg && !dmg.silent && amount >= 0.5) {
const shown = dmg.type === 'element' ? dmg.element : dmg.type === 'elemental' ? (dmg.element || 'true') : dmg.type;
battle._ev(['dmg', target.id, Math.round(amount), shown]);
}
if (battle._hooks.damaged) battle.emit('damaged', { source, target, amount, type: dmg ? dmg.type : 'true', dmg });
if (target.side === 'ally' && target.skill && dmg && !dmg.noSp && dmg.type !== 'element') battle._skills.onDamaged(target);
const dead = target.bossPool ? target.bossPool.hp <= 0 : target.hp <= 0;
if (dead && target.alive) battle.kill(target, source);
return dealt;
}
/** Is `target`'s `el` gauge locked: its `<el>Burst` buff is up, or its burst is resolving right now (see header). */
export function burstLocked(target, el) {
return !!(target.burstPending && target.burstPending[el]) || !!target.findBuff(el + 'Burst');
}
/** Element gauge accumulation + burst. Fires `elementHit` { source, target, dmg } first (mutable amount/mul, cancel). */
export function applyElement(battle, source, target, dmg) {
const el = dmg.element;
if (!el || !(el in target.elem)) return 0;
if (target.s.flags.invulnerable || sleepBlocks(target, source, dmg)) return 0;
if (burstLocked(target, el)) return 0;
if (battle._hooks.elementHit) {
battle.emit('elementHit', { source, target, dmg });
if (dmg.cancel || !target.alive || !target.deployed || burstLocked(target, el)) return 0;
if (dmg.type !== 'element') return dealDamage(battle, source, target, dmg); // a handler converted it
}
const max = target.gaugeMax;
let amt = dmg.amount * dmg.mul * target.s.elemTakenMul * (1 - clamp01(target.def?.epDamageResistance ?? 0));
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
target.elem[el] = Math.min(max, target.elem[el] + amt);
battle._ev(['dmg', target.id, Math.round(amt), el]);
if (source) source.stats.elem = (source.stats.elem ?? 0) + amt;
if (battle._hooks.damaged) battle.emit('damaged', { source, target, amount: amt, type: 'element', dmg });
if (target.elem[el] >= max && target.alive) elementBurst(battle, source, target, el);
return amt;
}
/**
* A burst (see header): side-aware official effects; the `<el>Burst` buff locks the gauge for its duration. The unit
* is marked `burstPending[el]` while the burst resolves (hook, lock, hits), so nothing re-bursts it in the meantime.
*/
export function elementBurst(battle, source, target, el) {
if (!ELEMENT[el] || !target.alive || burstLocked(target, el)) return;
const pending = target.burstPending || (target.burstPending = {});
pending[el] = true;
try { resolveBurst(battle, source, target, el); } finally { pending[el] = false; }
}
function resolveBurst(battle, source, target, el) {
const cfg = ELEMENT[el];
target.elem[el] = target.gaugeMax;
const reset = () => { target.elem[el] = 0; };
battle.fx('burst', { x: target.x, y: target.y, id: target.id, element: el });
if (battle._hooks.elementBurst) battle.emit('elementBurst', { source, target, element: el });
if (!target.alive) { reset(); return; }
const tags = ['burst', el];
const hit = (amount, type) => battle.dealDamage(source, target, { amount, type, element: el, canDodge: false, tags });
const lock = (duration, extra = {}) => {
if (!(duration > 0)) { reset(); return null; }
return battle.addBuff(target, { key: `${el}Burst`, duration, visible: true, onExpire: reset, onRemove: reset, ...extra, flags: { burstLock: true, ...(extra.flags || {}) } });
};
if (el === 'necrosis') {
lock(cfg.duration, {
mods: { atkMul: 1 - cfg.atkDownPct }, interval: 1,
onTick: () => battle.dealDamage(source, target, { amount: cfg.dps, type: 'true', canDodge: false, tags: ['burst', 'necrosis'] }),
});
return;
}
const enemy = target.side === 'enemy';
const c = enemy ? cfg.enemy : cfg.ally;
if (el === 'burn') {
lock(c.duration, { mods: { resFlat: -c.resDown } });
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
} else if (el === 'neural') {
lock(c.duration);
if (enemy) {
hit(c.elemDamage, 'elemental');
if (target.alive) battle.applyStatus(target, 'palsy', { value: c.palsy, source });
} else {
hit(c.damage, c.type);
if (target.alive) battle.applyStatus(target, 'stun', { duration: c.stun, source, force: true });
}
} else if (el === 'apoptosis') {
if (enemy) {
// 50 % 虚弱 that recovers linearly over the burst, 800 元素伤害 per second
lock(c.duration, {
mods: { atkMul: 1 - c.weaken }, interval: 1,
onTick: ({ buff }) => {
buff.mods = { atkMul: 1 - c.weaken * Math.max(0, buff.timeLeft - 1) / c.duration };
target.markDirty();
hit(c.elemDps, 'elemental');
},
});
} else {
// 15 s: no skill activation, −1 SP per second, 100 arts damage per second
lock(c.duration, {
flags: { silence: true }, interval: 1,
onTick: () => {
const sk = target.skill;
if (sk && !sk.noSkill && sk.kind !== 'passive' && !(sk.active && sk.isTimed) && sk.sp > 0) sk.sp = Math.max(0, sk.sp - c.spLossPerSec);
hit(c.dps, c.dpsType);
},
});
}
} else if (el === 'erosion') {
// the permanent DEF cut lands first, then the hit
lock(c.duration);
battle.addBuff(target, { key: 'erosionDown', refresh: 'stack', stacks: 1, maxStacks: 1e6, mods: { defFlat: -c.defDown }, visible: true });
if (enemy) hit(c.elemDamage, 'elemental'); else hit(c.damage, c.type);
}
}
/** Add `n` 麻痹 stacks (at most PALSY_MAX). */
export function palsyBuff(n) {
const stacks = Math.max(1, Math.min(PALSY_MAX, Math.floor(Number.isFinite(n) ? n : 1)));
return { key: 'palsy', refresh: 'stack', stacks, maxStacks: PALSY_MAX, visible: true, status: 'palsy' };
}
/** Reduce an element gauge (e.g. wandermedic "回复元素损伤"). Returns the amount removed. */
export function reduceElement(target, amount, el = null) {
let removed = 0;
const els = el ? [el] : Object.keys(target.elem);
for (const k of els) {
if (burstLocked(target, k)) continue;
const take = Math.min(target.elem[k], amount - removed);
if (take > 0) { target.elem[k] -= take; removed += take; }
if (removed >= amount) break;
}
return removed;
}
/** Heal pipeline. Returns the HP actually restored. */
export function heal(battle, source, target, amount, opts = {}) {
if (!target || !target.alive || target.removed || !target.deployed || target.bossPool) return 0;
const self = source === target || !!opts.self;
if (!self && (target.s.flags.noHeal || (target.profile && target.profile.noHeal))) return 0;
let amt = amount * (source && source.s ? source.s.healingDealtMul : 1) * target.s.healingTakenMul;
if (!(amt > 0) || !Number.isFinite(amt)) return 0;
if (battle._hooks.heal) {
const ctx = { source, target, amount: amt, opts };
battle.emit('heal', ctx);
amt = Number.isFinite(ctx.amount) ? Math.max(0, ctx.amount) : 0;
// a handler may have killed / retreated the target: healing a dead unit would leave it "dead with hp > 0"
if (!target.alive || !target.deployed) return 0;
}
const max = target.s.maxHp;
const actual = Math.max(0, Math.min(amt, max - target.hp));
target.hp = Math.min(max, target.hp + actual);
if (opts.overheal && amt > actual) {
// overheal becomes a shield (capped at max HP) so hp stays within [0, maxHp]
const cur = target.findBuff('overheal');
const val = Math.min(max, (cur ? cur.shield : 0) + (amt - actual));
battle.addBuff(target, { key: 'overheal', shield: val, duration: opts.overhealDuration ?? Infinity });
}
if (source) {
source.stats.heal += actual;
if (source.side === 'ally' && source.ownerId != null) { const pp = battle._pp(source.ownerId); if (pp) pp.healingDone += actual; }
}
if (actual >= 0.5 && !opts.silent) battle._ev(['heal', target.id, Math.round(actual)]);
return actual;
}
+92
View File
@@ -0,0 +1,92 @@
// server/sim/dir.js — the 4 deploy directions (DESIGN §3 "Facing (corrected)", research 09 §1.2). Pure ESM, shared by
// the sim, the match (server/match) and browsers (/sim/dir.js).
//
// Every board piece / ally unit has `dir ∈ UP | RIGHT | DOWN | LEFT` (default RIGHT, toward the gates). Row 0 is the
// BOTTOM row, so UP = +row. Forward vectors [dRow, dCol]: UP (1,0), RIGHT (0,1), DOWN (−1,0), LEFT (0,−1).
// Range grids / relative offsets `[dRow, dCol]` are authored facing RIGHT and rotated:
// RIGHT (dr, dc) · UP (dc, −dr) · LEFT (−dr, −dc) · DOWN (−dc, dr)
// (a quarter turn per step: the "up" side of a right-facing unit — its left hand — becomes the left side when it faces
// UP). `rangeExtend` is applied along +dCol BEFORE rotating (targeting.absoluteRangeKeys). The right-hand Final
// Assault player is mirrored: col c → 20 − c with RIGHT ↔ LEFT (UP / DOWN unchanged) — `mirrorDir`.
// The legacy scalar facing (+1 / −1) survives only as the derived horizontal sign `hSign(dir)` (sprite flip).
export const DIRS = Object.freeze(['UP', 'RIGHT', 'DOWN', 'LEFT']);
export const DEFAULT_DIR = 'RIGHT';
/** Forward vector [dRow, dCol] per direction. */
export const DIR_VEC = Object.freeze({
UP: Object.freeze([1, 0]), RIGHT: Object.freeze([0, 1]), DOWN: Object.freeze([-1, 0]), LEFT: Object.freeze([0, -1]),
});
const OPPOSITE = Object.freeze({ UP: 'DOWN', DOWN: 'UP', RIGHT: 'LEFT', LEFT: 'RIGHT' });
const MIRROR = Object.freeze({ UP: 'UP', DOWN: 'DOWN', RIGHT: 'LEFT', LEFT: 'RIGHT' });
export const isDir = (d) => d === 'UP' || d === 'RIGHT' || d === 'DOWN' || d === 'LEFT';
/**
* A direction from anything a caller may hold: a direction name (any case), a legacy facing sign (−1 ⇒ LEFT, +1 ⇒
* RIGHT) or junk (⇒ `fallback`).
*/
export function normDir(d, fallback = DEFAULT_DIR) {
if (isDir(d)) return d;
if (typeof d === 'string') { const u = d.trim().toUpperCase(); if (isDir(u)) return u; }
if (typeof d === 'number' && Number.isFinite(d) && d !== 0) return d < 0 ? 'LEFT' : 'RIGHT';
return isDir(fallback) ? fallback : DEFAULT_DIR;
}
/** Forward vector [dRow, dCol] of a direction (junk ⇒ RIGHT). */
export const dirVec = (d) => DIR_VEC[normDir(d)];
/** Rotate a facing-RIGHT offset [dr, dc] into absolute [dRow, dCol] for direction `d`. */
export function rotateOffset(dr, dc, d) {
// `0 - x` (not `-x`): never a negative zero
switch (normDir(d)) {
case 'UP': return [dc, 0 - dr];
case 'LEFT': return [0 - dr, 0 - dc];
case 'DOWN': return [0 - dc, dr];
default: return [dr, dc];
}
}
/** Inverse of rotateOffset: an absolute delta [dRow, dCol] in the facing-RIGHT frame of direction `d`. */
export function toLocal(ar, ac, d) {
switch (normDir(d)) {
case 'UP': return [0 - ac, ar];
case 'LEFT': return [0 - ar, 0 - ac];
case 'DOWN': return [ac, 0 - ar];
default: return [ar, ac];
}
}
/** Horizontal sign for sprite flipping (LEFT ⇒ −1, everything else +1). */
export const hSign = (d) => (normDir(d) === 'LEFT' ? -1 : 1);
/** RIGHT ↔ LEFT (the Final Assault right-side mirror); UP / DOWN unchanged. */
export const mirrorDir = (d) => MIRROR[normDir(d)];
export const oppositeDir = (d) => OPPOSITE[normDir(d)];
/** Are two directions perpendicular? */
export const perpendicular = (a, b) => { const [ar, ac] = dirVec(a), [br, bc] = dirVec(b); return ar * br + ac * bc === 0; };
/** Direction of a (row, col) delta: the dominant axis (ties → horizontal); a zero delta ⇒ `fallback`. */
export function dirFromDelta(dRow, dCol, fallback = DEFAULT_DIR) {
const r = Number(dRow) || 0, c = Number(dCol) || 0;
if (r === 0 && c === 0) return normDir(fallback);
if (Math.abs(c) >= Math.abs(r)) return c > 0 ? 'RIGHT' : 'LEFT';
return r > 0 ? 'UP' : 'DOWN';
}
/** Tile [r, c] reached from (r, c) by the facing-RIGHT offset [dr, dc] of a unit facing `d`. */
export function offsetTile(r, c, dr, dc, d) {
const [a, b] = rotateOffset(dr, dc, d);
return [r + a, c + b];
}
/** Tile `k` steps in front of (k < 0: behind) (r, c) for direction `d`. */
export function frontOf(r, c, d, k = 1) {
const [fr, fc] = dirVec(d);
return [r + fr * k, c + fc * k];
}
/**
* Tie-break order of a tile relative to a unit, in the unit's facing-RIGHT frame: [localRow, localCol] = toLocal of the
* absolute delta. Comparing it lexicographically (localBefore) instead of the absolute tile key keeps "first tile"
* choices turning with the unit; for a RIGHT-facing unit it is exactly the tile-key order (row, then col).
*/
export const localOrder = (dRow, dCol, d) => toLocal(dRow, dCol, d);
/** Lexicographic `a` < `b` of two localOrder pairs (`b` null ⇒ true). */
export const localBefore = (a, b) => !b || a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]);
+429
View File
@@ -0,0 +1,429 @@
// server/sim/grid.js — field grid, tile queries, passability and the official ground pathfinding (DESIGN §3,
// research 08 §3).
//
// Coordinates: row 0 = bottom, col 0 = left. Tile (r,c) has world centre (x=c, y=r).
// Stage input: `{ id, rows: string[19] (bottom-first), legend?: {glyph: {...}}, devices?: [...] }`.
// Legend entries are normalised from either the research format (heightType/buildableType/passableMask/tileKey)
// or the build-data format (height/build/passable/terrain); missing glyphs fall back to DEFAULT_LEGEND.
//
// Pathfinding = the official client's (`Torappu.Battle.SPFA`, research 08 §3.1/§3.4): one FLOW FIELD per destination,
// a FIFO SPFA from the destination over the rect with the 4 neighbours UP (row+1), RIGHT, DOWN, LEFT (in that order,
// no diagonal edges), edge cost = the neighbour's move cost (1, or OBSTACLE_COST 1000 for an "obstacle-like" tile: a
// crate), strict-improvement relaxation (a tile keeps the first parent that reached it); then the parents are
// smoothed row-major in place — each tile's parent jumps to the farthest ancestor in Bresenham line of sight (every
// tile on the line walkable and not a crate; a diagonal step needs both orthogonal neighbours clear). A ground enemy
// walks straight toward `next[tile]` of its current tile (ai.js). Fields are cached per (destination, grid version).
//
// Blockable-ground preference (user playtest: "the lower-gate enemies of 战场#01 walk up the col-9 floor lane where no
// operator can block them"). Two refinements on top of the official algorithm; `dist` (so the crate cost 1000 and
// every grid route length) stays exactly the official one:
// * tie-break: the SPFA relaxes on (dist, pen) lexicographically, `pen` = number of NON-BLOCKABLE walkable tiles
// (floor / gate / goal / teleport tiles — `blockable()` false: not LOW ground buildable for melee) on the chain.
// Among equal-length chains the one with the fewest non-blockable tiles wins; remaining ties go to the first
// parent in SPFA order (the official order unless a pen improvement re-queued a tile).
// * smoothing: a line of sight may only cross a non-blockable tile (including the corner tiles of a diagonal step)
// that lies on the tile's own raw chain between its two ends — a smoothed segment never cuts across floor the grid
// route does not walk, so an enemy never slips past an operator standing on the road next to the floor lane.
// This one also bends routes whose raw chain is the official one (a diagonal that would clip off-chain floor
// becomes an L, mostly inside the boss arena's central floor): the smoothed polyline can be up to 2 tiles
// longer than the official one (test/sim/pathing-blockable.test.js bounds it).
// Unavoidable non-blockable tiles (gates, goals, teleports and single-exit floor, e.g. (12,9) next to the upper
// gate) stay on the route; test/sim/pathing-blockable.test.js lists them per stage, gate and field.
//
// Obstacle layer (bit flags per tile): BLOCK (射击台 / mounds / `setObstacle(r, c, on)` — impassable [ASSUMED for
// platforms, research 08 §8 #1]) and CRATE (阻隔工事 devices — `setObstacle(r, c, on, 'crate')`: walkable at cost 1000,
// blocks line of sight; an enemy that must cross one is blocked by the device and breaks it). Any change bumps
// `version`, which invalidates the cached fields (enemies re-read their next step). `groundPassable` (placement,
// displacement, content) still treats both kinds as occupied unless `ignoreObstacles`.
import { ROWS, COLS } from './constants.js';
/** Move cost of an obstacle-like tile (client `Tile.get_moveCost`: obstacle-like ? 1000 : 1). */
export const OBSTACLE_COST = 1000;
/** Obstacle-layer bits. */
export const OB_BLOCK = 1;
export const OB_CRATE = 2;
/** Official neighbour order `GridPosition.GRID_FOUR_WAYS`: UP (row+1), RIGHT, DOWN, LEFT — do not reorder. */
const FOUR_WAYS = [[1, 0], [0, 1], [-1, 0], [0, -1]];
/** Cached flow fields per grid version (destinations: route ends, checkpoints, attract points). */
const FIELD_CACHE_MAX = 64;
/** Built-in legend (research 05 §2.3) used when a stage has no legend entry for a glyph. */
export const DEFAULT_LEGEND = Object.freeze({
'#': { height: 'HIGH', build: 'NONE', pass: 'FLY', key: 'tile_forbidden' },
X: { height: 'HIGH', build: 'NONE', pass: 'NONE', key: 'tile_forbidden' },
r: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_road' },
R: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_road' },
f: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_floor' },
p: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_floor' },
h: { height: 'HIGH', build: 'RANGED', pass: 'FLY', key: 'tile_wall' },
b: { height: 'LOW', build: 'ALL', pass: 'FLY', key: 'tile_fence_bound' },
a: { height: 'HIGH', build: 'NONE', pass: 'FLY', key: 'tile_achand' },
A: { height: 'HIGH', build: 'NONE', pass: 'FLY', key: 'tile_achand' },
S: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_start', special: 'start' },
E: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_end', special: 'end' },
I: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_telin', special: 'telin' },
O: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_telout', special: 'telout' },
m: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_mire', terrain: 'mire' },
g: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_smog', terrain: 'smog' },
d: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_deepsea', terrain: 'deepsea' },
i: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_infection', terrain: 'infection' },
});
const TERRAIN_BY_KEY = { tile_mire: 'mire', tile_smog: 'smog', tile_deepsea: 'deepsea', tile_infection: 'infection', tile_deepwater: 'deepsea' };
const SPECIAL_BY_KEY = { tile_start: 'start', tile_end: 'end', tile_telin: 'telin', tile_telout: 'telout' };
function normPass(v) {
if (v === true) return 'ALL';
if (v === false) return 'NONE';
const s = String(v ?? '').toUpperCase();
if (s === 'ALL' || s === 'GROUND' || s === 'WALK') return 'ALL';
if (s.includes('FLY')) return 'FLY';
if (s === 'NONE' || s === '') return 'NONE';
return 'ALL';
}
/** Normalise one legend entry (any known format) into `{ height, build, pass, key, terrain, special }`. */
export function normalizeLegendEntry(glyph, e) {
const base = DEFAULT_LEGEND[glyph] || { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_floor' };
if (!e || typeof e !== 'object') return { ...base, glyph };
const key = e.tileKey ?? e.key ?? e.tile ?? base.key;
const heightRaw = e.height ?? e.heightType;
const height = heightRaw == null ? base.height : (/HIGH/i.test(String(heightRaw)) ? 'HIGH' : 'LOW');
const buildRaw = e.build ?? e.buildable ?? e.buildableType;
const build = buildRaw == null ? base.build : (buildRaw === true ? 'ALL' : buildRaw === false ? 'NONE' : String(buildRaw).toUpperCase());
let pass;
if (e.pass != null) pass = normPass(e.pass);
else if (e.passable != null || e.passableMask != null) pass = normPass(e.passable ?? e.passableMask);
else if (e.groundPassable != null) pass = e.groundPassable ? 'ALL' : (e.flyPassable === false ? 'NONE' : 'FLY');
else pass = base.pass;
const terrain = e.terrain ?? TERRAIN_BY_KEY[key] ?? base.terrain ?? null;
const special = e.special ?? SPECIAL_BY_KEY[key] ?? base.special ?? null;
return { glyph, key, height, build, pass, terrain, special };
}
const EMPTY_TILE = Object.freeze({ glyph: 'X', key: 'tile_forbidden', height: 'HIGH', build: 'NONE', pass: 'NONE', terrain: null, special: null });
/** A melee operator may stand (and block) on this terrain: LOW ground buildable for ALL or MELEE. */
function isBlockableTile(t) { return t.height === 'LOW' && (t.build === 'ALL' || t.build === 'MELEE'); }
export class Grid {
/**
* @param {object} stage stage entry (rows bottom-first)
* @param {{r0:number,r1:number,c0:number,c1:number}} rect inclusive simulation bounds
*/
constructor(stage, rect) {
this.stage = stage || {};
this.rect = rect;
this.rows = ROWS;
this.cols = COLS;
const rows = Array.isArray(this.stage.rows) ? this.stage.rows : [];
const legend = this.stage.legend || {};
const cache = new Map();
const info = (g) => {
if (!cache.has(g)) cache.set(g, Object.freeze(normalizeLegendEntry(g, legend[g])));
return cache.get(g);
};
/** @type {object[]} */
this.tiles = new Array(ROWS * COLS);
for (let r = 0; r < ROWS; r++) {
const line = typeof rows[r] === 'string' ? rows[r] : (Array.isArray(rows[r]) ? rows[r].join('') : '');
for (let c = 0; c < COLS; c++) {
const g = line[c];
this.tiles[r * COLS + c] = g ? info(g) : EMPTY_TILE;
}
}
/** 1 = walkable terrain an operator cannot block on (floor, gate, goal, teleport…): the path tie-break penalty */
this.unblockable = new Uint8Array(ROWS * COLS);
for (let k = 0; k < ROWS * COLS; k++) {
const t = this.tiles[k];
this.unblockable[k] = t.pass === 'ALL' && !isBlockableTile(t) ? 1 : 0;
}
/** obstacle bits per tile (OB_BLOCK | OB_CRATE) */
this.obstacle = new Uint8Array(ROWS * COLS);
this.version = 0;
/** @type {Map<string, FlowField>} */
this._fields = new Map();
}
key(r, c) { return r * COLS + c; }
inBounds(r, c) { return r >= 0 && r < ROWS && c >= 0 && c < COLS; }
inRect(r, c) { const R = this.rect; return r >= R.r0 && r <= R.r1 && c >= R.c0 && c <= R.c1; }
/** Tile info (frozen) or an impassable placeholder outside the stage. */
tile(r, c) { return this.inBounds(r, c) ? this.tiles[r * COLS + c] : EMPTY_TILE; }
/** Ground units may stand / be placed here (inside the rect, terrain passable, no obstacle of any kind). */
groundPassable(r, c, ignoreObstacles = false) {
if (!this.inRect(r, c)) return false;
const t = this.tiles[r * COLS + c];
if (t.pass !== 'ALL') return false;
return ignoreObstacles || !this.obstacle[r * COLS + c];
}
/** Walkable for the flow field: in the rect, terrain passable for WALK, not hard-blocked (crates ARE walkable). */
walkable(r, c, ignoreObstacles = false) {
if (!this.inRect(r, c)) return false;
const k = r * COLS + c;
if (this.tiles[k].pass !== 'ALL') return false;
return ignoreObstacles || !(this.obstacle[k] & OB_BLOCK);
}
flyPassable(r, c) { return this.inRect(r, c) && this.tile(r, c).pass !== 'NONE'; }
/** Whether a unit may stand on this tile. `ranged` units may also use LOW ALL/MELEE tiles (DESIGN §3). */
canStand(r, c, { ranged = false } = {}) {
const t = this.tile(r, c);
if (t.build === 'NONE') return false;
if (ranged) return t.build === 'ALL' || t.build === 'RANGED' || (t.height === 'LOW' && t.build === 'MELEE');
return t.height === 'LOW' && (t.build === 'ALL' || t.build === 'MELEE');
}
isLow(r, c) { return this.tile(r, c).height === 'LOW'; }
/** Ground a melee operator can be deployed on and block from (LOW, buildable ALL / MELEE) — terrain only. */
blockable(r, c) { return isBlockableTile(this.tile(r, c)); }
/**
* Toggle an obstacle. `kind` 'block' (default: impassable — platforms, mounds, content) or 'crate' (obstacle-like:
* walkable at OBSTACLE_COST, blocks line of sight). Each kind is its own bit, so a crate turned into a platform on
* the same tile (map cards) stays consistent whatever the order of the two calls.
*/
setObstacle(r, c, on, kind = 'block') {
if (!this.inBounds(r, c)) return;
const k = r * COLS + c;
const bit = kind === 'crate' ? OB_CRATE : OB_BLOCK;
const v = on ? (this.obstacle[k] | bit) : (this.obstacle[k] & ~bit);
if (this.obstacle[k] === v) return;
this.obstacle[k] = v;
this.version++;
this._fields.clear();
}
/** Any obstacle (crate or block) on the tile. */
isObstacle(r, c) { return this.inBounds(r, c) && this.obstacle[r * COLS + c] !== 0; }
/** An obstacle-like (cost-1000) crate on the tile. */
isCrate(r, c) { return this.inBounds(r, c) && (this.obstacle[r * COLS + c] & OB_CRATE) !== 0; }
/** A hard block (platform / mound / content) on the tile. */
isBlocked(r, c) { return this.inBounds(r, c) && (this.obstacle[r * COLS + c] & OB_BLOCK) !== 0; }
/** List tiles of a given special kind ('start'|'end'|'telin'|'telout') inside the rect. */
specialTiles(kind) {
const out = [];
const R = this.rect;
for (let r = R.r0; r <= R.r1; r++) for (let c = R.c0; c <= R.c1; c++) if (this.tile(r, c).special === kind) out.push([r, c]);
return out;
}
// ---------------------------------------------------------------------------------------------------------------
// official flow field (research 08 §3.4)
/**
* Flow field toward (er, ec). `allowDiagonal` false ⇒ line of sight only along a row or a column (every autochess
* route allows diagonals). `ignoreObstacles` ⇒ blocks and crates are ignored (fallback when the goal is walled off).
* @returns {FlowField}
*/
flowField(er, ec, { allowDiagonal = true, ignoreObstacles = false } = {}) {
er |= 0; ec |= 0;
const ck = `${er},${ec},${allowDiagonal ? 1 : 0},${ignoreObstacles ? 1 : 0}`;
const hit = this._fields.get(ck);
if (hit) return hit;
const f = this._buildField(er, ec, allowDiagonal, ignoreObstacles);
if (this._fields.size >= FIELD_CACHE_MAX) this._fields.delete(this._fields.keys().next().value);
this._fields.set(ck, f);
return f;
}
_buildField(er, ec, allowDiagonal, ignore) {
const N = ROWS * COLS;
const dist = new Int32Array(N).fill(-1);
const parent = new Int32Array(N).fill(-1);
/** non-blockable tiles on the raw chain (tile included, destination excluded) — the equal-length tie-break */
const pen = new Int32Array(N);
const f = { dest: -1, dist, parent, pen, next: parent, len: null, version: this.version, allowDiagonal, ignore };
if (!this.inBounds(er, ec)) return f;
const dest = er * COLS + ec;
f.dest = dest;
const inQ = new Uint8Array(N);
const unb = this.unblockable;
const crate = (k) => !ignore && (this.obstacle[k] & OB_CRATE) !== 0;
// SPFA (FIFO), seeded with the destination (walkable or not); relaxation on (dist, pen) lexicographically
const q = new Int32Array(N * 4 + 8);
let qh = 0, qt = 0;
const push = (k) => {
if (qt >= q.length) { q.copyWithin(0, qh, qt); qt -= qh; qh = 0; }
q[qt++] = k;
};
dist[dest] = 0;
push(dest);
inQ[dest] = 1;
while (qh < qt) {
const cur = q[qh++];
inQ[cur] = 0;
const r = (cur / COLS) | 0, c = cur - r * COLS;
for (let i = 0; i < 4; i++) {
const nr = r + FOUR_WAYS[i][0], nc = c + FOUR_WAYS[i][1];
if (!this.walkable(nr, nc, ignore)) continue;
const nb = nr * COLS + nc;
const nd = dist[cur] + (crate(nb) ? OBSTACLE_COST : 1);
const np = pen[cur] + unb[nb];
if (dist[nb] < 0 || nd < dist[nb] || (nd === dist[nb] && np < pen[nb])) {
dist[nb] = nd;
pen[nb] = np;
parent[nb] = cur;
if (!inQ[nb]) { push(nb); inQ[nb] = 1; }
}
}
}
// smoothing: row-major, in place (client `_PostprocessAndMakeNextMapSmoothly`); a line from n toward ancestor a
// may cross a non-blockable tile only when that tile is on n's raw chain between n and a (onChain / minDist)
const next = new Int32Array(parent);
const onChain = new Int32Array(N).fill(-1);
let from = -1, minDist = 0;
const clear = (r, c) => {
if (!this.walkable(r, c, ignore)) return false;
const k = r * COLS + c;
if (crate(k)) return false;
return !unb[k] || (onChain[k] === from && dist[k] >= minDist);
};
const los = allowDiagonal ? (a, b) => bresenhamClear(a, b, clear) : (a, b) => segmentClear(a, b, clear);
for (let n = 0; n < N; n++) {
if (dist[n] < 0 || next[n] < 0) continue;
for (let x = n, guard = N; x >= 0 && guard-- > 0; x = parent[x]) onChain[x] = n;
from = n;
let b = next[n];
while (next[b] >= 0) {
minDist = dist[next[b]];
if (!los(n, next[b])) break;
b = next[b];
}
next[n] = b;
}
f.next = next;
return f;
}
/** Geometric length (tiles) of the smoothed route from tile key k to the field's destination (memoised). */
fieldLength(f, k) {
if (!f || k < 0 || f.dist[k] < 0) return Infinity;
if (!f.len) f.len = new Float64Array(ROWS * COLS).fill(-1);
const stack = [];
let x = k;
while (x >= 0 && f.len[x] < 0) {
if (x === f.dest || f.next[x] < 0) { f.len[x] = 0; break; }
stack.push(x);
x = f.next[x];
if (stack.length > ROWS * COLS) return Infinity; // cannot happen (dist strictly decreases); defensive
}
for (let i = stack.length - 1; i >= 0; i--) {
const a = stack[i], b = f.next[a];
const ar = (a / COLS) | 0, ac = a - ar * COLS, br = (b / COLS) | 0, bc = b - br * COLS;
f.len[a] = f.len[b] + Math.hypot(br - ar, bc - ac);
}
return f.len[k];
}
/**
* Smoothed waypoints [[r,c]…] from (sr,sc) to (er,ec) along the flow field (start and goal included), or null when
* the start cannot reach the goal. With `ignoreObstacles` the field ignores blocks and crates.
*/
waypoints(sr, sc, er, ec, { ignoreObstacles = false, allowDiagonal = true } = {}) {
if (!this.inBounds(sr, sc) || !this.inBounds(er, ec)) return null;
const start = sr * COLS + sc;
const goal = er * COLS + ec;
if (start === goal) return [[sr, sc]];
const f = this.flowField(er, ec, { ignoreObstacles, allowDiagonal });
if (f.dist[start] < 0) return null;
const out = [[sr, sc]];
let k = start, guard = ROWS * COLS;
while (k !== goal && guard-- > 0) {
k = f.next[k];
if (k < 0) return null;
out.push([(k / COLS) | 0, k % COLS]);
}
return out;
}
/**
* Tiles crossed by the enemy route from (sr,sc) to (er,ec): the smoothed waypoints joined by their Bresenham lines
* (start and goal included; consecutive tiles are 8-neighbours). null when unreachable (unless ignoreObstacles).
*/
findPath(sr, sc, er, ec, { ignoreObstacles = false } = {}) {
const wp = this.waypoints(sr, sc, er, ec, { ignoreObstacles });
if (!wp) return null;
const out = [wp[0]];
for (let i = 1; i < wp.length; i++) {
const seg = bresenhamTiles(wp[i - 1], wp[i]);
for (let j = 1; j < seg.length; j++) out.push(seg[j]);
}
return out;
}
/** Length (tiles) of a polyline of [r,c] points. */
static pathLength(pts) {
let len = 0;
for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
return len;
}
}
/**
* @typedef {object} FlowField
* @property {number} dest destination tile key (-1 outside the grid)
* @property {Int32Array} dist SPFA distance to the destination (-1 = unreachable)
* @property {Int32Array} parent raw BFS parent
* @property {Int32Array} pen non-blockable tiles on the raw chain (tie-break among equal `dist`)
* @property {Int32Array} next smoothed parent (the tile to walk straight toward)
* @property {Float64Array|null} len memoised smoothed length (fieldLength)
*/
/** Bresenham line of sight between tile keys a → b (client `_RaycastBresenhamLine`). */
function bresenhamClear(a, b, clear) {
let r = (a / COLS) | 0, c = a - r * COLS;
const r1 = (b / COLS) | 0, c1 = b - r1 * COLS;
const dr = Math.abs(r1 - r), dc = Math.abs(c1 - c);
const sr = r1 > r ? 1 : -1, sc = c1 > c ? 1 : -1;
let err = dc - dr;
if (!clear(r, c)) return false;
while (r !== r1 || c !== c1) {
const e2 = 2 * err;
let nr = r, nc = c;
if (e2 > -dr) { err -= dr; nc += sc; }
if (e2 < dc) { err += dc; nr += sr; }
if (nr !== r && nc !== c && !(clear(nr, c) && clear(r, nc))) return false;
r = nr; c = nc;
if (!clear(r, c)) return false;
}
return true;
}
/** Row / column line of sight (client `_RaycastSegmentLine`, routes without diagonal moves). */
function segmentClear(a, b, clear) {
let r = (a / COLS) | 0, c = a - r * COLS;
const r1 = (b / COLS) | 0, c1 = b - r1 * COLS;
if (r !== r1 && c !== c1) return false;
const sr = Math.sign(r1 - r), sc = Math.sign(c1 - c);
if (!clear(r, c)) return false;
while (r !== r1 || c !== c1) {
r += sr; c += sc;
if (!clear(r, c)) return false;
}
return true;
}
/** Tiles of the Bresenham line [r0,c0] → [r1,c1] (same stepping as bresenhamClear). */
export function bresenhamTiles(a, b) {
let [r, c] = a;
const [r1, c1] = b;
const dr = Math.abs(r1 - r), dc = Math.abs(c1 - c);
const sr = r1 > r ? 1 : -1, sc = c1 > c ? 1 : -1;
let err = dc - dr;
const out = [[r, c]];
let guard = 256;
while ((r !== r1 || c !== c1) && guard-- > 0) {
const e2 = 2 * err;
if (e2 > -dr) { err -= dr; c += sc; }
if (e2 < dc) { err += dc; r += sr; }
out.push([r, c]);
}
return out;
}
+64
View File
@@ -0,0 +1,64 @@
// server/sim/nodeData.js — the Node-only default data loader of the simulation (DESIGN §14).
//
// server/sim/** is shared with browsers (served read-only at /sim/), so every Node API lives here. simdata.js imports
// this module dynamically — and only under Node — to build its default DataSource:
// * generated data: `server/data.js → getData()` (data/*.json, tools/build-data.mjs),
// * research fallback: docs/research/03-operators.json, 05-enemies.json, 05-maps.json (sim tests before/without data).
// Browsers never load this file: they inject the fetched /data/*.json with simdata.js setSimData(data).
//
// This module must not import simdata.js (simdata awaits this module at its top level; a cycle would deadlock).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/** Repository root. */
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
const QUIET = Object.freeze({ warn() {}, error() {}, info() {} });
/** data/*.json through the server/data.js singleton (null when unavailable). */
export async function loadGenerated() {
try {
const mod = await import('../data.js');
return mod.getData({ log: QUIET });
} catch {
return null;
}
}
/** Drop the server/data.js singleton, then load again (tests / hot reload after data regeneration). */
export async function reloadGenerated() {
try {
const mod = await import('../data.js');
mod.resetData?.();
} catch { /* ignore */ }
return loadGenerated();
}
/**
* Research JSON in the raw-map shape a DataSource accepts: `{ chess, enemies, tokens, stages, waves }` (tokens carry
* `byOwner[chessId]` variants; stages get the research legend).
*/
export function loadResearch() {
const read = (f) => {
try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'docs', 'research', f), 'utf8')); } catch { return null; }
};
const ops = read('03-operators.json');
const en = read('05-enemies.json');
const maps = read('05-maps.json');
const chess = {};
const tokens = {};
for (const c of ops?.chess ?? []) {
chess[c.chessId] = c;
for (const t of c.tokens ?? []) {
if (!tokens[t.tokenId]) tokens[t.tokenId] = { ...t, byOwner: {} };
tokens[t.tokenId].byOwner[c.chessId] = t;
}
}
const stages = {};
for (const [id, s] of Object.entries(maps?.stages ?? {})) {
stages[id] = { ...s, legend: maps?._meta?.legend ?? null };
}
return { chess, enemies: en?.enemies ?? {}, tokens, stages, waves: maps?.roundLevels ?? {} };
}
+471
View File
@@ -0,0 +1,471 @@
// server/sim/professions.js — default combat behaviour per profession / subProfessionId (DESIGN §5.6 traits).
//
// A *profile* describes how a unit attacks. It is resolved once per unit:
// PROFESSION_DEFAULTS[profession] → SUB[subProfessionId] → trait-text / trait-blackboard tunables (TUNE[sub]) →
// data fields (dmgType/attackKind/projectile/canHitFly/targetPriority from data/chess.json) → kit.trait overrides.
// Profile fields:
// attack 'melee'|'ranged' dmgType 'phys'|'arts'|'true'|'heal'|'none' projectile 'none'|'arrow'|'bolt'|'bomb'|'lob'|'orb'|'drone'
// canHitFly bool maxTargets n (≥1) hitAllBlocked bool (attack every blocked enemy)
// allInRange bool splashRadius tiles splashScale (× damage for splash victims)
// splashOthersOnly bool groundOnly bool hits n (damage instances per attack)
// chain {count, falloff, radius, sluggish} heal {mode:'single'|'multi'|'chain', count, falloff, farMul, elementHealRatio}
// priority 'fly'|'lowDef'|'ranged'|'lowestHp'|'highestHp'|'nearest'|'farthest'|'notBurst'|null
// noAttack bool (never attacks) noAttackUnlessSkill bool (attacks only while its skill is active)
// noHeal bool (cannot be healed by others) blockFly bool onHitStatus {key, duration, value}
// dmgMul(battle, unit, target) → number afterHit(battle, unit, target, {dealt,x,y})
// canAttack(battle, unit) → bool afterAttack(battle, unit, targets)
// hitsFn(battle, unit) → n install(battle, unit) — per-unit hooks, called once at setup
// tb — the unit's trait blackboard (data `trait.bb`), used for tunables (module upgrades included on elites)
// Behaviour per subprofession is documented in docs/SIM.md §Professions. Front / side tests use the unit's direction
// (`dir`, sim/dir.js): offsets are compared in its facing-RIGHT frame.
import { toLocal } from './dir.js';
const P = (o) => Object.freeze(o);
const num = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
export const PROFESSION_DEFAULTS = Object.freeze({
SNIPER: P({ attack: 'ranged', dmgType: 'phys', projectile: 'arrow', canHitFly: true }),
CASTER: P({ attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true }),
MEDIC: P({ attack: 'ranged', dmgType: 'heal', projectile: 'orb', canHitFly: true, heal: { mode: 'single' } }),
SUPPORT: P({ attack: 'ranged', dmgType: 'arts', projectile: 'bolt', canHitFly: true }),
TANK: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
WARRIOR: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
PIONEER: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
SPECIAL: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
TOKEN: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
});
// --------------------------------------------------------------------------------------------------------------
// install helpers (per-unit hooks). All use engine helpers only; tunables come from unit.profile.
const installSelfHealOnHit = (capByBlock) => (battle, unit) => {
battle.on('attack', (ctx) => {
if (ctx.attacker !== unit || !unit.alive) return;
let n = ctx.targets.length;
if (capByBlock) n = Math.min(n, Math.max(1, unit.s.blockCnt));
if (n > 0) battle.heal(unit, unit, (unit.profile.selfHeal ?? 50) * n, { self: true });
}, { owner: unit, priority: -10 });
};
const installHpDrain = (battle, unit) => {
battle.every(1, () => {
if (!unit.alive || !unit.deployed) return;
const loss = unit.s.maxHp * (unit.profile.hpDrain ?? 0.03);
// non-lethal: the drain never knocks the operator out on its own
if (unit.hp - loss < 1) unit.hp = Math.min(unit.hp, 1);
else battle.loseHp(unit, loss, { source: unit, silent: true });
}, { owner: unit });
};
const installMerchant = (battle, unit) => {
const iv = unit.profile.merchantInterval ?? 3;
const cost = unit.profile.merchantCost ?? 3;
battle.every(iv, () => {
if (!unit.alive || !unit.deployed) return;
const pl = battle.getPlayer(unit.ownerId);
if (!pl) return;
// `merchantPay` { unit, cost, cancel }: content may change the payment or waive it (cancel)
const ctx = battle.hasHook('merchantPay') ? battle.emit('merchantPay', { unit, cost, cancel: false }) : null;
if (!unit.alive || !unit.deployed || (ctx && ctx.cancel)) return;
const c = ctx && Number.isFinite(ctx.cost) ? Math.max(0, ctx.cost) : cost;
if (pl.dp >= c) battle.addDp(unit.ownerId, -c);
else battle.retreat(unit, { reason: 'merchant' });
}, { owner: unit });
};
const installCharger = (battle, unit) => {
battle.on('kill', (ctx) => {
if (ctx.killer === unit && ctx.victim.side === 'enemy') battle.addDp(unit.ownerId, unit.profile.dpOnKill ?? 1);
}, { owner: unit });
};
const installDollkeeper = (battle, unit) => {
// the substitute's HP comes from the unit's own substitute token (风丸 纸偶) for its selected skill / module
// (DESIGN §16); a dollkeeper without one (归溟幽灵鲨) must not borrow another operator's token stats → 50 % of its
// own max HP
const dollHp = () => {
const tokId = (unit.def.tokens || []).map((t) => (typeof t === 'string' ? t : t?.tokenId)).find((t) => t && /shadow|doll/.test(t));
const tok = tokId ? battle.data.getToken?.(tokId, unit.defId, unit.def?.loadout ?? null) : null;
return tok && tok.stats.maxHp > 0 ? tok.stats.maxHp : unit.base.maxHp * 0.5;
};
battle.on('fatal', (ctx) => {
if (ctx.unit !== unit || ctx.prevented || unit.trait.doll) return;
ctx.prevented = true;
unit.trait.doll = true;
battle.addBuff(unit, {
key: 'trait:substitute', duration: unit.profile.dollDuration ?? 20, visible: true,
mods: { blockCnt: -99, hpMul: Math.max(0.05, dollHp() / Math.max(1, unit.base.maxHp)) },
onExpire: () => {
unit.trait.doll = false;
if (unit.alive) { unit.markDirty(); unit.hp = unit.s.maxHp; battle.fx('swap', { x: unit.x, y: unit.y, id: unit.id }); }
},
});
battle.releaseBlocked(unit);
unit.markDirty();
unit.hp = unit.s.maxHp;
battle.fx('substitute', { x: unit.x, y: unit.y, id: unit.id });
}, { owner: unit, priority: -100 });
battle.on('death', (ctx) => { if (ctx.unit === unit) unit.trait.doll = false; }, { owner: unit });
};
const installLibrator = (battle, unit) => {
// ATK ramps linearly to +rampMax (bb atk, default +200 %) over rampTime s (bb max_stack_cnt, default 40) while
// the skill is inactive; blocks nothing while inactive; everything resets when the skill ends.
const rampMax = unit.profile.rampMax ?? 2;
const rampTime = unit.profile.rampTime ?? 40;
const rampInit = Math.min(rampMax, unit.profile.rampInit ?? 0); // golden module: "部署后获得+100%加成" (init_atk)
const applyBlock = () => {
if (unit.skill?.active) battle.removeBuff(unit, 'trait:libratorBlock');
else { battle.addBuff(unit, { key: 'trait:libratorBlock', mods: { blockCnt: -99 } }); battle.releaseBlocked(unit); }
};
unit.trait.ramp = 0;
battle.every(1, () => {
if (!unit.alive || !unit.deployed || unit.skill?.active) return;
unit.trait.ramp = Math.min(rampMax, unit.trait.ramp + rampMax / rampTime);
battle.addBuff(unit, { key: 'trait:libratorRamp', mods: { atkPct: unit.trait.ramp } });
}, { owner: unit });
const applyRamp = () => {
if (unit.trait.ramp > 0) battle.addBuff(unit, { key: 'trait:libratorRamp', mods: { atkPct: unit.trait.ramp } });
else battle.removeBuff(unit, 'trait:libratorRamp');
};
battle.on('deploy', (ctx) => { if (ctx.unit === unit) { unit.trait.ramp = rampInit; applyRamp(); applyBlock(); } }, { owner: unit });
battle.on('skillStart', (ctx) => { if (ctx.unit === unit) applyBlock(); }, { owner: unit });
battle.on('skillEnd', (ctx) => {
if (ctx.unit !== unit) return;
unit.trait.ramp = 0;
applyRamp();
applyBlock();
}, { owner: unit });
};
const installPhalanx = (battle, unit) => {
const apply = () => {
if (unit.skill?.active) battle.removeBuff(unit, 'trait:phalanxGuard');
else battle.addBuff(unit, { key: 'trait:phalanxGuard', mods: { defPct: unit.profile.guardDef ?? 2, resFlat: unit.profile.guardRes ?? 20 } });
};
battle.on('deploy', (ctx) => { if (ctx.unit === unit) apply(); }, { owner: unit });
battle.on('skillStart', (ctx) => { if (ctx.unit === unit) apply(); }, { owner: unit });
battle.on('skillEnd', (ctx) => { if (ctx.unit === unit) apply(); }, { owner: unit });
};
const installBearer = (battle, unit) => {
battle.on('skillStart', (ctx) => {
if (ctx.unit === unit) { battle.addBuff(unit, { key: 'trait:bearer', mods: { blockCnt: -99 } }); battle.releaseBlocked(unit); }
}, { owner: unit });
battle.on('skillEnd', (ctx) => { if (ctx.unit === unit) battle.removeBuff(unit, 'trait:bearer'); }, { owner: unit });
};
const installStalker = (battle, unit) => {
const p = unit.profile.dodge ?? 0.5;
battle.addBuff(unit, { key: 'trait:stalker', mods: { dodgePhys: p, dodgeArts: p, taunt: -1 }, persist: true, allowDead: true });
};
const installHunter = (battle, unit) => {
const max = unit.profile.ammoMax ?? 8;
unit.trait.ammo = max;
unit.trait.reloadAcc = 0;
battle.on('tick', (ctx) => {
if (!unit.alive || !unit.deployed) return;
if (battle.time - unit.lastAttackAt >= 1 && unit.trait.ammo < max) {
unit.trait.reloadAcc += ctx.dt;
if (unit.trait.reloadAcc >= 1) { unit.trait.reloadAcc -= 1; unit.trait.ammo++; }
} else unit.trait.reloadAcc = 0;
}, { owner: unit });
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.trait.ammo = max; }, { owner: unit });
};
const installTactician = (battle, unit) => {
const spawn = () => {
if (!unit.alive || !unit.deployed) return;
if (unit.trait.reinforcement && unit.trait.reinforcement.alive) return;
const tile = battle.findTacticalPoint(unit);
if (!tile) return;
unit.trait.reinforcement = battle.spawnToken(unit, 'token_tactician_reinforce', tile[0], tile[1], {
def: {
name: '援军', stats: {
maxHp: unit.base.maxHp * 0.7, atk: unit.base.atk * 0.5, def: unit.base.def, magicResistance: 0,
blockCnt: 1, baseAttackTime: 1.2, attackSpeed: 100, respawnTime: 999, cost: 0,
}, rangeGrid: [[0, 0]], dmgType: 'phys', attackKind: 'melee', profession: 'TOKEN',
},
});
};
battle.on('deploy', (ctx) => { if (ctx.unit === unit) spawn(); }, { owner: unit });
};
const installFunnel = (battle, unit) => {
unit.trait.funnelTarget = null;
unit.trait.funnelScale = unit.profile.funnel?.init ?? 0.2;
};
const installMystic = (battle, unit) => {
unit.trait.stored = 0;
const max = unit.profile.storeMax ?? 3;
battle.on('tick', () => {
if (!unit.canAct) return;
if (unit.atkCd <= 0 && !unit.trait.hadTarget && unit.trait.stored < max) {
unit.trait.storeAcc = (unit.trait.storeAcc ?? 0) + battle.dt;
if (unit.trait.storeAcc >= unit.s.interval) { unit.trait.storeAcc = 0; unit.trait.stored++; }
}
}, { owner: unit });
};
const installBard = (battle, unit) => {
battle.every(1, () => {
if (!unit.canAct) return;
const amount = unit.s.atk * (unit.profile.auraRatio ?? 0.1);
for (const ally of battle.alliesInGrid(unit)) {
if (ally.hp < ally.s.maxHp) battle.heal(unit, ally, amount, { aura: true });
}
}, { owner: unit });
};
const installSkywalker = (battle, unit) => {
battle.addBuff(unit, { key: 'trait:skywalker', flags: { blockFly: true }, persist: true, allowDead: true });
};
/** Target's tile offset from the unit in the unit's facing-RIGHT frame ([dRow, dCol], sim/dir.js toLocal). */
const localOffset = (unit, target) => toLocal(Math.round(target.y) - unit.tileR, Math.round(target.x) - unit.tileC, unit.dir);
const inTraitGrid = (unit, target, grid) => {
const [dr, dc] = localOffset(unit, target);
for (const [r, c] of grid) if (r === dr && c === dc) return true;
return false;
};
/** 'reaperrange' default front test: the target stands on the unit's own line, at or ahead of it along its facing. */
const onFrontLine = (unit, target) => {
const [fr, fc] = unit.fwd;
const [lateral] = localOffset(unit, target);
return lateral === 0 && (target.x - unit.x) * fc + (target.y - unit.y) * fr >= 0;
};
// --------------------------------------------------------------------------------------------------------------
// subprofession table
export const SUB = Object.freeze({
// --- SNIPER
fastshot: P({ priority: 'fly', dmgMul: (b, u, t) => (t.isFlying ? (u.profile.flyScale ?? 1) : 1) }),
closerange: P({}),
longrange: P({ priority: 'lowDef' }),
aoesniper: P({ splashRadius: 1.1, projectile: 'bomb' }),
bombarder: P({ splashRadius: 1.0, projectile: 'bomb', groundOnly: true, canHitFly: false,
afterHit: (battle, unit, target, info) => {
// aftershocks: (times − 1) extra hits at append_atk_scale × ATK (default one hit at 50 %)
const n = Math.max(1, (unit.profile.shockTimes ?? 2) - 1);
for (let i = 1; i <= n; i++) {
battle.after(0.3 * i, () => {
for (const e of battle.enemiesInRadius(info.x, info.y, unit.profile.splashRadius || 1)) {
if (e.isFlying) continue;
battle.dealDamage(unit, e, { amount: unit.s.atk * (unit.profile.shockScale ?? 0.5), type: 'phys', isSplash: true, tags: ['aftershock'] });
}
});
}
} }),
hunter: P({ install: installHunter,
canAttack: (battle, unit) => (unit.trait.ammo ?? 8) > 0,
dmgMul: (b, u) => u.profile.ammoScale ?? 1.2,
afterAttack: (battle, unit) => { unit.trait.ammo = Math.max(0, (unit.trait.ammo ?? 8) - 1); } }),
loopshooter: P({ projectile: 'lob',
afterAttack: (battle, unit, targets) => {
const t = targets[0];
if (!t) return;
const d = Math.hypot(t.x - unit.x, t.y - unit.y);
unit.atkCd = Math.max(unit.atkCd, (2 * d) / 10);
} }),
reaperrange: P({ allInRange: true,
dmgMul: (battle, unit, target) => {
const grid = unit.profile.frontGrid;
const front = grid ? inTraitGrid(unit, target, grid) : onFrontLine(unit, target);
return front ? (unit.profile.frontScale ?? 1.5) : 1;
} }),
// --- CASTER
splashcaster: P({ splashRadius: 1.1 }),
blastcaster: P({ splashRadius: 1.1, projectile: 'bomb' }),
chain: P({ chain: { count: 3, falloff: 0.15, radius: 1.8, sluggish: 0.5 } }),
funnel: P({ projectile: 'drone', install: installFunnel,
dmgMul: (battle, unit, target) => {
const f = unit.profile.funnel || { init: 0.2, delta: 0.15, max: 1.1 };
if (unit.trait.funnelTarget === target.id) unit.trait.funnelScale = Math.min(f.max, (unit.trait.funnelScale ?? f.init) + f.delta);
else { unit.trait.funnelTarget = target.id; unit.trait.funnelScale = f.init; }
return unit.trait.funnelScale;
} }),
mystic: P({ install: installMystic,
hitsFn: (battle, unit) => { const n = 1 + (unit.trait.stored ?? 0); unit.trait.stored = 0; return n; } }),
phalanx: P({ noAttackUnlessSkill: true, splashRadius: 1.1, install: installPhalanx }),
primcaster: P({}),
corecaster: P({}),
// --- MEDIC
physician: P({ heal: { mode: 'single' } }),
ringhealer: P({ heal: { mode: 'multi', count: 3 } }),
chainhealer: P({ heal: { mode: 'chain', count: 3, falloff: 0.25 } }),
healer: P({ heal: { mode: 'single', farMul: 0.8, nearDist: 2 } }),
wandermedic: P({ heal: { mode: 'single', elementHealRatio: 0.5 } }),
incantationmedic: P({ dmgType: 'arts', projectile: 'bolt', heal: null,
afterHit: (battle, unit, target, info) => {
if (!(info.dealt > 0)) return;
const ally = battle.lowestHpAllyInRange(unit);
if (ally) battle.heal(unit, ally, info.dealt * (unit.profile.healRatio ?? 0.5), { tags: ['incantation'] });
} }),
// --- SUPPORT
slower: P({ onHitStatus: { key: 'sluggish', duration: 0.8 } }),
underminer: P({}),
bard: P({ noAttack: true, dmgType: 'heal', heal: null, install: installBard }),
ritualist: P({}),
craftsman: P({ attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false }),
summoner: P({}),
// --- TANK
protector: P({}),
guardian: P({}),
shotprotector: P({ attack: 'ranged', canHitFly: true, projectile: 'arrow' }),
primprotector: P({}),
unyield: P({ noHeal: true }),
duelist: P({}),
fortress: P({ fortress: true, splashRadius: 1.0, projectile: 'bomb' }),
// --- WARRIOR
centurion: P({ hitAllBlocked: true }),
crusher: P({ hitAllBlocked: true }),
fearless: P({}),
fighter: P({}),
hammer: P({ splashRadius: 1.0, splashScale: 0.5, splashOthersOnly: true }),
instructor: P({ dmgMul: (battle, unit, target) => (target.blockedBy === unit ? 1 : (unit.profile.unblockedScale ?? 1.2)) }),
librator: P({ noAttackUnlessSkill: true, install: installLibrator }),
lord: P({ canHitFly: true,
dmgMul: (battle, unit, target) => {
if (target.blockedBy === unit) return 1;
const [dr, dc] = localOffset(unit, target);
return dr === 0 && (dc === 0 || dc === 1) ? 1 : (unit.profile.rangedScale ?? 0.8);
} }),
musha: P({ noHeal: true, install: installSelfHealOnHit(false) }),
reaper: P({ noHeal: true, allInRange: true, install: installSelfHealOnHit(true) }),
sword: P({ hits: 2 }),
artsfghter: P({ dmgType: 'arts' }),
swordmaster: P({ hits: 2 }),
// --- PIONEER
pioneer: P({}),
charger: P({ install: installCharger }),
tactician: P({ install: installTactician,
dmgMul: (battle, unit, target) => (unit.trait.reinforcement && target.blockedBy === unit.trait.reinforcement ? (unit.profile.reinforceScale ?? 1.5) : 1) }),
agent: P({ canHitFly: true }),
counsellor: P({}),
bearer: P({ install: installBearer }),
// --- SPECIAL
alchemist: P({ attack: 'ranged', canHitFly: true, projectile: 'lob' }),
dollkeeper: P({ install: installDollkeeper }),
executor: P({}),
geek: P({ install: installHpDrain }),
hookmaster: P({ canHitFly: true }),
merchant: P({ install: installMerchant }),
pusher: P({ hitAllBlocked: true }),
skywalker: P({ canHitFly: true, blockFly: true, install: installSkywalker }),
stalker: P({ allInRange: true, install: installStalker }),
traper: P({ attack: 'ranged', canHitFly: false, projectile: 'none' }),
});
/**
* Trait tunables per subprofession, read from the trait text and the trait blackboard (`def.traitBb`, data
* `trait.bb`; elites include their module upgrade). Returns profile overrides.
*/
const TUNE = {
fastshot: (tb) => ({ flyScale: num(tb.atk_scale, 1) }),
bombarder: (tb) => ({ shockScale: num(tb['attack@append_atk_scale'], 0.5), shockTimes: num(tb['attack@times'], 2) }),
hunter: (tb) => ({ ammoMax: num(tb.value, 8), ammoScale: num(tb.atk_scale, 1.2) }),
reaperrange: (tb, def) => ({ frontScale: num(tb.atk_scale, 1.5), frontGrid: def.raw?.trait?.rangeGrid ?? null }),
funnel: (tb) => ({ funnel: { init: num(tb.init_atk_scale, 0.2), delta: num(tb.delta_atk_scale, 0.15), max: num(tb.max_atk_scale, 1.1) } }),
mystic: (tb) => ({ storeMax: num(tb.times, 3) }),
phalanx: (tb) => ({ guardDef: num(tb.def, 2), guardRes: num(tb.magic_resistance, 20) }),
healer: (tb, def, p) => ({ heal: { ...p.heal, farMul: num(tb.heal_scale, 0.8) } }),
wandermedic: (tb, def, p) => ({ heal: { ...p.heal, elementHealRatio: num(tb.ep_heal_ratio, 0.5) } }),
incantationmedic: (tb) => ({ healRatio: num(tb.scale, 0.5) }),
chainhealer: (tb, def, p) => {
const o = { ...p.heal };
const m = String(def.trait || '').match(/在(\d+)个友方单位间跳跃/);
if (m) o.count = +m[1];
const f = String(def.trait || '').match(/治疗量降低(\d+)%/);
if (f) o.falloff = +f[1] / 100;
if (tb['attack@chain.max_target'] != null) o.count = num(tb['attack@chain.max_target'], o.count);
if (tb['attack@chain.atk_scale'] != null) o.falloff = 1 - num(tb['attack@chain.atk_scale'], 1 - o.falloff);
return { heal: o };
},
ringhealer: (tb, def, p) => ({ heal: { ...p.heal, count: /同时恢复三个|同时恢复3个/.test(String(def.trait || '')) ? 3 : (p.heal.count ?? 3) } }),
chain: (tb, def, p) => {
const o = { ...p.chain };
const m = String(def.trait || '').match(/在(\d+)个敌人间跳跃/);
if (m) o.count = +m[1];
const f = String(def.trait || '').match(/伤害降低(\d+)%/);
if (f) o.falloff = +f[1] / 100;
if (tb['attack@max_target'] != null) o.count = num(tb['attack@max_target'], o.count);
if (tb['attack@sluggish'] != null) o.sluggish = num(tb['attack@sluggish'], o.sluggish);
if (tb['attack@chain.atk_scale'] != null) o.falloff = 1 - num(tb['attack@chain.atk_scale'], 1 - o.falloff);
return { chain: o };
},
slower: (tb) => ({ onHitStatus: { key: 'sluggish', duration: num(tb.sluggish, 0.8) } }),
underminer: (tb) => (tb.atk != null && tb.duration != null ? { onHitStatus: { key: 'weaken', duration: num(tb.duration, 2), value: Math.abs(num(tb.atk, 0.1)) } } : {}),
bard: (tb) => ({ auraRatio: num(tb['attack@atk_to_hp_recovery_ratio'], 0.1) }),
hammer: (tb) => ({ splashScale: num(tb['attack@atk_scale_2'], 0.5), splashRadius: num(tb['attack@ability_range_radius'], 1) }),
instructor: (tb) => ({ unblockedScale: num(tb.atk_scale, 1.2) }),
librator: (tb) => ({ rampMax: num(tb.atk, 2), rampTime: num(tb.max_stack_cnt, 40), rampInit: num(tb.init_atk, 0) }),
lord: (tb) => ({ rangedScale: num(tb.atk_scale, 0.8) }),
musha: (tb) => ({ selfHeal: num(tb.value, 50) }),
reaper: (tb) => ({ selfHeal: num(tb.value, 50) }),
charger: (tb) => ({ dpOnKill: num(tb.cost, 1) }),
tactician: (tb) => ({ reinforceScale: num(tb.atk_scale, 1.5) }),
dollkeeper: (tb) => ({ dollDuration: num(tb.duration, 20) }),
geek: (tb) => ({ hpDrain: num(tb.hp_ratio, 0.03) }),
merchant: (tb) => ({ merchantInterval: num(tb.interval, 3), merchantCost: Math.abs(num(tb.cost, -3)) }),
stalker: (tb) => ({ dodge: num(tb.prob, 0.5) }),
};
const PRIORITY_ALIASES = {
FLY_FIRST: 'fly', fly: 'fly', LOW_DEF: 'lowDef', lowDef: 'lowDef', lowestDef: 'lowDef', RANGED: 'ranged', ranged: 'ranged',
lowestHp: 'lowestHp', highestHp: 'highestHp', nearest: 'nearest', farthest: 'farthest',
};
/**
* Resolve the attack profile of a unit def. `kitTrait` (optional) overrides everything.
* @returns {object} mutable profile
*/
export function resolveProfile(def, kitTrait = null) {
const prof = PROFESSION_DEFAULTS[def.profession] || PROFESSION_DEFAULTS.WARRIOR;
const sub = (def.subProf && SUB[def.subProf]) || {};
const tb = def.traitBb || {};
const p = {
sub: def.subProf || null,
attack: 'melee', dmgType: 'phys', projectile: 'none', canHitFly: false, maxTargets: 1,
hitAllBlocked: false, allInRange: false, splashRadius: 0, splashScale: 1, splashOthersOnly: false,
groundOnly: false, hits: 1, chain: null, heal: null, priority: null, noAttack: false,
noAttackUnlessSkill: false, noHeal: false, blockFly: false,
...prof, ...sub, tb,
};
const tune = def.subProf && TUNE[def.subProf];
if (tune) Object.assign(p, tune(tb, def, p));
// data-provided fields (build-data parsed them from trait text) take precedence over table defaults,
// except for 'none' (no normal attack: phalanx/librator/bard) and 'heal' kinds whose attack shape stays table-defined.
const ak = def.attackKind ? String(def.attackKind).toLowerCase() : null;
if (def.dmgType) p.dmgType = def.dmgType;
if (ak === 'ranged' || ak === 'melee') {
p.attack = ak;
if (def.projectile) p.projectile = def.projectile;
if (def.canHitFly != null) p.canHitFly = !!def.canHitFly;
else p.canHitFly = p.attack === 'ranged' || p.canHitFly;
} else if (ak === 'heal') {
p.attack = 'ranged';
p.dmgType = 'heal';
} else if (ak === 'none') {
if (!p.noAttack && !p.noAttackUnlessSkill) p.noAttackUnlessSkill = true;
}
if (def.targetPriority) p.priority = PRIORITY_ALIASES[def.targetPriority] ?? def.targetPriority;
if (def.splashRadius != null && def.splashRadius > 0) p.splashRadius = def.splashRadius;
if (def.type === 'token') {
if (p.dmgType === 'heal') p.heal = p.heal || { mode: 'single' };
if (def.stats.atk <= 0) p.noAttack = true;
}
if (p.dmgType === 'heal' && !p.heal && !p.noAttack) p.heal = { mode: 'single' };
if (p.dmgType !== 'heal' && p.heal) p.heal = null;
if (p.dmgType === 'none') p.noAttack = true;
if (kitTrait) Object.assign(p, kitTrait);
return p;
}
/** Every subProfessionId with a specific behaviour (used by tests to check coverage). */
export const KNOWN_SUBPROFESSIONS = Object.freeze(Object.keys(SUB));
+78
View File
@@ -0,0 +1,78 @@
// server/sim/projectiles.js — projectile flight & impact (DESIGN §5.4 addProjectile).
//
// A projectile homes on `target` (a unit) or flies to a fixed point `to` ({x,y}). On arrival `onHit(ctx)` runs
// with ctx = { battle, projectile, target (null if it died / point shot), x, y }. When a homing target dies
// mid-flight the projectile fizzles, unless `hitDead: true` (then it lands at the last known position).
import { PROJECTILE_SPEED } from './constants.js';
let seq = 0;
const fin = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
export class ProjectileSystem {
constructor(battle) {
this.battle = battle;
/** @type {object[]} */
this.list = [];
}
add(p) {
const from = p.from || { x: 0, y: 0 };
// non-finite coordinates would never "arrive" (NaN distance) and leak the projectile for maxAge
const fx = fin(from.x, 0), fy = fin(from.y, 0);
const target = p.target && typeof p.target === 'object' ? p.target : null;
const proj = {
id: ++seq,
x: fx,
y: fy,
target,
// the target "life" it was fired at: an op that dies and is redeployed mid-flight is a new target (fizzle)
tseq: target ? target.deploySeq : 0,
tx: fin(p.to ? p.to.x : (target ? target.x : fx), fx),
ty: fin(p.to ? p.to.y : (target ? target.y : fy), fy),
speed: p.speed > 0 ? p.speed : PROJECTILE_SPEED,
onHit: p.onHit ?? null,
visual: p.visual ?? 'arrow',
source: p.source ?? (p.from && p.from.id != null ? p.from : null),
hitDead: !!p.hitDead,
data: p.data ?? null,
age: 0,
maxAge: p.maxAge > 0 ? p.maxAge : 10,
};
this.list.push(proj);
return proj;
}
update(dt) {
if (!this.list.length) return;
const keep = [];
const arrived = [];
for (const p of this.list) {
p.age += dt;
if (p.target) {
if (p.target.alive && !p.target.hidden && p.target.deploySeq === p.tseq) { p.tx = fin(p.target.x, p.tx); p.ty = fin(p.target.y, p.ty); }
else if (!p.hitDead) continue; // fizzle
else p.target = null;
}
const dx = p.tx - p.x, dy = p.ty - p.y;
const d = Math.hypot(dx, dy);
const step = p.speed * dt;
if (d <= step || p.age >= p.maxAge) {
p.x = p.tx; p.y = p.ty;
arrived.push(p);
} else {
p.x += (dx / d) * step;
p.y += (dy / d) * step;
keep.push(p);
}
}
this.list = keep;
for (const p of arrived) {
if (!p.onHit) continue;
const t = p.target && p.target.alive && p.target.deploySeq === p.tseq ? p.target : null;
this.battle._safe(() => p.onHit({ battle: this.battle, projectile: p, target: t, x: p.x, y: p.y }), 'projectile.onHit', p.source);
}
}
clear() { this.list = []; }
}
+56
View File
@@ -0,0 +1,56 @@
// server/sim/rng.js — seeded PRNG (mulberry32) + helpers. The ONLY source of randomness in the sim.
/**
* Create a deterministic PRNG. Returns a function producing floats in [0, 1) with helper methods.
* @param {number} seed uint32 (any number is coerced)
*/
export function createRng(seed = 1) {
let s = (Number(seed) >>> 0) || 0x9e3779b9;
const next = () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const rng = () => next();
/** integer in [0, n) */
rng.int = (n) => Math.floor(next() * Math.max(0, n));
/** float in [a, b) */
rng.range = (a, b) => a + next() * (b - a);
/** true with probability p */
rng.chance = (p) => next() < p;
/** random element (undefined for empty) */
rng.pick = (arr) => (arr && arr.length ? arr[Math.floor(next() * arr.length)] : undefined);
/** in-place Fisher–Yates shuffle, returns arr */
rng.shuffle = (arr) => {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(next() * (i + 1));
const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
}
return arr;
};
/** weighted pick: items with weightFn(item) */
rng.weighted = (arr, weightFn) => {
let total = 0;
for (const x of arr) total += Math.max(0, weightFn(x));
if (total <= 0) return undefined;
let r = next() * total;
for (const x of arr) { r -= Math.max(0, weightFn(x)); if (r < 0) return x; }
return arr[arr.length - 1];
};
/** current internal state (for debugging / hashing) */
rng.state = () => s;
return rng;
}
/** Derive a child seed deterministically (e.g. per field). */
export function deriveSeed(seed, salt) {
let h = (Number(seed) >>> 0) ^ 0x85ebca6b;
const str = String(salt);
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 0x9e3779b1);
h ^= h >>> 13;
}
return h >>> 0;
}
+700
View File
@@ -0,0 +1,700 @@
// server/sim/simdata.js — data access + normalisation layer for the simulation.
//
// The sim never reads raw JSON records directly: every chess / enemy / token record is normalised once into an
// engine "def" (cached, frozen input is never mutated). Sources, in order:
// 1. an injected data object (Battle opts.data: `{ chess, enemies, tokens, stages, waves }` maps or getters)
// 2. the default source:
// Node — `server/data.js → getData()` (data/*.json generated by tools/build-data.mjs) with a research JSON
// fallback (docs/research/03-operators.json, 05-enemies.json, 05-maps.json), loaded by ./nodeData.js
// so the sim and its tests work before/without generated data;
// browser — whatever the page injected with `setSimData(data)` (the fetched /data/*.json, DESIGN §14).
// Both the research record shapes and the build-data shapes (docs/DATA.md) are accepted.
//
// Operator loadouts (DESIGN §16, DATA.md §2.2): `getChess(id, { skillIndex, moduleId })` resolves the def of a chess
// with the selected skill (def.skill / its blackboard / trigger) and — elite only — module (stats = statsBase + attr,
// trait, talents); `getToken(id, owner, ownerLoadout)` the owner-loadout variant of a summon. Pure helpers for the
// match / UI (implemented in shared/loadoutRecord.js, re-exported here): resolveLoadout, loadoutRecord (the resolved
// chess record), composeStats / composeTalents. (Which choices a player may make is shared/protocol.js
// loadoutOptions / checkLoadout.)
//
// This module is pure ESM shared with browsers (served at /sim/): no Node API is imported here. Under Node the default
// loader (./nodeData.js) is imported dynamically at module evaluation; browsers never load it.
// setSimData(data) inject the full game data object ({ chess, enemies, tokens, stages, waves, bonds, items, … });
// it becomes the default source (no research fallback) and `getSimData()` (the browser stand-in of
// server/data.js getData() used by sim/content/support serves it).
// getSimData() the injected data (browser) or the generated data (Node), or null.
import { resolveRecordLoadout, composeStats, composeTalents, loadoutRecord } from '../../shared/loadoutRecord.js';
// ---------------------------------------------------------------------------------------------------------------
// generic helpers
/** Flatten an Arknights blackboard `[{key,value,valueStr}]` (or a plain object) into `{ key: value }`. */
export function flattenBlackboard(bb) {
if (!bb) return {};
if (!Array.isArray(bb)) return { ...bb };
const out = {};
for (const e of bb) {
if (!e || e.key == null) continue;
const v = e.valueStr != null && e.valueStr !== '' && (e.value === 0 || e.value == null) ? e.valueStr : e.value;
out[e.key] = v;
}
return out;
}
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(+v) ? +v : d));
function toArrayOfPairs(grid) {
if (!Array.isArray(grid)) return null;
const out = [];
for (const p of grid) {
if (Array.isArray(p) && p.length >= 2) out.push([num(p[0]), num(p[1])]);
else if (p && typeof p === 'object' && ('row' in p || 'r' in p)) out.push([num(p.row ?? p.r), num(p.col ?? p.c)]);
}
return out;
}
export function normDmgType(v, fallback = 'phys') {
if (v == null) return fallback;
const s = String(Array.isArray(v) ? v[0] : v).toUpperCase();
if (s.startsWith('PHYS')) return 'phys';
if (s.startsWith('MAG') || s === 'ARTS') return 'arts';
if (s === 'HEAL' || s === 'HEALING') return 'heal';
if (s === 'TRUE' || s === 'REAL' || s === 'PURE') return 'true';
if (s === 'NO_DAMAGE' || s === 'NONE' || s === 'NO') return 'none';
if (s.startsWith('ELEM')) return 'true';
return fallback;
}
export function normSpType(v) {
const s = String(v ?? '').toUpperCase();
if (s.includes('TIME')) return 'time';
if (s.includes('ATTACK')) return 'attack';
if (s.includes('DAMAGE') || s.includes('HURT')) return 'hurt';
return 'none';
}
export function normTriggerRule(v) {
const s = String(v ?? 'DEFAULT').toUpperCase();
if (s === 'ALWAYS' || s === 'SP_FULL') return 'SP_FULL';
if (s.startsWith('CUSTOM_RANGE')) return 'CUSTOM_RANGE';
return s;
}
// ---------------------------------------------------------------------------------------------------------------
// def normalisers
/** Normalise the skill part of a chess/token record. Returns null when there is no skill. */
export function normalizeSkill(rec) {
const s = rec && (rec.skill ?? null);
if (!s || typeof s !== 'object') return null;
const bb = s.bb ? { ...s.bb } : flattenBlackboard(s.blackboard);
const trig = s.trigger ?? rec.skillTrigger ?? {};
let rule = normTriggerRule(typeof trig === 'string' ? trig : trig.rule);
const matchedBy = typeof trig === 'object' ? String(trig.matchedBy ?? '') : '';
const skillIndex = s.skillIndex ?? s.index ?? null;
// Research correction (03 addendum C1): profession/subprofession rows apply to skill 1 only.
if (/^(profession|subProfession)/.test(matchedBy) && skillIndex != null && skillIndex !== 0) rule = 'DEFAULT';
const trigGrid = toArrayOfPairs((typeof trig === 'object' && (trig.customRangeGrid ?? trig.rangeGrid ?? trig.grid)) || rec.skillTriggerCustomRangeGrid);
if (/^skcom_withdraw/.test(String(s.skillId ?? s.id ?? ''))) return null;
const durRaw = s.duration ?? bb.duration ?? 0;
const skillType = String(s.skillType ?? s.type ?? 'MANUAL').toUpperCase();
return {
id: s.skillId ?? s.id ?? 'skill',
name: s.name ?? '',
index: skillIndex,
skillType,
durationType: String(s.durationType ?? 'NONE').toUpperCase(),
duration: num(durRaw, 0),
spType: normSpType(s.spType),
spCost: Math.max(0, num(s.spCost, 0)),
initSp: Math.max(0, num(s.initSp, 0)),
maxCharges: Math.max(1, num(s.maxChargeTime ?? s.maxCharges ?? s.charges, 1)),
rangeGrid: toArrayOfPairs(s.rangeGrid),
trigger: { rule, grid: trigGrid },
kind: s.kind ?? null,
bb,
description: s.description ?? s.desc ?? '',
};
}
function normStats(st) {
st = st || {};
return {
maxHp: Math.max(1, num(st.maxHp ?? st.hp, 1000)),
atk: Math.max(0, num(st.atk, 0)),
def: Math.max(0, num(st.def, 0)),
res: Math.max(0, num(st.magicResistance ?? st.res ?? st.resistance, 0)),
aspd: num(st.attackSpeed ?? st.aspd, 100) || 100,
bat: Math.max(0.05, num(st.baseAttackTime ?? st.bat, 1)),
blockCnt: Math.max(0, num(st.blockCnt ?? st.block, 1)),
cost: Math.max(0, num(st.cost, 10)),
respawnTime: Math.max(0, num(st.respawnTime ?? st.redeploy, 70)),
spRecovery: num(st.spRecoveryPerSec ?? st.spRecovery, 1),
tauntLevel: num(st.tauntLevel ?? st.taunt, 0),
massLevel: num(st.massLevel ?? st.mass, 0),
hpRecoveryPerSec: num(st.hpRecoveryPerSec, 0),
moveSpeed: num(st.moveSpeed, 1),
};
}
/** Normalise a chess record (research 03 shape or data/chess.json shape) into an OpDef. */
export function normalizeChess(rec) {
const id = rec.chessId ?? rec.id;
const golden = !!(rec.isGolden ?? rec.golden ?? /_b$/.test(String(id)));
const baseId = rec.baseChessId ?? rec.baseId ?? String(id).replace(/_b$/, '_a');
const skill = normalizeSkill(rec);
const talents = (rec.talents ?? []).map((t) => ({
name: t.name ?? '',
description: t.description ?? t.desc ?? '',
bb: t.bb ? { ...t.bb } : flattenBlackboard(t.blackboard),
bbStr: t.bbStr ? { ...t.bbStr } : {},
rangeGrid: toArrayOfPairs(t.rangeGrid),
tokenKey: t.tokenKey ?? null,
}));
return {
type: 'chess',
id,
baseId,
golden,
name: rec.name ?? id,
charId: rec.charId ?? null,
tier: num(rec.tier, 1),
rarity: num(rec.rarity, 4),
profession: String(rec.profession ?? 'WARRIOR').toUpperCase(),
subProf: rec.subProfessionId ?? rec.subProfession ?? rec.subProf ?? null,
position: String(rec.position ?? 'MELEE').toUpperCase(),
bonds: [...(rec.bondIds ?? rec.bonds ?? [])],
stats: normStats(rec.stats),
rangeGrid: toArrayOfPairs(rec.rangeGrid ?? rec.range?.grid) ?? [[0, 0], [0, 1]],
dmgType: rec.dmgType ? normDmgType(rec.dmgType) : null,
attackKind: rec.attackKind ?? null,
projectile: rec.projectile ?? null,
canHitFly: rec.canHitFly ?? null,
targetPriority: rec.targetPriority ?? null,
splashRadius: rec.splashRadius ?? null,
trait: typeof rec.trait === 'string' ? rec.trait : (rec.trait?.desc ?? rec.traitText ?? ''),
traitBb: rec.traitBb ?? (rec.trait && typeof rec.trait === 'object' ? { ...(rec.trait.bb || {}) } : flattenBlackboard(rec.traitBlackboard)),
immune: immuneSet(rec.immunities ?? rec.immune),
skill,
talents,
tokens: rec.tokens ?? [],
spine: rec.assets?.spine ?? rec.spine ?? rec.charId ?? null,
avatar: rec.assets?.avatar ?? rec.avatar ?? rec.charId ?? null,
raw: rec,
};
}
// ---------------------------------------------------------------------------------------------------------------
// operator loadouts (DESIGN §16): selected skill + module of a chess
//
// A loadout `{ skillIndex, moduleId }` (also accepted: `{ skill, module }`, the client's per-browser shape) is resolved
// against a data/chess.json record: `skillIndex` must be the index of one of `rec.skills` (the skills unlocked at the
// record's status), `moduleId` 'none' or the uniEquipId of one of `rec.modules` (golden chess only). Anything else —
// missing, unknown, not unlocked at this record (a tier-1/2 normal chess has no S3) — falls back to the default.
// Research-shaped records (no `skills` list) always resolve to their single skill.
/** Resolve a loadout against a chess record (shared/loadoutRecord.js resolveRecordLoadout). */
export const resolveLoadout = resolveRecordLoadout;
export { composeStats, composeTalents, loadoutRecord };
/** Cache key suffix of a resolved non-default loadout ('' for the default). */
export function loadoutKey(lo) {
return lo && !lo.isDefault ? `|s${lo.skillIndex}|m${lo.moduleId ?? ''}` : '';
}
/** Build an immunity Set from an object `{stun:true,…}` or an array `['stun', 'feared']`. */
export function immuneSet(v, extra = null) {
const out = new Set();
const add = (k) => out.add(String(k).replace(/Immune$/, '').toLowerCase());
if (Array.isArray(v)) v.forEach(add);
else if (v && typeof v === 'object') for (const k of Object.keys(v)) if (v[k]) add(k);
if (Array.isArray(extra)) extra.forEach(add);
return out;
}
/** Normalise an enemy record (research 05 shape or data/enemies.json shape) into an EnemyDef. */
export function normalizeEnemy(key, e) {
const st = e.stats ?? e;
const range = num(st.rangeRadius ?? st.range, 0);
const applyWay = String(e.applyWay ?? st.applyWay ?? (range > 0 ? 'RANGED' : 'MELEE')).toUpperCase();
let dmgType = normDmgType(st.dmgType ?? e.dmgType ?? e.dmg, 'phys');
if (applyWay === 'NONE') dmgType = 'none';
const immune = immuneSet(st.immunities ?? e.immune ?? e.immunities, st.otherImmunities ?? e.otherImmunities);
const extra = e.extra ?? {};
return {
type: 'enemy',
id: key,
key,
name: e.name ?? key,
rank: String(e.rank ?? 'NORMAL').toUpperCase(),
maxHp: Math.max(1, num(st.maxHp ?? st.hp, 1000)),
atk: Math.max(0, num(st.atk, 0)),
def: Math.max(0, num(st.def, 0)),
res: Math.max(0, num(st.res ?? st.magicResistance, 0)),
aspd: num(st.aspd ?? st.attackSpeed, 100) || 100,
bat: Math.max(0.1, num(st.bat ?? st.baseAttackTime, 2)),
rangeRadius: range > 0 ? range : 0,
moveSpeed: Math.max(0, num(st.moveSpeed ?? st.spd, 1)),
massLevel: num(st.massLevel ?? st.mass, 1),
lpr: num(st.lpr ?? e.lpr ?? e.lifePointReduce, 1),
motion: String(st.motion ?? e.motion ?? 'WALK').toUpperCase() === 'FLY' ? 'FLY' : 'WALK',
applyWay,
dmgType,
immune,
tauntLevel: num(st.tauntLevel ?? extra.tauntLevel ?? e.tauntLevel, 0),
blockCnt: Math.max(1, num(st.blockCnt ?? e.blockCnt ?? extra.blockCnt, 1)),
epResistance: num(st.elementRes ?? extra.epResistance ?? e.epResistance, 0),
epDamageResistance: num(st.elementDmgRes ?? extra.epDamageResistance ?? e.epDamageResistance, 0),
hpRecoveryPerSec: num(st.hpRecoveryPerSec ?? extra.hpRecoveryPerSec ?? e.hpRecoveryPerSec, 0),
notCountInTotal: !!(e.notCountInTotal),
tags: e.tags ?? [],
abilities: e.abilities ?? [],
skills: e.skills ?? [],
talent: e.talents?.bb ?? e.talent ?? {},
spine: e.spine ?? key,
avatar: e.iconId ?? e.avatar ?? key,
raw: e,
};
}
/** Normalise a token record (+ optional owner-specific variant: `t.variants[ownerChessId]`). */
export function normalizeToken(id, t0, ownerChessId = null, variantOverride = null) {
const variant = variantOverride ?? (ownerChessId && t0.variants ? t0.variants[ownerChessId] : null);
const t = variant ? { ...t0, ...variant } : t0;
const skill = normalizeSkill(t);
const traitText = typeof t.trait === 'string' ? t.trait : (t.trait?.desc ?? '');
const desc = String(t.description ?? t.desc ?? traitText ?? '');
const stats = normStats(t.stats ?? {});
const ak = t.attackKind ? String(t.attackKind).toLowerCase() : null;
return {
type: 'token',
id,
baseId: id,
golden: false,
name: t.name ?? id,
charId: id,
tier: num(t.tier, 1),
rarity: 1,
profession: String(t.profession ?? 'TOKEN').toUpperCase(),
subProf: t.subProfessionId ?? t.subProf ?? null,
position: String(t.position ?? 'MELEE').toUpperCase(),
bonds: [],
stats,
rangeGrid: toArrayOfPairs(t.rangeGrid) ?? [[0, 0], [0, 1]],
dmgType: t.dmgType ? normDmgType(t.dmgType) : (/法术/.test(desc) ? 'arts' : /真实/.test(desc) ? 'true' : /恢复|治疗/.test(desc) ? 'heal' : 'phys'),
attackKind: ak,
projectile: t.projectile ?? null,
canHitFly: t.canHitFly ?? null,
targetPriority: t.targetPriority ?? null,
untargetable: t.untargetable ?? /不会受到攻击|不会成为/.test(desc),
immune: immuneSet(t.immunities),
trait: desc,
traitBb: t.trait && typeof t.trait === 'object' ? { ...(t.trait.bb || {}) } : {},
skill,
// chess/token records carry a talent array; enemy-shaped records (炎佑 enemy_9012_acloon) one `{ bb, bbStr }` object
talents: (Array.isArray(t.talents) ? t.talents : t.talents && typeof t.talents === 'object' ? [t.talents] : [])
.filter((x) => x && typeof x === 'object')
.map((x) => ({ name: x.name ?? '', description: x.desc ?? x.description ?? '', bb: x.bb ? { ...x.bb } : flattenBlackboard(x.blackboard), bbStr: x.bbStr ? { ...x.bbStr } : {}, rangeGrid: toArrayOfPairs(x.rangeGrid), tokenKey: x.tokenKey ?? null })),
tokens: [],
count: num(t.count, 1),
// how the owner (with its loadout) produces this token: ⊆ ['talent','skill','display'] (data variant / bySkill);
// [] or ['display'] = the owner does not produce it; null = unknown (no owner variant)
sources: variant && Array.isArray(t.sources) ? [...t.sources] : null,
spine: t.spine ?? t.assets?.spine ?? id,
avatar: t.avatar ?? t.assets?.avatar ?? id,
raw: t0,
};
}
// ---------------------------------------------------------------------------------------------------------------
// stage / route / wave helpers
/** Accepts a stage entry and returns a sim-ready stage `{ id, rows (bottom-first strings), legend, devices }`. */
export function normalizeStage(id, s) {
if (!s) return null;
if (s._norm) return s;
let rows = s.rows ?? s.grid ?? null;
if (!rows && Array.isArray(s.rowsTopDown)) rows = [...s.rowsTopDown].reverse().map((l) => String(l).replace(/^\s*\d+\s/, ''));
const devices = (s.devices ?? []).map((d) => ({
key: d.key ?? d.id,
name: d.name ?? d.key,
row: num(d.row ?? (Array.isArray(d.pos) ? d.pos[0] : d.pos?.row), -1),
col: num(d.col ?? (Array.isArray(d.pos) ? d.pos[1] : d.pos?.col), -1),
dir: d.dir ?? 'UP',
hidden: !!d.hidden,
active: d.active !== false,
role: d.role ?? null,
stats: d.stats ?? null,
raw: d,
}));
return { _norm: true, id: s.id ?? s.stageId ?? id, name: s.name ?? id, rows, legend: s.legend ?? s.tiles ?? null, devices, special: s.special ?? null, raw: s };
}
function pairOf(v) {
if (Array.isArray(v)) return [num(v[0]), num(v[1])];
if (v && typeof v === 'object') return [num(v.row ?? v.r), num(v.col ?? v.c)];
return null;
}
/** Normalise a route (research `{m,s,e,cp}` or data `{motion,start,end,checkpoints}`) into a RouteSpec. */
export function normalizeRoute(r) {
if (!r) return null;
const m = String(r.motion ?? r.m ?? 'W').toUpperCase();
const motion = m.startsWith('F') ? 'FLY' : 'WALK';
const start = pairOf(r.start ?? r.s ?? r.startPosition);
const end = pairOf(r.end ?? r.e ?? r.endPosition);
const cps = [];
if (Array.isArray(r.steps) && r.steps.length) {
for (const st of r.steps) {
const t = String(st.t ?? st.type ?? 'move').toUpperCase();
cps.push({ type: normCpType(t), pos: pairOf(st.p ?? st.pos) ?? [0, 0], time: num(st.s ?? st.time, 0) });
}
return { motion, start, end, checkpoints: cps };
}
for (const cp of r.checkpoints ?? r.cp ?? []) {
if (Array.isArray(cp) && typeof cp[0] === 'number') {
cps.push({ type: 'MOVE', pos: [num(cp[0]), num(cp[1])], time: 0 });
} else if (Array.isArray(cp)) {
const [type, a, b, t] = cp;
cps.push({ type: normCpType(type), pos: [num(a), num(b)], time: num(t, 0) });
} else if (cp && typeof cp === 'object') {
cps.push({ type: normCpType(cp.type), pos: pairOf(cp.pos ?? cp.position ?? [cp.row, cp.col]) ?? [0, 0], time: num(cp.time ?? cp.t, 0) });
}
}
return { motion, start, end, checkpoints: cps };
}
function normCpType(t) {
const s = String(t ?? 'MOVE').toUpperCase();
if (s.includes('DISAPPEAR')) return 'DISAPPEAR';
if (s.includes('APPEAR')) return 'APPEAR';
if (s.includes('WAIT')) return 'WAIT';
return 'MOVE';
}
/**
* Convert a wave template (research roundLevels entry or data/waves.json entry) into `{ routes, spawns }`.
* Research actions: `{ key, n, pre, int, route }` inside waves[].fragments[].actions (times are cumulative:
* wave.preDelay + fragment.preDelay + action.pre).
*/
export function spawnsFromTemplate(tpl, { mods = null, tag = undefined } = {}) {
if (!tpl) return { routes: [], spawns: [] };
const routes = (tpl.routes ?? []).map(normalizeRoute);
const spawns = [];
if (Array.isArray(tpl.spawns)) {
for (const s of tpl.spawns) {
if (s.action && String(s.action).toUpperCase() !== 'SPAWN') continue; // non-spawn actions are content's job
const key = s.enemyKey ?? s.key;
if (!key) continue;
const sp = {
time: num(s.time ?? s.t, 0), enemyKey: key, routeIndex: num(s.routeIndex ?? s.route, 0),
count: Math.max(1, num(s.count ?? s.n, 1)), interval: num(s.interval ?? s.int, 0),
mods: s.mods ?? mods ?? undefined, tag: s.tag ?? tag, bounty: s.bounty, sourcePlayerId: s.sourcePlayerId,
};
if (s.unharmful || sp.tag === 'part') sp.countInTotal = false;
if (s.hidden) sp.hidden = true;
if (s.group != null) sp.group = s.group;
spawns.push(sp);
}
} else {
let waveT = 0;
for (const w of tpl.waves ?? []) {
waveT += num(w.preDelay, 0);
let fragT = waveT;
let fragEnd = fragT;
for (const f of w.fragments ?? []) {
fragT += num(f.preDelay, 0);
for (const a of f.actions ?? []) {
const key = a.key ?? a.enemyKey;
if (!key || !String(key).startsWith('enemy_')) continue;
const n = Math.max(1, num(a.n ?? a.count, 1));
const t0 = fragT + num(a.pre ?? a.preDelay, 0);
const iv = num(a.int ?? a.interval, 0);
spawns.push({ time: t0, enemyKey: key, routeIndex: num(a.route ?? a.routeIndex, 0), count: n, interval: iv, mods: mods ?? undefined, tag });
fragEnd = Math.max(fragEnd, t0 + iv * (n - 1));
}
}
waveT = Math.max(waveT, fragEnd) + num(w.postDelay, 0);
}
}
return { routes, spawns, maxPlayTime: tpl.maxPlayTime ?? tpl.timeLimit ?? null, overrides: tpl.overrides ?? null, extraRoutes: (tpl.extraRoutes ?? []).map(normalizeRoute) };
}
// ---------------------------------------------------------------------------------------------------------------
// data sources
const IS_NODE = typeof process !== 'undefined' && !!(process && process.versions && process.versions.node);
/** Node default loader (./nodeData.js) or null in browsers. */
let nodeLoader = null;
/** Raw generated data (Node: data/*.json) or the data injected with setSimData (browser). */
let generatedCache = null;
/** Raw research maps (Node only; loaded lazily). */
let researchCache = null;
let injected = false;
if (IS_NODE) {
try {
nodeLoader = await import('./nodeData.js');
generatedCache = await nodeLoader.loadGenerated();
} catch {
nodeLoader = null;
}
}
function loadResearch() {
if (researchCache) return researchCache;
researchCache = nodeLoader ? nodeLoader.loadResearch() : { chess: {}, enemies: {}, tokens: {}, stages: {}, waves: {} };
return researchCache;
}
function asMap(v, idKey) {
if (!v) return null;
if (Array.isArray(v)) {
const m = {};
for (const x of v) if (x && x[idKey] != null) m[x[idKey]] = x;
return m;
}
// Allow wrapper objects like { chess: {...} } or { enemies: {...} }
return v;
}
function unwrap(v, key) {
if (v && typeof v === 'object' && !Array.isArray(v) && v[key] && typeof v[key] === 'object' && Object.keys(v).length <= 4) return v[key];
return v;
}
/**
* Deep-freeze a normalised def (chess / enemy / token). Defs are cached and shared by every unit of every battle in
* the process: a kit or content module that mutated one (`bb.x = …`, `def.rangeGrid.push(…)`) would silently change
* later battles and break determinism. Frozen, such a write throws inside the (guarded) content code instead.
* `raw` (the source record) and Sets (immunities) are left as they are.
*/
export function freezeDef(d) {
if (!d || typeof d !== 'object' || Object.isFrozen(d)) return d;
Object.freeze(d);
for (const k of Object.keys(d)) {
if (k === 'raw') continue;
const v = d[k];
if (v && typeof v === 'object' && !(v instanceof Set) && !(v instanceof Map)) freezeDef(v);
}
return d;
}
/**
* A DataSource resolves ids to normalised defs. `raw` is `{ chess, enemies, tokens, stages, waves }`.
*/
export class DataSource {
constructor(raw = {}, fallback = null) {
this.raw = {
chess: asMap(unwrap(raw.chess, 'chess'), 'chessId') ?? {},
enemies: asMap(unwrap(raw.enemies, 'enemies'), 'key') ?? {},
tokens: asMap(unwrap(raw.tokens, 'tokens'), 'tokenId') ?? {},
stages: asMap(unwrap(raw.stages, 'stages'), 'id') ?? {},
waves: asMap(unwrap(raw.waves, 'waves'), 'id') ?? {},
};
this.fallback = fallback;
this._chess = new Map();
this._enemy = new Map();
this._token = new Map();
this._stage = new Map();
}
rawChess(id) { return this.raw.chess[id] ?? this.fallback?.rawChess(id) ?? null; }
rawEnemy(key) {
return this.raw.enemies[key] ?? this.raw.enemies['enemy_' + key] ?? this.raw.enemies[String(key).replace(/^enemy_/, '')] ?? this.fallback?.rawEnemy(key) ?? null;
}
rawToken(id) { return this.raw.tokens[id] ?? this.fallback?.rawToken(id) ?? null; }
rawStage(id) { return this.raw.stages[id] ?? this.fallback?.rawStage(id) ?? null; }
rawWave(id) { return this.raw.waves[id] ?? this.fallback?.rawWave(id) ?? null; }
/**
* Chess def for the default loadout, or for `loadout` `{ skillIndex, moduleId }` (DESIGN §16; resolved with
* resolveLoadout — illegal choices fall back to the default). `def.loadout` = the resolved loadout (null for records
* without skill choices); `def.id` is always the chess id. Defs are cached per (id, resolved loadout).
*/
getChess(id, loadout = null) {
if (loadout == null && this._chess.has(id)) return this._chess.get(id);
const r = this.rawChess(id);
const lo = r ? resolveLoadout(r, loadout) : null;
const key = id + loadoutKey(lo);
if (this._chess.has(key)) return this._chess.get(key);
let d = null;
if (r) {
d = normalizeChess({ chessId: id, ...loadoutRecord(r, lo) });
d.loadout = lo;
freezeDef(d);
}
this._chess.set(key, d);
return d;
}
getEnemy(key) {
if (this._enemy.has(key)) return this._enemy.get(key);
const r = this.rawEnemy(key);
const k = String(key).startsWith('enemy_') ? key : 'enemy_' + key;
const d = r ? freezeDef(normalizeEnemy(k, r)) : null;
this._enemy.set(key, d);
return d;
}
/**
* Token def; `ownerChessId` selects owner-specific stats when the data has them, `ownerLoadout` (`{ skillIndex,
* moduleId }`, e.g. the owner def's `loadout`) the owner's selected skill / module: `variants[owner].bySkill[i]`
* (token skill of that slot, count, sources) and `.byModule[id]` (module token attributes, trait, talents).
* `def.sources` / `def.count` are those of the owner's loadout (a hand piece exists only when `sources` has more
* than 'display'; PlayerState / the kits' skill summons read them from here).
*/
getToken(id, ownerChessId = null, ownerLoadout = null) {
const olo = ownerChessId && ownerLoadout ? resolveLoadout(this.rawChess(ownerChessId), ownerLoadout) : null;
const ck = id + '|' + (ownerChessId ?? '') + loadoutKey(olo);
if (this._token.has(ck)) return this._token.get(ck);
let r = this.rawToken(id);
if (r && ownerChessId && !r.variants) {
// research shape: byOwner[chessId] holds the owner-specific token record
const byOwner = r.byOwner?.[ownerChessId] ?? r.statsByOwner?.[ownerChessId];
if (byOwner) r = { ...r, ...(byOwner.stats ? byOwner : { stats: byOwner }) };
}
if (!r && ownerChessId) {
// look inside the owner chess record
const owner = this.rawChess(ownerChessId);
const t = (owner?.tokens ?? []).find((x) => x && typeof x === 'object' && (x.tokenId ?? x.id) === id);
if (t) r = t;
}
let owner = ownerChessId;
if (r && r.variants && owner && !r.variants[owner]) {
// fall back to the normal/golden sibling variant, then any variant
const alt = String(owner).replace(/_b$/, '_a');
owner = r.variants[alt] ? alt : null;
}
if (r && r.variants && !owner) owner = Object.keys(r.variants)[0] ?? null;
let variant = r && r.variants && owner ? r.variants[owner] ?? null : null;
if (variant && olo && !olo.isDefault) {
if (!olo.skillIsDefault && variant.bySkill && variant.bySkill[olo.skillIndex]) variant = { ...variant, ...variant.bySkill[olo.skillIndex] };
if (!olo.moduleIsDefault && variant.byModule && variant.byModule[olo.moduleId]) variant = { ...variant, ...variant.byModule[olo.moduleId] };
}
const d = r ? freezeDef(normalizeToken(id, r, owner, variant)) : null;
this._token.set(ck, d);
return d;
}
getStage(id) {
if (this._stage.has(id)) return this._stage.get(id);
const d = normalizeStage(id, this.rawStage(id));
this._stage.set(id, d);
return d;
}
getWave(id) { return this.rawWave(id); }
chessIds() {
const s = new Set(Object.keys(this.raw.chess));
if (this.fallback) for (const k of this.fallback.chessIds()) s.add(k);
return [...s];
}
stageIds() {
const s = new Set(Object.keys(this.raw.stages));
if (this.fallback) for (const k of this.fallback.stageIds()) s.add(k);
return [...s];
}
waveIds() {
const s = new Set(Object.keys(this.raw.waves));
if (this.fallback) for (const k of this.fallback.waveIds()) s.add(k);
return [...s];
}
}
let defaultSource = null;
let researchSource = null;
/** Research-only data source (Node: docs/research JSON; browsers: empty). Used as fallback and by tests. */
export function getResearchSource() {
if (!researchSource) researchSource = new DataSource(loadResearch());
return researchSource;
}
/** Whether generated (or injected) data with chess records is available. */
export function hasGeneratedData() {
const g = generatedCache;
return !!(g && g.chess && Object.keys(unwrap(g.chess, 'chess') || {}).length > 0);
}
/**
* Default data source. Node: generated data with the research fallback. Browser: the data injected with setSimData
* (no fallback: server and client battles must resolve every id identically), empty before that.
*/
export function getDefaultSource() {
if (!defaultSource) defaultSource = injected || !nodeLoader ? new DataSource(generatedCache ?? {}, null) : new DataSource(generatedCache ?? {}, getResearchSource());
return defaultSource;
}
/**
* Inject the game data (browser: the fetched /data/*.json as `{ chess, enemies, tokens, stages, waves, bonds, items,
* garrisons, bands, effects, … }`). It becomes the default source and the `getSimData()` object. Returns the new
* default DataSource.
* @param {object} data
*/
export function setSimData(data) {
generatedCache = data && typeof data === 'object' ? data : null;
injected = true;
defaultSource = null;
return getDefaultSource();
}
/** The raw data object behind the default source (injected or generated), or null. */
export function getSimData() {
return generatedCache;
}
/** Reset caches (tests / hot reload after data regeneration). Node reloads data/*.json; browsers keep the injection. */
export async function reloadDataSources() {
if (nodeLoader && !injected) generatedCache = await nodeLoader.reloadGenerated();
defaultSource = null;
}
/** Build a DataSource from an injected object (maps or a DataSource instance). */
export function toDataSource(data) {
if (!data) return getDefaultSource();
if (data instanceof DataSource) return data;
if (typeof data.getChess === 'function' && typeof data.getEnemy === 'function') return data;
return new DataSource(data, getDefaultSource());
}
/**
* Per-battle data view resolving operator loadouts from the battle's PlayerBattleInput units (DESIGN §16): `getChess(id)`
* and `getToken(id, ownerChessId)` without an explicit loadout use the loadout the inputs give that chess id (an
* operator entry without `skillIndex` / `moduleId` = the default). Everything else is the underlying source (prototype
* delegation: caches stay shared). Within one field a chess id has one loadout per player (loadouts are per base chess);
* when two players of a multi-player field give the same chess id different loadouts the first one wins for id-only
* lookups (`view.loadoutConflicts` lists them) — callers that pass the unit's own loadout (`getChess(id, { skillIndex,
* moduleId })`, `getToken(id, owner, ownerDef.loadout)`) are always exact. Returns `ds` itself when no unit carries a
* loadout, and an existing view unchanged (`isLoadoutView`). Used by spec.js createBattleFromSpec and by
* content/index.js installContent (every Battle, however constructed).
* @param {object} ds DataSource
* @param {object[]} players PlayerBattleInput[] (spec players)
*/
export function withUnitLoadouts(ds, players) {
if (!ds || typeof ds.getChess !== 'function' || isLoadoutView(ds)) return ds;
const map = new Map();
const conflicts = new Set();
let any = false;
for (const p of Array.isArray(players) ? players : []) {
for (const u of (p && Array.isArray(p.units) ? p.units : [])) {
if (!u || u.kind === 'token' || typeof u.chessId !== 'string') continue;
if (u.skillIndex != null || u.moduleId != null) any = true;
const lo = { skillIndex: u.skillIndex ?? null, moduleId: u.moduleId ?? null };
const prev = map.get(u.chessId);
if (!prev) map.set(u.chessId, lo);
else if (prev.skillIndex !== lo.skillIndex || prev.moduleId !== lo.moduleId) conflicts.add(u.chessId);
}
}
if (!any) return ds;
const view = Object.create(ds);
view.getChess = (id, loadout = null) => ds.getChess(id, loadout ?? map.get(id) ?? null);
if (typeof ds.getToken === 'function') {
view.getToken = (id, owner = null, ownerLoadout = null) => ds.getToken(id, owner, ownerLoadout ?? (owner != null ? map.get(owner) ?? null : null));
}
view.loadoutConflicts = [...conflicts];
return view;
}
/** Whether `ds` is a per-battle loadout view (withUnitLoadouts). */
export function isLoadoutView(ds) {
return !!ds && typeof ds === 'object' && Object.prototype.hasOwnProperty.call(ds, 'loadoutConflicts');
}
+405
View File
@@ -0,0 +1,405 @@
// server/sim/skills.js — skill runtime: SP, charges, trigger rules, kinds, SkillSpec interpretation (DESIGN §5.6).
//
// SP types: 'time' (+spRecovery/s), 'attack' (+1 per attack), 'hurt' (+1 per hit taken), 'none'.
// No SP gain while a duration/ammo/toggle skill is active, while stunned, or while the unit has the noSp flag.
// Charges (maxCharges > 1): SP fills to spCost → +1 charge (SP restarts) until charges == max (SP stays full).
// Trigger rules: DEFAULT (ready + about to attack/heal + enemy / injured ally in the INITIAL range — or, checked every
// tick, an enemy inside one of the content trigger ranges added with addTriggerRange: 海嗣, 流形),
// TAKE_DAMAGE (ready + just took a hit), SP_FULL/ALWAYS (as soon as ready), CUSTOM_RANGE (enemy inside the
// custom trigger grid), SEARCH (an enemy inside the INITIAL range, checked every tick without waiting for an attack:
// BWIKI "解放者/阵法术师子职业干员 技能1,初始攻击范围内出现敌人后自动释放", research 03 Addendum C1 — not any enemy on
// the field, which burnt the skill on enemies that had just spawned), GDGLOW_SKILL_2 = DEFAULT, NEVER/MANUAL (never
// auto-cast: the kit calls skill.activate() itself); any unknown rule falls back to DEFAULT. Units that never attack
// (noAttack profiles) evaluate DEFAULT every tick instead.
// Kinds: duration (mods for `duration` s), ammo (mods until `ammo` attacks were made; optional duration cap),
// instant (onStart + optional one-shot attack override for the next attack), charges (= instant with charges),
// passive (always on from deployment, no SP), toggle (stays on until death once activated).
// SkillSpec fields (all optional): kind, duration, ammo, spCost, initSp, charges, spType, trigger,
// mods, flags, targeting {maxTargets, rangeGrid, priority, allInRange, rangeExtend},
// attack {dmgType, atkScale, splashRadius, splashScale, hits, projectile, maxTargets, dmgMul, onHit, heal…},
// heal (bool: heal-type skill for the trigger rule), onStart(ctx), onEnd(ctx), onHit(ctx), onAttack(ctx), onTick(ctx).
// ctx passed to spec callbacks: { battle, unit, skill, bb, target?, dealt?, targets?, dt?, reason? }; onAttack's ctx
// also carries `noAmmo` (set it to true: this attack spends no ammo). onEnd runs while the skill's mods / range are
// still applied (`active` is already false); they are removed right after it (unless onEnd re-activated the skill).
import { absoluteRangeKeys } from './targeting.js';
const TICK_RULES = new Set(['SP_FULL', 'SEARCH', 'CUSTOM_RANGE']);
/** Enemies that satisfy a content trigger range (any targetable enemy, flyers included). */
const TRIGGER_PROFILE = Object.freeze({ canHitFly: true });
export class SkillRuntime {
/**
* @param {object} battle
* @param {object} unit
* @param {object|null} def normalised skill def (data)
* @param {object|null} spec SkillSpec from the kit
* @param {object} bb blackboard
*/
constructor(battle, unit, def, spec, bb = {}) {
Object.defineProperty(this, 'battle', { value: battle, writable: true, enumerable: false });
Object.defineProperty(this, 'unit', { value: unit, writable: true, enumerable: false });
this.def = def || {};
this.spec = spec || {};
this.bb = bb;
const s = this.spec;
const d = this.def;
this.id = s.id ?? d.id ?? 'skill';
this.name = s.name ?? d.name ?? '';
this.kind = s.kind ?? 'instant';
this.duration = Number.isFinite(+s.duration) ? +s.duration : (d.duration > 0 ? d.duration : 0);
this.ammo = Math.max(0, Math.floor(+s.ammo || 0));
this.baseSpCost = Math.max(0, Number.isFinite(+s.spCost) ? +s.spCost : (d.spCost ?? 0));
this.initSp = Math.max(0, Number.isFinite(+s.initSp) ? +s.initSp : (d.initSp ?? 0));
this.maxCharges = Math.max(1, Math.floor(s.charges ?? d.maxCharges ?? 1));
this.spType = s.spType ?? d.spType ?? 'time';
const trig = typeof s.trigger === 'string' ? { rule: s.trigger } : (s.trigger || d.trigger || {});
this.rule = String(trig.rule ?? 'DEFAULT').toUpperCase();
if (this.rule === 'ALWAYS') this.rule = 'SP_FULL';
if (this.rule === 'MANUAL') this.rule = 'NEVER';
if (this.rule.startsWith('CUSTOM_RANGE')) this.rule = 'CUSTOM_RANGE';
this.triggerGrid = trig.grid ?? trig.rangeGrid ?? d.trigger?.grid ?? null;
this.healSkill = s.heal ?? (unit.profile && unit.profile.dmgType === 'heal' && !!unit.profile.heal);
if (this.kind === 'passive') this.baseSpCost = 0;
this._spCostMul = 1; // content may set spCostMul (e.g. 绝技 ×0.7) — see the accessor below
this.sp = 0;
this.charges = 0;
this.active = false;
this.timeLeft = 0;
this.ammoLeft = 0;
this.pending = false; // instant/charges: next attack uses spec.attack
this.activations = 0;
this.lastStart = -Infinity;
this._buffKey = `skill:${unit.id}`;
this._trigKeys = null;
this.triggerRanges = []; // content trigger ranges (addTriggerRange)
this.noSkill = !spec; // unit without any skill spec
}
/**
* Extra DEFAULT-trigger range (海嗣 "攻击范围视为自身攻击范围的延伸", 流形): `fn(battle, unit)` returns a list whose
* entries are ally units (their current `rangeKeys` count while they are on the field) or arrays of absolute tile
* keys. A targetable enemy (flyers included) on those tiles satisfies the DEFAULT rule (and unknown DEFAULT-like
* rules); it is checked every tick, since the unit itself may have nothing to attack. Returns an unregister fn.
*/
addTriggerRange(fn) {
if (typeof fn !== 'function') return () => {};
this.triggerRanges.push(fn);
return () => { const i = this.triggerRanges.indexOf(fn); if (i >= 0) this.triggerRanges.splice(i, 1); };
}
/** An enemy inside one of the content trigger ranges. */
_extraTriggerSatisfied() {
const b = this.battle;
const u = this.unit;
for (const fn of this.triggerRanges) {
const list = b._safe(() => fn(b, u), 'skill.triggerRange', u);
if (!list || typeof list[Symbol.iterator] !== 'function') continue;
for (const x of list) {
let keys = null;
if (Array.isArray(x)) keys = x;
else if (x && typeof x === 'object' && x.side === 'ally' && x.alive && x.deployed && !x.hidden) keys = x.rangeKeys;
if (keys && keys.length && b.enemiesInKeys(keys, u, TRIGGER_PROFILE).length) return true;
}
}
return false;
}
get spCost() {
const c = Math.floor(this.baseSpCost * this.spCostMul + (this.unit.s ? this.unit.s.spCostFlat : 0));
// content writes spCostMul / spCostFlat: a NaN/Infinity cost would freeze SP forever — use the base cost instead
return Number.isFinite(c) ? Math.max(0, c) : this.baseSpCost;
}
/** SP cost multiplier (content-writable). Invalid values count as 1; SP/charges are re-normalised immediately. */
get spCostMul() { return this._spCostMul; }
set spCostMul(v) {
this._spCostMul = typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 1;
if (this.unit) this._normalize();
}
get ready() { return !this.noSkill && this.kind !== 'passive' && this.charges >= 1; }
get isTimed() { return this.kind === 'duration' || this.kind === 'ammo' || this.kind === 'toggle'; }
_ctx(extra) { return { battle: this.battle, unit: this.unit, skill: this, bb: this.bb, ...extra }; }
_call(fnName, extra) {
const fn = this.spec[fnName];
if (typeof fn !== 'function') return undefined;
return this.battle._safe(() => fn(this._ctx(extra)), `skill.${fnName}`, this.unit);
}
/** Called on every (re)deployment. `carry` = { sp, skillActive } for unite helpers. */
reset(carry = null) {
this.active = false;
this.pending = false;
this.timeLeft = 0;
this.ammoLeft = 0;
this.charges = 0;
this.sp = 0;
this._trigKeys = null;
if (this.noSkill) return;
if (this.kind === 'passive') {
this._startPassive();
return;
}
this.sp = 0;
this.gainSp(carry && Number.isFinite(carry.sp) ? carry.sp : this.initSp, 'init', true);
// a free (spCost 0) non-passive skill is available once per deployment
if (this.spCost <= 0) this.charges = this.maxCharges;
// unite helpers whose timed skill was running when their combat ended: it keeps running (a fresh duration/ammo),
// without spending a charge — the carried SP is what they had accumulated (0 while a skill runs).
if (carry && carry.skillActive && this.isTimed) this.activate('carry', { free: true });
}
_startPassive() {
this.active = true;
this._applyMods();
this._call('onStart', { reason: 'passive' });
}
_applyMods() {
const s = this.spec;
if (s.mods || s.flags) {
this.battle.addBuff(this.unit, { key: this._buffKey, mods: s.mods || null, flags: s.flags || null, tags: ['skill'] });
}
if (s.targeting && (s.targeting.rangeGrid || s.targeting.rangeExtend)) this.battle._refreshRange(this.unit);
}
_removeMods() {
this.battle.removeBuff(this.unit, this._buffKey);
if (this.spec.targeting && (this.spec.targeting.rangeGrid || this.spec.targeting.rangeExtend)) this.battle._refreshRange(this.unit);
}
/** Add SP (fires the `spGain` hook). Ignored while a duration/ammo/toggle skill runs (its bar shows the skill). */
gainSp(amount, reason = 'time', silent = false) {
if (this.noSkill || this.kind === 'passive' || !(amount > 0)) return 0;
if (this.active && this.isTimed && reason !== 'init') return 0;
let cost = this.spCost;
if (this.charges >= this.maxCharges && this.sp >= cost) return 0;
let amt = amount;
const b = this.battle;
if (!silent && b._hooks.spGain) {
const ctx = { unit: this.unit, amount: amt, reason, skill: this };
b.emit('spGain', ctx);
amt = Number.isFinite(ctx.amount) ? ctx.amount : 0;
if (!(amt > 0)) return 0;
// handlers may change the cost (spCostMul), end the skill or kill the unit: re-read, never use a stale cost
if (this.kind === 'passive' || this.noSkill) return 0;
cost = this.spCost;
if (this.charges >= this.maxCharges && this.sp >= cost) return 0;
}
if (cost <= 0) return 0; // free skills never recharge (see reset)
this.sp += amt;
while (this.sp >= cost && this.charges < this.maxCharges) {
this.charges++;
if (this.charges < this.maxCharges) this.sp -= cost;
else this.sp = cost;
}
if (this.charges >= this.maxCharges) this.sp = cost;
return amt;
}
/** Re-establish sp/charges invariants after an SP-cost change (e.g. content sets spCostMul mid-battle). */
_normalize() {
if (this.noSkill || this.kind === 'passive') return;
const cost = this.spCost;
if (cost <= 0) { if (this.sp > 0) { this.sp = 0; this.charges = this.maxCharges; } return; } // became free
while (this.sp >= cost && this.charges < this.maxCharges) {
this.charges++;
this.sp = this.charges < this.maxCharges ? this.sp - cost : cost;
}
if (this.sp > cost) this.sp = cost;
}
/** Per-tick update: durations, SP regen, tick-based triggers. */
tick(dt) {
if (this.noSkill) return;
const u = this.unit;
if (this.kind !== 'passive' && this.sp > this.spCost) this._normalize();
if (this.active && this.kind !== 'passive') {
if (this.spec.onTick) this._call('onTick', { dt });
if (this.kind === 'duration' || (this.kind === 'ammo' && Number.isFinite(this.timeLeft))) {
this.timeLeft -= dt;
if (this.timeLeft <= 1e-9) { this.end('duration'); }
}
} else if (this.active && this.kind === 'passive' && this.spec.onTick) {
this._call('onTick', { dt });
}
if (!u.canAct) return;
if (this.spType === 'time' && !(this.active && this.isTimed) && !u.s.flags.noSp) {
const rate = u.s.spRecovery;
if (rate > 0) this.gainSp(rate * dt, 'time');
}
if (!this.ready || u.s.flags.silence) return;
if (this.active && this.isTimed) return;
if (TICK_RULES.has(this.rule)) {
if (this._tickRuleSatisfied()) this.activate(this.rule);
} else if (this.rule !== 'TAKE_DAMAGE' && this.rule !== 'NEVER') {
// DEFAULT (and unknown rules) for units that cannot attack right now: check the initial range every tick;
// content trigger ranges are checked every tick for everyone (nothing may be in the unit's own range)
const prof = u.profile;
if (prof && (prof.noAttack || (prof.noAttackUnlessSkill && !this.active))) {
if (this._defaultCondition()) this.activate('DEFAULT');
} else if (this.triggerRanges.length && !this.healSkill && this._extraTriggerSatisfied()) this.activate('DEFAULT');
}
}
_tickRuleSatisfied() {
const b = this.battle;
const u = this.unit;
if (this.rule === 'SP_FULL') return true;
// SEARCH: an enemy inside the initial attack range (research 03 Addendum C1), every tick (librators / phalanxes
// and 安洁莉娜 do not attack while the skill is off, so DEFAULT's "about to attack" never comes)
if (this.rule === 'SEARCH') return this._defaultCondition();
if (this.rule === 'CUSTOM_RANGE') {
if (!this.triggerGrid) return this._defaultCondition();
if (!this._trigKeys) this._trigKeys = absoluteRangeKeys(this.triggerGrid, u.tileR, u.tileC, u.dir, 0);
return b.enemiesInKeys(this._trigKeys, u, { canHitFly: true }).length > 0;
}
return false;
}
/**
* DEFAULT rule condition: an enemy (or injured ally for heal skills) inside the initial range (baseRangeKeys: own
* grid + permanent rangeExtend), or an enemy inside a content trigger range (addTriggerRange; not for heal skills).
*/
_defaultCondition() {
const b = this.battle;
const u = this.unit;
if (b.rangeChanged(u)) b._refreshRange(u);
const keys = u.baseRangeKeys || u.rangeKeys;
if (keys) {
if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0;
if (b.enemiesInKeys(keys, u, u.profile).length > 0) return true;
}
return !this.healSkill && this.triggerRanges.length > 0 && this._extraTriggerSatisfied();
}
/** Called by the attack loop right before a normal attack/heal. Returns true when the skill fired. */
onAboutToAttack() {
if (!this.ready || this.unit.s.flags.silence) return false;
if (this.active && this.isTimed) return false;
if (this.rule === 'TAKE_DAMAGE' || this.rule === 'NEVER' || TICK_RULES.has(this.rule)) return false;
if (!this._defaultCondition()) return false;
return this.activate('DEFAULT');
}
/** TAKE_DAMAGE trigger + INCREASE_WHEN_TAKEN_DAMAGE SP. */
onDamaged() {
if (this.noSkill) return;
if (this.spType === 'hurt' && !(this.active && this.isTimed)) this.gainSp(1, 'hurt');
if (this.rule === 'TAKE_DAMAGE' && this.ready && !(this.active && this.isTimed) && this.unit.canAct && !this.unit.s.flags.silence) {
this.activate('TAKE_DAMAGE');
}
}
/** Activate now (consumes one charge). Returns true on success. */
activate(reason = 'manual', { free = false } = {}) {
if (this.noSkill || this.kind === 'passive') return false;
if (!free && !this.ready) return false;
if (this.active && this.isTimed) return false;
const u = this.unit;
if (!u.alive || !u.deployed) return false;
if (!free) {
const wasFull = this.charges >= this.maxCharges;
this.charges--;
if (this.maxCharges === 1 || wasFull) this.sp = 0;
}
this.activations++;
this.lastStart = this.battle.time;
const b = this.battle;
if (this.isTimed) {
this.active = true;
this.timeLeft = this.kind === 'duration' ? Math.max(0.01, this.duration) : (this.kind === 'ammo' && this.duration > 0 ? this.duration : Infinity);
this.ammoLeft = this.kind === 'ammo' ? Math.max(1, this.ammo) : 0;
this._applyMods();
} else {
// instant / charges
this.active = true;
this.pending = !!this.spec.attack;
// (a targeting-only instant skill must still switch to its skill range for the pending attack)
if (this.spec.mods || this.spec.flags || this.spec.targeting) this._applyMods();
}
b._ev(['skill', u.id, 1]);
u.skillAnimUntil = b.time + 0.5;
this._call('onStart', { reason });
if (b._hooks.skillStart) b.emit('skillStart', { unit: u, skill: this, reason });
if (!this.isTimed && !this.pending) this.end('instant');
return true;
}
/**
* Called after each attack/heal performed by the unit. `noAmmo` (Battle.forceAttack opts, or `ctx.noAmmo = true`
* set by the spec's onAttack) = this attack spends no ammo of a running ammo skill (no `ammoUsed`).
*/
onAttackPerformed(targets, usedOverride, noAmmo = false) {
if (this.noSkill) return;
const b = this.battle;
// AK: attacks made by the skill (the "next attack" of an instant/charge skill, every attack of a running timed
// skill — including the one that ends it) never recover attack-type SP ⇒ a cost-N skill fires every N+1 attacks.
const skillAttack = this.active && (this.isTimed || (!!usedOverride && this.pending));
if (this.active && typeof this.spec.onAttack === 'function') {
const fn = this.spec.onAttack;
const ctx = this._ctx({ targets, noAmmo: !!noAmmo });
b._safe(() => fn(ctx), 'skill.onAttack', this.unit);
noAmmo = !!ctx.noAmmo;
}
if (this.active && this.kind === 'ammo' && noAmmo) {
// an extra attack that spends no bullet
} else if (this.active && this.kind === 'ammo') {
this.ammoLeft--;
if (b._hooks.ammoUsed) b.emit('ammoUsed', { unit: this.unit, left: this.ammoLeft, skill: this });
if (this.ammoLeft <= 0) this.end('ammo');
} else if (usedOverride && this.pending && !this.isTimed) {
this.pending = false;
this.end('instant');
}
if (this.spType === 'attack' && !skillAttack && !(this.active && this.isTimed)) this.gainSp(1, 'attack');
}
/**
* End the active skill. onEnd runs while the skill's mods / range are still applied (end-of-skill effects — finishers,
* bombardments — use the skill's stats and range; `active` is already false), then they are removed (kept when onEnd
* re-activated the skill), then `skillEnd` fires.
*/
end(reason = 'end') {
if (!this.active || this.kind === 'passive' && reason !== 'death') return;
const u = this.unit;
const b = this.battle;
this.active = false;
this.pending = false;
this.timeLeft = 0;
this.ammoLeft = 0;
const n = this.activations;
this._call('onEnd', { reason });
if (!this.active && this.activations === n) this._removeMods();
if (b._hooks.skillEnd) b.emit('skillEnd', { unit: u, skill: this, reason });
if (this.kind !== 'passive') b._ev(['skill', u.id, 0]);
}
/** The attack override to use for the next attack, or null. */
attackOverride() {
if (!this.active) return null;
if (this.isTimed || this.kind === 'passive') return this.spec.attack || null;
return this.pending ? (this.spec.attack || null) : null;
}
targetingOverride() {
if (!this.active || !this.spec.targeting) return null;
if (!this.isTimed && this.kind !== 'passive' && !this.pending) return null;
return this.spec.targeting;
}
// ---- helpers for content -------------------------------------------------------------------------------
// (non-finite arguments are ignored: a NaN timer/ammo count would keep the skill active forever)
addAmmo(n) { if (this.active && this.kind === 'ammo' && Number.isFinite(n)) this.ammoLeft += n; }
extend(seconds) { if (this.active && Number.isFinite(this.timeLeft) && Number.isFinite(seconds)) this.timeLeft += seconds; }
addCharge(n = 1) { if (!Number.isFinite(n)) return; this.charges = Math.max(0, Math.min(this.maxCharges, this.charges + n)); if (this.charges >= this.maxCharges) this.sp = this.spCost; }
stop() { this.end('stopped'); }
[Symbol.for('nodejs.util.inspect.custom')]() {
return `Skill<${this.id} ${this.kind} sp=${this.sp.toFixed(2)}/${this.spCost} ch=${this.charges}${this.active ? ' active' : ''}>`;
}
}
+97
View File
@@ -0,0 +1,97 @@
// server/sim/snapshot.js — compact serialization for clients (DESIGN §8.2).
//
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total }
// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId? }
// dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1).
// flags bits & anim codes come from shared/constants.js (UF / ANIM).
import { UF, ANIM } from '../../shared/constants.js';
import { DIE_ANIM_TIME, ATTACK_ANIM_TIME, DEPLOY_ANIM_TIME } from './constants.js';
const r2 = (v) => Math.round(v * 100) / 100;
const r1 = (v) => Math.round(v * 10) / 10;
/** Static per-unit info sent on spawn / in m.field. */
export function unitInfo(u) {
const d = u.def || {};
return {
id: u.id,
kind: u.kind,
side: u.side,
ownerId: u.ownerId ?? null,
defId: u.defId,
name: u.name,
tier: d.tier ?? (d.rank === 'BOSS' ? 3 : d.rank === 'ELITE' ? 2 : 1),
golden: !!d.golden,
spine: d.spine ?? d.charId ?? u.defId,
avatar: d.avatar ?? d.charId ?? u.defId,
x: r2(u.x),
y: r2(u.y),
facing: u.facing ?? 1,
dir: u.dir ?? 'RIGHT',
maxHp: Math.max(1, Math.round(u.s.maxHp)),
motion: u.motion === 'FLY' ? 'FLY' : undefined,
boss: u.isBoss ? true : undefined,
uid: u.uid ?? undefined,
// DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound)
skillIndex: u.side === 'ally' && Number.isInteger(d.skill?.index) ? d.skill.index : undefined,
// DESIGN §16: an elite ally's equipped module (uniEquipId | 'none'; display only — a teammate's unit in a shared
// field shows its owner's module in the detail card)
moduleId: u.side === 'ally' && d.golden && typeof d.loadout?.moduleId === 'string' ? d.loadout.moduleId : undefined,
};
}
/** Status flag bitmask. */
export function flagsOf(u) {
const f = u.s.flags;
let bits = 0;
if (u.side === 'enemy' ? !!u.blockedBy : u.blocking.length > 0) bits |= UF.BLOCKED;
if (f.stun && !f.freeze && !f.sleep) bits |= UF.STUNNED;
if (f.freeze) bits |= UF.FROZEN;
if (f.stealth) bits |= UF.STEALTH;
if (u.skill && u.skill.active && u.skill.kind !== 'passive') bits |= UF.SKILL;
if (u.s.shield > 0 || u.buffs.some((b) => b.shieldHits > 0)) bits |= UF.SHIELD;
if (f.invulnerable) bits |= UF.INVULN;
if (f.cold) bits |= UF.COLD;
if (f.sleep) bits |= UF.SLEEP;
if (u.motion === 'FLY') bits |= UF.FLYING;
return bits;
}
/** Animation code. */
export function animOf(u, t) {
if (!u.alive) return ANIM.DIE;
if (u.s.flags.stun) return ANIM.STUN;
if (t - u.deployedAt < DEPLOY_ANIM_TIME && u.side === 'ally') return ANIM.DEPLOY;
if (t < (u.skillAnimUntil ?? -1)) return ANIM.SKILL;
if (t - u.lastAttackAt < ATTACK_ANIM_TIME) return u.skill && u.skill.active && u.skill.kind !== 'passive' ? ANIM.SKILL : ANIM.ATTACK;
if (u.side === 'enemy' && u.moving && !u.blockedBy) return ANIM.MOVE;
return ANIM.IDLE;
}
/** Snapshot tuple for one unit. */
export function unitTuple(u, t) {
const sk = u.skill;
const spMax = sk && !sk.noSkill ? sk.spCost : 0;
let sp = sk && !sk.noSkill ? sk.sp : 0;
if (sk && sk.active && sk.isTimed) {
// show remaining duration/ammo as a draining bar
if (sk.kind === 'ammo') sp = spMax * (sk.ammoLeft / Math.max(1, sk.ammo));
else if (Number.isFinite(sk.timeLeft) && sk.duration > 0) sp = spMax * (sk.timeLeft / sk.duration);
}
// hp is rounded up (a living unit never shows 0) but never above the rounded max HP
const maxHp = Math.max(1, Math.round(u.s.maxHp));
const hp = u.alive ? Math.min(Math.max(1, Math.ceil(u.hp)), maxHp) : 0;
return [u.id, r2(u.x), r2(u.y), hp, maxHp, r1(sp), spMax, flagsOf(u), animOf(u, t)];
}
/** Units included in a snapshot: deployed & visible, plus recently dead ones (DIE animation). */
export function snapshotUnits(units, t) {
const out = [];
for (const u of units) {
if (u.hidden) continue;
if (u.alive && u.deployed) out.push(unitTuple(u, t));
else if (!u.alive && t - u.deathAt < DIE_ANIM_TIME && u.deathAt > -Infinity) out.push(unitTuple(u, t));
}
return out;
}
+348
View File
@@ -0,0 +1,348 @@
// server/sim/spec.js — BattleSpec: the JSON description of one battle, built by the server and simulated identically
// by the server (headless / takeover / verification) and by browsers (the authoritative client and display replicas).
// DESIGN §14. Pure ESM (served at /sim/spec.js): no Node API.
//
// const spec = buildBattleSpec({ battleId, fieldId, kind, seed, modeId, round, stageId, rect, timeLimit, players,
// spawns, routes, flags, enemyOverrides, waveId, bossId, content, boss })
// const battle = createBattleFromSpec(spec, dataSource, { sharedBoss?, BattleClass?, logger?, recordEvents?, quiet? })
//
// A spec is JSON-safe by construction (it IS the JSON round trip of its inputs: `undefined` / functions vanish,
// Infinity / NaN become null), so the server builds its own battles from exactly what the clients receive. Object
// references (stage records, the shared boss pool, the data source) never travel: `stageId` names the stage, `boss`
// carries the pool numbers at battle start and the side that runs the battle attaches a pool object:
// * server: the match's SharedBossPool (or a crediting wrapper on takeover, server/match/finalAssault.js),
// * client: a LocalBossPool (below) reconciled with the server's `b.pool` broadcasts.
// Determinism: the sim uses no wall clock and no Math.random; the same spec + the same data give the same result on
// every machine (test/match/clientCombat.test.js compares result digests).
//
// Operator loadouts (DESIGN §16): every operator entry of `players[].units[]` may carry `skillIndex` (character skill
// slot, 0-based) and `moduleId` (uniEquipId or 'none'); buildBattleSpec keeps them when well-formed (else drops them =
// the default). createBattleFromSpec hands the Battle a per-battle data view (withUnitLoadouts) that resolves each
// operator def — and its summons — for the loadout of its chess (simdata getChess(id, loadout) / getToken(id, owner,
// ownerLoadout)); an explicit loadout argument always wins over the view's per-chess lookup.
import { Battle } from './Battle.js';
import { toDataSource, withUnitLoadouts } from './simdata.js';
export const SPEC_VERSION = 1;
/** Deep JSON round trip (exactly what a spec becomes on the wire). */
export function jsonClone(v) {
if (v === undefined) return undefined;
return JSON.parse(JSON.stringify(v));
}
// JSON has no Infinity: ±Infinity inputs become ±1e308 (still "larger than anything" for the sim's comparisons and
// caps) instead of null (which the sim would read as 0); NaN becomes null (the sim treats both as "missing").
const INF = 1e308;
const specReplacer = (k, v) => (typeof v === 'number' && !Number.isFinite(v) ? (Number.isNaN(v) ? null : v > 0 ? INF : -INF) : v);
/**
* Build the JSON BattleSpec of one field (DESIGN §14). Inputs are the Battle options the match computed (DESIGN §5.1)
* minus object references: `stageId` instead of a stage object, `boss: { poolHp, poolMax }` instead of a pool.
* @returns {object} a fresh JSON-safe object
*/
export function buildBattleSpec(o = {}) {
const bossLike = o.kind === 'boss' || o.kind === 'hidden';
const tl = Number(o.timeLimit);
const spec = {
v: SPEC_VERSION,
battleId: o.battleId ?? null,
fieldId: o.fieldId ?? null,
kind: o.kind ?? 'normal',
seed: (Number(o.seed) >>> 0) || 1,
modeId: o.modeId ?? null,
round: Number.isInteger(o.round) ? o.round : 0,
stageId: o.stageId ?? null,
rect: o.rect ?? null,
// boss / hidden fields end by the shared pool or the match (null = no limit)
timeLimit: !bossLike && tl > 0 && Number.isFinite(tl) ? tl : null,
players: Array.isArray(o.players) ? o.players : [],
// a spawn at time Infinity never happens and is not counted (Battle._queueSpawn): leave it out
spawns: (Array.isArray(o.spawns) ? o.spawns : []).filter((x) => !(x && Number(x.time) === Infinity)),
routes: Array.isArray(o.routes) ? o.routes : [],
flags: o.flags ?? {},
enemyOverrides: o.enemyOverrides ?? {},
waveId: o.waveId ?? null,
bossId: o.bossId ?? null,
content: o.content ?? 'full',
boss: bossLike && o.boss ? { poolHp: Number(o.boss.poolHp) || 0, poolMax: Number(o.boss.poolMax) || 1 } : null,
};
const out = JSON.parse(JSON.stringify(spec, specReplacer));
for (const p of out.players) for (const u of (p && Array.isArray(p.units) ? p.units : [])) if (u && typeof u === 'object') sanitizeUnitLoadout(u);
return out;
}
const LOADOUT_ID = /^[A-Za-z0-9_\-]{1,64}$/;
/** Keep a unit's `skillIndex` / `moduleId` only when well-formed (the data layer checks legality). Mutates `u`. */
export function sanitizeUnitLoadout(u) {
if ('skillIndex' in u && !(Number.isInteger(u.skillIndex) && u.skillIndex >= 0 && u.skillIndex <= 9)) delete u.skillIndex;
if ('moduleId' in u && !(typeof u.moduleId === 'string' && LOADOUT_ID.test(u.moduleId))) delete u.moduleId;
if (u.kind === 'token') { delete u.skillIndex; delete u.moduleId; }
return u;
}
// withUnitLoadouts(ds, players) — the per-battle loadout data view — lives in simdata.js (content/index.js
// installContent applies it to every Battle, however constructed); re-exported here for spec users.
export { withUnitLoadouts };
/**
* Construct the Battle a spec describes. The spec is deep-copied (one spec may build several battles: a display
* replica, a takeover re-simulation, a verification run).
* @param {object} spec BattleSpec
* @param {any} dataSource DataSource (or raw data maps; default: the sim's default source)
* @param {{ sharedBoss?: object|null, BattleClass?: Function, logger?: object, recordEvents?: boolean, quiet?: boolean,
* content?: string }} [opts]
*/
export function createBattleFromSpec(spec, dataSource, opts = {}) {
if (!spec || typeof spec !== 'object') throw new TypeError('createBattleFromSpec: spec required');
const s = jsonClone(spec);
const bossLike = s.kind === 'boss' || s.kind === 'hidden';
let sharedBoss = opts.sharedBoss ?? null;
if (!sharedBoss && bossLike && s.boss) sharedBoss = new LocalBossPool(s.boss.poolMax, s.boss.poolHp);
const BattleClass = typeof opts.BattleClass === 'function' ? opts.BattleClass : Battle;
const battleOpts = {
seed: s.seed,
kind: s.kind,
modeId: s.modeId,
round: s.round,
stageId: s.stageId,
rect: s.rect ?? undefined,
timeLimit: s.timeLimit == null ? (bossLike ? Infinity : undefined) : s.timeLimit,
players: s.players ?? [],
spawns: s.spawns ?? [],
routes: s.routes ?? [],
sharedBoss,
flags: s.flags ?? {},
fieldId: s.fieldId,
enemyOverrides: s.enemyOverrides ?? {},
waveId: s.waveId ?? null,
data: BattleClass === Battle ? withUnitLoadouts(toDataSource(dataSource), s.players) : dataSource,
content: opts.content ?? s.content ?? 'full',
};
if (s.bossId != null) battleOpts.bossId = s.bossId;
if (opts.logger) battleOpts.logger = opts.logger;
if (opts.recordEvents === false) battleOpts.recordEvents = false;
if (opts.quiet) battleOpts.quiet = true;
const b = new BattleClass(battleOpts);
b.battleId = s.battleId ?? null;
return b;
}
/**
* Client-side view of the shared boss HP pool (DESIGN §14 b.pool): the server owns the pool; this field adds its own
* damage locally and shows `server hp − local damage the server has not acknowledged yet`.
* damage(playerId, amount) called by the sim; returns the damage dealt (≤ the remaining displayed hp)
* sync(serverHp, ackedCum) a b.pool broadcast: the server's hp and how much of THIS field's cumulative damage
* (`cum`) it has already counted
* cum / byPlayer cumulative damage of this field (reported as b.progress.bossDmg / .by)
*/
export class LocalBossPool {
constructor(maxHp, hp = maxHp) {
this.maxHp = Math.max(1, Number(maxHp) || 1);
const h = Number(hp);
this.serverHp = Number.isFinite(h) ? Math.max(0, Math.min(this.maxHp, h)) : this.maxHp;
this.cum = 0;
this.acked = 0;
/** @type {Record<string, number>} */
this.byPlayer = {};
}
get hp() { return Math.max(0, this.serverHp - Math.max(0, this.cum - this.acked)); }
/** Only used by the sim's fallback path when damage() throws. */
set hp(v) {
const n = Number(v);
if (Number.isFinite(n)) this.serverHp = Math.max(0, n) + Math.max(0, this.cum - this.acked);
}
damage(playerId, amount) {
const a = Number(amount);
const left = this.hp;
if (!Number.isFinite(a) || a <= 0 || left <= 0) return 0;
const dealt = Math.min(left, a);
this.cum += dealt;
if (playerId != null) this.byPlayer[playerId] = (this.byPlayer[playerId] || 0) + dealt;
return dealt;
}
sync(serverHp, ackedCum) {
const h = Number(serverHp);
if (Number.isFinite(h)) this.serverHp = Math.max(0, Math.min(this.maxHp, h));
const a = Number(ackedCum);
if (Number.isFinite(a) && a >= 0) this.acked = a;
}
}
/**
* LP meter of a battle: cumulative LP the field cost its team so far — enemy leaks weighted by `lpr` (data
* lifePointReduce; 0 for harmless units, 1 when absent) plus leader "扣除目标生命" effects (the sim's 'lpLoss' hook).
* Boss / hidden fields report it as b.progress.leaks; the server charges the team LP with the deltas.
* @returns {{ lp: number, detach: () => void }}
*/
export function attachLpMeter(battle) {
const meter = { lp: 0, detach() {} };
if (!battle || typeof battle.on !== 'function') return meter;
const h1 = battle.on('enemyLeak', (ctx) => {
const e = ctx && ctx.enemy;
if (!e) return;
const lpr = Number.isFinite(e.lpr) && e.lpr >= 0 ? e.lpr : 1;
meter.lp += lpr;
}, { priority: -1000, owner: 'lpMeter' });
const h2 = battle.on('lpLoss', (ctx) => {
const n = Number(ctx && ctx.amount);
if (Number.isFinite(n) && n > 0) meter.lp += n;
}, { priority: -1000, owner: 'lpMeter' });
meter.detach = () => { try { battle.off?.(h1); battle.off?.(h2); } catch { /* ignore */ } };
return meter;
}
/**
* Progress numbers of a battle for b.progress / the teammates' waiting UI: game time, kills, total, counted leaks
* (normal / unite) and the boss pool damage of this field.
*/
export function battleProgress(battle) {
const r = battle && typeof battle.result === 'function' && battle.finished ? battle.result() : null;
let leaks = 0;
const pp = battle && battle._perPlayer ? battle._perPlayer : (r && r.perPlayer) || {};
for (const k of Object.keys(pp)) for (const l of pp[k].leaked || []) if (l && l.counted !== false) leaks++;
const pool = battle && battle.sharedBoss;
const gt = Number(battle && battle.time) || 0;
return {
gt: Math.round(gt * 1000) / 1000,
killed: Math.max(0, Math.trunc(Number(battle && battle.killed) || 0)),
total: Math.max(0, Math.trunc(Number(battle && battle.total) || 0)),
leaks,
bossDmg: pool && Number.isFinite(pool.cum) ? pool.cum : 0,
done: !!(battle && battle.finished),
};
}
const r4 = (v) => Math.round((Number(v) || 0) * 1e4) / 1e4;
const modsKey = (m) => (m && typeof m === 'object' ? JSON.stringify(Object.keys(m).filter((k) => m[k] !== undefined).sort().map((k) => [k, m[k]])) : '');
/**
* Canonical summary of a BattleResult (everything the match consumes): used to compare a client's result with the
* server's re-simulation (SP_VERIFY) and by the determinism tests. Returns `{ json, hash }`.
*/
export function resultDigest(result) {
const res = result && typeof result === 'object' ? result : {};
const per = res.perPlayer && typeof res.perPlayer === 'object' ? res.perPlayer : {};
const players = Object.keys(per).sort().map((pid) => {
const p = per[pid] || {};
const lg = p.layerGains && typeof p.layerGains === 'object' ? p.layerGains : {};
return [
pid,
p.killed | 0, p.total | 0, !!p.perfect, r4(p.coins),
Object.keys(lg).sort().map((k) => [k, r4(lg[k])]),
// mods included: a leak re-enters the 联防 with them (round multipliers, bounty id)
(Array.isArray(p.leaked) ? p.leaked : []).map((l) => `${l && l.enemyKey}|${l && l.counted === false ? 0 : 1}|${(l && l.sourcePlayerId) || ''}|${modsKey(l && l.mods)}`).sort(),
Math.round(Number(p.damageDealt) || 0), Math.round(Number(p.bossDamage) || 0),
(Array.isArray(p.unitsEnd) ? p.unitsEnd : []).map((u) => [u && u.uid, r4(u && u.hpPct), r4(u && u.sp), !!(u && u.alive)]),
];
});
const json = JSON.stringify([res.reason ?? null, r4(res.time), res.killed | 0, res.total | 0, players]);
let h = 2166136261;
for (let i = 0; i < json.length; i++) { h ^= json.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
return { json, hash: h.toString(16).padStart(8, '0') };
}
const fnum = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const cap = (list, n) => (Array.isArray(list) ? list.slice(0, n) : []);
const isKey = (v) => typeof v === 'string' && v.length > 0 && v.length <= 64 && /^[A-Za-z0-9_\-.:]+$/.test(v);
const uidOr = (v) => (Number.isInteger(v) && v >= 1 && v <= 2 ** 31 ? v : null);
const keyOr = (v) => (isKey(v) ? v : null);
function compactMods(m) {
if (!m || typeof m !== 'object') return null;
const out = {};
let n = 0;
for (const k of Object.keys(m)) {
if (n >= 16 || k.length > 32) break;
const v = m[k];
if ((typeof v === 'number' && Number.isFinite(v)) || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 64) || v === null) { out[k] = v; n++; }
}
return out;
}
/**
* The part of a BattleResult the server consumes, in the b.result wire shape (shared/protocol.js isBattleResult):
* numbers only where the match reads them, list sizes capped, names dropped (the server names units from its data).
*/
export function compactResult(res) {
const r = res && typeof res === 'object' ? res : {};
const perPlayer = {};
for (const pid of Object.keys(r.perPlayer || {}).slice(0, 4)) {
const p = r.perPlayer[pid] || {};
const layerGains = {};
for (const [k, v] of Object.entries(p.layerGains || {}).slice(0, 40)) if (isKey(k) && fnum(v) > 0) layerGains[k] = Math.min(1e4, fnum(v));
const total = Math.max(0, Math.trunc(fnum(p.total)));
perPlayer[pid] = {
killed: Math.min(total, Math.max(0, Math.trunc(fnum(p.killed)))),
total,
leaked: cap(p.leaked, 400).filter((l) => l && isKey(l.enemyKey)).map((l) => {
const o = { enemyKey: l.enemyKey, mods: compactMods(l.mods), lpr: Math.max(0, Math.min(1000, fnum(l.lpr, 1))), sourcePlayerId: keyOr(l.sourcePlayerId), tag: typeof l.tag === 'string' && l.tag.length <= 16 ? l.tag : null, counted: l.counted !== false };
if (l.boss) o.boss = true;
if (l.spawned) o.spawned = true;
return o;
}),
perfect: !!p.perfect,
layerGains,
coins: Math.max(0, fnum(p.coins)),
damageDealt: Math.max(0, Math.round(fnum(p.damageDealt))),
bossDamage: Math.max(0, Math.round(fnum(p.bossDamage))),
healingDone: Math.max(0, Math.round(fnum(p.healingDone))),
deaths: Math.max(0, Math.trunc(fnum(p.deaths))),
unitsEnd: cap(p.unitsEnd, 64).filter(Boolean).map((u) => ({
uid: uidOr(u.uid), defId: keyOr(u.defId), hpPct: Math.max(0, Math.min(1, fnum(u.hpPct))), sp: Math.max(0, Math.min(1e5, fnum(u.sp))),
skillActive: !!u.skillActive, alive: !!u.alive,
})),
unitStats: cap(p.unitStats, 160).filter(Boolean).map((u) => ({
uid: uidOr(u.uid), defId: keyOr(u.defId), kind: typeof u.kind === 'string' && u.kind.length <= 16 ? u.kind : 'op',
dmg: Math.max(0, Math.round(fnum(u.dmg))), kills: Math.max(0, Math.trunc(fnum(u.kills))), heal: Math.max(0, Math.round(fnum(u.heal))),
taken: Math.max(0, Math.round(fnum(u.taken))), attacks: Math.max(0, Math.trunc(fnum(u.attacks))),
})),
};
}
const out = {
reason: ['cleared', 'timeout', 'forced'].includes(r.reason) ? r.reason : 'forced',
time: Math.max(0, Math.min(1e5, fnum(r.time))),
killed: Math.max(0, Math.trunc(fnum(r.killed))),
total: Math.max(0, Math.trunc(fnum(r.total))),
perPlayer,
errors: Math.max(0, Math.min(1e9, Math.trunc(fnum(r.errors)))),
};
if (out.killed > out.total) out.killed = out.total;
if (Array.isArray(r.unspawned) && r.unspawned.length) {
out.unspawned = cap(r.unspawned, 400).filter((u) => u && isKey(u.enemyKey)).map((u) => ({
enemyKey: u.enemyKey, sourcePlayerId: keyOr(u.sourcePlayerId), tag: typeof u.tag === 'string' && u.tag.length <= 16 ? u.tag : null,
time: Math.max(0, Math.min(1e6, fnum(u.time))),
}));
}
return out;
}
/** Bytes a b.result frame may use (the socket's inbound limit is 64 KB; headroom for the envelope). */
export const RESULT_FRAME_BUDGET = 60 * 1024;
/**
* Keep a compact result under the frame budget (a larger frame closes the socket: the server would take the field over
* at the very end). Never touches what settles LP / funds / layers of a normal field. In order: drop the per-unit
* statistics, drop the leaks' `mods` (the server rebuilds them from the spec), and — boss fields only, whose leaks cost
* team LP through b.progress — drop leak entries. Returns the (possibly) trimmed copy.
* @param {object} result compactResult(...) output
* @param {{ bossLike?: boolean, budget?: number, battleId?: string }} [o]
*/
export function fitResult(result, { bossLike = false, budget = RESULT_FRAME_BUDGET, battleId = '' } = {}) {
const size = (r) => JSON.stringify({ t: 'b.result', battleId, result: r, rid: 2147483647 }).length;
if (!result || typeof result !== 'object' || size(result) <= budget) return result;
const r = { ...result, perPlayer: {} };
for (const [pid, p] of Object.entries(result.perPlayer || {})) r.perPlayer[pid] = { ...p, unitStats: [] };
if (size(r) <= budget) return r;
for (const p of Object.values(r.perPlayer)) p.leaked = (p.leaked || []).map((l) => ({ ...l, mods: null }));
if (size(r) <= budget || !bossLike) return r;
for (const p of Object.values(r.perPlayer)) p.leaked = [];
if (Array.isArray(r.unspawned)) r.unspawned = [];
return r;
}
+118
View File
@@ -0,0 +1,118 @@
// server/sim/targeting.js — range tests, target filters and priorities (DESIGN §3, §5.5).
//
// Grid ranges: `[dRow, dCol]` offsets relative to facing RIGHT, rotated by the unit's direction (sim/dir.js: RIGHT
// (dr,dc), UP (dc,−dr), LEFT (−dr,−dc), DOWN (−dc,dr); row 0 = bottom). An enemy is in range when the tile containing
// it (round(y), round(x)) is one of the absolute range tiles. `rangeExtend` (ability_range_forward_extend) adds N tiles
// past the furthest cell of every row, along +dCol BEFORE rotating (DESIGN §3).
// Operator priority: (1) enemies it blocks, (2) profile priority (fly/lowDef/…), (3) higher enemy taunt,
// (4) least remaining path distance to the goal, (5) earliest spawned.
import { COLS, ROWS } from './constants.js';
import { normDir, rotateOffset } from './dir.js';
/**
* Build the absolute tile-key list of a grid for a unit standing at (r,c) facing `dir` ('UP'|'RIGHT'|'DOWN'|'LEFT';
* a legacy facing sign ±1 is read as RIGHT / LEFT).
*/
export function absoluteRangeKeys(grid, r, c, dir, extend = 0) {
const keys = [];
const seen = new Set();
const d = normDir(dir);
const add = (dr, dc) => {
const [ar, ac] = rotateOffset(dr, dc, d);
const rr = r + ar, cc = c + ac;
if (rr < 0 || rr >= ROWS || cc < 0 || cc >= COLS) return;
const k = rr * COLS + cc;
if (!seen.has(k)) { seen.add(k); keys.push(k); }
};
// kits may hand over junk (a number, a string, [[1,'a']]): ignore anything that is not an integer [dr, dc] pair
// instead of throwing inside the attack loop every tick
if (!Array.isArray(grid) || !Number.isInteger(r) || !Number.isInteger(c)) return keys;
const cells = grid.filter((p) => Array.isArray(p) && Number.isInteger(p[0]) && Number.isInteger(p[1]));
for (const [dr, dc] of cells) add(dr, dc);
if (extend > 0 && Number.isFinite(extend)) {
const maxByRow = new Map();
for (const [dr, dc] of cells) maxByRow.set(dr, Math.max(maxByRow.get(dr) ?? -Infinity, dc));
for (const [dr, mx] of maxByRow) for (let k = 1; k <= Math.min(extend, COLS); k++) add(dr, mx + k);
}
return keys;
}
/** Tile key of a unit's current position. */
export function tileKeyOf(u) {
const r = Math.round(u.y), c = Math.round(u.x);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return -1;
return r * COLS + c;
}
/** Can `attacker` (ally) target enemy `e` at all (ignoring range)? */
export function canTargetEnemy(attacker, e, profile) {
if (!e.alive || e.hidden || !e.deployed) return false;
const f = e.s.flags;
if (f.untargetable || (f.sleep && !(profile && profile.hitSleep))) return false;
if (f.stealth && !f.reveal && !e.blockedBy) return false;
if (e.isFlying && !(profile && profile.canHitFly)) return false;
if (profile && profile.groundOnly && e.isFlying) return false;
return true;
}
/** Can enemy `e` target ally `a`? `ranged` = the attack is a ranged (non-blocked) one. */
export function canTargetAlly(e, a, ranged) {
if (!a.alive || !a.deployed || a.hidden || a.kind === 'device') return false;
const f = a.s.flags;
if (f.untargetable || f.sleep) return false;
if (ranged && f.stealth && e.blockedBy !== a) return false;
return true;
}
const PRIORITY_FNS = {
fly: (e) => (e.isFlying ? 0 : 1),
lowDef: (e) => e.s.def,
highDef: (e) => -e.s.def,
ranged: (e) => (e.base.rangeRadius > 0 && e.def?.applyWay !== 'MELEE' ? 0 : 1),
lowestHp: (e) => e.hp,
highestHp: (e) => -e.hp,
lowestHpRatio: (e) => e.hpRatio,
highestAtk: (e) => -e.s.atk,
boss: (e) => (e.isBoss ? 0 : 1),
notBurst: (e) => (e.s.flags.burstLock ? 1 : 0),
ground: (e) => (e.isFlying ? 1 : 0),
};
/**
* Sort candidate enemies for an attacker (in place) and return them.
* @param {object} attacker ally unit
* @param {object[]} cands enemies
* @param {string|null} priority profile/skill priority key
*/
export function sortEnemyTargets(battle, attacker, cands, priority) {
if (cands.length <= 1) return cands;
const pf = priority ? (PRIORITY_FNS[priority] || (priority === 'nearest' || priority === 'farthest' ? null : null)) : null;
const ax = attacker.x, ay = attacker.y;
const keyed = cands.map((e) => ({
e,
b: e.blockedBy === attacker ? 0 : 1,
p: pf ? pf(e) : priority === 'nearest' ? Math.hypot(e.x - ax, e.y - ay) : priority === 'farthest' ? -Math.hypot(e.x - ax, e.y - ay) : 0,
t: -(e.s.taunt || 0),
d: battle.remainingDistance(e),
s: e.spawnSeq,
}));
keyed.sort((a, b) => a.b - b.b || a.p - b.p || a.t - b.t || a.d - b.d || a.s - b.s);
for (let i = 0; i < keyed.length; i++) cands[i] = keyed[i].e;
return cands;
}
/**
* Enemy target selection among allies: blocker first → highest taunt → latest deployed (DESIGN §5.5).
*/
export function sortAllyTargets(enemy, cands) {
if (cands.length <= 1) return cands;
cands.sort((a, b) => {
const ba = enemy.blockedBy === a ? 0 : 1, bb = enemy.blockedBy === b ? 0 : 1;
if (ba !== bb) return ba - bb;
const ta = a.s.taunt || 0, tb = b.s.taunt || 0;
if (ta !== tb) return tb - ta;
return b.deploySeq - a.deploySeq;
});
return cands;
}
+211
View File
@@ -0,0 +1,211 @@
// server/sim/units.js — Unit model (operators, tokens, enemies, devices) and stat aggregation (DESIGN §5.2).
//
// Aggregation (recomputed lazily whenever buffs change — `unit.markDirty()`):
// ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul
// res = clamp((base + Σflat) × Πmul, 0, 100)
// aspd = clamp(base + Σaspd, 10, 600) (base is 100 for almost everyone)
// interval = bat × (1 + ΣbatPct) × 100 / aspd (ΣbatPct floored at −0.9)
// moveSpeed = (base + ΣmoveFlat) × ΠmoveMul (tiles/s = moveSpeed × MOVE_SCALE)
// massLevel = max(0, base + ΣmassFlat) (重量: displacement, 浮空 halving; 失重 = massFlat −1)
// Changing maxHp keeps the HP ratio. The sim keeps floats; rounding happens only in snapshots.
import { aggregateMods } from './buffs.js';
import { flagsOf } from './snapshot.js';
import { ASPD_MIN, ASPD_MAX, ELEMENT_GAUGE_MAX, ELEMENT_GAUGE_MAX_LEADER } from './constants.js';
import { DIR_VEC, normDir } from './dir.js';
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const EMPTY = Object.freeze({});
/** Aggregates can overflow (huge stacked *Mul mods → Infinity) — fall back to `d` so no stat is ever non-finite. */
const fin = (v, d) => (Number.isFinite(v) ? v : d);
export class Unit {
/**
* @param {object} init
*/
constructor(init) {
// Heavy references are non-enumerable so assertion diffs / logs never walk the whole battle graph.
for (const k of ['def', 'player', 'ownerUnit', 'kit', 'profile', 'skill', 'route', 'bossPool', 'blockedBy', 'blocking', 'carry', 'mem', 'trait']) {
Object.defineProperty(this, k, { value: null, writable: true, enumerable: false, configurable: true });
}
this.id = init.id;
this.side = init.side; // 'ally' | 'enemy'
this.kind = init.kind; // 'op' | 'token' | 'enemy' | 'device'
this.def = init.def; // normalised def (chess / token / enemy / device)
this.defId = init.defId ?? init.def?.id ?? 'unknown';
this.name = init.name ?? init.def?.name ?? this.defId;
this.ownerId = init.ownerId ?? null; // playerId (enemies: field owner)
this.uid = init.uid ?? null; // match piece uid (ops/tokens from the board)
this.ownerUnit = init.ownerUnit ?? null; // summoner (tokens)
this.x = init.x ?? 0;
this.y = init.y ?? 0;
this.tileR = init.tileR ?? Math.round(this.y);
this.tileC = init.tileC ?? Math.round(this.x);
this.homeR = this.tileR;
this.homeC = this.tileC;
// deploy direction (sim/dir.js; allies: from the board piece, default RIGHT); `facing` is its derived horizontal
// sign (sprite flip only) — a legacy `init.facing` ±1 still maps to RIGHT / LEFT
this.dir = normDir(init.dir ?? init.facing);
this.base = {
maxHp: 1, atk: 0, def: 0, res: 0, aspd: 100, bat: 1, blockCnt: 0, moveSpeed: 0, spRecovery: 1,
tauntLevel: 0, massLevel: 0, hpRecoveryPerSec: 0, cost: 0, respawnTime: 0, rangeRadius: 0,
...(init.base || {}),
};
/** @type {object[]} */
this.buffs = [];
this._dirty = true;
this._s = null;
this.hp = this.base.maxHp;
this.alive = true;
this.deployed = false;
this.removed = false; // permanently gone (enemies, tokens, retreated-for-good)
this.blocking = []; // ops: enemies currently blocked
this.blockedBy = null; // enemies: blocker unit
this.motion = init.motion ?? 'GROUND'; // enemies: 'WALK' | 'FLY'
this.profile = null; // resolved profession/attack profile
this.skill = null; // SkillRuntime
this.kit = null;
this.atkCd = 0;
this.lastAttackAt = -Infinity;
this.lastHitAt = -Infinity;
this.deployedAt = -Infinity;
this.deathAt = -Infinity;
this.respawnAt = Infinity;
this.deploySeq = 0;
this.spawnSeq = 0;
this.elem = { burn: 0, neural: 0, necrosis: 0, apoptosis: 0, erosion: 0 };
this.burstPending = null; // { [element]: true } while that element's burst resolves (damage.js burstLocked)
this.tags = new Set(init.tags || []);
this.mem = {}; // free scratch space for content (per unit)
this.trait = {}; // profession runtime state
this.stats = { dmg: 0, kills: 0, heal: 0, taken: 0, attacks: 0 };
this.hidden = false; // enemies inside a DISAPPEAR segment
this.anim = 0;
this.persist = { redeployMul: 1, freeRedeploys: 0 };
this.isBoss = false;
this.bossPool = null;
}
markDirty() { this._dirty = true; }
/** Horizontal sign of the direction (LEFT ⇒ −1, else +1): sprite flip only — geometry uses `dir` / `fwd`. */
get facing() { return this.dir === 'LEFT' ? -1 : 1; }
/** Legacy setter: ±1 ⇒ RIGHT / LEFT (prefer assigning `dir`). */
set facing(v) { this.dir = normDir(v, this.dir); }
/** Forward vector [dRow, dCol] (UP = +row). */
get fwd() { return DIR_VEC[this.dir] ?? DIR_VEC.RIGHT; }
/** Aggregated stats (lazy). */
get s() {
if (this._dirty) this._recalc();
return this._s;
}
get maxHp() { return this.s.maxHp; }
get atk() { return this.s.atk; }
_recalc() {
const prevMax = this._s ? this._s.maxHp : this.base.maxHp;
const { add, mul, flags, shield, permRangeExtend } = aggregateMods(this.buffs);
const a = (k) => add[k] ?? 0;
const m = (k) => mul[k] ?? 1;
const b = this.base;
const bHp = fin(b.maxHp, 1) > 0 ? fin(b.maxHp, 1) : 1;
const maxHp = Math.max(1, fin((bHp + a('hpFlat')) * Math.max(0, 1 + a('hpPct')) * m('hpMul'), bHp));
const atk = Math.max(0, fin((b.atk + a('atkFlat')) * Math.max(0, 1 + a('atkPct')) * m('atkMul'), fin(b.atk, 0)));
const def = Math.max(0, fin((b.def + a('defFlat')) * Math.max(0, 1 + a('defPct')) * m('defMul'), fin(b.def, 0)));
const res = clamp(fin((b.res + a('resFlat')) * m('resMul'), fin(b.res, 0)), 0, 100);
const aspd = clamp(fin(b.aspd + a('aspd'), 100), ASPD_MIN, ASPD_MAX);
const bBat = fin(b.bat, 1) > 0 ? fin(b.bat, 1) : 1;
const bat = fin(bBat * Math.max(0.1, 1 + a('batPct')), bBat);
const s = {
maxHp, atk, def, res, aspd, bat,
interval: (bat * 100) / aspd,
blockCnt: Math.max(0, fin(Math.round(b.blockCnt + a('blockCnt')), 0)),
moveSpeed: Math.max(0, fin((b.moveSpeed + a('moveFlat')) * m('moveMul'), fin(b.moveSpeed, 0))),
rangeExtend: Math.max(0, Math.round(a('rangeExtend'))),
baseRangeExtend: Math.max(0, fin(Math.round(permRangeExtend), 0)), // permanent part (initial range)
massLevel: Math.max(0, fin(fin(b.massLevel, 0) + a('massFlat'), 0)),
maxTargets: a('maxTargets'),
// DESIGN §5.3 lists `taunt` as a buff flag (+aggro) as well as a numeric mod: the flag counts as +1 level
taunt: (Number.isFinite(b.tauntLevel) ? b.tauntLevel : 0) + a('taunt') + (flags.taunt === true ? 1 : 0),
dodgePhys: clamp(a('dodgePhys'), 0, 1),
dodgeArts: clamp(a('dodgeArts'), 0, 1),
defIgnoreFlat: a('defIgnoreFlat'),
defIgnorePct: clamp(a('defIgnorePct'), 0, 1),
resIgnoreFlat: a('resIgnoreFlat'),
resIgnorePct: clamp(a('resIgnorePct'), 0, 1),
dmgDealtMul: m('dmgDealtMul'),
physDealtMul: m('physDealtMul'),
artsDealtMul: m('artsDealtMul'),
dmgTakenMul: m('dmgTakenMul'),
physTakenMul: m('physTakenMul'),
artsTakenMul: m('artsTakenMul'),
trueTakenMul: m('trueTakenMul'),
elemTakenMul: m('elemTakenMul'),
healingDealtMul: m('healingDealtMul'),
healingTakenMul: m('healingTakenMul'),
atkScaleMul: m('atkScaleMul'),
spRecovery: Math.max(0, fin((b.spRecovery + a('spRecoveryFlat')) * m('spRecoveryMul'), 0)),
spCostFlat: a('spCostFlat'),
redeployMul: m('redeployMul'),
hpRegen: fin(b.hpRecoveryPerSec + a('hpRegen') + a('hpRegenRatio') * maxHp, 0),
shield,
flags: flags || EMPTY,
};
s.flags.stun = !!(flags.stun || flags.freeze || flags.sleep || flags.levitate);
this._s = s;
this._dirty = false;
if (this.alive && prevMax > 0 && Math.abs(prevMax - maxHp) > 1e-9) {
this.hp = clamp(fin(this.hp * (maxHp / prevMax), maxHp), 0, maxHp);
}
}
/** Current flags object (stun/freeze/sleep/…) */
get flags() { return this.s.flags; }
/** Can act at all this tick (not stunned/frozen/sleeping/levitated). */
get canAct() { return this.alive && this.deployed && !this.hidden && !this.s.flags.stun; }
hasFlag(k) { return !!this.s.flags[k]; }
/** Ground unit: enemies that WALK and aren't levitated; ally units standing on a LOW tile. */
get isFlying() { return this.motion === 'FLY'; }
get hpRatio() { const mh = this.s.maxHp; return mh > 0 ? this.hp / mh : 0; }
/** Snapshot status bitmask (UF bits: blocked, stunned, frozen, stealth, skill, shield, invuln, cold, sleep, flying). */
get statusFlags() { return flagsOf(this); }
/** Damage type of the unit's normal attack ('phys' | 'arts' | 'true' | 'heal' | 'none'). */
get dmgType() { return (this.profile && this.profile.dmgType) || (this.def && this.def.dmgType) || 'phys'; }
/** Displacement weight (= current massLevel incl. `massFlat` buffs). Enemies also carry `lpr` (LP cost) as a plain field. */
get weight() { return this.s.massLevel; }
get spMax() { return this.skill ? this.skill.spCost : 0; }
get sp() { return this.skill ? this.skill.sp : 0; }
isOp() { return this.kind === 'op'; }
isEnemy() { return this.side === 'enemy'; }
isAlly() { return this.side === 'ally'; }
/** Element gauge capacity: 1000, leaders (enemy rank BOSS) 2000 (official "·我方" burst terms). */
get gaugeMax() {
return this.side === 'enemy' && (this.isBoss || (this.def && this.def.rank === 'BOSS')) ? ELEMENT_GAUGE_MAX_LEADER : ELEMENT_GAUGE_MAX;
}
/** Element gauge helper */
elemRatio(el) { return (this.elem[el] ?? 0) / this.gaugeMax; }
findBuff(key) {
for (const b of this.buffs) if (b.key === key) return b;
return null;
}
hasBuff(key) { return this.findBuff(key) !== null; }
toString() { return `${this.kind}#${this.id}(${this.name})`; }
/** Compact inspection (avoids dumping the whole battle graph in assertion messages / logs). */
[Symbol.for('nodejs.util.inspect.custom')]() {
return `Unit<${this.kind}#${this.id} ${this.defId} hp=${Math.round(this.hp)}${this.alive ? '' : ' dead'} @${this.x.toFixed(2)},${this.y.toFixed(2)}>`;
}
}