Files
ark-stronghold-protocol/server/match/waves.js
T

667 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/match/waves.js — per-match enemy setup and per-round spawn generation: the OFFICIAL generator decoded from
// the client (`RandomEnemyGenerater`, research 08 §2; DESIGN §14 "Corrections from research 08/09"). No custom balance:
// counts, kinds, timing and stat scaling are the official ones.
//
// Per match (setupMatchWaves):
// * stage: weighted (stages[id].weight) among mode.stages that are active with weight > 0 — official mode data:
// 标准 (FUNNY) / 入门 = 战场#01 only, 险境 = 8 stages, 绝境 / 终极 = 7 (no 战场#01), weight 50 each, so the match seed
// picks uniformly (test/match/stagepick.test.js)
// * factions: `specialEnemyNum` (3) distinct types among the involveRandom ones (FLY/TIMES/ELEMENT/DOT/INVISIBLE/
// REFLECTION), uniform (shuffle, take 3)
// * type schedule: each chosen type owns `count` (3) of the `maxLevelCnt` (15) round slots, SPECIAL fills the other 6;
// the slots are shuffled — typeSlots[r-1] = the ONE type of round r (the briefing shows the 3 types, the order
// stays hidden)
// * picks: for every round 1..15, generated now (so the preview and every battle agree): half = r ≤ 7; a weighted
// (entry.weight) special entry of the round's type and half whose SPECIAL key is not in the mode's
// inactiveEnemyKeys (attached keys are never filtered); normal / elite = a random attached key
// * boss: weighted mode.bossWeights; hidden boss: weighted mode.hiddenBossWeights (independent)
// Per round (buildNormalWave / buildBossWave; one composition shared by every player):
// * every template SPAWN action whose key is a placeholder (N/E/S walk, NF/EF/SF fly) becomes the pick's
// normal / elite / special key — or is SKIPPED (not spawned, not previewed) when the new key's movement class differs
// from the placeholder's (ground rounds drop NF/EF/SF, FLY rounds drop N/E/S);
// n' = clamp(roundHalfEven(f32(f32(n·P(tpl))/f(tpl)) / f32(P(new)/f(new))), 1, 5) per ACTION (P = enemies.json
// attrPower, f = beFactor), unit i spawning at time + i·max(n·interval/n', 0.05·n·interval) (same window);
// literal keys (R1 源石虫, leader escorts) and T/TF tokens are kept; 炎佑 (enemy_9012_acloon) is never an enemy
// * leader / hidden rounds use the pick of their own slot (14, 15; solo 标准 9), so only E or EF escorts spawn
// * stat scaling: mods = config.modes[m].enemyScale[r] (the PRTS table = the ENEMY effects 攻坚装备 / 补给线 / 急行军,
// which hit every enemy but 炎佑 — no other multiplier), also for bounty enemies and leader parts; the leader takes
// the ATK / speed multipliers but never HP (its HP is the shared server pool)
// * every SpawnSpec = one action ({ time, enemyKey, routeIndex, count, interval, mods, actionIndex, preview }):
// `mods.slot` (placeholder class) lets 联防 route leaks, `preview` = { gate, start, fly, elite, boss } feeds previewOf
// Bounties (bountySpawns / withBounties): each bounty unit joins the round template's first `lrsldr` (walker) / `yokai`
// (flyer) action (else action 0), inserted among that action's own units at floor((i+1)·len/(n+1)), and the whole list
// is spread over the action window (client `_InsertSpActionToNormal` + `_CalculateActionPredelay`, decoded).
// 联防 (buildUniteWave): walkers → the escaped template's `lrsldr` action, flyers → `yokai`, tokens → `gopro_2` /
// `lazerd` (else action 0); owner k starts 0.5·k s after the action, units step min(max(W/M, 0.05·W), 5 s) with M the
// largest owner group (client `_CalculateActionPredelayConsiderUid`, decoded).
import { createRng, deriveSeed } from '../sim/rng.js';
const ACLOON = 'enemy_9012_acloon';
const DEFAULT_PLACEHOLDERS = Object.freeze({
N: { cls: 'normal', fly: false }, E: { cls: 'elite', fly: false }, S: { cls: 'special', fly: false },
NF: { cls: 'normal', fly: true }, EF: { cls: 'elite', fly: true }, SF: { cls: 'special', fly: true },
});
const MIN_ACTION_INTERVAL_RATIO = 0.05;
/** 联防 timing (client `_CalculateActionPredelayConsiderUid`): owner offset ACTION_INTERVAL_FOR_UID, unit step cap
* MAX_ACTION_INTERVAL_FOR_UID. */
const UNITE_OWNER_STEP = 0.5;
const UNITE_MAX_UNIT_STEP = 5;
/** Weighted pick of [id, weight] pairs with the rng. */
export function weightedPick(rng, pairs) {
let total = 0;
for (const [, w] of pairs) total += Math.max(0, Number(w) || 0);
if (total <= 0) return pairs.length ? pairs[0][0] : null;
let r = rng() * total;
for (const [id, w] of pairs) { r -= Math.max(0, Number(w) || 0); if (r < 0) return id; }
return pairs[pairs.length - 1][0];
}
/** C# Math.Round(double): banker's rounding (half to even). */
export function roundHalfEven(x) {
const f = Math.floor(x);
const d = x - f;
if (d === 0.5) return f % 2 === 0 ? f : f + 1;
return Math.floor(x + 0.5);
}
const gen = (gd) => (gd.factions.generation && typeof gd.factions.generation === 'object' ? gd.factions.generation : {});
const intOr = (v, d) => (Number.isInteger(v) && v > 0 ? v : d);
/** Official movement class of an enemy key (randomEnemyAttributeDict.isFlyEnemy, else its motion). */
export function isFlyKey(gd, key) {
const e = gd.enemy(key);
if (!e) return false;
if (typeof e.isFlyEnemy === 'boolean') return e.isFlyEnemy;
return !!(e.stats && e.stats.motion === 'FLY');
}
/** Official attribute power P(k) (float32), enemies.json `attrPower`; derived from the stats when absent. */
export function attrPower(gd, key) {
const e = gd.enemy(key);
if (!e) return 0;
if (typeof e.attrPower === 'number' && Number.isFinite(e.attrPower)) return e.attrPower;
const s = e.stats || {};
const f = Math.fround;
const bf = gen(gd).beFactors || {};
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
let p = f(f(f(n(s.atk) * (bf.atk ?? 5)) + f(n(s.maxHp) * (bf.hp ?? 1))) + f(n(s.def) * (bf.def ?? 3)));
p = f(p + f((bf.res ?? 3) * n(s.res)));
return p;
}
const beFactor = (gd, key) => {
const v = gd.enemy(key)?.beFactor;
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 1;
};
/**
* Units a placeholder action of `n` template enemies becomes (client `_CalculateEnemyCountByBattleEffectiveness`):
* clamp(roundHalfEven(f32(f32(f32(n·P(t))/f(t)) / f32(P(new)/f(new)))), min, max).
*/
export function replacedCount(gd, tplKey, n, newKey) {
const g = gen(gd);
const min = Number.isInteger(g.minReplacedEnemyCount) ? g.minReplacedEnemyCount : 1;
const max = Number.isInteger(g.maxReplacedEnemyCount) ? g.maxReplacedEnemyCount : 5;
const f = Math.fround;
const origin = f(f(n * attrPower(gd, tplKey)) / beFactor(gd, tplKey));
const unit = f(attrPower(gd, newKey) / beFactor(gd, newKey));
if (!(unit > 0)) return min;
const ratio = f(origin / unit);
return Math.max(min, Math.min(max, roundHalfEven(ratio)));
}
/** Placeholder key → { slot, cls, fly } (factions.generation.placeholders, else built from templateSlots). */
function placeholderMap(gd) {
const g = gen(gd);
if (g.placeholders && typeof g.placeholders === 'object') return g.placeholders;
const out = {};
const ts = gd.factions.templateSlots && typeof gd.factions.templateSlots === 'object' ? gd.factions.templateSlots : {};
for (const [slot, key] of Object.entries(ts)) if (DEFAULT_PLACEHOLDERS[slot]) out[key] = { slot, ...DEFAULT_PLACEHOLDERS[slot] };
return out;
}
const firstHalfMax = (gd) => intOr(gen(gd).firstHalfMaxRound, Math.floor(intOr(gen(gd).maxLevelCnt, 15) / 2));
/** The special entry list (factions.json entries) sorted by key. */
function entryList(gd) {
const e = gd.factions.entries && typeof gd.factions.entries === 'object' ? Object.values(gd.factions.entries) : [];
return e.filter((x) => x && typeof x.key === 'string').sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
}
/**
* The round pick of a type (client `_GenerateRandomEnemyData`): weighted entry of that type and half whose SPECIAL key
* is allowed in the mode, plus a random attached normal / elite key (never filtered by the mode).
* @returns {null | { round: number, type: string, key: string, normal: string|null, elite: string|null, fly: boolean, firstHalf: boolean }}
*/
export function pickRoundEntry(gd, rng, type, round) {
const half = round <= firstHalfMax(gd);
const ok = (e) => e.type === type && !!e.firstHalf === half && e.key !== ACLOON && !!gd.enemy(e.key) && !gd.inactiveEnemies.has(e.key);
let cands = entryList(gd).filter(ok);
if (!cands.length && type !== 'SPECIAL') return pickRoundEntry(gd, rng, 'SPECIAL', round);
if (!cands.length) return null;
const idx = weightedPick(rng, cands.map((e, i) => [i, e.weight ?? 1]));
const e = cands[Number(idx) || 0];
const pickKey = (list) => {
const keys = (Array.isArray(list) ? list : []).map((x) => (typeof x === 'string' ? x : x && x.key)).filter((k) => typeof k === 'string' && gd.enemy(k) && k !== ACLOON);
return keys.length ? keys[Math.floor(rng() * keys.length)] : null;
};
return { round, type: e.type, key: e.key, normal: pickKey(e.N), elite: pickKey(e.E), fly: typeof e.fly === 'boolean' ? e.fly : isFlyKey(gd, e.key), firstHalf: half };
}
/** Round type schedule: each chosen type × its slot count, filled with SPECIAL to maxLevelCnt, shuffled. */
export function makeTypeSchedule(gd, rng, factions) {
const g = gen(gd);
const max = intOr(g.maxLevelCnt, 15);
const fill = typeof g.fillType === 'string' ? g.fillType : (typeof g.alwaysIncludedType === 'string' ? g.alwaysIncludedType : 'SPECIAL');
const types = gd.factions.types && typeof gd.factions.types === 'object' ? gd.factions.types : {};
const slots = [];
for (const t of factions) {
const n = intOr(types[t]?.count, 3);
for (let i = 0; i < n && slots.length < max; i++) slots.push(t);
}
while (slots.length < max) slots.push(fill);
rng.shuffle(slots);
return slots;
}
function scheduleRng(rng) {
const seed = typeof rng.state === 'function' ? deriveSeed(rng.state(), 'waves:schedule') : Math.floor(rng() * 4294967296);
return createRng(seed);
}
/**
* @param {import('./gamedata.js').GameData} gd
* @param {Function} rng
* @returns {{ stageId: string|null, factions: string[], bossId: string|null, hiddenBossId: string|null, typeSlots: string[], picks: object[] }}
* `factions` also carries the schedule as a non-enumerable `schedule` property ({ typeSlots, picks }), so callers
* that keep only the factions array (Match: buildNormalWave(gd, rng, this.factions, r)) get the official rounds.
*/
export function setupMatchWaves(gd, rng) {
// stage
const stageIds = Array.isArray(gd.mode.stages) ? gd.mode.stages : [];
const stagePairs = stageIds.map((id) => [id, gd.stage(id)]).filter(([, s]) => s && s.active !== false && Number(s.weight) > 0).map(([id, s]) => [id, s.weight]);
let stageId = stagePairs.length ? weightedPick(rng, stagePairs) : null;
if (!stageId) {
const any = Object.keys(gd.raw.stages || {}).filter((id) => gd.stage(id) && gd.stage(id).active);
stageId = any.length ? any.sort()[0] : null;
}
// factions
const types = gd.factions.types && typeof gd.factions.types === 'object' ? gd.factions.types : {};
const n = Number.isInteger(gen(gd).specialEnemyNum) ? gen(gd).specialEnemyNum : 3;
const random = Object.values(types).filter((t) => t && t.involveRandom).map((t) => t.type).sort();
const shuffled = random.slice();
rng.shuffle(shuffled);
const chosen = shuffled.slice(0, Math.min(n, shuffled.length));
const factions = chosen.slice().sort((a, b) => (types[a]?.sortId ?? 9) - (types[b]?.sortId ?? 9));
// bosses
const bw = gd.bossWeights(false);
const bossId = bw.length ? weightedPick(rng, bw) : null;
const hw = gd.bossWeights(true);
const hiddenBossId = hw.length && gd.hiddenRound ? weightedPick(rng, hw) : null;
// the 15-round schedule and picks (own rng stream derived from the setup rng's state: the draws above and whatever
// the caller draws next from `rng` are unchanged)
const srng = scheduleRng(rng);
const typeSlots = makeTypeSchedule(gd, srng, chosen);
const picks = [null];
for (let r = 1; r <= typeSlots.length; r++) picks.push(pickRoundEntry(gd, srng, typeSlots[r - 1], r));
Object.defineProperty(factions, 'schedule', { value: Object.freeze({ typeSlots: typeSlots.slice(), picks }), enumerable: false });
return { stageId, factions, bossId, hiddenBossId, typeSlots, picks };
}
/**
* The pick of round r: from a setup object ({ picks }), a factions array carrying the hidden schedule, or — for a plain
* factions array (tools, tests) — drawn now: the round's type from the schedule's marginal distribution (each faction
* 3 of 15 slots, SPECIAL the rest), then its entry.
*/
export function roundPick(gd, rng, factionsOrSetup, round) {
const src = factionsOrSetup;
const picks = src && !Array.isArray(src) && Array.isArray(src.picks) ? src.picks : (src && src.schedule && Array.isArray(src.schedule.picks) ? src.schedule.picks : null);
if (picks) return picks[round] ?? null;
const factions = Array.isArray(src) ? src : (src && Array.isArray(src.factions) ? src.factions : []);
const g = gen(gd);
const types = gd.factions.types && typeof gd.factions.types === 'object' ? gd.factions.types : {};
const max = intOr(g.maxLevelCnt, 15);
const pairs = factions.map((t) => [t, intOr(types[t]?.count, 3)]);
const used = pairs.reduce((s, [, w]) => s + w, 0);
pairs.push([typeof g.fillType === 'string' ? g.fillType : 'SPECIAL', Math.max(1, max - used)]);
return pickRoundEntry(gd, rng, weightedPick(rng, pairs), round);
}
/** Enemy scale mods for round r (bosses excluded by the caller). */
export function scaleFor(gd, r) {
return gd.enemyScale(r);
}
/** Slot class of an enemy that has no placeholder slot (literal template keys, bounty adds). */
export function classOf(gd, enemyKey) {
const e = gd.enemy(enemyKey);
const fly = isFlyKey(gd, enemyKey);
const elite = !!(e && e.rank === 'ELITE');
if (fly) return elite ? 'EF' : 'NF';
return elite ? 'E' : 'N';
}
/** Preview pen upper zone anchor row ((18,7); the lower anchor is (15,7)) and the client's row offsets. */
const PEN_UPPER_ROW = 18;
const PREVIEW_ROW_OFFSET = 6;
const PREVIEW_ROW_OFFSET_LEADER = 13;
/**
* 'upper' | 'lower' preview zone of a route start (client `AutoChessEnemyPreviewManager`, research 08 §4.2): target row =
* start row + 6 (leader rounds: + 13); the zone is the Manhattan-nearest anchor among those with anchor row ≤ target
* row — (18,7) upper, (15,7) lower — so only a target row ≥ 18 reaches the upper zone: normal fields row 12 (upper
* gate (12,10); rows 9–11 lower), boss fields row 5 (rows 2–4 lower: the leaders at (3,x)/(4,x) and the (2,10) gate).
* A target below row 15 has no zone (the client picks a random pen tile) and is reported 'lower'.
* @param {number[]|null} start [row, col]
* @param {boolean} [leader] leader / hidden round (default: a boss-field row, ≤ 8)
*/
export function gateOf(start, leader) {
const r = Array.isArray(start) ? start[0] : null;
if (!Number.isFinite(r)) return 'lower';
const lead = typeof leader === 'boolean' ? leader : r <= 8;
const target = r + (lead ? PREVIEW_ROW_OFFSET_LEADER : PREVIEW_ROW_OFFSET);
// both anchors share col 7, so with target ≥ 18 the upper one is always the nearer; below it only (15,7) qualifies
return target >= PEN_UPPER_ROW ? 'upper' : 'lower';
}
const isLeaderTemplate = (tpl) => !!tpl && (tpl.kind === 'boss' || tpl.kind === 'hidden');
function previewInfo(gd, key, route, boss = false, leader = undefined) {
const e = gd.enemy(key);
const start = route && Array.isArray(route.start) ? route.start.slice(0, 2) : null;
return { gate: gateOf(start, leader), start, fly: isFlyKey(gd, key), elite: !!(e && e.rank && e.rank !== 'NORMAL'), boss };
}
/**
* The actions of a template with the round pick applied.
* @returns {{ spawns: object[], actions: object[] }} actions[i] = { index, key, tplKey, slot, time, count, tplCount, window, routeIndex, valid,
* server } (`server` = client ShouldActionUpToServer: valid, not the leader, no random spawn group)
*/
function templateSpawns(gd, tpl, round, pick) {
const scale = scaleFor(gd, round);
const ph = placeholderMap(gd);
const routes = Array.isArray(tpl.routes) ? tpl.routes : [];
const leader = isLeaderTemplate(tpl);
const spawns = [];
const actions = [];
const list = Array.isArray(tpl.spawns) ? tpl.spawns : [];
for (let i = 0; i < list.length; i++) {
const s = list[i];
if (!s || (s.action && String(s.action).toUpperCase() !== 'SPAWN')) continue;
const key0 = s.key ?? s.enemyKey;
if (typeof key0 !== 'string' || key0 === ACLOON) continue;
const tag = s.tag ?? null;
const isBoss = tag === 'boss';
const isPart = tag === 'part';
const n = Math.max(1, Math.min(50, Number.isInteger(s.count) ? s.count : 1));
const interval = Math.max(0, Number(s.interval) || 0);
const time = Math.max(0, Number(s.time) || 0);
const routeIndex = Number.isInteger(s.routeIndex) ? s.routeIndex : 0;
const window = n * interval;
let key = key0;
let count = n;
let step = interval;
const p = !isBoss && !isPart ? ph[key0] : null;
if (p && pick) {
const newKey = p.cls === 'special' ? pick.key : p.cls === 'elite' ? pick.elite : pick.normal;
if (typeof newKey === 'string' && gd.enemy(newKey)) {
if (isFlyKey(gd, newKey) !== !!p.fly) {
// the other movement class: not spawned, not sent, not previewed (isValid = false)
actions.push({ index: i, key: null, tplKey: key0, slot: p.slot ?? s.slot ?? null, time, count: 0, tplCount: n, window, routeIndex, valid: false, server: false });
continue;
}
count = replacedCount(gd, key0, n, newKey);
step = Math.max(window / count, MIN_ACTION_INTERVAL_RATIO * window);
key = newKey;
}
}
if (!gd.enemy(key)) continue;
const slot = s.slot || classOf(gd, key);
const spec = {
time,
enemyKey: key,
routeIndex,
count,
interval: count > 1 ? step : 0,
// the round multipliers are ENEMY effects on every enemy but 炎佑 (aceffect_enemy_1–5 `enemy_attribute_mul`,
// enemy_exclude = enemy_9012_acloon): leader parts take them all; the leader takes ATK / speed but not HP — its HP
// is the server pool, "领袖单位于服务器的生命值加成不受上述加成影响" (PRTS 下半)
mods: isBoss ? { atkMul: scale.atkMul, speedMul: scale.speedMul, slot } : { hpMul: scale.hpMul, atkMul: scale.atkMul, speedMul: scale.speedMul, slot },
actionIndex: i,
preview: previewInfo(gd, key, routes[routeIndex], isBoss, leader),
};
if (tag) spec.tag = tag;
if (s.unharmful || isPart) spec.countInTotal = false;
spawns.push(spec);
// client ShouldActionUpToServer: valid, not the leader, no randomSpawnGroupKey — only those units are in the list a
// bounty is inserted into (the others spawn locally on their own timing)
actions.push({ index: i, key, tplKey: key0, slot: s.slot ?? null, time, count, tplCount: n, window, routeIndex, valid: true, server: !isBoss && !s.group });
}
return { spawns, actions };
}
function entriesView(gd, pick) {
if (!pick) return { ground: null, fly: null };
const e = gd.factions.entries && gd.factions.entries[pick.key];
const view = e ? { ...e, pick } : { key: pick.key, type: pick.type, firstHalf: pick.firstHalf, pick };
return pick.fly ? { ground: null, fly: view } : { ground: view, fly: null };
}
/**
* Shared composition of a normal round.
* @param {string[]|object} factions setupMatchWaves().factions (carries the schedule), the setup object, or a plain
* factions array (the round's type is then drawn with `rng`)
* @returns {{ templateId: string|null, spawns: object[], routes: object[], extraRoutes: object[], timeLimit: number,
* overrides: object, pick: object|null, entries: { ground: object|null, fly: object|null }, actions: object[] }}
*/
export function buildNormalWave(gd, rng, factions, round) {
const rc = gd.roundCfg(round);
const templateId = rc && typeof rc.template === 'string' ? rc.template : null;
const tpl = templateId ? gd.wave(templateId) : null;
const timeLimit = gd.combatTimeLimit(round);
if (!tpl) return { templateId, spawns: [], routes: [], extraRoutes: [], timeLimit, overrides: {}, pick: null, entries: { ground: null, fly: null }, actions: [] };
const pick = roundPick(gd, rng, factions, round);
const { spawns, actions } = templateSpawns(gd, tpl, round, pick);
return {
templateId,
spawns,
routes: Array.isArray(tpl.routes) ? tpl.routes : [],
extraRoutes: Array.isArray(tpl.extraRoutes) ? tpl.extraRoutes : [],
timeLimit,
overrides: tpl.overrides && typeof tpl.overrides === 'object' ? tpl.overrides : {},
pick,
entries: entriesView(gd, pick),
actions,
};
}
/**
* Boss / hidden round template for one boss field; escorts follow the pick of the round's own slot.
* @param {{ bossId: string, solo: boolean }} opts solo → `_s` template (lone player or solo mode)
*/
export function buildBossWave(gd, rng, factions, round, { bossId, solo }) {
const rc = gd.roundCfg(round);
const map = rc && rc.bossTemplates && typeof rc.bossTemplates === 'object' ? rc.bossTemplates : {};
let templateId = typeof map[bossId] === 'string' ? map[bossId] : null;
if (!templateId) {
const first = Object.values(map).find((v) => typeof v === 'string');
templateId = first || null;
}
if (templateId && solo && !/_s$/.test(templateId) && gd.wave(`${templateId}_s`)) templateId = `${templateId}_s`;
if (templateId && !solo && /_s$/.test(templateId) && gd.wave(templateId.replace(/_s$/, ''))) templateId = templateId.replace(/_s$/, '');
const tpl = templateId ? gd.wave(templateId) : null;
if (!tpl) return { templateId, spawns: [], routes: [], extraRoutes: [], timeLimit: Infinity, overrides: {}, pick: null, entries: { ground: null, fly: null }, actions: [] };
const pick = roundPick(gd, rng, factions, round);
const { spawns, actions } = templateSpawns(gd, tpl, round, pick);
return {
templateId,
spawns,
routes: Array.isArray(tpl.routes) ? tpl.routes : [],
extraRoutes: Array.isArray(tpl.extraRoutes) ? tpl.extraRoutes : [],
timeLimit: Infinity,
overrides: tpl.overrides && typeof tpl.overrides === 'object' ? tpl.overrides : {},
pick,
entries: entriesView(gd, pick),
actions,
};
}
/** Boss-field column that splits the left (L) and the right (R) player's half (the goals are at cols 2–3 / 17). */
const BOSS_MID_COL = 10;
/**
* Route index of the first template route with the requested motion (fallback 0). `side` ('L' | 'R') prefers a route
* that ends on that player's half of a boss field (pair templates route to both goals).
*/
export function routeByMotion(routes, fly, side = null) {
const ok = (r) => r && (fly ? r.motion === 'FLY' : r.motion !== 'FLY' && r.motion !== 'E_NUM');
if (side === 'L' || side === 'R') {
const j = routes.findIndex((r) => ok(r) && Array.isArray(r.end) && (side === 'R' ? r.end[1] > BOSS_MID_COL : r.end[1] < BOSS_MID_COL));
if (j >= 0) return j;
}
const i = routes.findIndex(ok);
return i >= 0 ? i : 0;
}
/**
* The template action an added enemy joins (client `_GetSpEnemyActionData`): the first SPAWN action of the round's
* ORIGINAL template whose key is the normal placeholder of the enemy's movement class (`templateEnemyNormal` /
* `…NormalFly`; tokens `templateEnemyToken` / `…TokenFly`), else the first SPAWN action.
*/
function hostAction(gd, wave, { fly, token = false }) {
const tpl = wave && wave.templateId ? gd.wave(wave.templateId) : null;
const list = tpl && Array.isArray(tpl.spawns) ? tpl.spawns : [];
const ts = gd.factions.templateSlots || {};
const want = token ? ts[fly ? 'TF' : 'T'] : ts[fly ? 'NF' : 'N'];
let first = -1;
for (let i = 0; i < list.length; i++) {
const s = list[i];
if (!s || (s.action && String(s.action).toUpperCase() !== 'SPAWN')) continue;
if (first < 0) first = i;
if ((s.key ?? s.enemyKey) === want) return { index: i, s };
}
return first >= 0 ? { index: first, s: list[first] } : null;
}
/**
* Bounty spawns for one player's battle (悬赏 / 战术特训 / 教鞭 / “神秘顾客”): kill bounties pay the killer (Battle
* bounty), perfect bounties are paid by the match at settlement. No bounty-specific stat change (the solo 70 % is the
* global base already in enemyScale). Client timing (`GenerateSelfBattleEnemyData` → `_GetSpEnemyActionData` →
* `_InsertSpActionToNormal` → `_CalculateActionPredelay`, decoded): every bounty unit joins the ORIGINAL template's
* first `lrsldr` (walker) / `yokai` (flyer) action (else action 0); the n bounty units of a host action are INSERTED
* among its own n' units — unit i at index floor((i+1)·len/(n+1)), len = the list length so far — (the bounty list
* alone when the host spawns nothing, e.g. a walker bounty in a FLY round), then the whole list is spread over the
* action window: unit j at preDelay + j·max(W/total, 0.05·W). Returned here: the bounty specs only (a bounty's
* consecutive units share a spec); the host action's own units are NOT re-timed — `withBounties` gives the exact
* combined list. Bounties of several battles (`rounds` > 1: "接下来两场作战", and the multi-round cards, which last
* choices.js MULTI_ROUND_BOUNTY_BATTLES) spawn in every battle of the player while they last — the Final Assault and the
* Hidden Core included (`side`: the player's half of the boss field, see routeByMotion).
* @param {Array<{ id: string, card: object }>} bounties
*/
export function bountySpawns(gd, round, wave, bounties, playerId, { solo = false, side = null } = {}) { // eslint-disable-line no-unused-vars
return bountyPlan(gd, round, wave, bounties, playerId, side).specs;
}
/** Runs of consecutive equal markers: [{ v, start, len }]. */
function runsOf(list) {
const out = [];
for (let j = 0; j < list.length; j++) {
const last = out[out.length - 1];
if (last && last.v === list[j]) last.len++;
else out.push({ v: list[j], start: j, len: 1 });
}
return out;
}
function bountyPlan(gd, round, wave, bounties, playerId, side) {
const scale = scaleFor(gd, round);
const routes = (wave && wave.routes) || [];
const acts = Array.isArray(wave && wave.actions) ? wave.actions : [];
const leader = isLeaderTemplate(wave && wave.templateId ? gd.wave(wave.templateId) : null);
const groups = new Map(); // host action index → { host, items: [{ b, c, fly, count }] }
for (const b of bounties || []) {
const c = b && b.card ? b.card : {};
if (!gd.enemy(c.enemyKey)) continue;
const fly = isFlyKey(gd, c.enemyKey);
const host = hostAction(gd, wave, { fly });
const key = host ? host.index : -1;
if (!groups.has(key)) groups.set(key, { host, items: [] });
groups.get(key).items.push({ b, c, fly, count: Math.max(1, Math.min(20, Number.isInteger(c.count) ? c.count : 1)) });
}
const specs = [];
const retime = new Map(); // host action index → { time, step, ownRuns: [{ start, len }] }
const OWN = 0;
for (const { host, items } of groups.values()) {
const hs = host ? host.s : null;
const time = hs ? Math.max(0, Number(hs.time) || 0) : 4;
const window = hs ? Math.max(1, Number.isInteger(hs.count) ? hs.count : 1) * Math.max(0, Number(hs.interval) || 0) : 0;
const act = host ? acts.find((a) => a.index === host.index) : null;
// the host's own units: only a server-sent action has any (a leader / random-group / invalid host spawns the
// bounty list alone over its window — client _InsertSpActionToNormal finds no normal list for it)
const own = act && act.valid && act.server !== false ? act.count : 0;
const sp = [];
for (const it of items) for (let k = 0; k < it.count; k++) sp.push(it);
// _InsertSpActionToNormal: insert the bounty units among the action's own units (list.Insert(FloorToInt(...)))
let list;
if (own > 0) {
list = new Array(own).fill(OWN);
for (let i = 0; i < sp.length; i++) list.splice(Math.floor(((i + 1) * list.length) / (sp.length + 1)), 0, sp[i]);
} else {
list = sp.slice();
}
const step = window > 0 ? Math.max(window / list.length, MIN_ACTION_INTERVAL_RATIO * window) : 0;
const runs = runsOf(list);
if (host && own > 0) retime.set(host.index, { time, step, ownRuns: runs.filter((r) => r.v === OWN) });
for (const run of runs) {
if (run.v === OWN) continue;
const { b, c, fly } = run.v;
let routeIndex = hs && Number.isInteger(hs.routeIndex) ? hs.routeIndex : routeByMotion(routes, fly, side);
const r = routes[routeIndex];
const motionOk = r && (fly ? r.motion === 'FLY' : r.motion !== 'FLY' && r.motion !== 'E_NUM');
const sideOk = !(side === 'L' || side === 'R') || (r && Array.isArray(r.end) && (side === 'R' ? r.end[1] > BOSS_MID_COL : r.end[1] < BOSS_MID_COL));
if (!motionOk || !sideOk) routeIndex = routeByMotion(routes, fly, side);
const spec = {
time: time + run.start * step,
enemyKey: c.enemyKey,
routeIndex,
count: run.len,
interval: run.len > 1 ? step : 0,
mods: { hpMul: scale.hpMul, atkMul: scale.atkMul, speedMul: scale.speedMul, slot: classOf(gd, c.enemyKey), bountyId: b.id },
tag: 'bounty',
ownerPlayerId: playerId,
preview: previewInfo(gd, c.enemyKey, routes[routeIndex], false, leader),
};
if (host) spec.actionIndex = host.index;
if (c.payout !== 'perfect' && Number(c.coin) > 0) spec.bounty = { coins: Math.trunc(c.coin), ownerPlayerId: playerId };
specs.push(spec);
}
}
return { specs, retime };
}
/**
* A player's full spawn list for the round, exactly like the client: the wave's specs (copied; the own units of an
* action that bounties join are re-timed around the inserted bounty units — one spec per run of consecutive own units)
* followed by the bounty specs. Use it instead of `[...wave.spawns, ...bountySpawns(…)]`, which keeps the host
* action's own spacing.
*/
export function withBounties(gd, round, wave, bounties, playerId, { side = null } = {}) {
const { specs, retime } = bountyPlan(gd, round, wave, bounties, playerId, side);
const out = [];
for (const s of wave.spawns || []) {
const rt = s.tag !== 'bounty' && Number.isInteger(s.actionIndex) ? retime.get(s.actionIndex) : null;
if (!rt) { out.push({ ...s, mods: s.mods ? { ...s.mods } : undefined }); continue; }
for (const run of rt.ownRuns) {
out.push({ ...s, mods: s.mods ? { ...s.mods } : undefined, time: rt.time + run.start * rt.step, count: run.len, interval: run.len > 1 ? rt.step : 0 });
}
}
return out.concat(specs);
}
/**
* nextEnemies preview (m.private.nextEnemies): one entry per spawn action (research 08 §4.2 input), in spawn order:
* `{ enemyKey, count, gate: 'upper'|'lower', t, fly, elite, boss, source: 'wave'|'bounty', tag, start? }` — `tag` 'boss' /
* 'bounty' / null kept for the drawer's grouping; Σ count = the enemies of the round (HUD). Boss parts are omitted.
* The client places the models in the gate's pen zone (≤ 50 shown, elites and bosses always) — except a leader: its
* entry carries its spawn tile `start` ([row, col] on the boss field) and the Final Assault / Hidden Core prep shows it
* standing there (render/app.js, community report #12; display only).
*/
export function previewOf(spawns) {
const out = [];
let seq = 0;
for (const s of spawns || []) {
if (!s || typeof s.enemyKey !== 'string' || s.tag === 'part') continue;
const tag = s.tag === 'boss' ? 'boss' : s.tag === 'bounty' ? 'bounty' : null;
const pv = s.preview && typeof s.preview === 'object' ? s.preview : {};
out.push({
enemyKey: s.enemyKey,
count: Math.max(1, Math.trunc(Number(s.count) || 1)),
gate: pv.gate === 'upper' ? 'upper' : 'lower',
t: Math.max(0, Number(s.time) || 0),
fly: !!pv.fly,
elite: !!pv.elite || tag === 'boss',
boss: tag === 'boss',
source: tag === 'bounty' ? 'bounty' : 'wave',
tag,
...(tag === 'boss' && Array.isArray(pv.start) && pv.start.length >= 2 && pv.start.every(Number.isFinite) ? { start: [pv.start[0], pv.start[1]] } : {}),
_seq: seq++,
});
}
out.sort((a, b) => a.t - b.t || a._seq - b._seq);
for (const e of out) delete e._seq;
return out;
}
/**
* 联防 spawns from the union of leaked enemies (research 08 §5; client `GenerateHelpBattleEnemyData` /
* `_GetSpEnemyActionData` / `_CalculateActionPredelayConsiderUid`, decoded): every leak becomes a 1-unit action on the
* route of the escaped template's host action — walkers the first `lrsldr` action, flyers `yokai`, tokens (enemies
* only ever summoned — enemies.json tokenOnly, or `isToken` on the leak) `gopro_2` / `lazerd`, else action 0; elites
* and specials are NOT routed through the E / S actions. Per host action the units are grouped by owner (source
* player, first-leak order); with W = count·interval of the host action and M = the largest owner group,
* step = min(max(W / M, 0.05·W), MAX_ACTION_INTERVAL_FOR_UID 5 s), and unit i of the k-th owner spawns at
* preDelay + k·ACTION_INTERVAL_FOR_UID (0.5 s) + i·step.
* @param {Array<{ enemyKey: string, mods: object|null, sourcePlayerId: string, bounty?: object|null, isToken?: boolean }>} leaked
* @param {number} helperCount 1 | 2
* @returns {{ templateId: string|null, spawns: object[], routes: object[] }}
*/
export function buildUniteWave(gd, leaked, helperCount, timeLimit) { // eslint-disable-line no-unused-vars
const templates = gd.unite.templates;
const templateId = templates[String(helperCount)] || templates[helperCount] || null;
const tpl = templateId ? gd.wave(templateId) : null;
const routes = tpl && Array.isArray(tpl.routes) ? tpl.routes : [];
const ts = gd.factions.templateSlots || {};
const list = tpl && Array.isArray(tpl.spawns) ? tpl.spawns : [];
const isSpawn = (s) => s && !(s.action && String(s.action).toUpperCase() !== 'SPAWN');
/** client `_GetSpEnemyActionData`: the first action whose key is the class placeholder, else action 0 */
const hostIndex = (fly, token) => {
const want = token ? ts[fly ? 'TF' : 'T'] : ts[fly ? 'NF' : 'N'];
for (let i = 0; i < list.length; i++) if (isSpawn(list[i]) && (list[i].key ?? list[i].enemyKey) === want) return i;
return list.length ? 0 : -1;
};
const groups = new Map(); // host action index → Map(owner → leaks), insertion order
const flyOf = new Map();
for (const l of leaked || []) {
if (!l || !gd.enemy(l.enemyKey)) continue;
const fly = isFlyKey(gd, l.enemyKey);
const token = l.isToken === true || gd.enemy(l.enemyKey).tokenOnly === true;
const idx = hostIndex(fly, token);
if (!groups.has(idx)) { groups.set(idx, new Map()); flyOf.set(idx, fly); }
const owners = groups.get(idx);
const owner = l.sourcePlayerId ?? '';
if (!owners.has(owner)) owners.set(owner, []);
owners.get(owner).push(l);
}
const spawns = [];
for (const [idx, owners] of groups) {
const a = idx >= 0 ? list[idx] : null;
const t0 = a ? Math.max(0, Number(a.time) || 0) : 3;
const window = a ? Math.max(1, Number.isInteger(a.count) ? a.count : 1) * Math.max(0, Number(a.interval) || 0) : 40;
let most = 1;
for (const units of owners.values()) most = Math.max(most, units.length);
const step = Math.min(Math.max(window / most, MIN_ACTION_INTERVAL_RATIO * window), UNITE_MAX_UNIT_STEP);
let routeIndex = a && Number.isInteger(a.routeIndex) ? a.routeIndex : routeByMotion(routes, flyOf.get(idx));
if (routeIndex < 0 || routeIndex >= routes.length) routeIndex = 0;
let k = 0;
for (const units of owners.values()) {
const start = t0 + k * UNITE_OWNER_STEP;
units.forEach((l, i) => {
const spec = {
time: start + i * step, enemyKey: l.enemyKey, routeIndex, count: 1, interval: 0,
mods: l.mods ? { ...l.mods } : { slot: classOf(gd, l.enemyKey) }, sourcePlayerId: l.sourcePlayerId,
preview: previewInfo(gd, l.enemyKey, routes[routeIndex]),
};
if (idx >= 0) spec.actionIndex = idx;
if (l.bounty && l.bounty.coins > 0) spec.bounty = { coins: l.bounty.coins, ownerPlayerId: l.bounty.ownerPlayerId ?? l.sourcePlayerId };
if (l.tag === 'bounty') spec.tag = 'bounty';
spawns.push(spec);
});
k++;
}
}
spawns.sort((a, b) => a.time - b.time);
return { templateId, spawns, routes };
}