// 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); } /** The spawn mods of a round's scale for a non-leader enemy: HP / ATK / speed, plus `supplyHpMul` when the round has one. */ export function roundMods(scale) { const m = { hpMul: scale.hpMul, atkMul: scale.atkMul, speedMul: scale.speedMul }; if (scale.supplyHpMul != null) m.supplyHpMul = scale.supplyHpMul; return m; } /** 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 下半). `supplyHpMul` (roundMods) rides along for // the 器物 hit-count units, which 补给线 / 补给线II leave out (archetypes.js `times`; also a 频次 enemy's death spawn) mods: isBoss ? { atkMul: scale.atkMul, speedMul: scale.speedMul, slot } : { ...roundMods(scale), 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: { ...roundMods(scale), 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 }; }