卫戍协议:盟约 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

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+264
View File
@@ -0,0 +1,264 @@
// server/match/StubMatch.js — the platform STUB match, kept for platform tests (test/lobby.test.js) and as a
// minimal reference implementation of the lobby⇄match interface documented at the top of server/match/Match.js.
// It is NOT used in production (server/lobby.js imports the real Match from ./Match.js).
//
//
// This file exists so the platform (http/ws/lobby) runs end to end before the real match engine lands.
// The real implementation (DESIGN §6) MUST keep exactly the interface documented below; server/lobby.js
// is the only caller.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────────
// MATCH INTERFACE (platform contract: server/lobby.js ⇄ server/match/Match.js)
// ─────────────────────────────────────────────────────────────────────────────────────────────────────
//
// new Match(opts)
// opts.roomCode string 4-letter room code (logging only)
// opts.mode 'solo' | 'coop'
// opts.difficulty 'FUNNY'|'NORMAL'|'HARD'|'ABYSS'
// opts.modeId string modeIdFor(mode, difficulty), e.g. 'mode_multi_hard'
// opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean }>
// sorted by seat, 1–4 entries, ≥ 1 human; solo ⇒ exactly 1 human and no bots.
// Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2).
// opts.seed uint32 master seed for all match randomness
// opts.data frozen game data (server/data.js getData()); may lack keys while data is generated
// opts.log { info, warn, error, debug }
// opts.now () => ms epoch injectable clock (Date.now in production)
// opts.send(playerId, msg) → boolean unicast; never throws; silently drops for bots, disconnected
// or departed players (they get a full resync on reconnect).
// `b.snap` frames may be dropped under backpressure.
// opts.broadcast(msg) → void to every connected, non-departed human of the room.
// opts.onEnd(summary) → void call EXACTLY ONCE when the match is over, after m.result was
// sent. The platform then (synchronously) returns the room to
// LOBBY and (asynchronously, next macrotask) calls dispose().
// `summary` is free-form JSON (kept as room.lastSummary).
//
// start() Called once, right after construction. Must broadcast the first m.public and send
// each human its m.private. May call onEnd synchronously (the platform copes).
// handle(playerId, msg) A validated 'g.*' intent (msg passed validateC2S; never 'g.leave', which the
// → { ok: true } platform turns into onLeave). Return { error: ERR code (shared/constants.js),
// | { error, detail? } detail?: string } to reject. Must not throw (if it does, the platform logs and
// replies ERR.INTERNAL). Replies ('ok'/'error' with rid) are sent by the platform.
// onDisconnect(playerId) A human's socket dropped. The seat is kept; apply the auto-play policy.
// onReconnect(playerId) The human is back (new socket with its token) or re-sent hello on a live socket
// (a resync request). May be called without a preceding onDisconnect. Resend full
// state: m.public, m.private and, if the player is watching a battle, m.field
// (+ next b.snap).
// onLeave(playerId) The human quit permanently (g.leave, room.leave, or the 10-minute reconnect
// window expired). They will never return under this playerId in this match;
// treat as quit (AI takes over / eliminated per DESIGN). No onDisconnect follows.
// dispose() Stop every timer/interval and release resources. Idempotent. After dispose the
// platform ignores send/broadcast/onEnd from this instance.
//
// Bot seats never produce intents or hooks: the match drives bots itself (server/match/bot.js).
// Messages the match emits are the S2C 'm.*' / 'b.*' frames of DESIGN §8.2 (room.* frames are platform-owned).
// ─────────────────────────────────────────────────────────────────────────────────────────────────────
//
// STUB behaviour: start() enters INFO_CHECK (duration from data/config.json → timers.infoCheck, default 25 s)
// and sends m.public / m.private in the §8.2/§8.3 shapes (empty shop/hand/board) so UI code can render them.
// Every known 'g.*' intent is accepted; 'g.infoReady' marks the player ready. Once every human is ready
// (departed humans count as ready) or the INFO_CHECK deadline passes, the stub sends m.result and calls
// onEnd — so the lobby's "play again" loop can be exercised, also from a browser that has no briefing UI.
// 'g.emote' is relayed as m.emote (1 s cooldown) so clients can test broadcast plumbing.
import { C2S } from '../../shared/protocol.js';
import { PHASE, ERR, EMOTE_COOLDOWN_MS, GEO, modeIdFor } from '../../shared/constants.js';
import { getConfig, getMode } from '../data.js';
const GAME_TYPES = new Set(Object.keys(C2S).filter((t) => Object.hasOwn(C2S, t) && t.startsWith('g.')));
const DEFAULT_INFO_CHECK_S = 25;
const MAX_TIMER_MS = 2 ** 31 - 1;
const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
/** Positive finite number or the fallback. */
const posNum = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback);
// STUB — replaced in Core phase
export class StubMatch {
/** @param {object} opts see MATCH INTERFACE above */
constructor(opts) {
if (!opts || !Array.isArray(opts.seats) || opts.seats.length === 0) throw new TypeError('Match: seats required');
if (typeof opts.send !== 'function' || typeof opts.broadcast !== 'function' || typeof opts.onEnd !== 'function') {
throw new TypeError('Match: send/broadcast/onEnd callbacks required');
}
this.roomCode = opts.roomCode;
this.mode = opts.mode;
this.difficulty = opts.difficulty;
this.modeId = opts.modeId || modeIdFor(opts.mode, opts.difficulty);
this.seed = opts.seed >>> 0;
this.log = opts.log || noopLog;
this.now = opts.now || Date.now;
this.sendFn = opts.send;
this.broadcastFn = opts.broadcast;
this.onEndFn = opts.onEnd;
const data = opts.data && typeof opts.data === 'object' ? opts.data : {};
const config = getConfig(data) || {};
const modeCfg = getMode(this.modeId, data) || {};
this.lastRound = Number.isInteger(modeCfg.lastRound) && modeCfg.lastRound > 0
? modeCfg.lastRound
: (this.modeId === 'mode_single_funny' ? 9 : 14);
this.infoCheckMs = Math.min(MAX_TIMER_MS, posNum(config.timers && config.timers.infoCheck, DEFAULT_INFO_CHECK_S) * 1000);
/** @type {Map<string, {seat:number, playerId:string, name:string, isBot:boolean, connected:boolean, left:boolean, ready:boolean, lastEmoteAt:number}>} */
this.players = new Map();
for (const s of opts.seats) {
this.players.set(s.playerId, { ...s, left: false, ready: !!s.isBot, lastEmoteAt: -Infinity });
}
this.phase = PHASE.LOBBY;
this.deadline = 0;
this.timer = null;
this.ended = false;
this.disposed = false;
}
start() {
if (this.disposed || this.ended || this.phase !== PHASE.LOBBY) return;
this.phase = PHASE.INFO_CHECK;
this.deadline = this.now() + this.infoCheckMs;
this.timer = setTimeout(() => { this.timer = null; this.maybeFinish(true); }, this.infoCheckMs);
this.timer.unref?.();
this.broadcastFn(this.publicView());
for (const p of this.players.values()) if (!p.isBot) this.sendFn(p.playerId, this.privateView(p));
}
/**
* @param {string} playerId
* @param {{ t: string }} msg validated intent
* @returns {{ ok: true } | { error: string }}
*/
handle(playerId, msg) {
const p = this.players.get(playerId);
if (!p || p.isBot || p.left) return { error: ERR.NOT_IN_ROOM };
if (this.disposed || this.ended || !msg || !GAME_TYPES.has(msg.t)) return { error: ERR.WRONG_PHASE };
if (msg.t === 'g.infoReady') {
if (this.phase !== PHASE.INFO_CHECK) return { error: ERR.WRONG_PHASE };
if (!p.ready) {
p.ready = true;
this.broadcastFn(this.publicView());
this.maybeFinish(false);
}
} else if (msg.t === 'g.emote') {
const now = this.now();
if (now - p.lastEmoteAt >= EMOTE_COOLDOWN_MS) {
p.lastEmoteAt = now;
this.broadcastFn({ t: 'm.emote', playerId, id: msg.id });
}
}
return { ok: true };
}
onDisconnect(playerId) {
const p = this.players.get(playerId);
if (!p || p.isBot || this.disposed) return;
p.connected = false;
if (!this.ended) this.broadcastFn(this.publicView());
}
onReconnect(playerId) {
const p = this.players.get(playerId);
if (!p || p.isBot || p.left || this.disposed) return;
const wasConnected = p.connected;
p.connected = true;
this.sendFn(playerId, this.publicView());
this.sendFn(playerId, this.privateView(p));
if (!this.ended && !wasConnected) this.broadcastFn(this.publicView());
}
onLeave(playerId) {
const p = this.players.get(playerId);
if (!p || p.isBot || p.left || this.disposed) return;
p.left = true;
p.connected = false;
if (this.ended) return;
this.broadcastFn(this.publicView());
this.maybeFinish(false);
}
dispose() {
this.disposed = true;
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
}
// ---- stub internals ------------------------------------------------------------------------------
/** End the stub match when every human is ready (or unconditionally once the deadline passed). */
maybeFinish(deadlinePassed) {
if (this.ended || this.disposed) return;
if (!deadlinePassed) {
for (const p of this.players.values()) if (!p.isBot && !p.left && !p.ready) return;
}
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
const humansLeft = [...this.players.values()].some((p) => !p.isBot && !p.left);
this.ended = true;
this.phase = PHASE.RESULT;
this.deadline = 0;
const summary = {
stub: true, victory: false, roundsPassed: 0, modeId: this.modeId, difficulty: this.difficulty, seed: this.seed,
reason: deadlinePassed ? 'timeout' : 'confirmed',
players: [...this.players.values()].map((p) => ({ playerId: p.playerId, seat: p.seat, name: p.name, isBot: p.isBot, lp: 0, title: null })),
};
if (humansLeft) {
this.broadcastFn(this.publicView());
this.broadcastFn({ t: 'm.result', ...summary });
}
this.onEndFn(summary);
}
/** DESIGN §8.2 m.public (players[].status uses the §8.3 enum). */
publicView() {
return {
t: 'm.public',
phase: this.phase,
round: 0,
lastRound: this.lastRound,
deadline: this.phase === PHASE.INFO_CHECK ? this.deadline : 0,
serverNow: this.now(),
modeId: this.modeId,
difficulty: this.difficulty,
stageId: null,
factions: [],
disabledBonds: [],
bannedChess: [],
bossId: null,
players: [...this.players.values()].map((p) => ({
playerId: p.playerId, seat: p.seat, name: p.name, isBot: p.isBot, connected: p.isBot || (p.connected && !p.left),
alive: true, lp: 0, bandId: null, shopLevel: 1, boardCount: 0, ready: p.ready, bonds: [], fieldId: null,
status: p.left ? 'left' : p.ready ? 'ready' : 'deciding',
})),
fields: [],
stub: true,
message: '对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。',
};
}
/** DESIGN §8.3 m.private with an empty economy. */
privateView(p) {
return {
t: 'm.private',
playerId: p.playerId,
seat: p.seat,
alive: true,
lp: 0,
funds: 0,
bandId: null,
ready: p.ready,
canReady: true,
shop: {
level: 1, maxLevel: 6, upgradePrice: 0, refreshPrice: 0, freeRefreshes: 0, frozen: false,
slots: [], rewardOffer: null,
},
hand: new Array(GEO.HAND_SIZE).fill(null),
temp: new Array(GEO.TEMP_SIZE).fill(null),
board: [],
deployCap: 0,
deployCount: 0,
bonds: [],
effects: [],
nextEnemies: [],
stats: { dmgDealt: 0, kills: 0, leaks: 0, gold: 0, refreshes: 0, merges: 0 },
stub: true,
};
}
}
/** Alias so tests can `import { Match } from './StubMatch.js'`. */
export { StubMatch as Match };
+422
View File
@@ -0,0 +1,422 @@
// server/match/audit.js — rule auditor for sweeps and tests (tools/matchrun.mjs --check, test/match/fullmatch.test.js).
//
// attachAudit(m) wraps a live Match's phase transitions and a few prep handlers (instance-level wrappers; the engine
// is untouched) and records every rule violation it observes, next to the structural invariants of invariants.js:
// prep end leftover funds lost (carry bands excepted), temp resolved, reward offers expired
// round start income = config income(r) (= min(3 + r, 12)) + pending funds, upgrade price −1 (floor 0) from R2,
// temp wiped, offers earned after the last prep kept, frozen slots kept in place (same id; chess slots
// keep their index, the item slot(s) follow the chess slots, so a level-up moves them right),
// everything else rerolled with tier ≤ shop level from the unbanned pool, freeze toggle released
// prep handlers buy / sell / refresh / levelUp pay exactly price / +sell price / refresh price (free first) / level
// price, the level rises by one and its price resets to the next base
// combat start temp empty, everyone ready, funds lost (carry bands excepted), unfrozen shop cleared, one field per
// alive player
// drafts every seat holds an allowed band with LP = totalHp; 机变: one card per alive player, card ↔ picker
// maps consistent, 6 (co-op) / 3 (solo) cards
// 联防 decided after the COMBAT_END pause from the players still in: runs iff co-op with ≥ 1 leaker and
// ≥ 1 perfect player; helpers = unite.js helperOrder (PRTS: units > active bond > layers > standing
// units > seat, research 08 §5); leakers = players with counted leaks
// settle no unite ⇒ loss = min(cap, counted leaks); after 联防 a leaker loses ≤ cap (its leaked enemies'
// offspring count too), everybody else ≤ min(cap, own counted leaks); LP ≤ 0 ⇔ eliminated
// final assault fields pair alive players by seat, team LP = Σ alive LP, boss pool = bossPoolHp(); hidden core only
// after a win when hiddenEligible() holds
// result each title ≤ once, ≤ 1 title per player, onlyOnWin titles only on a win, roundsPassed per player,
// Σ alive players' LP = the merged team LP after the Final Assault
// deadlines every timed phase's m.public deadline equals its configured duration × timerScale (a solo match
// runs BATTLE_CHECK / ROUND_START / SETTLE silently: deadline 0, Match.soloUntimed)
// Checks never throw into the match: an exception inside a check is itself recorded as a violation.
import { PHASE } from '../../shared/constants.js';
import { collectViolations } from './invariants.js';
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
import { helperOrder } from './unite.js';
/**
* @param {import('./Match.js').Match} m
* @param {{ invariants?: boolean, limit?: number }} [opts] invariants: also run collectViolations at phase changes
*/
export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
const gd = m.gd;
const audit = {
violations: [],
checks: 0,
/** shop tier histogram of rolled chess slots: level → { tier: n } */
odds: {},
phases: 0,
};
const where = () => `${m.phase} R${m.round}`;
const fail = (msg) => { if (audit.violations.length < limit) audit.violations.push(`[${where()}] ${msg}`); };
const check = (label, fn) => {
audit.checks++;
try { fn(); } catch (e) { fail(`audit ${label} threw: ${e && e.message}`); }
};
const carries = (ps) => gd.leftoverKeptBands.includes(ps.bandId);
const wrap = (obj, name, around) => {
const orig = obj[name];
if (typeof orig !== 'function') return;
obj[name] = function wrapped(...args) { return around.call(this, orig.bind(this), ...args); };
};
const expectDeadline = (seconds, label, { silentSolo = false } = {}) => check(`deadline ${label}`, () => {
// a solo match's presentation steps run silently (no countdown, Match.soloUntimed)
const want = seconds > 0 && !(silentSolo && m.soloUntimed) ? Math.max(0, Math.round(seconds * 1000 * m.timerScale)) : 0;
const got = m.deadline ? m.deadline - m.sched.now() : 0;
if (Math.abs(got - want) > 1) fail(`${label}: deadline in ${got} ms, expected ${want} ms`);
});
const runInvariants = () => {
if (!invariants) return;
audit.phases++;
for (const v of collectViolations(m, { limit: 10 })) fail(`invariant: ${v}`);
};
// ---- per-player prep handlers and round start --------------------------------------------------------------
const incomeEv = new Map();
wrap(m, 'dispatch', function (orig, ps, hook, ev, opts) {
if (hook === 'onIncome' && ev && ps) incomeEv.set(ps, { initial: { income: ev.income, pending: ev.pending }, ev });
return orig(ps, hook, ev, opts);
});
for (const ps of m.players.values()) {
wrap(ps, '_rollChessSlot', function (orig) {
const s = orig();
if (s) {
const t = gd.tierOf(s.id);
const row = (audit.odds[ps.shop.level] ||= {});
row[t] = (row[t] || 0) + 1;
check('shop roll', () => {
const base = gd.baseIdOf(s.id);
if (t > ps.shop.level) fail(`${ps.playerId}: rolled tier ${t} at shop level ${ps.shop.level}`);
if (!m.pool.has(base)) fail(`${ps.playerId}: rolled ${s.id} outside the match pool (banned/hidden)`);
if (s.basePrice !== gd.chessPrice(s.id)) fail(`${ps.playerId}: ${s.id} basePrice ${s.basePrice} != ${gd.chessPrice(s.id)}`);
});
}
return s;
});
wrap(ps, 'startRound', function (orig, r) {
const f0 = ps.funds;
const p0 = ps.pendingFunds;
const up0 = ps.shop.upgradePrice;
const kept = ps.shop.slots.map((s, i) => (s ? { i, kind: s.kind, id: s.id, frozen: !!s.frozen, sold: !!s.sold } : null)).filter(Boolean);
const offers0 = ps.offers.slice();
const layout0 = ps.shop.layout || { chess: ps.shop.slots.length, item: 0 };
incomeEv.delete(ps);
const res = orig(r);
check('round start', () => {
const id = ps.playerId;
const inc = incomeEv.get(ps);
const want = gd.income(r);
if (want !== Math.min(3 + r, 12) && !gd.economy.income) fail(`income(${r}) ${want}`);
if (!inc) fail(`${id}: no onIncome dispatch`);
else {
if (inc.initial.income !== want || inc.initial.pending !== p0) fail(`${id}: onIncome started with ${inc.initial.income}+${inc.initial.pending}, expected ${want}+${p0}`);
const nn = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0);
const credited = nn(inc.ev.income) + nn(inc.ev.pending);
if (ps.funds - f0 !== credited) fail(`${id}: funds ${f0} → ${ps.funds}, credited ${credited}`);
}
const upWant = r > 1 ? Math.max(0, up0 - 1) : up0;
if (ps.shop.upgradePrice !== upWant) fail(`${id}: upgrade price ${up0} → ${ps.shop.upgradePrice}, expected ${upWant}`);
if (ps.pendingFunds !== 0) fail(`${id}: pending funds not cleared`);
if (!ps.tempEmpty) fail(`${id}: temp not wiped at round start`);
// offers of the last prep expired at its end; the ones queued after it (SETTLE merges) wait for this prep
if (offers0.some((o) => !ps.offers.includes(o))) fail(`${id}: a reward offer earned after the prep was dropped at the round start`);
if (ps.shop.frozen) fail(`${id}: freeze toggle still on after the round start`);
const { chess, item } = gd.shopSlots(ps.shop.level);
if (ps.shop.slots.length !== chess + item) fail(`${id}: ${ps.shop.slots.length} shop slots at level ${ps.shop.level}, expected ${chess}+${item}`);
for (const k of kept) {
if (!k.frozen || k.sold) { fail(`${id}: slot ${k.i} (${k.id}) survived combat unfrozen/sold`); continue; }
// chess slots keep their index; item slots keep their place after the chess slots (whose count may have grown)
const at = k.i < layout0.chess ? k.i : chess + (k.i - layout0.chess);
const s = ps.shop.slots[at];
if (!s || s.id !== k.id || s.kind !== k.kind) fail(`${id}: frozen slot ${k.i} (${k.id}) not kept in place (slot ${at} now ${s ? s.id : 'empty'})`);
}
for (const s of ps.shop.slots) if (s && s.frozen) fail(`${id}: slot ${s.id} still frozen after the round start`);
});
return res;
});
// exact payment checks are skipped when content could pay out / refund around the action (checked before & after)
wrap(ps, 'buy', function (orig, slotIdx) {
const slot = ps.shop.slots[slotIdx];
const price = slot && !slot.sold ? ps.priceOf(slot) : null;
const f0 = ps.funds;
const fx = hasSpendEffects(m, ps);
const res = orig(slotIdx);
if (res && res.ok) check('buy', () => {
if (!slot || !slot.sold) fail(`${ps.playerId}: bought slot ${slotIdx} is not marked sold`);
if (ps.funds !== f0 - price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: buy paid ${f0 - ps.funds}, price ${price}`);
});
return res;
});
wrap(ps, 'sell', function (orig, uid) {
const loc = ps.find(uid);
const gain = loc && loc.piece.kind === 'chess' ? gd.sellPrice(loc.piece.id) : null;
const f0 = ps.funds;
const fx = hasSpendEffects(m, ps);
const res = orig(uid);
if (res && res.ok) check('sell', () => {
if (ps.funds - f0 !== gain && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: sell paid ${ps.funds - f0}, expected ${gain}`);
if (ps.find(uid)) fail(`${ps.playerId}: sold piece ${uid} still owned`);
});
return res;
});
wrap(ps, 'refresh', function (orig) {
const free = ps.shop.freeRefreshes > 0;
const f0 = ps.funds;
const fr0 = ps.shop.freeRefreshes;
const fx = hasSpendEffects(m, ps);
const res = orig();
if (res && res.ok && !fx && !hasSpendEffects(m, ps)) check('refresh', () => {
const paid = f0 - ps.funds;
if (free ? paid !== 0 || ps.shop.freeRefreshes !== fr0 - 1 : paid !== gd.refreshPrice) fail(`${ps.playerId}: refresh (free ${free}) paid ${paid}`);
});
return res;
});
wrap(ps, 'levelUp', function (orig) {
const lv = ps.shop.level;
const price = ps.shop.upgradePrice;
const f0 = ps.funds;
const fx = hasSpendEffects(m, ps);
const res = orig();
if (res && res.ok) check('levelUp', () => {
if (ps.shop.level !== lv + 1) fail(`${ps.playerId}: level ${lv} → ${ps.shop.level}`);
if (f0 - ps.funds !== price && !fx && !hasSpendEffects(m, ps)) fail(`${ps.playerId}: level-up paid ${f0 - ps.funds}, price ${price}`);
const next = gd.upgradeBase(ps.shop.level) ?? 0;
if (ps.shop.upgradePrice !== next) fail(`${ps.playerId}: upgrade price after level-up ${ps.shop.upgradePrice}, expected ${next}`);
});
return res;
});
wrap(ps, 'endPrep', function (orig) {
const res = orig();
check('prep end', () => {
// leftover funds are lost at prep end (carry bands excepted); gains after this (SETTLE effects) are kept
if (ps.funds !== 0 && !carries(ps)) fail(`${ps.playerId}: kept ${ps.funds} funds past the prep end without a carry band`);
if (!ps.tempEmpty) fail(`${ps.playerId}: temp not resolved at prep end`);
if (ps.offers.length) fail(`${ps.playerId}: reward offer survived the prep end`);
});
return res;
});
wrap(ps, 'setReady', function (orig, on) {
const res = orig(on);
if (res && res.ok && on) check('ready', () => { if (!ps.tempEmpty) fail(`${ps.playerId}: ready with a non-empty temp`); });
return res;
});
}
// ---- phases ------------------------------------------------------------------------------------------------
wrap(m, 'enterInfoCheck', function (orig) {
const r = orig();
runInvariants();
if (m.phase === PHASE.INFO_CHECK && m.deadline) expectDeadline(gd.timer('infoCheck'), 'INFO_CHECK');
return r;
});
wrap(m, 'enterBandDraft', function (orig) {
const r = orig();
runInvariants();
check('band draft', () => {
if (m.phase !== PHASE.BAND_DRAFT) return;
const d = m.draft;
const ids = m.order.map((p) => p.playerId).sort();
if (JSON.stringify(d.order.slice().sort()) !== JSON.stringify(ids)) fail(`draft order ${d.order} != seats ${ids}`);
if (m.isSolo) { if (m.deadline) fail('solo band draft is timed'); } else expectDeadline(gd.timer('bandDraft'), 'BAND_DRAFT');
});
return r;
});
wrap(m, 'enterBattleCheck', function (orig) {
const r = orig();
runInvariants();
check('bands', () => {
for (const ps of m.order) {
if (!ps.bandId || !gd.bandAllowed(ps.bandId)) fail(`${ps.playerId}: band ${ps.bandId} not allowed`);
if (ps.lp !== gd.startLp(ps.bandId)) fail(`${ps.playerId}: LP ${ps.lp} != totalHp ${gd.startLp(ps.bandId)} of ${ps.bandId}`);
}
expectDeadline(gd.timer('battleCheck'), 'BATTLE_CHECK', { silentSolo: true });
});
return r;
});
wrap(m, 'startRound', function (orig, rr) {
const res = orig(rr);
runInvariants();
check('round start phase', () => {
if (m.phase !== PHASE.ROUND_START) return;
expectDeadline(2, 'ROUND_START', { silentSolo: true });
const isBoss = rr === gd.bossRound || rr === gd.hiddenRound;
if (isBoss ? !m.bossWaves : !m.wave) fail('round without its wave');
});
return res;
});
wrap(m, 'startSpTurn', function (orig) {
const res = orig();
check('sp turn', () => {
if (m.phase !== PHASE.SP_DRAFT || !m.sp) return;
const s = m.sp;
if (s.idx >= s.order.length) return;
if (m.isSolo) { if (m.deadline) fail('solo 机变 is timed'); } else expectDeadline(s.idx === 0 ? gd.timer('spFirst') : gd.timer('spTurn'), `SP_DRAFT turn ${s.idx}`);
});
return res;
});
wrap(m, 'finishSpDraft', function (orig) {
const s = m.sp;
if (m.phase === PHASE.SP_DRAFT && s) check('sp draft', () => {
const alive = m.alivePlayers().map((p) => p.playerId);
const want = m.isSolo ? 3 : 6;
if (s.cards.length > want) fail(`${s.cards.length} 机变 cards (max ${want})`);
if (s.order.length !== alive.length) fail(`机变 order ${s.order.length} for ${alive.length} alive`);
for (const pid of alive) {
const idx = s.picks[pid];
if (idx == null) fail(`${pid} ends 机变 without a card`);
else if (s.taken[idx] !== pid) fail(`${pid} picked card ${idx} held by ${s.taken[idx]}`);
}
const holders = Object.values(s.taken);
if (new Set(holders).size !== holders.length) fail('a player took two 机变 cards');
});
return orig();
});
wrap(m, 'enterPrep', function (orig) {
const res = orig();
runInvariants();
check('prep', () => {
if (m.phase !== PHASE.PREP) return;
if (m.isSolo) { if (m.deadline) fail('solo prep is timed'); } else if (!m._prepEndQueued) expectDeadline(gd.prepTime(m.round), 'PREP');
});
return res;
});
wrap(m, 'startCombat', function (orig) {
const res = orig();
runInvariants();
check('combat start', () => {
for (const ps of m.alivePlayers()) {
const id = ps.playerId;
if (!ps.tempEmpty) fail(`${id}: temp not empty at combat start`);
if (!ps.ready) fail(`${id}: not ready at combat start`);
if (ps.funds !== 0 && !carries(ps)) fail(`${id}: kept ${ps.funds} funds into combat`);
if (ps.offers.length) fail(`${id}: reward offer survived the prep`);
for (const s of ps.shop.slots) if (s && (!s.frozen || s.sold)) fail(`${id}: unfrozen/sold slot ${s.id} survived into combat`);
}
});
return res;
});
let expectUnite = null;
// the 联防 decision (both combat modes): made after the COMBAT_END pause from the players still in
wrap(m, '_afterCombat', function (orig) {
if (m.phase === PHASE.COMBAT) check('unite trigger', () => {
const counted = (pid) => ((m.lastResults.get(pid) || {}).leaked || []).filter((l) => l && l.counted !== false).length;
const alive = m.alivePlayers();
const leak = alive.some((p) => counted(p.playerId) > 0);
const perfect = alive.some((p) => counted(p.playerId) === 0);
expectUnite = { round: m.round, expect: !m.isSolo && leak && perfect };
});
return orig();
});
wrap(m, 'startUnite', function (orig, plan) {
check('unite plan', () => {
const res = m.lastResults;
const counted = (pid) => ((res.get(pid) || {}).leaked || []).filter((l) => l && l.counted !== false).length;
const alive = m.alivePlayers();
const leakers = alive.filter((p) => counted(p.playerId) > 0).map((p) => p.playerId).sort();
const perfect = alive.filter((p) => counted(p.playerId) === 0);
const helpers = helperOrder(m, perfect, res).map((p) => p.playerId);
if (helpers.length > gd.unite.maxHelpers) fail(`${helpers.length} 联防 helpers (max ${gd.unite.maxHelpers})`);
if (plan.helpers.some((p) => !p.alive || p.left)) fail(`联防 helper eliminated / departed: ${plan.helpers.filter((p) => !p.alive || p.left).map((p) => p.playerId)}`);
if (m.isSolo) fail('联防 in solo');
if (JSON.stringify(plan.leakers.map((p) => p.playerId).sort()) !== JSON.stringify(leakers)) fail(`联防 leakers ${plan.leakers.map((p) => p.playerId)} != ${leakers}`);
if (JSON.stringify(plan.helpers.map((p) => p.playerId)) !== JSON.stringify(helpers)) fail(`联防 helpers ${plan.helpers.map((p) => p.playerId)} != ${helpers}`);
});
const r = orig(plan);
runInvariants();
return r;
});
wrap(m, 'settle', function (orig, plan, uniteResult) {
check('unite trigger', () => {
if (expectUnite && expectUnite.round === m.round && expectUnite.expect !== !!plan) fail(`联防 ${plan ? 'ran' : 'skipped'} but ${expectUnite.expect ? '≥ 1 leaker and ≥ 1 perfect player' : 'not both a leaker and a perfect player'}`);
expectUnite = null;
});
const before = new Map(m.alivePlayers().map((ps) => [ps, ps.lp]));
const res = orig(plan, uniteResult);
check('settle', () => {
const cap = gd.lpCapPerRound;
const uniteRan = !!(plan && uniteResult && !uniteResult.synthetic);
for (const [ps, lp0] of before) {
const r = m.lastResults.get(ps.playerId) || { leaked: [] };
const counted = (r.leaked || []).filter((l) => l && l.counted !== false).length;
// after 联防 a leaker pays for every surviving enemy of its source — enemies spawned by its leaked enemies
// (splitters, summoners) included — so only the cap bounds it; everybody else never exceeds own leaks
const max = uniteRan && plan.leakers.includes(ps) ? cap : Math.min(cap, counted);
if (ps.alive) {
const loss = lp0 - ps.lp;
if (loss < 0) fail(`${ps.playerId}: LP rose in settle ${lp0} → ${ps.lp}`);
if (loss > max) fail(`${ps.playerId}: lost ${loss} LP with ${counted} counted leaks (cap ${cap})`);
if (!uniteRan && loss !== max) fail(`${ps.playerId}: lost ${loss} LP, expected min(${cap}, ${counted})`);
if (ps.lp <= 0) fail(`${ps.playerId}: alive with LP ${ps.lp}`);
} else {
// eliminated: LP is clamped to 0, so the loss was ≥ lp0 and still ≤ min(cap, counted)
if (lp0 > max) fail(`${ps.playerId}: eliminated from ${lp0} LP with only ${counted} counted leaks`);
if (ps.eliminatedRound !== m.round) fail(`${ps.playerId}: eliminated round ${ps.eliminatedRound} != ${m.round}`);
if (ps.lp !== 0) fail(`${ps.playerId}: eliminated with LP ${ps.lp}`);
}
}
expectDeadline(3, 'SETTLE', { silentSolo: true });
});
runInvariants();
return res;
});
wrap(m, 'startFinalAssault', function (orig, hidden) {
const alive = m.alivePlayers();
const lpSum = alive.reduce((s, p) => s + Math.max(0, p.lp), 0);
const teamLp0 = m.teamLp;
const res = orig(hidden);
runInvariants();
check('final assault', () => {
if (!alive.length) return;
const groups = pairPlayers(alive).map((g) => g.map((p) => p.playerId).join(','));
const fields = m.fields.map((f) => f.players.join(','));
if (JSON.stringify(groups) !== JSON.stringify(fields)) fail(`boss fields ${fields.join(' | ')} != seat pairs ${groups.join(' | ')}`);
m.fields.forEach((f, i) => { if (f.fieldId !== `b${i + 1}`) fail(`boss field id ${f.fieldId}`); });
if (!hidden && m.teamLp !== lpSum) fail(`team LP ${m.teamLp} != Σ alive LP ${lpSum}`);
if (hidden && m.teamLp !== teamLp0) fail(`hidden core changed team LP ${teamLp0} → ${m.teamLp}`);
const want = bossPoolHp(gd, hidden ? m.hiddenBossId : m.bossId, alive.length);
if (!m.bossPool || m.bossPool.maxHp !== want) fail(`boss pool ${m.bossPool && m.bossPool.maxHp} != ${want}`);
if (hidden && !hiddenEligible(gd, { layerSum: m.hiddenLayerSum, teamLp: m.teamLp })) fail('hidden core entered while not eligible');
if (hidden && gd.difficulty === 'FUNNY') fail('hidden core on FUNNY');
});
return res;
});
wrap(m, 'finish', function (orig, outcome) {
const res = orig(outcome);
check('result', () => {
const r = m.lastResultMsg;
if (!r) { fail('no m.result'); return; }
const titles = r.players.map((p) => p.title && p.title.id).filter(Boolean);
if (new Set(titles).size !== titles.length) fail(`a title was given twice: ${titles}`);
const cfg = Array.isArray(gd.config.titles) ? gd.config.titles : [];
for (const p of r.players) {
const t = p.title && cfg.find((x) => x.id === p.title.id);
if (t && t.onlyOnWin && !r.victory) fail(`${p.playerId}: win-only title ${t.id} on a defeat`);
const ps = m.players.get(p.playerId);
const want = !ps.alive && ps.eliminatedRound != null ? Math.max(0, ps.eliminatedRound - 1) : r.victory ? gd.bossRound + (r.hiddenCleared ? 1 : 0) : Math.max(0, Math.min(m.round, gd.bossRound) - 1);
if (p.roundsPassed !== want) fail(`${p.playerId}: roundsPassed ${p.roundsPassed}, expected ${want}`);
}
if (r.hiddenReached && !m.hiddenReached) fail('hiddenReached mismatch');
// after the Final Assault the alive players' LP are their shares of the merged team pool
if (r.teamLp != null && m.alivePlayers().some((p) => p.lpAtFinal != null)) {
const sum = r.players.filter((p) => p.alive).reduce((s, p) => s + p.lp, 0);
if (sum !== r.teamLp) fail(`Σ alive result LP ${sum} != team LP ${r.teamLp}`);
}
});
return res;
});
return audit;
}
/** Content that may pay out or refund around a purchase (then exact price checks are skipped). */
function hasSpendEffects(m, ps) {
const reg = m.registry;
const keys = ['onBuy', 'onSpend', 'onSold', 'onRefresh', 'onLevelUp', 'onGain', 'onLayers'];
const any = (h) => h && keys.some((k) => typeof h[k] === 'function');
if (reg.globals().some(([, h]) => any(h))) return true;
if (ps.bandId && any(reg.get(`band:${ps.bandId}`))) return true;
for (const id of m.gd.bondIds) if (any(reg.get(`bond:${id}`))) return true;
for (const e of ps.effects) if (e && typeof e.key === 'string' && any(reg.get(e.key))) return true;
for (const p of ps.allChess()) {
const rec = m.gd.chess(p.id);
for (const gid of (rec && rec.garrisonIds) || []) { const g = m.gd.garrison(gid); if (g && reg.get(`garrison:${g.effectKey}`)) return true; }
for (const it of p.items || []) if (any(reg.get(`item:${String(it.id).replace(/_[ab]$/, '')}`))) return true;
}
return false;
}
+127
View File
@@ -0,0 +1,127 @@
// server/match/board.js — placement legality (DESIGN §3), board/hand/temp slot helpers.
//
// Deploy map: for every tile of the own board region (rows 9–12, cols 2–10) the legality class is derived from the
// stage legend (data/stages.json `tiles[glyph]`: height + buildable) and the stage devices:
// 'melee' — LOW tile with buildable ALL/MELEE: melee AND ranged chess may stand here
// ("所有行动内远程干员可部署在近战位").
// 'ranged' — HIGH tile with buildable ALL/RANGED, or a tile under an active platform (射击台): ranged only.
// (absent) — not deployable (NONE, forbidden, road lanes, tiles under active crates/mounds).
// Per-player overrides (terrain 机变 cards, content): `deviceOverrides { alias: active }` toggles stage devices,
// `tileOverrides { 'r,c': 'melee'|'ranged'|'none' }` force a class. The result equals stages[id].deployTiles.normal
// for the unmodified stage (asserted in test/match/board.test.js).
//
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable).
// Facing: board pieces carry `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js); `pieceDir` reads it (absent ⇒ RIGHT),
// `parseDir` validates an intent's optional direction.
import { GEO } from '../../shared/constants.js';
import { DEFAULT_DIR, isDir } from '../sim/dir.js';
export const FIELD = GEO.FIELD; // { r0: 9, r1: 12, c0: 2, c1: 10 }
export const tileKey = (r, c) => `${r},${c}`;
export const parseKey = (k) => { const [r, c] = String(k).split(',').map(Number); return [r, c]; };
/** A board piece's direction (default RIGHT). */
export const pieceDir = (piece) => (piece && isDir(piece.dir) ? piece.dir : DEFAULT_DIR);
/** An intent's optional direction: undefined / null ⇒ RIGHT, a direction name ⇒ itself, anything else ⇒ null (invalid). */
export const parseDir = (d) => (d === undefined || d === null ? DEFAULT_DIR : isDir(d) ? d : null);
export const inField = (r, c) => Number.isInteger(r) && Number.isInteger(c) && r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1;
const OBSTACLE_ROLES = new Set(['crate', 'mound']);
const PLATFORM_ROLES = new Set(['platform']);
/**
* @param {object|null} stage data/stages.json entry
* @param {{ deviceOverrides?: Record<string, boolean>, tileOverrides?: Record<string, string> }} [opts]
* @returns {Map<string, 'melee'|'ranged'>}
*/
export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {} } = {}) {
/** @type {Map<string, 'melee'|'ranged'>} */
const map = new Map();
const rows = stage && Array.isArray(stage.rows) ? stage.rows : null;
const legend = stage && stage.tiles && typeof stage.tiles === 'object' ? stage.tiles : {};
for (let r = FIELD.r0; r <= FIELD.r1; r++) {
const line = rows && typeof rows[r] === 'string' ? rows[r] : null;
for (let c = FIELD.c0; c <= FIELD.c1; c++) {
let cls = null;
if (line) {
const g = line[c];
const t = g != null && Object.hasOwn(legend, g) ? legend[g] : null;
if (t) {
const b = t.buildable;
if (t.height === 'LOW' && (b === 'ALL' || b === 'MELEE')) cls = 'melee';
else if ((t.height === 'HIGH' && (b === 'ALL' || b === 'RANGED')) || (t.height === 'LOW' && b === 'RANGED')) cls = 'ranged';
}
} else if (!stage) {
// no stage data at all: an open board (tests / degraded data) — everything melee-deployable except col 9
cls = c === 9 ? null : 'melee';
}
if (cls) map.set(tileKey(r, c), cls);
}
}
const devices = stage && Array.isArray(stage.devices) ? stage.devices : [];
for (const d of devices) {
if (!d || !Array.isArray(d.pos)) continue;
const [r, c] = d.pos;
if (!inField(r, c)) continue;
let active;
if (d.alias != null && deviceOverrides && Object.hasOwn(deviceOverrides, d.alias)) active = !!deviceOverrides[d.alias];
else if (typeof d.active === 'boolean') active = d.active;
else active = !d.hidden;
if (!active) continue;
const k = tileKey(r, c);
if (OBSTACLE_ROLES.has(d.role)) map.delete(k);
else if (PLATFORM_ROLES.has(d.role)) map.set(k, 'ranged');
}
for (const [k, v] of Object.entries(tileOverrides || {})) {
const [r, c] = parseKey(k);
if (!inField(r, c)) continue;
if (v === 'melee' || v === 'ranged') map.set(tileKey(r, c), v);
else if (v === 'none') map.delete(tileKey(r, c));
}
return map;
}
/** Placement class of a chess / token record: 'melee' | 'ranged' | 'all'. */
export function positionClass(rec) {
const p = rec && typeof rec.position === 'string' ? rec.position.toUpperCase() : 'ALL';
if (p === 'MELEE') return 'melee';
if (p === 'RANGED') return 'ranged';
return 'all';
}
/** Whether a unit of placement class `pos` may stand on (r, c). */
export function canPlace(map, pos, r, c) {
if (!inField(r, c)) return false;
const cls = map.get(tileKey(r, c));
if (!cls) return false;
if (pos === 'melee') return cls === 'melee';
return true; // ranged / all: melee tiles and ranged tiles
}
/** Legal tiles for a placement class, in reading order (top→bottom, left→right). */
export function legalTiles(map, pos) {
const out = [];
for (let r = FIELD.r1; r >= FIELD.r0; r--) {
for (let c = FIELD.c0; c <= FIELD.c1; c++) if (canPlace(map, pos, r, c)) out.push([r, c]);
}
return out;
}
/** Board pieces sorted in deployment order: top→bottom (row desc) then left→right (col asc). */
export function boardOrder(board) {
return [...board.entries()]
.map(([k, p]) => { const [r, c] = parseKey(k); return { r, c, piece: p }; })
.sort((a, b) => b.r - a.r || a.c - b.c);
}
/** Index of the free slot to fill (right→left, config.economy.handFillOrder), or -1. */
export function freeSlot(arr) {
for (let i = arr.length - 1; i >= 0; i--) if (arr[i] == null) return i;
return -1;
}
export function countFree(arr) {
let n = 0;
for (const x of arr) if (x == null) n++;
return n;
}
+172
View File
@@ -0,0 +1,172 @@
// server/match/bondsMeta.js — bond member counting, activation tiers and persistent layers (DESIGN §6.3,
// research 02 §2.1–§2.2).
//
// Counting modes (data/bonds.json `countMode`):
// BOARD distinct base chess (normal/elite of one operator count once) on the board carrying the bond
// BOARD_AND_DECK BOARD + the hand (整备区; the 5 temporary slots do not count) — 远见 / 奇迹 / 投资人
// BOARD_ALL_CHESS every elite (精锐) chess on the board, duplicates included — 绝技 (thresholdTemplate *_golden)
// Membership = the chess's own `bonds` + bonds granted by 变形同构体 (an item with `canGiveBond`) worn together with
// an item that has a `giveBondId` (research 02 §2.1).
// Special rules:
// 调和 (maniShip): while active, every CORE bond that already has ≥ 1 real member on the board gets count +1
// (one +1 total, research 02 §2.1 [ASSUMED]).
// 助力 (deputShip): tier 1 needs `thresholds[0]` distinct operators; the upper tiers count operators that differ
// in name OR elite state (PRTS 修正).
// 独行 (soloShip, count_threshold_downward): active iff 1 ≤ count ≤ maxCount (distinct 独行 operators).
// Bonds in the mode's static inactive list (FUNNY) never activate and are omitted.
// `tier` = number of thresholds reached (downward: 1 when active); `active = tier ≥ 1`.
// Layers (`ps.layers[bondId]`) persist the whole match; they are reported for every bond but only matter while active.
export const HARMONY_BOND = 'maniShip';
export const DEPUTY_BOND = 'deputShip';
/**
* Bonds of a chess piece: own bonds ∪ bonds granted by 变形同构体 pairings.
* @param {import('./gamedata.js').GameData} gd
* @param {{ id: string, items?: Array<{ id: string }> }} piece
* @returns {string[]}
*/
export function pieceBonds(gd, piece) {
const c = gd.chess(piece.id);
const out = c && Array.isArray(c.bonds) ? c.bonds.slice() : [];
const items = Array.isArray(piece.items) ? piece.items : [];
if (items.length >= 2) {
const recs = items.map((it) => gd.item(it.id)).filter(Boolean);
if (recs.some((r) => r.canGiveBond)) {
for (const r of recs) {
if (r.canGiveBond) continue;
if (typeof r.giveBondId === 'string' && gd.bond(r.giveBondId) && !out.includes(r.giveBondId)) out.push(r.giveBondId);
}
}
}
return out;
}
/**
* Compute every bond's state for a player.
* @param {import('./gamedata.js').GameData} gd
* @param {{ board: Map<string, any>, hand: Array<any>, layers: Record<string, number>, bondCountBonus?: Record<string, number> }} ps
* @returns {Record<string, { count: number, active: boolean, tier: number, layers: number }>}
*/
export function computeBonds(gd, ps) {
const boardChess = [];
for (const p of ps.board.values()) if (p && p.kind === 'chess') boardChess.push(p);
const handChess = ps.hand.filter((p) => p && p.kind === 'chess');
/** bondId → Set(baseId) on board / hand; bondId → Set(baseId|golden) on board */
const onBoard = new Map();
const onBoardVariant = new Map();
const inHand = new Map();
const add = (m, bond, key) => { let s = m.get(bond); if (!s) m.set(bond, (s = new Set())); s.add(key); };
for (const p of boardChess) {
const base = gd.baseIdOf(p.id);
const golden = gd.isGolden(p.id);
for (const b of pieceBonds(gd, p)) { add(onBoard, b, base); add(onBoardVariant, b, `${base}|${golden ? 1 : 0}`); }
}
for (const p of handChess) {
const base = gd.baseIdOf(p.id);
for (const b of pieceBonds(gd, p)) add(inHand, b, base);
}
const goldenOnBoard = boardChess.filter((p) => gd.isGolden(p.id)).length;
/** @type {Record<string, { count: number, active: boolean, tier: number, layers: number }>} */
const out = {};
const layersOf = (id) => {
const v = ps.layers && ps.layers[id];
return Number.isFinite(v) && v > 0 ? v : 0;
};
const bonus = ps.bondCountBonus || {};
const bonusOf = (id) => (Number.isInteger(bonus[id]) ? bonus[id] : 0);
// first pass: raw counts
const raw = {};
for (const id of gd.bondIds) {
if (gd.modeInactiveBonds.has(id)) continue;
const bond = gd.bond(id);
let count;
if (bond.countMode === 'BOARD_ALL_CHESS' || bond.thresholdTemplate === 'count_threshold_upward_golden') {
count = goldenOnBoard;
} else if (bond.countMode === 'BOARD_AND_DECK') {
const s = new Set([...(onBoard.get(id) || []), ...(inHand.get(id) || [])]);
count = s.size;
} else {
count = (onBoard.get(id) || new Set()).size;
}
raw[id] = Math.max(0, count + bonusOf(id));
}
// 调和: +1 to core bonds with ≥ 1 real board member while it is active
const harmony = gd.bond(HARMONY_BOND);
const harmonyActive = harmony && raw[HARMONY_BOND] != null && tierFor(harmony, raw[HARMONY_BOND]) >= 1;
for (const id of Object.keys(raw)) {
const bond = gd.bond(id);
let count = raw[id];
if (harmonyActive && bond.isCore && (onBoard.get(id)?.size ?? 0) >= 1) count += 1;
let tier = tierFor(bond, count);
if (id === DEPUTY_BOND && tier >= 1) {
// upper tiers: operators differing in name OR elite state
const variants = (onBoardVariant.get(id)?.size ?? 0) + bonusOf(id);
const th = thresholdsOf(bond);
let t = 1;
for (let i = 1; i < th.length; i++) if (variants >= th[i]) t = i + 1;
tier = t;
}
out[id] = { count, active: tier >= 1, tier, layers: layersOf(id) };
}
return out;
}
/** Ascending member-count thresholds of a bond (fallback: activeCount). */
export function thresholdsOf(bond) {
if (Array.isArray(bond.thresholds) && bond.thresholds.length) return bond.thresholds.filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
if (Number.isFinite(bond.activeCount)) return [bond.activeCount];
return [2];
}
/** Tier for a member count (downward bonds: 1 while 1 ≤ count ≤ maxCount). */
export function tierFor(bond, count) {
const th = thresholdsOf(bond);
if (bond.thresholdTemplate === 'count_threshold_downward') {
const max = Number.isFinite(bond.maxCount) ? bond.maxCount : th[0];
return count >= th[0] && count <= max ? 1 : 0;
}
let t = 0;
for (const n of th) if (count >= n) t++;
return t;
}
/** Σ layers over active bonds (hidden-core check, 不朽盟约 title). */
export function activatedLayers(bonds) {
let n = 0;
for (const b of Object.values(bonds || {})) if (b.active) n += b.layers || 0;
return n;
}
/**
* View list for m.private / m.public: bonds with members or layers, active first, then layers desc, then data order.
* @param {import('./gamedata.js').GameData} gd
* @param {ReturnType<typeof computeBonds>} bonds
* @param {{ full?: boolean }} [opts] full → include thresholds/countsHand (m.private)
*/
export function bondList(gd, bonds, { full = false } = {}) {
const order = new Map(gd.bondIds.map((id, i) => [id, i]));
const list = [];
for (const [bondId, b] of Object.entries(bonds || {})) {
if (!(b.count > 0 || b.layers > 0 || b.active)) continue;
const e = { bondId, count: b.count, active: b.active, tier: b.tier, layers: b.layers };
if (full) {
const bond = gd.bond(bondId);
e.thresholds = bond ? thresholdsOf(bond) : [];
e.countsHand = !!(bond && bond.countMode === 'BOARD_AND_DECK');
}
list.push(e);
}
list.sort((a, b) => (b.active - a.active) || (b.layers - a.layers) || ((order.get(a.bondId) ?? 99) - (order.get(b.bondId) ?? 99)));
return list;
}
/** Snapshot for PlayerBattleInput.bonds: { [bondId]: { count, active, tier, layers } } (plain copy). */
export function bondSnapshot(bonds) {
const o = {};
for (const [id, b] of Object.entries(bonds || {})) o[id] = { count: b.count, active: b.active, tier: b.tier, layers: b.layers };
return o;
}
+1165
View File
File diff suppressed because it is too large. Load diff
+301
View File
@@ -0,0 +1,301 @@
// server/match/builtinMeta.js — engine built-in prep effects registered BEFORE content (content overrides a key by
// registering it again). They cover the purely meta, data-driven pieces the match needs to be playable before the
// content phase lands: consume-on-equip items, Arts, a few persistent EffectRefs used by 机变 defaults.
//
// Items are matched by their official buff key (data/items.json buffs[].key), numbers come from the item's params
// (normal `_a` / golden `_b` record), never hard-coded:
// equip_destory_gain_random_coin {min,max} 盟约之币 / 骑士储蓄罐 funds += rand[min,max]
// use_equip_reward_char_chess_bond_layer {layer} 随身身份牌 layers of the target's bonds (无需激活)
// use_equip_reward_random_char_chess_in_shop {count} 紧急调度券 take `count` random chess from the shop
// gain_coin_when_round_start {count} 精打细算玩偶 +count funds every round start
// use_equip_reward_char_chess_with_same_bond {count} 简易通讯机 `count` random chess sharing a bond
// use_equip_gain_coin_when_next_round_start {count} 见钱眼开玩偶 +count funds next round
// equip_destory_deployment_cnt_change {count} 人事部文档 deploy cap = count
// use_equip_upgrade_char / equip_round_start_upgrade_char 博士投影 promote (golden: now, normal: next round)
// use_equip_reward_char_chess 拟态物质 3rd copy or a same-bond chess
// use_equip_reward_special_goods_char_chess {refresh_cnt} 寻呼模块 offer N same-bond chess (≤ shop level)
// use_equip_recruit_new_char_and_give_char_to_player_most_bond {refresh_cnt} 信标 destroy target, offer N same-tier
// chess, gift the original next prep
// sell_char_count_gain_equip_owner_bond {count} 商业包装方案 every `count` sells → same-bond chess
// char_chess_transformation_equip 突变细胞 after battle: holder → random tier+1
// trap_copy_front_char 画卷 (Art) copy the chess on the tile / in front
// trap_create_self_choice {choice_event} 教鞭 / 神秘顾客 (Art) add a random bounty to your next battle
// [ASSUMED simplifications, documented in docs/META.md: 教鞭/神秘顾客 pick the bounty for the player instead of opening
// a personal choice overlay; 突变细胞 consumes itself.]
import { getData } from '../data.js';
import { itemKey } from './gamedata.js';
const int = (v, d = 0) => (Number.isFinite(v) ? Math.trunc(v) : d);
/** item record params of the concrete (normal/golden) item */
const paramsOf = (ctx, item) => {
const rec = item ? ctx.gd.item(item.id) : null;
return rec && rec.params && typeof rec.params === 'object' ? rec.params : {};
};
/** random chess sharing at least one bond with `bonds`, tier ≤ maxTier */
function rollSameBond(ctx, bonds, maxTier) {
const set = new Set(bonds);
return ctx.rollChess({ maxTier, filter: (id) => { const c = ctx.gd.chess(id); return !!(c && Array.isArray(c.bonds) && c.bonds.some((b) => set.has(b))); } });
}
const ITEM_HANDLERS = {
equip_destory_gain_random_coin: {
onEquip(ctx, ev) {
const p = paramsOf(ctx, ev.item);
const lo = int(p.min, 1);
const hi = Math.max(lo, int(p.max, lo));
ctx.addFunds(lo + ctx.rng.int(hi - lo + 1), 'item');
},
},
use_equip_reward_char_chess_bond_layer: {
onEquip(ctx, ev) {
const n = int(paramsOf(ctx, ev.item).layer, 3);
for (const b of ctx.pieceBonds(ev.target.uid)) ctx.addLayers(b, n, { requireActive: false, reason: 'item' });
},
},
use_equip_reward_random_char_chess_in_shop: {
onEquip(ctx, ev) {
const n = Math.max(1, int(paramsOf(ctx, ev.item).count, 1));
for (let k = 0; k < n; k++) {
const slots = ctx.shopSlots();
const idx = slots.map((s, i) => (s && s.kind === 'chess' && !s.sold ? i : -1)).filter((i) => i >= 0);
if (!idx.length) break;
const i = ctx.rng.pick(idx);
const id = slots[i].id;
ctx.setShopSlot(i, null);
ctx.grantChess(id);
}
},
},
gain_coin_when_round_start: {
onEquip(ctx, ev) {
const n = int(paramsOf(ctx, ev.item).count, 1);
const rec = ctx.gd.item(ev.item.id);
ctx.addEffect({
id: `doll:${ev.item.uid}`, key: 'effect:builtin_round_coin', name: rec ? rec.name : '精打细算玩偶', desc: rec ? rec.desc : '',
iconKind: 'item', iconId: rec ? rec.iconId || rec.id : ev.item.id, battle: false, params: { count: n },
});
},
},
use_equip_reward_char_chess_with_same_bond: {
onEquip(ctx, ev) {
const n = Math.max(1, int(paramsOf(ctx, ev.item).count, 1));
const bonds = ctx.pieceBonds(ev.target.uid);
for (let k = 0; k < n; k++) {
const id = rollSameBond(ctx, bonds, ctx.shopLevel());
if (id) ctx.grantChess(id);
}
},
},
use_equip_gain_coin_when_next_round_start: {
onEquip(ctx, ev) { ctx.addPendingFunds(int(paramsOf(ctx, ev.item).count, 2)); },
},
equip_destory_deployment_cnt_change: {
onEquip(ctx, ev) { ctx.setDeployCapAtLeast(int(paramsOf(ctx, ev.item).count, 9)); },
},
use_equip_upgrade_char: {
onEquip(ctx, ev) {
if (ctx.gd.isGolden(ev.target.id)) { ev.error = 'BAD_TARGET'; ev.detail = 'already elite'; return; }
ctx.promote(ev.target.uid);
},
},
equip_round_start_upgrade_char: {
onEquip(ctx, ev) {
if (ctx.gd.isGolden(ev.target.id)) { ev.error = 'BAD_TARGET'; ev.detail = 'already elite'; return; }
ev.keep = true; // stays equipped until the next round start
},
onRoundStart(ctx) {
const { piece, holder } = ctx.source;
if (!piece || !holder) return;
ctx.destroyPiece(piece.uid);
ctx.promote(holder.uid);
},
},
use_equip_reward_char_chess: {
onEquip(ctx, ev) {
const base = ctx.gd.baseIdOf(ev.target.id);
const owned = [...ctx.board(), ...ctx.hand(), ...ctx.temp()].filter((p) => p && p.kind === 'chess' && !p.golden && ctx.gd.baseIdOf(p.id) === base).length;
if (owned >= 2) { if (ctx.grantChess(base)) return; }
const id = rollSameBond(ctx, ctx.pieceBonds(ev.target.uid), 6);
if (id) ctx.grantChess(id);
},
},
use_equip_reward_special_goods_char_chess: {
onEquip(ctx, ev) {
const n = Math.max(1, int(paramsOf(ctx, ev.item).refresh_cnt, 3));
const bonds = ctx.pieceBonds(ev.target.uid);
const ids = [];
for (let k = 0; k < n; k++) { const id = rollSameBond(ctx, bonds, ctx.shopLevel()); if (id) ids.push(id); }
if (ids.length) ctx.offerChess(ids, { source: 'item' });
},
},
use_equip_recruit_new_char_and_give_char_to_player_most_bond: {
onEquip(ctx, ev) {
const target = ctx.piece(ev.target.uid);
if (!target) return;
const tier = ctx.gd.tierOf(target.id);
const n = Math.max(1, int(paramsOf(ctx, ev.item).refresh_cnt, 2));
const bonds = ctx.pieceBonds(target.uid);
const original = ctx.gd.baseIdOf(target.id);
ctx.destroyPiece(target.uid);
const ids = [];
for (let k = 0; k < n; k++) { const id = ctx.rollChess({ tier }); if (id) ids.push(id); }
if (ids.length) ctx.offerChess(ids, { source: 'item', tier });
// co-op: next prep, send the original chess to the teammate with the most members of its bonds
const mates = ctx.teammates();
if (!mates.length) return;
let best = -1;
let pick = [];
for (const t of mates) {
const score = bonds.reduce((s, b) => s + t.bondCount(b), 0);
if (score > best) { best = score; pick = [t]; } else if (score === best) pick.push(t);
}
const to = ctx.rng.pick(pick);
if (to) ctx.addEffect({ id: `gift:${ev.item.uid}`, key: 'effect:builtin_gift', hidden: true, battle: false, params: { toPlayerId: to.playerId, chessId: original } });
},
},
sell_char_count_gain_equip_owner_bond: {
onSold(ctx) {
const { piece, holder } = ctx.source;
if (!piece || !holder) return;
const need = Math.max(1, int(paramsOf(ctx, piece).count, 8));
const k = `pack:${piece.uid}`;
if (ctx.incCounter(k) >= need) {
ctx.setCounter(k, 0);
const id = rollSameBond(ctx, ctx.pieceBonds(holder.uid), ctx.shopLevel());
if (id) ctx.grantChess(id);
}
},
},
char_chess_transformation_equip: {
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;
ctx.destroyPiece(piece.uid);
ctx.transform(holder.uid, id);
},
},
trap_copy_front_char: {
onArt(ctx, ev) {
const target = ev.targets.find((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 });
if (!copy) { ev.error = 'HAND_FULL'; return; }
for (const it of target.items || []) {
const itemPiece = ctx.grantItem(it.id);
if (itemPiece) ctx.equipDirect(itemPiece.uid, copy.uid);
}
},
},
trap_create_self_choice: {
onArt(ctx, ev) {
const rec = ctx.gd.item(ev.item.id);
const perfectOnly = !!(rec && typeof rec.desc === 'string' && rec.desc.includes('完美'));
const cards = ((ctx.data.choices && ctx.data.choices.cards && ctx.data.choices.cards.bounty) || [])
.filter((c) => c && c.tier <= 2 && (perfectOnly ? c.payout === 'perfect' : c.payout === 'kill') && ctx.gd.enemy(c.enemyKey) && !ctx.gd.inactiveEnemies.has(c.enemyKey));
const card = ctx.rng.pick(cards);
if (!card) { ev.error = 'BAD_TARGET'; ev.detail = 'no bounty available'; return; }
ctx.addBounty(card);
},
},
};
/** Persistent EffectRef handlers used by built-ins and 机变 defaults. */
const EFFECT_HANDLERS = {
builtin_round_coin: {
onRoundStart(ctx) {
const n = int(ctx.source.ref && ctx.source.ref.params && ctx.source.ref.params.count, 0);
if (n > 0) ctx.addFunds(n, 'doll');
},
},
builtin_gift: {
onRoundStart(ctx) {
const ref = ctx.source.ref;
ctx.removeEffect(ref.id);
const p = ref.params || {};
const to = ctx.player(p.toPlayerId);
if (!to || !p.chessId) return;
const got = to.grantChess(p.chessId);
if (got) to.giftTicker(ctx.name, p.chessId);
},
},
// 整备: the next purchased item becomes advanced (golden)
builtin_next_buy_golden_item: {
onBuy(ctx, ev) {
const ref = ctx.source.ref;
if (ev.kind !== 'item' || !ev.piece || ctx.gd.isGolden(ev.piece.id)) return;
if (ctx.upgradeItem(ev.piece.uid)) {
ref.counter = Math.max(0, (ref.counter || 1) - 1);
if (ref.counter <= 0) ctx.removeEffect(ref.id);
}
},
},
// 升华: the next purchased operator becomes elite
builtin_next_buy_elite: {
onBuy(ctx, ev) {
const ref = ctx.source.ref;
if (ev.kind !== 'chess' || !ev.piece || ctx.gd.isGolden(ev.piece.id)) return;
if (ctx.promote(ev.piece.uid)) {
ref.counter = Math.max(0, (ref.counter || 1) - 1);
if (ref.counter <= 0) ctx.removeEffect(ref.id);
}
},
},
};
/**
* Register the built-ins on a registry. Item keys are resolved from the loaded data by buff key.
* @param {import('./effectsMeta.js').MetaRegistry} reg
* @param {Readonly<Record<string, any>>} [data]
*/
export function registerBuiltins(reg, data = null) {
let d = data;
if (!d) { try { d = getData({ log: { warn() {}, error() {}, info() {} } }); } catch { d = {}; } }
const items = d && d.items && typeof d.items === 'object' ? d.items : {};
const done = new Set();
for (const [id, rec] of Object.entries(items)) {
if (!rec || !Array.isArray(rec.buffs)) continue;
const key = itemKey(id);
if (done.has(key)) continue;
// golden and normal share the key; prefer the normal record's buff (博士投影 differs: handled by a merged handler)
const buffKeys = new Set();
for (const sib of [items[`${key}_a`], items[`${key}_b`], items[key]]) {
if (sib && Array.isArray(sib.buffs)) for (const b of sib.buffs) if (b && typeof b.key === 'string') buffKeys.add(b.key);
}
const handlers = [...buffKeys].map((k) => ITEM_HANDLERS[k]).filter(Boolean);
if (!handlers.length) continue;
done.add(key);
reg.register(`item:${key}`, mergeHandlers(handlers, buffKeys));
}
for (const [k, h] of Object.entries(EFFECT_HANDLERS)) reg.register(`effect:${k}`, h);
return reg;
}
/**
* Combine several buff-key handlers of one item family (e.g. 博士投影 normal = delayed, golden = immediate): each hook
* dispatches to the handler whose buff key belongs to the concrete item (normal/golden) in the event.
*/
function mergeHandlers(handlers, buffKeys) {
if (handlers.length === 1) return handlers[0];
const byKey = [...buffKeys].filter((k) => ITEM_HANDLERS[k]).map((k) => [k, ITEM_HANDLERS[k]]);
const out = {};
const hooks = new Set();
for (const [, h] of byKey) for (const name of Object.keys(h)) hooks.add(name);
for (const hook of hooks) {
out[hook] = function (ctx, ev) {
const item = (ev && ev.item) || (ctx.source && ctx.source.piece);
const rec = item ? ctx.gd.item(item.id) : null;
const own = new Set(rec && Array.isArray(rec.buffs) ? rec.buffs.map((b) => b.key) : []);
for (const [k, h] of byKey) if (own.has(k) && typeof h[hook] === 'function') h[hook](ctx, ev);
};
}
return out;
}
export const BUILTIN_ITEM_BUFF_KEYS = Object.freeze(Object.keys(ITEM_HANDLERS));
export const BUILTIN_EFFECT_KEYS = Object.freeze(Object.keys(EFFECT_HANDLERS).map((k) => `effect:${k}`));
+254
View File
@@ -0,0 +1,254 @@
// server/match/choices.js — 机变 (SP draft) card generation and application (DESIGN §6.1, research 01 A4,
// data/choices.json).
//
// Generation (generateDraft): the family is a weighted pick from choices.schedule[modeId].rounds[r].families; the card
// count is `cards` (co-op 6, solo 3):
// bounty 悬赏决策 distinct cards.bounty entries with tier ∈ bountyTiers whose enemy exists and is active in the mode
// supply 道具补给 random normal EQUIP shop items with tier in supplyTiers [lo, hi] (duplicates allowed)
// shop 机密商店 random normal EQUIP shop items of any tier I–VI (duplicates allowed); FREE — the official card
// text is "无需消耗资金,获得装备补给" (research 01 A4/04 addendum), so the price is 0
// tactic 战术决策 distinct cards.tactic entries; terrain cards only for the match stage; a 驰援 card
// (single_special_choice_gain_bond_chess) or a 盟誓 card (global_special_choice_bond_addlayer)
// only while at least one of its bonds is live in this match (opts.bondAvailable = Match.bondLive:
// not in the mode's static inactive list — 标准 turns off 拉特兰 / 阿戈尔 / 卡西米尔 / 奥术 … —
// and still with chess in the pool); a card acting only on dead bonds would do nothing
// Application (applyCard): a registered `choice:<effectId>` handler (content) wins; otherwise the family default:
// bounty → the picker's bounty list (waves.js adds the enemies to the next `rounds` battles; kill payout to the
// killer via the Battle, perfect payout at settlement when the picker's own battle was perfect)
// supply / shop → the item goes to the picker's hand (overflow temp)
// tactic → by effect buff key (data/effects.json):
// global_special_choice_gain_equip {count,pool} `count` items from the server pool
// global_special_choice_bond_addlayer {count,bond_list} layers +count to each bond (无需激活)
// global_special_choice_gain_coin {count} funds
// global_special_choice_refresh_free {count} free refreshes (stack)
// single_special_choice_gloden_equip_chess {count} next `count` bought items become golden (EffectRef)
// single_special_choice_gloden_char_chess {count} next `count` bought operators become elite (EffectRef)
// single_special_choice_gain_bond_chess {count,bond} `count` random chess of the bond (≤ shop level)
// auto_chess_change_map {alias: 0|1} device toggles on the picker's board (deploy legality) + battle
// anything else (env_gbuff…, enemy_attribute_mul…) a battle EffectRef for the sim content (playerEffects)
// cards with `team: true` apply to the picker AND every alive teammate ("若存在其他队友则他们也获得").
import { weightedPick } from './waves.js';
export const FAMILY_NAMES = { bounty: '悬赏决策', supply: '道具补给', shop: '机密商店', tactic: '战术决策' };
function scheduleFor(gd, round) {
const sch = gd.choices.schedule && gd.choices.schedule[gd.modeId];
const r = sch && sch.rounds && sch.rounds[String(round)];
if (r && typeof r === 'object') return r;
// fallback: a supply draft
return { families: [{ family: 'supply', weight: 1 }], cards: gd.isSolo ? 3 : 6, supplyTiers: [1, Math.min(6, 1 + Math.floor(round / 3))], bountyTiers: [1, 2] };
}
function formatCount(gd) {
const f = gd.choices.format || {};
const multi = f.multi && Number.isInteger(f.multi.cards) ? f.multi.cards : 6;
const solo = f.solo && Number.isInteger(f.solo.cards) ? f.solo.cards : 3;
return gd.isSolo ? solo : multi;
}
function eligibleItems(gd, lo, hi) {
const out = [];
for (let t = lo; t <= hi; t++) for (const id of gd.shopItemsByTier[t] || []) out.push(id);
return out;
}
function itemCard(gd, id) {
const it = gd.item(id);
return { kind: 'item', id, name: it ? it.name : id, desc: it ? it.desc || '' : '', tier: it && Number.isInteger(it.tier) ? it.tier : 1, price: 0 };
}
/**
* Build the draft cards for an SP round.
* @returns {{ family: string, name: string, desc: string, eventId: string|null, cards: object[] } | null}
*/
export function generateDraft(gd, rng, round, { stageId = null, bondAvailable = null } = {}) {
const sch = scheduleFor(gd, round);
const fams = Array.isArray(sch.families) && sch.families.length ? sch.families.map((f) => [f.family, f.weight]) : [['supply', 1]];
let family = weightedPick(rng, fams) || 'supply';
const n = Number.isInteger(sch.cards) && sch.cards > 0 ? Math.min(sch.cards, 6) : formatCount(gd);
const opts = { stageId, bondAvailable };
let cards = buildCards(gd, rng, family, n, sch, opts);
if (!cards.length && family !== 'supply') { family = 'supply'; cards = buildCards(gd, rng, family, n, sch, opts); }
if (!cards.length) return null;
cards.forEach((c, i) => { c.idx = i; c.family = family; });
const famInfo = gd.choices.families && gd.choices.families[family];
const events = sch.events && Array.isArray(sch.events[family]) ? sch.events[family] : [];
const eventId = events.length ? events[Math.floor(rng() * events.length)] : null;
return { family, name: famInfo && famInfo.name ? famInfo.name : FAMILY_NAMES[family] || family, desc: famInfo && famInfo.desc ? famInfo.desc : '', eventId, cards };
}
/** Bond granted by a 驰援 tactic card (effect buff single_special_choice_gain_bond_chess), else null. */
export function reinforcementBond(gd, effectId) {
const eff = gd.effect(effectId);
const buffs = eff && Array.isArray(eff.buffs) ? eff.buffs : [];
const b = buffs.find((x) => x && x.key === 'single_special_choice_gain_bond_chess');
return b && b.bbStr && typeof b.bbStr.bond === 'string' ? b.bbStr.bond : null;
}
/**
* Bonds a tactic card only acts on: the 驰援 bond (single_special_choice_gain_bond_chess) and the bonds of a 盟誓 card
* (global_special_choice_bond_addlayer bond_list). null when the card does anything else (it is always useful).
*/
export function cardTargetBonds(gd, effectId) {
const eff = gd.effect(effectId);
const buffs = eff && Array.isArray(eff.buffs) ? eff.buffs.filter(Boolean) : [];
if (!buffs.length) return null;
const out = [];
for (const b of buffs) {
const bs = b.bbStr || {};
if (b.key === 'single_special_choice_gain_bond_chess' && typeof bs.bond === 'string') out.push(bs.bond);
else if (b.key === 'global_special_choice_bond_addlayer') out.push(...String(bs.bond_list || '').split(',').map((x) => x.trim()).filter(Boolean));
else return null;
}
return out.length ? out : null;
}
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null } = {}) {
const cardsData = gd.choices.cards || {};
if (family === 'bounty') {
const tiers = Array.isArray(sch.bountyTiers) && sch.bountyTiers.length ? sch.bountyTiers : [1, 2];
const pool = (Array.isArray(cardsData.bounty) ? cardsData.bounty : []).filter((c) => c && tiers.includes(c.tier) && gd.enemy(c.enemyKey) && !gd.inactiveEnemies.has(c.enemyKey));
const pick = pool.slice();
rng.shuffle(pick);
return pick.slice(0, n).map((c) => ({
kind: 'bounty', id: c.effectId, name: c.name, desc: c.desc || '', tier: c.tier, coin: c.coin, payout: c.payout,
rounds: c.rounds, enemyKey: c.enemyKey, count: c.count,
}));
}
if (family === 'supply' || family === 'shop') {
let lo = 1;
let hi = 6;
if (family === 'supply' && Array.isArray(sch.supplyTiers) && sch.supplyTiers.length === 2) [lo, hi] = sch.supplyTiers;
let list = eligibleItems(gd, lo, hi);
if (!list.length) list = eligibleItems(gd, 1, 6);
const out = [];
for (let i = 0; i < n && list.length; i++) out.push(itemCard(gd, list[Math.floor(rng() * list.length)]));
return out;
}
if (family === 'tactic') {
// a card whose every target bond is dead in this match (mode-inactive, or no chess left in the pool) does nothing
const bondOk = (c) => {
if (typeof bondAvailable !== 'function') return true;
const bonds = cardTargetBonds(gd, c.effectId);
return !bonds || bonds.some((b) => !!bondAvailable(b));
};
const pool = (Array.isArray(cardsData.tactic) ? cardsData.tactic : []).filter((c) => c && (c.kind !== 'terrain' || (stageId && c.stageId === stageId)) && gd.effect(c.effectId) && bondOk(c));
const pick = pool.slice();
rng.shuffle(pick);
return pick.slice(0, n).map((c) => ({ kind: 'tactic', id: c.effectId, name: c.name, desc: c.desc || '', tier: null, team: !!c.team, tacticKind: c.kind }));
}
return [];
}
/** Public card view. */
export function cardView(c) {
const v = { idx: c.idx, kind: c.kind, id: c.id, name: c.name, desc: c.desc, tier: c.tier ?? null };
if (c.kind === 'bounty') Object.assign(v, { coin: c.coin, payout: c.payout, rounds: c.rounds, enemyKey: c.enemyKey, count: c.count });
if (c.kind === 'item') v.price = 0;
if (c.kind === 'tactic') Object.assign(v, { team: c.team, tacticKind: c.tacticKind });
return v;
}
/**
* Apply a picked card to a player (registry override → family default). Team tactic cards are applied to every
* alive teammate as well.
* @param {import('./Match.js').Match} m
* @param {import('./PlayerState.js').PlayerState} ps
*/
export function applyCard(m, ps, card) {
const targets = card.kind === 'tactic' && card.team ? [ps, ...m.alivePlayers().filter((p) => p !== ps)] : [ps];
for (const p of targets) {
const ev = { card: { ...card }, family: card.family, picker: ps.playerId, forTeammate: p !== ps };
const handled = m.dispatcher.runKey(p, `choice:${card.id}`, 'onChoicePick', { kind: 'choice', card }, ev);
if (!handled) applyDefault(m, p, card);
// every other source observes the pick (the card's own key already ran above)
m.dispatch(p, 'onChoicePick', ev, { skipKey: `choice:${card.id}` });
p.recompute();
}
}
function applyDefault(m, ps, card) {
const gd = m.gd;
if (card.kind === 'bounty') {
const src = (gd.choices.cards && Array.isArray(gd.choices.cards.bounty) ? gd.choices.cards.bounty : []).find((c) => c.effectId === card.id) || card;
m.addBounty(ps, src);
return;
}
if (card.kind === 'item') {
ps.acquireItem(card.id, { source: 'choice' });
return;
}
if (card.kind !== 'tactic') return;
const eff = gd.effect(card.id);
const buffs = eff && Array.isArray(eff.buffs) ? eff.buffs : [];
let handled = false;
let battle = false;
for (const b of buffs) {
const bb = b.bb || {};
const bs = b.bbStr || {};
const count = Number.isInteger(bb.count) ? bb.count : 1;
switch (b.key) {
case 'global_special_choice_gain_equip': {
for (let i = 0; i < count; i++) {
const id = m.rollItemId({ pool: bs.pool });
if (id) ps.acquireItem(id, { source: 'choice' });
}
handled = true;
break;
}
case 'global_special_choice_bond_addlayer': {
const bonds = String(bs.bond_list || '').split(',').map((s) => s.trim()).filter(Boolean);
for (const bid of bonds) ps.addLayers(bid, count, { requireActive: false, reason: 'choice' });
handled = true;
break;
}
case 'global_special_choice_gain_coin':
ps.addFunds(count, { reason: 'choice' });
handled = true;
break;
case 'global_special_choice_refresh_free':
ps.shop.freeRefreshes += count;
ps.dirty();
handled = true;
break;
case 'single_special_choice_gloden_equip_chess':
addRef(ps, card, eff, 'effect:builtin_next_buy_golden_item', { counter: count, battle: false });
handled = true;
break;
case 'single_special_choice_gloden_char_chess':
addRef(ps, card, eff, 'effect:builtin_next_buy_elite', { counter: count, battle: false });
handled = true;
break;
case 'single_special_choice_gain_bond_chess': {
for (let i = 0; i < count; i++) {
const id = m.pool.roll(m.rngMeta, { maxTier: Math.max(1, ps.shop.level), filter: (cid) => { const c = gd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); } })
|| m.pool.roll(m.rngMeta, { maxTier: 6, filter: (cid) => { const c = gd.chess(cid); return !!(c && Array.isArray(c.bonds) && c.bonds.includes(bs.bond)); } });
if (id) ps.acquireChess(id, { source: 'choice' });
}
handled = true;
break;
}
case 'auto_chess_change_map': {
for (const [alias, v] of Object.entries(bb)) if (alias.includes('#')) ps.deviceOverrides[alias] = Number(v) !== 0;
ps.invalidateDeployMap();
battle = true;
break;
}
case 'global_special_choice_all_activated':
break;
default:
battle = true;
}
}
if (battle || !handled) addRef(ps, card, eff, `choice:${card.id}`, { battle: true });
}
function addRef(ps, card, eff, key, { counter = null, battle = true } = {}) {
const id = `${card.id}#${ps.m.nextUid()}`;
ps.effects.push({
id, key, name: card.name, desc: card.desc, iconKind: card.team ? 'team' : 'choice', iconId: eff && eff.decoIconId ? eff.decoIconId : card.id,
counter, battle, params: eff && eff.params ? { ...eff.params } : {}, data: { effectId: card.id, tacticKind: card.tacticKind ?? null },
});
ps.dirty();
}
+557
View File
@@ -0,0 +1,557 @@
// server/match/effectsMeta.js — registry + dispatcher of prep-phase ("SERVER_*") effects (DESIGN §6.4).
// Full API reference with examples: docs/META.md.
//
// Registry keys (one handler object per key; a later register() of the same key replaces the earlier one, so content
// registered by server/sim/content/*.js → registerMeta(registry) overrides the engine's built-in defaults):
// garrison:<effectKey> 特质 prep effects (data/garrisons.json effectKey, e.g. 'garrison:SERVER_ADD_BOND_METHOD')
// band:<bandId> strategy (band) effects
// bond:<bondId> bond meta effects (助力 per-round layers, 远见 milestones, …)
// item:<itemKey> equipment / Arts (itemKey = item id without the _a/_b suffix, e.g. 'item:chess_item_1_03_e')
// choice:<effectId> 机变 card application (overrides the family default in choices.js)
// effect:<id> persistent EffectRefs added with ctx.addEffect({ key: 'effect:<id>' … })
// global:<name> runs for every player on every hook
//
// Hooks (handler methods; all optional, signature (ctx, ev)):
// onRoundStart onIncome onPrepStart onPrepEnd onGain onSold onRefresh onPrice onBuy onSpend onMerge onLevelUp
// onBattleStart onBattleResult onChoicePick onEquip onArt onDestroy onLayers
// Garrisons fire only on the hook of their eventType (SERVER_GAIN→onGain of the gained piece, SERVER_PREP_START→
// onRoundStart, SERVER_PREP_FIN→onPrepEnd, SERVER_CHESS_SOLD→onSold of the sold piece, SERVER_PRICE→onPrice of the
// slot's chess, SERVER_REFRESH_SHOP→onRefresh); the dispatcher calls `handler[hook] ?? handler.run`. A handler may
// widen that per garrison with `garrisonHooks(garrison) → hook[]` (e.g. "<进入休整期时><休整期结束时>"). Owned-piece
// garrisons fire for board pieces, and for hand pieces unless bbStr.conditionkey is 'character_target_inboard'.
// 投资人 (investShip) active ⇒ SERVER_GAIN garrisons run ×2 (×3 at ≥ 100 layers) — owned by the dispatcher.
// ctx.triggerGarrisons(uid, eventType) re-runs another piece's garrisons (铃兰, "触发…的获得时效果", 特质相同).
// Items: onEquip / onArt / onDestroy go to the item's own handler only; every other hook runs for items equipped on
// owned chess (ev.source.holder). Dispatch order per player: global → band → bonds → garrisons (board reading order,
// then hand) → equipped items → effects (insertion order). Every call is try/catch-guarded; nested dispatch depth
// is capped (MAX_DEPTH) so content can never loop the server.
import { itemKey } from './gamedata.js';
import { boardOrder, parseKey, tileKey } from './board.js';
import { pieceBonds as bondsOfPiece } from './bondsMeta.js';
import { registerAllMeta } from '../sim/content/index.js';
import { registerBuiltins } from './builtinMeta.js';
export const HOOKS = Object.freeze([
'onRoundStart', 'onIncome', 'onPrepStart', 'onPrepEnd', 'onGain', 'onSold', 'onRefresh', 'onPrice', 'onBuy',
'onSpend', 'onMerge', 'onLevelUp', 'onBattleStart', 'onBattleResult', 'onChoicePick', 'onEquip', 'onArt',
'onDestroy', 'onLayers',
]);
/** Non-hook members a handler object may carry. */
const HANDLER_EXTRAS = new Set(['run', 'garrisonHooks']);
/** Garrison eventType → dispatcher hook. */
export const GARRISON_HOOK = Object.freeze({
SERVER_GAIN: 'onGain',
SERVER_PREP_START: 'onRoundStart',
SERVER_PREP_FIN: 'onPrepEnd',
SERVER_CHESS_SOLD: 'onSold',
SERVER_PRICE: 'onPrice',
SERVER_REFRESH_SHOP: 'onRefresh',
});
const KEY_RE = /^(garrison|band|bond|item|choice|effect|global):[A-Za-z0-9_\-.:#]+$/;
export const MAX_DEPTH = 6;
export class MetaRegistry {
constructor() {
/** @type {Map<string, object>} */
this._h = new Map();
/** registration problems that do not throw (unknown hook names — usually a typo) */
this.warnings = [];
}
/**
* Register a handler object (or a bare function = { run }) under a key. Replaces an existing registration.
* @param {string} key
* @param {object|Function} handler
*/
register(key, handler) {
if (typeof key !== 'string' || !KEY_RE.test(key)) throw new TypeError(`MetaRegistry.register: bad key ${String(key)}`);
const h = typeof handler === 'function' ? { run: handler } : handler;
if (!h || typeof h !== 'object') throw new TypeError(`MetaRegistry.register(${key}): handler must be an object or function`);
for (const name of Object.keys(h)) {
if (typeof h[name] === 'function' && !HOOKS.includes(name) && !HANDLER_EXTRAS.has(name) && this.warnings.length < 200) {
this.warnings.push(`${key}: '${name}' is not a hook (${HOOKS.join(' ')})`);
}
}
this._h.set(key, h);
return this;
}
unregister(key) { return this._h.delete(key); }
get(key) { return this._h.get(key) ?? null; }
has(key) { return this._h.has(key); }
keys() { return [...this._h.keys()]; }
// sugar
garrison(effectKey, h) { return this.register(`garrison:${effectKey}`, h); }
band(bandId, h) { return this.register(`band:${bandId}`, h); }
bond(bondId, h) { return this.register(`bond:${bondId}`, h); }
item(key, h) { return this.register(`item:${itemKey(key)}`, h); }
choice(effectId, h) { return this.register(`choice:${effectId}`, h); }
effect(id, h) { return this.register(`effect:${id}`, h); }
global(name, h) { return this.register(`global:${name}`, h); }
globals() {
const out = [];
for (const [k, h] of this._h) if (k.startsWith('global:')) out.push([k, h]);
return out;
}
}
let defaultRegistry = null;
/** Build a registry: engine built-ins first, then every content module's registerMeta (content wins). */
export function createRegistry({ builtins = true, content = true, log = console } = {}) {
const reg = new MetaRegistry();
if (builtins) {
try { registerBuiltins(reg); } catch (e) { log.error?.('[meta] registerBuiltins failed', e); }
}
if (content) {
try { registerAllMeta(reg); } catch (e) { log.error?.('[meta] registerAllMeta failed', e); }
}
if (reg.warnings.length) log.warn?.(`[meta] ${reg.warnings.length} handler method(s) are not hooks and will never run, e.g. ${reg.warnings.slice(0, 3).join('; ')}`);
return reg;
}
/** Process-wide registry (server boot): built-ins + content. */
export function getDefaultRegistry() {
if (!defaultRegistry) defaultRegistry = createRegistry();
return defaultRegistry;
}
/** Tests: drop the singleton. */
export function resetDefaultRegistry() { defaultRegistry = null; }
// =====================================================================================================
// dispatcher
export class EffectDispatcher {
/**
* @param {import('./Match.js').Match} m
* @param {MetaRegistry} registry
*/
constructor(m, registry) {
this.m = m;
this.registry = registry;
this.depth = 0;
this.errors = 0;
this._errKeys = new Set();
/** `${key}.${hook}: message` → { count, stack } (diagnostics, tools/matchrun.mjs --errors) */
this.errorsByKey = new Map();
}
_report(key, hook, e) {
this.errors++;
const msg = `${key}.${hook}: ${e && e.message ? e.message : e}`;
const rec = this.errorsByKey.get(msg);
if (rec) rec.count++;
else if (this.errorsByKey.size < 500) this.errorsByKey.set(msg, { count: 1, stack: e && e.stack ? String(e.stack) : null });
if (this._errKeys.has(msg)) return;
this._errKeys.add(msg);
if (this._errKeys.size > 500) this._errKeys.clear();
this.m.log.error?.(`[match ${this.m.roomCode}] meta handler error ${msg}`, e && e.stack ? e.stack.split('\n').slice(0, 4).join(' | ') : '');
}
_call(ps, key, handler, hook, source, ev, repeat = 1) {
const fn = typeof handler[hook] === 'function' ? handler[hook] : source.kind === 'garrison' && typeof handler.run === 'function' ? handler.run : null;
if (!fn) return;
const ctx = makeCtx(this.m, ps, source, hook, ev);
for (let i = 0; i < repeat; i++) {
try { fn.call(handler, ctx, ev); } catch (e) { this._report(key, hook, e); }
}
}
/** 获得时 multiplier from 投资人 (investShip): ×2 while active, ×3 at ≥ 100 layers (research 02 §3.17). */
investRepeat(ps) {
const b = ps.bonds && ps.bonds.investShip;
if (!b || !b.active) return 1;
const bond = this.m.gd.bond('investShip');
const bb = bond && bond.bb ? bond.bb : {};
// data: bb.count 2 ("每次触发2次"), layerMilestones [{ layer: 100 }] ("<达到100层>…每次触发3次")
const base = Number.isInteger(bb.count) && bb.count > 1 ? bb.count : 2;
const ms = bond && Array.isArray(bond.layerMilestones) ? bond.layerMilestones.find((x) => x && Number(x.layer) > 0) : null;
const hi = ms ? Number(ms.layer) : Number.isFinite(bb.layer) && bb.layer > 0 ? bb.layer : 100;
return b.layers >= hi ? base + 1 : base;
}
/**
* Dispatch a hook for one player. `ev` is passed to every handler (mutable: onPrice writes ev.price, onSold
* ev.gain, onBattleStart ev.input, …) and returned.
*/
dispatch(ps, hook, ev = {}, { quiet = false, skipKey = null } = {}) {
if (!ps || this.depth >= MAX_DEPTH) {
if (ps && !quiet) this.m.log.warn?.(`[match ${this.m.roomCode}] meta dispatch depth limit (${hook})`);
return ev;
}
this.depth++;
try {
const reg = this.registry;
// 1. globals
for (const [key, h] of reg.globals()) this._call(ps, key, h, hook, { kind: 'global', key }, ev);
// 2. band
if (ps.bandId) {
const key = `band:${ps.bandId}`;
const h = reg.get(key);
if (h) this._call(ps, key, h, hook, { kind: 'band', key, bandId: ps.bandId, band: this.m.gd.band(ps.bandId) }, ev);
}
// 3. bonds
for (const bondId of this.m.gd.bondIds) {
const key = `bond:${bondId}`;
const h = reg.get(key);
if (h) this._call(ps, key, h, hook, { kind: 'bond', key, bondId, bond: ps.bonds[bondId] ?? null }, ev);
}
// 4. garrisons
this._garrisons(ps, hook, ev);
// 5. equipped items (not for the item-specific hooks)
if (hook !== 'onEquip' && hook !== 'onArt' && hook !== 'onDestroy') {
for (const holder of ownedChess(ps)) {
for (const it of holder.items || []) {
const key = `item:${itemKey(it.id)}`;
const h = reg.get(key);
if (h) this._call(ps, key, h, hook, { kind: 'item', key, piece: it, holder, item: this.m.gd.item(it.id) }, ev);
}
}
}
// 6. persistent effects
for (const ref of ps.effects.slice()) {
if (!ref || typeof ref.key !== 'string' || ref.key === skipKey) continue;
const h = reg.get(ref.key);
if (h) this._call(ps, ref.key, h, hook, { kind: 'effect', key: ref.key, ref }, ev);
}
} finally {
this.depth--;
}
return ev;
}
/** Hooks a garrison handler runs on: its eventType's hook, or what `handler.garrisonHooks(garrison)` returns. */
_garrisonHooks(h, g) {
if (typeof h.garrisonHooks === 'function') {
try {
const list = h.garrisonHooks(g);
if (Array.isArray(list) && list.length) return list;
} catch (e) { this._report(`garrison:${g.effectKey}`, 'garrisonHooks', e); }
}
return [GARRISON_HOOK[g.eventType]];
}
_garrisons(ps, hook, ev) {
const gd = this.m.gd;
const run = (piece, where) => {
const rec = gd.chess(piece.id);
if (!rec || !Array.isArray(rec.garrisonIds)) return;
for (const gid of rec.garrisonIds) {
const g = gd.garrison(gid);
if (!g) continue;
const key = `garrison:${g.effectKey}`;
const h = this.registry.get(key);
if (!h || !this._garrisonHooks(h, g).includes(hook)) continue;
if (where === 'hand' && g.bbStr && g.bbStr.conditionkey === 'character_target_inboard') continue;
const repeat = hook === 'onGain' ? this.investRepeat(ps) : 1;
this._call(ps, key, h, hook, { kind: 'garrison', key, piece, garrisonId: gid, garrison: g, bb: g.bb || {}, bbStr: g.bbStr || {}, where }, ev, repeat);
}
};
if (hook === 'onGain' || hook === 'onSold') {
if (ev && ev.piece && ev.piece.kind === 'chess') run(ev.piece, hook === 'onGain' ? 'gained' : 'sold');
return;
}
if (hook === 'onPrice') {
if (ev && ev.slot && ev.slot.kind === 'chess') run({ uid: 0, kind: 'chess', id: ev.slot.id, items: [] }, 'shop');
return;
}
if (hook !== 'onRoundStart' && hook !== 'onPrepEnd' && hook !== 'onRefresh') return;
for (const { piece } of boardOrder(ps.board)) if (piece.kind === 'chess') run(piece, 'board');
for (const p of ps.hand) if (p && p.kind === 'chess') run(p, 'hand');
}
/**
* Run the garrisons of `piece` whose eventType is `eventType` as if that event happened now (ctx.triggerGarrisons).
* `asPiece` runs them with another piece as `ctx.source.piece` (特质 "与其相同" copies). 投资人 still multiplies
* SERVER_GAIN. SERVER_PRICE cannot be triggered. Returns the number of garrison handlers run.
*/
triggerGarrisons(ps, piece, eventType, { asPiece = null, triggeredBy = null } = {}) {
const gd = this.m.gd;
const hook = eventType === 'SERVER_PRICE' ? null : GARRISON_HOOK[eventType];
const rec = piece && piece.kind === 'chess' ? gd.chess(piece.id) : null;
if (!hook || !rec || !Array.isArray(rec.garrisonIds) || this.depth >= MAX_DEPTH) return 0;
const self = asPiece || piece;
let n = 0;
this.depth++;
try {
for (const gid of rec.garrisonIds) {
const g = gd.garrison(gid);
if (!g || g.eventType !== eventType) continue;
const key = `garrison:${g.effectKey}`;
const h = this.registry.get(key);
if (!h) continue;
const ev = hook === 'onGain' ? { piece: self, kind: 'chess', source: 'trigger' }
: hook === 'onSold' ? { piece: self, gain: 0 }
: hook === 'onRefresh' ? { slots: ps.shop.slots, free: true, price: 0, trigger: true }
: { round: this.m.round, trigger: true };
const repeat = hook === 'onGain' ? this.investRepeat(ps) : 1;
const loc = ps.find(self.uid);
const where = loc ? (loc.area === 'board' ? 'board' : loc.area) : 'trigger';
this._call(ps, key, h, hook, { kind: 'garrison', key, piece: self, garrisonId: gid, garrison: g, bb: g.bb || {}, bbStr: g.bbStr || {}, where, trigger: true, triggeredBy }, ev, repeat);
n++;
}
} finally {
this.depth--;
}
return n;
}
/** Item-specific hook (onEquip / onArt / onDestroy) for one item piece. */
dispatchItem(ps, item, holder, hook, ev) {
if (!item || this.depth >= MAX_DEPTH) return ev;
const key = `item:${itemKey(item.id)}`;
const h = this.registry.get(key);
if (!h) return ev;
this.depth++;
try {
this._call(ps, key, h, hook, { kind: 'item', key, piece: item, holder, item: this.m.gd.item(item.id) }, ev);
} finally {
this.depth--;
}
return ev;
}
/** Run one registry handler directly (choice cards). Returns true when a handler existed. */
runKey(ps, key, hook, source, ev) {
const h = this.registry.get(key);
if (!h) return false;
const fn = typeof h[hook] === 'function' ? h[hook] : typeof h.run === 'function' ? h.run : null;
if (!fn) return false;
if (this.depth >= MAX_DEPTH) return true;
this.depth++;
try {
const ctx = makeCtx(this.m, ps, { ...source, key }, hook, ev);
try { fn.call(h, ctx, ev); } catch (e) { this._report(key, hook, e); }
} finally {
this.depth--;
}
return true;
}
}
function ownedChess(ps) {
const out = [];
for (const { piece } of boardOrder(ps.board)) if (piece.kind === 'chess') out.push(piece);
for (const p of ps.hand) if (p && p.kind === 'chess') out.push(p);
for (const p of ps.temp) if (p && p.kind === 'chess') out.push(p);
return out;
}
// =====================================================================================================
// handler context — the ONLY way content mutates player state (see docs/META.md)
const finiteInt = (n) => (Number.isFinite(n) ? Math.trunc(n) : 0);
/**
* @param {import('./Match.js').Match} m
* @param {import('./PlayerState.js').PlayerState} ps
* @param {object} source
* @param {string} hook
* @param {object|null} [ev] the event being dispatched (onPrice: ctx.modifyPrice / ctx.setPrice edit ev.price)
*/
export function makeCtx(m, ps, source, hook, ev = null) {
const gd = m.gd;
const view = (p) => (p ? ps.pieceView(p) : null);
const ctx = {
hook,
source,
playerId: ps.playerId,
seat: ps.seat,
name: ps.name,
get round() { return m.round; },
get phase() { return m.phase; },
modeId: m.modeId,
difficulty: m.difficulty,
isSolo: m.isSolo,
isCoop: !m.isSolo,
data: gd.raw,
gd,
rng: m.rngMeta,
log: (msg) => m.log.info?.(`[meta ${m.roomCode}] ${ps.playerId}: ${msg}`),
// ---- reads
funds: () => ps.funds,
lp: () => ps.lp,
alive: () => ps.alive,
bandId: () => ps.bandId,
shopLevel: () => ps.shop.level,
bond: (id) => (ps.bonds[id] ? { ...ps.bonds[id] } : null),
bonds: () => Object.fromEntries(Object.entries(ps.bonds).map(([k, v]) => [k, { ...v }])),
bondActive: (id) => !!(ps.bonds[id] && ps.bonds[id].active),
bondCount: (id) => (ps.bonds[id] ? ps.bonds[id].count : 0),
layers: (id) => ps.layers[id] || 0,
board: () => boardOrder(ps.board).map(({ r, c, piece }) => ps.pieceView(piece, [r, c])),
hand: () => ps.hand.map(view),
temp: () => ps.temp.map(view),
/** View of an owned piece + `area` ('board'|'hand'|'temp'|'equipped'), `holderUid`, and `row`/`col` on the board. */
piece: (uid) => {
const l = ps.find(uid);
if (!l) return null;
const v = { ...(l.area === 'board' ? ps.pieceView(l.piece, parseKey(l.key)) : view(l.piece)), area: l.area, holderUid: l.holder ? l.holder.uid : null };
if (l.area === 'hand' || l.area === 'temp') v.idx = l.idx;
return v;
},
/**
* Board piece standing on (row, col) (row/col included, and its `dir`), or null. "身前一格" of a piece is one step
* along its facing: sim/dir.js frontOf(row, col, piece.dir) (content/support/meta.js frontPiece / behindPiece).
*/
pieceAt: (row, col) => { const p = Number.isInteger(row) && Number.isInteger(col) ? ps.board.get(tileKey(row, col)) : null; return p ? ps.pieceView(p, [row, col]) : null; },
chessRecord: (id) => gd.chess(id),
/** Bonds a chess piece counts for: its own + those granted by 变形同构体 pairings. */
pieceBonds: (uid) => { const l = ps.find(uid); return l && l.piece.kind === 'chess' ? bondsOfPiece(gd, l.piece) : []; },
/** 特质 of a chess piece: [{ garrisonId, effectKey, eventType, bb, bbStr, desc }]. */
garrisonsOf: (uid) => {
const l = ps.find(uid);
const rec = l && l.piece.kind === 'chess' ? gd.chess(l.piece.id) : null;
if (!rec || !Array.isArray(rec.garrisonIds)) return [];
return rec.garrisonIds.map((gid) => gd.garrison(gid)).filter(Boolean)
.map((g) => ({ garrisonId: g.garrisonId, effectKey: g.effectKey, eventType: g.eventType, bb: g.bb || {}, bbStr: g.bbStr || {}, desc: g.desc || '' }));
},
stats: () => ({ ...ps.stats }),
roundStats: () => ({ ...ps.round }),
shopSlots: () => ps.shop.slots.map((s) => (s ? { kind: s.kind, id: s.id, basePrice: s.basePrice, sold: !!s.sold, frozen: !!s.frozen } : null)),
// ---- counters (player scope, persistent)
counter: (k) => (Number.isFinite(ps.counters[k]) ? ps.counters[k] : 0),
setCounter: (k, v) => { if (typeof k === 'string' && Number.isFinite(v)) ps.counters[k] = v; return ps.counters[k] ?? 0; },
incCounter: (k, n = 1) => { if (typeof k !== 'string' || !Number.isFinite(n)) return 0; ps.counters[k] = (Number.isFinite(ps.counters[k]) ? ps.counters[k] : 0) + n; return ps.counters[k]; },
// ---- economy
addFunds: (n, reason = '') => ps.addFunds(finiteInt(n), { reason }),
addPendingFunds: (n) => { const v = finiteInt(n); if (v > 0) { ps.pendingFunds += v; ps.dirty(); } return v > 0 ? v : 0; },
spendFunds: (n) => { const v = finiteInt(n); if (!ps.spend(v)) return false; ps._afterSpend(v, 'effect'); return true; },
grantFreeRefresh: (n = 1) => { const v = finiteInt(n); if (v > 0) { ps.shop.freeRefreshes += v; ps.dirty(); } return ps.shop.freeRefreshes; },
// ---- prices (onPrice only; the same as writing ev.price)
modifyPrice: (delta) => { if (hook === 'onPrice' && ev && Number.isFinite(delta)) ev.price = Math.max(0, (Number(ev.price) || 0) + delta); return ev ? ev.price : null; },
setPrice: (v) => { if (hook === 'onPrice' && ev && Number.isFinite(v)) ev.price = Math.max(0, v); return ev ? ev.price : null; },
// ---- layers
addLayers: (bondId, n, opts = {}) => ps.addLayers(bondId, n, { requireActive: !!opts.requireActive, reason: opts.reason || source.key || '' }),
// ---- pieces
grantChess: (chessId, opts = {}) => {
if (!ps.alive) return null;
let id = chessId;
if (opts.golden) id = gd.goldenIdOf(chessId) || chessId;
if (!gd.chess(id)) return null;
const base = gd.baseIdOf(id);
// "some effects fail when the cap is hit" (research 06 §7): by default a chess of the pool needs a free copy
if (opts.requirePool !== false && m.pool.has(base) && m.pool.left(base) < 1) return null;
const p = ps.acquireChess(id, { source: opts.source || source.key || 'effect', toTemp: !!opts.toTemp, fromPool: opts.fromPool !== false });
return p ? view(p) : null;
},
grantItem: (itemId, opts = {}) => {
if (!ps.alive || !gd.item(itemId)) return null;
const p = ps.acquireItem(itemId, { source: opts.source || source.key || 'effect', toTemp: !!opts.toTemp });
return p ? view(p) : null;
},
/** Random chess id from the shared pool (copy-weighted). opts: { maxTier, tier, bond, filter(id) } */
rollChess: (opts = {}) => {
const f = (id) => {
if (opts.bond) { const c = gd.chess(id); if (!c || !Array.isArray(c.bonds) || !c.bonds.includes(opts.bond)) return false; }
return typeof opts.filter === 'function' ? !!opts.filter(id) : true;
};
return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f });
},
rollItem: (opts = {}) => m.rollItemId(opts),
/**
* Roll a choices.json pool: equip pools → { kind: 'item', id }; chess pools (items / weighted / shopEligible with
* tier, minTier, bond, golden) → { kind: 'chess', id, golden } (a pool chess needs a free copy). null when empty.
*/
rollPool: (poolId, opts = {}) => m.rollPool(poolId, { shopLevel: ps.shop.level, ...opts }),
/**
* Run another owned chess's 特质 of `eventType` now (SERVER_GAIN / SERVER_PREP_START / SERVER_PREP_FIN /
* SERVER_CHESS_SOLD / SERVER_REFRESH_SHOP). opts.asUid: run them as if they belonged to that piece. Returns the
* number of handlers run (nesting is depth-capped).
*/
triggerGarrisons: (uid, eventType, opts = {}) => {
const l = ps.find(uid);
if (!l || l.piece.kind !== 'chess' || typeof eventType !== 'string') return 0;
const as = opts.asUid != null ? ps.find(opts.asUid) : null;
if (opts.asUid != null && (!as || as.piece.kind !== 'chess')) return 0;
return m.dispatcher.triggerGarrisons(ps, l.piece, eventType, { asPiece: as ? as.piece : null, triggeredBy: source.key || null });
},
promote: (uid) => { const l = ps.find(uid); return l ? ps.promote(l.piece) : false; },
/** Replace a chess piece by another chess (keeps its tile when legal, keeps its equipment). */
transform: (uid, chessId) => { const l = ps.find(uid); if (!l || l.piece.kind !== 'chess') return null; const p = ps.transformChess(l.piece, chessId); return p ? view(p) : null; },
/** Normal item piece → its golden version in place. */
upgradeItem: (uid) => { const l = ps.find(uid); return l && l.piece.kind === 'item' ? ps.upgradeItem(l.piece) : false; },
/** Attach an owned hand/temp item to an owned chess without equip effects (copies, restores). */
equipDirect: (itemUid, chessUid) => {
const il = ps.find(itemUid);
const cl = ps.find(chessUid);
if (!il || il.piece.kind !== 'item' || il.area === 'equipped' || !cl || cl.piece.kind !== 'chess' || cl.area === 'equipped') return false;
ps._detach(il);
ps._attach(cl.piece, il.piece);
ps.checkItemMerges();
ps.recompute();
return true;
},
destroyPiece: (uid) => {
const l = ps.find(uid);
if (!l) return false;
ps._detach(l);
if (l.piece.kind === 'chess') {
ps.removeTokensOf(l.piece.uid);
for (const it of l.piece.items || []) ps.stow(it, { allowTemp: true });
l.piece.items = [];
ps.returnCopies(l.piece);
}
ps.recompute();
return true;
},
offerChess: (ids, opts = {}) => !!ps.pushRewardOffer(opts.source || source.key || 'effect', { ids, tier: opts.tier ?? null }),
/** Queue a free pick-one offer of items (shop.rewardOffer with slots of kind 'item'). */
offerItems: (ids, opts = {}) => !!ps.pushItemOffer(ids, { source: opts.source || source.key || 'effect', tier: opts.tier ?? null }),
setShopSlot: (i, slot) => {
if (!Number.isInteger(i) || i < 0 || i >= ps.shop.slots.length) return false;
if (slot == null) { ps.shop.slots[i] = null; ps.dirty(); return true; }
const kind = slot.kind === 'item' ? 'item' : 'chess';
if (kind === 'chess' ? !gd.chess(slot.id) : !gd.item(slot.id)) return false;
const basePrice = Number.isFinite(slot.price) ? Math.max(0, Math.trunc(slot.price)) : kind === 'chess' ? gd.chessPrice(slot.id) : gd.itemPrice(slot.id);
ps.shop.slots[i] = { kind, id: slot.id, basePrice, frozen: !!slot.frozen, sold: false };
ps.dirty();
return true;
},
addDeployCap: (n) => { const v = finiteInt(n); ps.deployCapBonus += v; ps.dirty(); return ps.deployCap; },
setDeployCapAtLeast: (n) => { const v = finiteInt(n); if (v > ps.deployCapMin) { ps.deployCapMin = Math.min(v, 12); ps.dirty(); } return ps.deployCap; },
setBondCountBonus: (bondId, n) => { if (gd.bond(bondId) && Number.isInteger(n)) { ps.bondCountBonus[bondId] = n; ps.recompute(); } },
setDeviceActive: (alias, on) => { if (typeof alias === 'string') { ps.deviceOverrides[alias] = !!on; ps.invalidateDeployMap(); ps.dirty(); } },
setTileOverride: (r, c, cls) => { if (Number.isInteger(r) && Number.isInteger(c) && ['melee', 'ranged', 'none'].includes(cls)) { ps.tileOverrides[`${r},${c}`] = cls; ps.invalidateDeployMap(); ps.dirty(); } },
// ---- effects (EffectRef: { id, key?, name, desc, iconKind, iconId, counter?, battle?, params?, data?, hidden? })
addEffect: (ref) => {
if (!ref || typeof ref.id !== 'string') return null;
const existing = ps.effects.find((e) => e.id === ref.id);
if (existing) { Object.assign(existing, ref); ps.dirty(); return existing; }
const e = { iconKind: 'choice', battle: true, ...ref };
ps.effects.push(e);
ps.dirty();
return e;
},
removeEffect: (id) => { const i = ps.effects.findIndex((e) => e.id === id); if (i < 0) return false; ps.effects.splice(i, 1); ps.dirty(); return true; },
effect: (id) => ps.effects.find((e) => e.id === id) ?? null,
setEffectCounter: (id, v) => { const e = ps.effects.find((x) => x.id === id); if (!e) return false; e.counter = v; ps.dirty(); return true; },
// ---- bounties / choices
addBounty: (card) => m.addBounty(ps, card),
// ---- messaging
toast: (text, kind = 'info') => m.toast(ps, kind, String(text)),
ticker: (text) => m.tickerText(String(text)),
/** CHAR_GIFT broadcast to this player: "{0}博士给你赠送了{1}". */
giftTicker: (fromName, chessId) => { const c = gd.chess(chessId); m.tickerFor('CHAR_GIFT', [String(fromName), c ? c.name : String(chessId)], { to: ps.playerId }); },
// ---- team
teammates: () => m.alivePlayers().filter((p) => p !== ps).map((p) => makeCtx(m, p, source, hook, null)),
player: (playerId) => { const p = m.players.get(playerId); return p && p.alive ? makeCtx(m, p, source, hook, null) : null; },
};
return ctx;
}
+789
View File
@@ -0,0 +1,789 @@
// server/match/fields.js — runs the battles of one combat phase (DESIGN §4, §11, §14).
//
// Server-run combat (legacy streaming mode, SP_COMBAT=server; and the Final Assault when no field has a connected human):
// FieldRunner steps every live field in lockstep:
// * real-time pacing (RealScheduler, or VirtualScheduler with instantCombat=false): an interval every 1000/30 ms
// accumulates elapsed real time × the match's game speed (forced 2×; `opts.combatSpeed` in tests/tools) and
// steps floor(acc / TICK) ticks, never more than maxTicksPerInterval(speed) (8 at 2×) per interval — the
// remainder is dropped so a stalled server never spirals.
// * instant (VirtualScheduler default): every field is stepped to completion synchronously.
// * a solo pause (Match.paused, g.pause) skips the intervals: the field clock stands still (HeadlessPacer too).
// Every SNAP_EVERY (3) ticks of a field its events are drained; watchers of that field get `b.ev` then `b.snap`, both
// carrying the field's game time `gt` (`emit: false` skips the streaming: server-run fields under client-side combat).
// Per-field isolation: an exception from step() force-ends that field as a timeout (and, if even that throws, the
// field is closed with a synthetic result). A hard cap (HARD_CAP_SECONDS of game time) force-ends anything left.
// Results that did not come from a finished battle carry `synthetic: true` (the match never charges LP for them).
//
// Client-side combat (DESIGN §14) helpers:
// HeadlessJob / runHeadless(battle) step a battle to its end — in wall-clock-bounded slices (bots, takeovers
// under a real scheduler) or at once (verification, virtual time) — and keep a
// progress timeline [[gt, killed, total]] for the teammates' waiting UI
// HeadlessPacer real-time pacing of a dynamic set of server-run battles without snapshots
// (boss fields that share the pool while other fields run on clients); a
// takeover's fast-forward is spread over the pacing intervals
// specBounds(spec, gd) / validateClientResult(spec, result, { gd })
// a client's b.result is accepted only when it is plausible for its spec;
// the returned result is rebuilt from whitelisted fields (never the raw object)
// syntheticResult(players, progress) stand-in when a boss field's client never reported
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
export const MAX_TICKS_PER_INTERVAL = 8;
export const INTERVAL_MS = 1000 / 30;
export const GAME_SPEED = 2;
export const HARD_CAP_SECONDS = 3700;
const SNAP_EVERY = Number.isInteger(SNAPSHOT_EVERY) && SNAPSHOT_EVERY > 0 ? SNAPSHOT_EVERY : 3;
/** Catch-up cap per pacing interval: 8 ticks at the normal 2× speed, proportionally more when sped up. */
export function maxTicksPerInterval(speed) {
return Math.max(MAX_TICKS_PER_INTERVAL, Math.ceil((Number(speed) || GAME_SPEED) * 4));
}
/**
* Wire frame of a snapshot. Every frame is `{ t: '<type>', …payload }`, so the snapshot's game time (DESIGN §8.2)
* travels as `gt` (game seconds) — the `t` key is the frame type 'b.snap'. `b.ev` frames carry the same `gt`.
*/
export function snapFrame(fieldId, snap) {
const { t: gt, ...rest } = snap || {};
return { ...rest, t: 'b.snap', fieldId, gt: typeof gt === 'number' && Number.isFinite(gt) ? gt : 0 };
}
/** Synthetic per-player result used when a field could not run at all (never punishes the player). */
export function emptyPerPlayer() {
return { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, bossDamage: 0, healingDone: 0, deaths: 0, unitsEnd: [], unitStats: [] };
}
/** A finished stand-in for a battle that failed to construct. */
export class DeadBattle {
constructor(opts, reason = 'forced') {
this.fieldId = opts.fieldId ?? null;
this.kind = opts.kind ?? 'normal';
this.rect = opts.rect ?? null;
this.stageId = opts.stageId ?? null;
this.finished = true;
this.time = 0;
this.tickCount = 0;
this.errorCount = 1;
const perPlayer = {};
for (const p of opts.players || []) perPlayer[p.playerId] = emptyPerPlayer();
this._result = { time: 0, reason, perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
this.errors = [];
}
step() {}
forceEnd() {}
result() { return this._result; }
snapshot() { return { fieldId: this.fieldId, t: 0, units: [], dp: 0, killed: 0, total: 0 }; }
drainEvents() { return []; }
fieldMeta() { return { fieldId: this.fieldId, kind: this.kind, rect: this.rect, stageId: this.stageId, units: [] }; }
on() { return null; }
off() {}
}
export class FieldRunner {
/**
* @param {import('./Match.js').Match} m
* @param {Array<{ fieldId: string, kind: string, players: string[], battle: any, live?: boolean }>} fields
* @param {{ onTick?: (runner: FieldRunner) => void, onDone: (runner: FieldRunner) => void }} hooks
*/
constructor(m, fields, { onTick = null, onDone, emit = true }) {
this.m = m;
this.fields = fields;
this.emit = emit !== false;
for (const f of fields) f.live = !f.battle.finished;
this.onTick = onTick;
this.onDone = onDone;
this.ticks = 0;
this.acc = 0;
this.last = 0;
this.interval = null;
this.done = false;
this.stopped = false;
this.crashes = 0;
}
/** Game seconds elapsed on the master clock (all fields step in lockstep). */
get time() { return this.ticks * TICK; }
start() {
if (this.fields.every((f) => !f.live)) { this._finish(); return; }
if (this.m.sched.instant) {
// one scheduler callback: run everything now (virtual time / tools)
this.m.later(0, () => this._runInstant());
return;
}
this.last = this.m.sched.now();
this.interval = this.m.sched.setInterval(() => this.m.guard(() => this._pump()), INTERVAL_MS);
}
stop() {
this.stopped = true;
if (this.interval) { this.m.sched.clearInterval(this.interval); this.interval = null; }
}
_runInstant() {
const cap = Math.ceil(HARD_CAP_SECONDS / TICK);
while (!this.stopped && !this.done && this.ticks < cap) {
this._tick();
if (this.fields.every((f) => !f.live)) break;
}
if (!this.done && !this.stopped) this._forceAll('timeout');
this._checkDone();
}
_pump() {
if (this.stopped || this.done) return;
const now = this.m.sched.now();
const dt = Math.max(0, now - this.last);
this.last = now;
if (this.m.paused) return; // solo pause (Match.setPause): the field clock stands still
const speed = Number.isFinite(this.m.gameSpeed) && this.m.gameSpeed > 0 ? this.m.gameSpeed : GAME_SPEED;
this.acc += (dt / 1000) * speed;
const cap = maxTicksPerInterval(speed);
let n = Math.floor(this.acc / TICK + 1e-9);
if (n > cap) { n = cap; this.acc = 0; } else this.acc -= n * TICK;
for (let i = 0; i < n && !this.done && !this.stopped; i++) {
this._tick();
if (this.fields.every((f) => !f.live)) break;
}
if (this.time >= HARD_CAP_SECONDS) this._forceAll('timeout');
this._checkDone();
}
_tick() {
this.ticks++;
for (const f of this.fields) {
if (!f.live) continue;
const b = f.battle;
try {
b.step();
} catch (e) {
this.crashes++;
this.m.reportError(`field ${f.fieldId} step`, e);
this._forceField(f, 'timeout');
}
if (b.finished) { f.live = false; this.m.markPublic(); }
if (this.ticks % SNAP_EVERY === 0 || !f.live) this._emit(f);
}
if (this.onTick) {
try { this.onTick(this); } catch (e) { this.m.reportError('field onTick', e); }
}
for (const f of this.fields) if (f.live && f.battle.finished) { f.live = false; this.m.markPublic(); this._emit(f); }
}
_forceField(f, reason) {
try {
f.battle.forceEnd(reason);
} catch (e) {
this.m.reportError(`field ${f.fieldId} forceEnd`, e);
}
if (!f.battle.finished) {
// the battle object is unusable: replace it by a finished stand-in (players keep a clean result)
const dead = new DeadBattle({ fieldId: f.fieldId, kind: f.kind, players: f.players.map((playerId) => ({ playerId })) }, 'forced');
f.battle = dead;
}
f.live = false;
this.m.markPublic();
}
/** Force-end every live field (team LP 0, hard cap). */
_forceAll(reason) {
for (const f of this.fields) if (f.live || !f.battle.finished) this._forceField(f, reason);
}
forceAll(reason = 'forced') { this._forceAll(reason); this._checkDone(); }
_emit(f) {
let ev = [];
try { ev = f.battle.drainEvents() || []; } catch (e) { this.m.reportError(`field ${f.fieldId} drainEvents`, e); }
if (!this.emit) return;
const watchers = this.m.watchersOf(f.fieldId);
if (!watchers.length) return;
let snapMsg = null;
try { snapMsg = snapFrame(f.fieldId, f.battle.snapshot()); } catch (e) { this.m.reportError(`field ${f.fieldId} snapshot`, e); }
const time = Number(f.battle.time);
const gt = snapMsg ? snapMsg.gt : Number.isFinite(time) ? time : 0;
const evMsg = ev.length ? { t: 'b.ev', fieldId: f.fieldId, gt, ev } : null;
for (const pid of watchers) {
if (evMsg) this.m.sendTo(pid, evMsg);
if (snapMsg) this.m.sendTo(pid, snapMsg);
}
}
_checkDone() {
if (this.done || this.stopped) return;
if (this.fields.some((f) => f.live && !f.battle.finished)) return;
this._finish();
}
_finish() {
if (this.done) return;
this.done = true;
if (this.interval) { this.m.sched.clearInterval(this.interval); this.interval = null; }
this.onDone(this);
}
/** Result of a field (never throws). */
resultOf(f) {
try {
const r = f.battle.result();
if (r && typeof r === 'object' && r.perPlayer) return r;
} catch (e) {
this.m.reportError(`field ${f.fieldId} result`, e);
}
const perPlayer = {};
for (const pid of f.players) perPlayer[pid] = emptyPerPlayer();
return { time: 0, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
}
}
// =====================================================================================================================
// client-side combat (DESIGN §14)
/** Real-time grace after a battle's time limit before the server takes a silent client's field over (ms). */
export const RESULT_GRACE_MS = 15_000;
/** A boss field whose authoritative client sent no b.progress for this long is handed over (ms). */
export const BOSS_SILENCE_MS = 12_000;
/** Game-time spacing of the progress timeline samples of a server-run field (s). */
export const TIMELINE_EVERY = 1;
/** Wall-clock budget of one headless slice under a real scheduler (ms): the host's event loop stays responsive. */
export const HEADLESS_SLICE_MS = 8;
/** Extra ticks per pacing interval while a server-paced boss field catches up to its clock (takeover). */
export const CATCHUP_TICKS_PER_INTERVAL = 240;
const perfNow = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
/**
* A server-run battle stepped to its end, in one go or in wall-clock-bounded slices (a low-power host must not stall
* its event loop for the ~0.1–1 s a whole battle takes, several times at once when bots fight): `run(budgetMs)` steps
* until the battle ends (→ true) or the budget is used (→ false; checked every 16 ticks). `timeline` grows while it
* runs ([[gt, killed, total]] every TIMELINE_EVERY game seconds, then the final state); `output()` once done gives
* `{ battle, result, timeline, crashed }`. A throwing battle is force-ended, then replaced by a DeadBattle; the run is
* bounded by HARD_CAP_SECONDS.
*/
export class HeadlessJob {
constructor(battle, { onError = null, players = [] } = {}) {
this.battle = battle;
this.onError = onError;
this.players = players;
this.timeline = [[0, 0, Number(battle && battle.total) || 0]];
this.every = Math.max(1, Math.round(TIMELINE_EVERY / TICK));
this.cap = Math.ceil(HARD_CAP_SECONDS / TICK);
this.n = 0;
this.crashed = false;
this.done = false;
this._out = null;
}
run(budgetMs = Infinity, now = perfNow) {
if (this.done) return true;
const timed = Number.isFinite(budgetMs);
const t0 = timed ? now() : 0;
let b = this.battle;
try {
let k = 0;
while (!b.finished && this.n < this.cap) {
b.step();
this.n++;
k++;
if (this.n % this.every === 0) this.timeline.push([Number(b.time) || 0, Number(b.killed) || 0, Number(b.total) || 0]);
if (timed && (k & 15) === 0 && !b.finished && now() - t0 >= budgetMs) return false;
}
if (!b.finished) b.forceEnd('timeout');
} catch (e) {
this.crashed = true;
if (this.onError) this.onError(e);
try { b.forceEnd('timeout'); } catch { /* replaced below */ }
if (!b.finished) b = this.battle = new DeadBattle({ fieldId: b.fieldId, kind: b.kind, players: this.players.map((playerId) => ({ playerId })) }, 'forced');
}
this._finish();
return true;
}
_finish() {
const b = this.battle;
let result = null;
try { result = b.result(); } catch (e) { if (this.onError) this.onError(e); }
if (!result || typeof result !== 'object' || !result.perPlayer) {
const perPlayer = {};
for (const pid of this.players) perPlayer[pid] = emptyPerPlayer();
result = { time: Number(b.time) || 0, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 1, synthetic: true };
}
this.timeline.push([Number(b.time) || 0, Number(b.killed) || 0, Number(b.total) || 0]);
this.done = true;
this._out = { battle: b, result, timeline: this.timeline, crashed: this.crashed };
}
output() { return this._out; }
}
/**
* Step a battle to its end right now (HeadlessJob in one go). Returns `{ battle, result, timeline, crashed }`.
*/
export function runHeadless(battle, opts = {}) {
const job = new HeadlessJob(battle, opts);
job.run(Infinity);
return job.output();
}
/** Timeline sample at game time `gt`: [gt, killed, total] of the last sample ≤ gt. */
export function timelineAt(timeline, gt) {
if (!Array.isArray(timeline) || !timeline.length) return [0, 0, 0];
let lo = 0, hi = timeline.length - 1;
if (gt >= timeline[hi][0]) return timeline[hi];
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (timeline[mid][0] <= gt) lo = mid; else hi = mid - 1;
}
return timeline[lo];
}
/**
* Real-time pacing of server-run battles without snapshots (DESIGN §14: boss fields that share the pool while other
* fields run on clients). `add({ battle, onDone(entry), onTick?(entry) })`; `skipTo(entry, gt)` fast-forwards a newly
* added battle silently (takeover). Same accumulator / cap rules as FieldRunner.
*/
export class HeadlessPacer {
constructor(m) {
this.m = m;
/** @type {Set<{ battle: any, onDone: Function, onTick?: Function, done?: boolean }>} */
this.entries = new Set();
this.interval = null;
this.acc = 0;
this.last = 0;
this.stopped = false;
}
add(entry) {
if (this.stopped || !entry || !entry.battle) return entry;
this.entries.add(entry);
if (!this.interval) {
this.last = this.m.sched.now();
this.acc = 0;
this.interval = this.m.sched.setInterval(() => this.m.guard(() => this._pump()), INTERVAL_MS);
}
return entry;
}
/**
* Fast-forward an entry's battle silently until its game time reaches `gt` (bounded). `budgetTicks` (default: all at
* once) spreads it over the pacing intervals instead — at most that many extra ticks per interval on top of the
* normal pacing — so a takeover late in a boss fight never stalls the host's event loop.
*/
skipTo(entry, gt, { budgetTicks = Infinity } = {}) {
const cap = Math.ceil(Math.min(Number(gt) || 0, HARD_CAP_SECONDS) / TICK);
if (Number.isFinite(budgetTicks) && budgetTicks > 0) {
entry.skipTicks = cap;
entry.skipBudget = Math.max(1, Math.floor(budgetTicks));
entry.skipped = 0;
return;
}
this._skip(entry, cap, Infinity);
}
/** Silent steps toward `cap` ticks (at most `max`); returns the steps taken. */
_skip(entry, cap, max) {
const b = entry.battle;
let k = 0;
try {
while (!b.finished && b.tickCount < cap && k < max) { b.step(); k++; }
} catch (e) {
this.m.reportError('pacer skip', e);
try { b.forceEnd('timeout'); } catch { /* ignore */ }
}
try { b.drainEvents?.(); } catch { /* ignore */ }
if (b.finished) this._finish(entry);
return k;
}
remove(entry) { this.entries.delete(entry); if (!this.entries.size) this._clear(); }
stop() { this.stopped = true; this._clear(); this.entries.clear(); }
_clear() { if (this.interval) { this.m.sched.clearInterval(this.interval); this.interval = null; } }
_pump() {
if (this.stopped) return;
const now = this.m.sched.now();
const dt = Math.max(0, now - this.last);
this.last = now;
if (this.m.paused) return; // solo pause (Match.setPause): the field clock stands still
const speed = Number.isFinite(this.m.gameSpeed) && this.m.gameSpeed > 0 ? this.m.gameSpeed : GAME_SPEED;
this.acc += (dt / 1000) * speed;
const cap = maxTicksPerInterval(speed);
let n = Math.floor(this.acc / TICK + 1e-9);
if (n > cap) { n = cap; this.acc = 0; } else this.acc -= n * TICK;
for (const e of [...this.entries]) {
if (this.stopped) break;
if (e.done) continue;
const b = e.battle;
if (e.skipTicks != null) {
// sliced fast-forward (takeover): the field clock keeps running, so the target moves with the pacing
e.skipTicks += n;
e.skipped += this._skip(e, e.skipTicks, e.skipBudget + n);
if (e.done) continue;
if (b.tickCount >= e.skipTicks) e.skipTicks = null;
if (e.onTick) { try { e.onTick(e); } catch (err) { this.m.reportError('pacer onTick', err); } }
continue;
}
for (let i = 0; i < n && !b.finished; i++) {
try { b.step(); } catch (err) {
this.m.reportError('pacer step', err);
try { b.forceEnd('timeout'); } catch { /* ignore */ }
break;
}
if (b.time >= HARD_CAP_SECONDS) { try { b.forceEnd('timeout'); } catch { /* ignore */ } }
}
if (e.onTick) { try { e.onTick(e); } catch (err) { this.m.reportError('pacer onTick', err); } }
if (b.finished) this._finish(e);
}
}
_finish(e) {
if (e.done) return;
e.done = true;
this.entries.delete(e);
if (!this.entries.size) this._clear();
try { e.onDone(e); } catch (err) { this.m.reportError('pacer onDone', err); }
}
}
/** Synthetic per-player results of a field whose client never reported (boss fields only; no LP relevance). */
export function syntheticResult(players, { bossBy = {}, time = 0 } = {}) {
const perPlayer = {};
for (const pid of players) perPlayer[pid] = { ...emptyPerPlayer(), bossDamage: Math.max(0, Number(bossBy[pid]) || 0) };
return { time, reason: 'forced', perPlayer, killed: 0, total: 0, errors: 0, synthetic: true };
}
// ---- result validation ------------------------------------------------------------------------------------------
const derivedCache = new WeakMap();
const ENEMY_KEY_RE = /enemy_[A-Za-z0-9_]+/g;
/** Enemy keys a spawned enemy's data record mentions (summons, splits, transformations): content may spawn them. */
function derivedKeys(gd, key) {
if (!gd || typeof gd.enemy !== 'function') return [];
let m = derivedCache.get(gd);
if (!m) { m = new Map(); derivedCache.set(gd, m); }
if (m.has(key)) return m.get(key);
const out = new Set();
const rec = gd.enemy(key);
if (rec) {
let text = '';
try { text = JSON.stringify(rec); } catch { text = ''; }
for (const k of text.match(ENEMY_KEY_RE) || []) if (k !== key && gd.enemy(k)) out.add(k);
}
const list = [...out];
m.set(key, list);
return list;
}
/**
* How many `child` enemies one `parent` can leave behind, when its data says so: a talent naming the child
* (`<X>.enemy_key`) with a count (`<X>.cnt`, e.g. 磨砻 DeadSpawn 2, 烹泉 4, 沉沙's unconsumed blades ≤ 4) or without one
* (DeathRattle: the 术师 of a 术师快艇 = 1) — the sim's kitDeathSpawn / kitBlades / DeathRattle kits. null when the data
* gives no such bound (periodic summoners such as 枯朽萃聚使徒's BornBugs, keys a record merely mentions).
*/
function offspringPerParent(gd, parent, child) {
const rec = gd && typeof gd.enemy === 'function' ? gd.enemy(parent) : null;
const t = rec && rec.talents && typeof rec.talents === 'object' ? rec.talents : null;
if (!t || !t.bbStr || typeof t.bbStr !== 'object') return null;
let n = null;
for (const [k, v] of Object.entries(t.bbStr)) {
if (v !== child || !k.endsWith('.enemy_key')) continue;
const prefix = k.slice(0, -'.enemy_key'.length);
const cnt = Number(t.bb && t.bb[`${prefix}.cnt`]);
if (Number.isFinite(cnt) && cnt > 0) n = (n || 0) + Math.max(1, Math.ceil(cnt));
else if (prefix === 'DeathRattle') n = (n || 0) + 1;
else return null; // a countless summon talent (a boss's endless 余音): no bound from data
}
return n;
}
const summonCache = new WeakMap();
function summonIndex(gd) {
let idx = summonCache.get(gd);
if (idx) return idx;
idx = { byOwner: new Map(), ownerless: [] };
const tokens = gd.raw && gd.raw.tokens && typeof gd.raw.tokens === 'object' ? gd.raw.tokens : {};
for (const [id, t] of Object.entries(tokens)) {
const owners = t && Array.isArray(t.owners) ? t.owners : [];
if (!owners.length) idx.ownerless.push(id);
for (const o of owners) { if (!idx.byOwner.has(o)) idx.byOwner.set(o, []); idx.byOwner.get(o).push(id); }
}
summonCache.set(gd, idx);
return idx;
}
/** Summon (token) ids a chess (normal or elite) can create: its data `tokens` + tokens naming it an owner. */
function summonsOf(gd, chessId) {
if (typeof gd.chess !== 'function' || typeof chessId !== 'string') return [];
const rec = gd.chess(chessId);
const out = new Set(rec && Array.isArray(rec.tokens) ? rec.tokens : []);
const idx = summonIndex(gd);
for (const id of [chessId, rec && rec.baseId, rec && rec.goldenId]) for (const t of (id && idx.byOwner.get(id)) || []) out.add(t);
return [...out];
}
/** Summons no chess owns (bond / band units every player may field: 炎佑, 预备干员-医疗, Touch). */
function ownerlessSummons(gd) { return summonIndex(gd).ownerless; }
const WORD_RE = /[A-Za-z][A-Za-z0-9_]*/g;
const recJson = (rec) => { try { return rec ? JSON.stringify(rec) : ''; } catch { return ''; } };
/**
* Bonds an IN_BATTLE layer gain of this player can name: its bond snapshot (every bond its lineup counts), plus bonds
* its band, its effects (机变 cards, 驻守 …), its units and their items mention (content grants layers to those —
* e.g. 克莱门莎's <阿戈尔>, requireActive: false). Anything else is a forged gain.
*/
function layerBondsOf(p, gd) {
const out = new Set(Object.keys(p.bonds && typeof p.bonds === 'object' ? p.bonds : {}));
if (typeof gd.bond !== 'function') return out;
const texts = [recJson(p.playerEffects), recJson(p.bonds)];
const band = p.bandId && typeof gd.band === 'function' ? gd.band(p.bandId) : null;
if (band) {
texts.push(recJson(band));
if (band.effectId && typeof gd.effect === 'function') texts.push(recJson(gd.effect(band.effectId)));
}
for (const u of Array.isArray(p.units) ? p.units : []) {
if (!u) continue;
if (u.kind === 'token') { if (typeof gd.token === 'function') texts.push(recJson(gd.token(u.tokenId))); continue; }
if (typeof gd.chess === 'function') texts.push(recJson(gd.chess(u.chessId)));
for (const it of Array.isArray(u.items) ? u.items : []) if (typeof gd.item === 'function') texts.push(recJson(gd.item(it)));
}
for (const t of texts) for (const w of t.match(WORD_RE) || []) if (!out.has(w) && gd.bond(w)) out.add(w);
return out;
}
/**
* Bounds of a battle derived from its spec: spawn counts per enemy key, keys content may add, bounty coins, the
* player ids and each player's unit uids.
*/
export function specBounds(spec, gd = null) {
const keyCounts = new Map();
/** `${enemyKey}|${sourcePlayerId}` → scheduled count (联防: whose leak re-enters) */
const keySourceCounts = new Map();
/** spawn keys whose schedule entries may leak without costing LP (countInTotal false, boss / part tags) */
const uncountedKeys = new Set();
let spawnCount = 0;
let bountyCoins = 0;
const sources = new Set();
for (const s of Array.isArray(spec && spec.spawns) ? spec.spawns : []) {
if (!s || typeof s.enemyKey !== 'string') continue;
const n = Math.max(1, Math.min(10_000, Math.floor(Number(s.count) || 1)));
spawnCount += n;
keyCounts.set(s.enemyKey, (keyCounts.get(s.enemyKey) || 0) + n);
const ks = `${s.enemyKey}|${typeof s.sourcePlayerId === 'string' ? s.sourcePlayerId : ''}`;
keySourceCounts.set(ks, (keySourceCounts.get(ks) || 0) + n);
if (s.countInTotal === false || s.tag === 'boss' || s.tag === 'part') uncountedKeys.add(s.enemyKey);
const coins = Math.max(Number(s.bounty && s.bounty.coins) || 0, Number(s.mods && s.mods.bountyCoins) || 0);
if (coins > 0) bountyCoins += coins * n;
if (typeof s.sourcePlayerId === 'string') sources.add(s.sourcePlayerId);
}
const derived = new Set();
for (const k of keyCounts.keys()) for (const d of derivedKeys(gd, k)) derived.add(d);
const maxTotal = spawnCount * 4 + 100;
// 联防: `${childKey}|${sourcePlayerId}` → how many content-spawned children the enemies that leaker sent in can leave
// (offspringPerParent per parent; maxTotal when the data gives no bound) — a survivor of a split / summon is billed
// only to a leaker whose own enemies can have produced it
const derivedSourceCounts = new Map();
for (const s of Array.isArray(spec && spec.spawns) ? spec.spawns : []) {
if (!s || typeof s.enemyKey !== 'string' || typeof s.sourcePlayerId !== 'string') continue;
const n = Math.max(1, Math.min(10_000, Math.floor(Number(s.count) || 1)));
for (const d of derivedKeys(gd, s.enemyKey)) {
const per = offspringPerParent(gd, s.enemyKey, d);
const ks = `${d}|${s.sourcePlayerId}`;
derivedSourceCounts.set(ks, Math.min(maxTotal, (derivedSourceCounts.get(ks) || 0) + (per == null ? maxTotal : n * per)));
}
}
for (const k of keyCounts.keys()) {
const rec = gd && typeof gd.enemy === 'function' ? gd.enemy(k) : null;
if (rec && rec.notCountInTotal) uncountedKeys.add(k);
}
const players = new Map();
for (const p of Array.isArray(spec && spec.players) ? spec.players : []) {
if (!p || typeof p.playerId !== 'string') continue;
const chess = new Map();
const all = new Map();
// unit types this player can field: its board's chess / tokens, their summons, and the ownerless summons of
// bonds / bands (炎佑, 预备干员) — the only names a statistic of a unit created in battle (no board uid) may carry
const defIds = new Set(gd ? ownerlessSummons(gd) : []);
for (const u of Array.isArray(p.units) ? p.units : []) {
if (!u || !Number.isInteger(u.uid)) continue;
const defId = u.kind === 'token' ? u.tokenId : u.chessId;
all.set(u.uid, defId);
if (typeof defId === 'string') defIds.add(defId);
if (u.kind !== 'token') {
chess.set(u.uid, defId);
if (gd) for (const t of summonsOf(gd, defId)) defIds.add(t);
}
}
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null });
}
const round = Number(spec && spec.round) || 0;
return {
keyCounts, keySourceCounts, derivedSourceCounts, uncountedKeys, derived, spawnCount, bountyCoins, sources, players,
// content spawns (splits, summons, boss minions) can add enemies beyond the schedule
maxTotal,
layerCap: 60 + 4 * round,
maxTime: spec && spec.timeLimit > 0 ? spec.timeLimit + 5 : HARD_CAP_SECONDS,
};
}
const finiteIn = (v, lo, hi) => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;
const sameMods = (a, b) => {
const ka = a && typeof a === 'object' ? Object.keys(a).filter((k) => a[k] !== undefined).sort() : [];
const kb = b && typeof b === 'object' ? Object.keys(b).filter((k) => b[k] !== undefined).sort() : [];
if (ka.length !== kb.length) return false;
for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i] || a[ka[i]] !== b[kb[i]]) return false;
return true;
};
/**
* Semantic validation of a client's BattleResult against the battle's spec (DESIGN §14 "Result validation"). The
* payload already passed shared/protocol.js isBattleResult (types, sizes). Checks: every spec player reported and
* nobody else; killed ≤ total ≤ bound; each leaked enemy key exists in the spawns (multiset bound; keys content may
* spawn are bounded by the total) — boss fields excepted (their leaks cost team LP through b.progress); a leak is
* `counted: false` only for enemies that never count (data notCountInTotal, countInTotal false, boss / part entries,
* content spawns); 联防: leaks + never-spawned re-entries per (enemy, leaker) ≤ what that leaker sent in, and a split /
* summon only on a leaker who sent in its parent, ≤ the parents' data offspring count (offspringPerParent); per-bond layer
* gains ≤ 60 + 4·round, only on bonds the player's lineup / band / effects / items name, none when the spec disables
* gains; coins ≤ the spawns' bounty coins; perfect consistent with the counted leaks; unit states only for the
* player's own units, within range.
* @returns {{ ok: true, result: object } | { ok: false, reason: string }}
*/
export function validateClientResult(spec, raw, { gd = null } = {}) {
const bad = (reason) => ({ ok: false, reason });
try {
if (!spec || !raw || typeof raw !== 'object' || !raw.perPlayer || typeof raw.perPlayer !== 'object') return bad('shape');
const B = specBounds(spec, gd);
const bossLike = spec.kind === 'boss' || spec.kind === 'hidden';
if (!['cleared', 'timeout', 'forced'].includes(raw.reason)) return bad('reason');
if (!finiteIn(raw.time, 0, B.maxTime)) return bad('time');
const pids = Object.keys(raw.perPlayer);
if (pids.length !== B.players.size || pids.some((pid) => !B.players.has(pid))) return bad('players');
const spawnMods = new Map();
for (const s of spec.spawns || []) {
if (!s || typeof s.enemyKey !== 'string') continue;
if (!spawnMods.has(s.enemyKey)) spawnMods.set(s.enemyKey, []);
spawnMods.get(s.enemyKey).push(s);
}
const leakCount = new Map();
// 联防: (enemyKey, leaker) budget shared by the helpers' leaks and the never-spawned re-entries
const sourceLeft = new Map(B.keySourceCounts);
const derivedLeft = new Map(B.derivedSourceCounts);
const takeFrom = (left, key, src) => {
const ks = `${key}|${src || ''}`;
const n = left.get(ks) || 0;
if (n <= 0) return false;
left.set(ks, n - 1);
return true;
};
const takeSource = (key, src) => takeFrom(sourceLeft, key, src);
// a content-spawned enemy (split, summon): billed only to a leaker who sent in a parent, within its offspring bound
const takeDerived = (key, src) => B.derived.has(key) && takeFrom(derivedLeft, key, src);
let coinsSum = 0;
const perPlayer = {};
for (const pid of pids) {
const p = raw.perPlayer[pid];
if (!p || typeof p !== 'object') return bad('player');
const own = B.players.get(pid);
if (!Number.isInteger(p.killed) || !Number.isInteger(p.total) || p.killed < 0 || p.killed > p.total || p.total > B.maxTotal) return bad('counts');
const leaked = [];
for (const l of Array.isArray(p.leaked) ? p.leaked : []) {
if (!l || typeof l.enemyKey !== 'string') return bad('leak');
const key = l.enemyKey;
if (gd && typeof gd.enemy === 'function' && !gd.enemy(key) && !B.keyCounts.has(key)) return bad('leak key');
const counted = l.counted !== false;
if (!bossLike) {
if (!B.keyCounts.has(key) && !B.derived.has(key)) return bad('leak key');
const n = (leakCount.get(key) || 0) + 1;
leakCount.set(key, n);
const bound = (B.keyCounts.get(key) || 0) + (B.derived.has(key) ? B.maxTotal : 0);
if (n > bound) return bad('leak multiset');
// a leak costs LP unless the enemy never counts (data notCountInTotal, countInTotal false, boss / part
// entries); content-spawned keys (summons, splits) may carry their own flag
if (!counted && !B.uncountedKeys.has(key) && !B.derived.has(key)) return bad('uncounted leak');
}
// mods / tag / source come from the spawn schedule (a leak re-enters 联防 with them). An enemy content spawned
// (a split, a summon) carries its parent's mods — the round multipliers, a bounty id — so the leak's mods may
// equal another schedule entry's: keep those (dropping them would re-enter it weaker and lose its bounty)
const cands = spawnMods.get(key) || [];
const match = cands.find((s) => sameMods(s.mods ?? null, l.mods ?? null)) || null;
const src = match || cands[0] || null;
const parent = match ? null : (spec.spawns || []).find((s) => s && typeof s.enemyKey === 'string' && sameMods(s.mods ?? null, l.mods ?? null)) || null;
const modsOf = (s) => (s && s.mods ? { ...s.mods } : null);
const mods = match ? modsOf(match) : parent ? modsOf(parent) : modsOf(src);
let sourcePlayerId = typeof l.sourcePlayerId === 'string' ? l.sourcePlayerId : null;
if (spec.kind === 'unite') {
if (!sourcePlayerId || !B.sources.has(sourcePlayerId)) sourcePlayerId = src && typeof src.sourcePlayerId === 'string' ? src.sourcePlayerId : null;
// whose LP a surviving enemy costs: never more of (key, leaker) than that leaker sent into the 联防 — or, for a
// split / summon, than the enemies that leaker sent in can leave behind
if (!takeSource(key, sourcePlayerId) && !takeDerived(key, sourcePlayerId)) return bad('leak source');
} else if (sourcePlayerId !== pid && !B.players.has(sourcePlayerId)) sourcePlayerId = pid;
const lpr = finiteIn(l.lpr, 0, 1000) ? l.lpr : 1;
const e = { enemyKey: key, mods, lpr, sourcePlayerId, tag: src ? (src.tag ?? null) : (typeof l.tag === 'string' ? l.tag : null), counted, spawned: true };
if (l.boss) e.boss = true;
leaked.push(e);
}
const countedLeaks = leaked.filter((l) => l.counted !== false).length;
if (!bossLike && countedLeaks > p.total + B.spawnCount) return bad('leaks > total');
if (typeof p.perfect !== 'boolean' || p.perfect !== (countedLeaks === 0)) return bad('perfect');
const layerGains = {};
for (const [bondId, n] of Object.entries(p.layerGains || {})) {
if (!finiteIn(n, 0, B.layerCap)) return bad('layer bound');
if (n > 0 && spec.flags && spec.flags.layerGainsEnabled === false) return bad('layers disabled');
if (gd && typeof gd.bond === 'function' && !gd.bond(bondId)) return bad('bond');
if (n > 0 && own.bonds && !own.bonds.has(bondId)) return bad('layer bond');
if (n > 0) layerGains[bondId] = n;
}
const coins = Number(p.coins) || 0;
if (!finiteIn(coins, 0, B.bountyCoins + 1e-6)) return bad('coins');
coinsSum += coins;
const unitsEnd = [];
const seen = new Set();
for (const u of Array.isArray(p.unitsEnd) ? p.unitsEnd : []) {
// units the sim created during the battle (no board uid) or not on this player's board carry nothing the match uses
if (!u || !Number.isInteger(u.uid) || !own.chess.has(u.uid) || seen.has(u.uid)) continue;
seen.add(u.uid);
if (!finiteIn(u.hpPct, 0, 1) || !finiteIn(u.sp, 0, 1e5)) return bad('unit state');
unitsEnd.push({ uid: u.uid, defId: own.chess.get(u.uid), hpPct: u.hpPct, sp: u.sp, skillActive: !!u.skillActive, alive: !!u.alive && u.hpPct > 0 });
}
const unitStats = [];
for (const u of Array.isArray(p.unitStats) ? p.unitStats : []) {
if (!u) continue;
const onBoard = Number.isInteger(u.uid) && own.all.has(u.uid);
const defId = onBoard ? own.all.get(u.uid) : (typeof u.defId === 'string' ? u.defId : null);
if (!defId) continue;
// a unit created in battle (summon) is one this player's lineup can field — never another player's operator
if (!onBoard && gd && !own.defIds.has(defId)) continue;
const rec = gd ? (gd.chess?.(defId) || gd.token?.(defId)) : null;
if (gd && !rec) continue;
unitStats.push({
uid: Number.isInteger(u.uid) ? u.uid : null, defId, name: rec && typeof rec.name === 'string' ? rec.name : defId, kind: u.kind === 'token' ? 'token' : 'op',
dmg: Math.max(0, Number(u.dmg) || 0), kills: Math.max(0, Math.trunc(Number(u.kills) || 0)), heal: Math.max(0, Number(u.heal) || 0),
taken: Math.max(0, Number(u.taken) || 0), attacks: Math.max(0, Math.trunc(Number(u.attacks) || 0)),
});
}
const stat = (v) => (finiteIn(v, 0, 1e13) ? v : 0);
perPlayer[pid] = {
killed: p.killed, total: p.total, leaked, perfect: countedLeaks === 0, layerGains, coins,
damageDealt: stat(p.damageDealt), bossDamage: stat(p.bossDamage), healingDone: stat(p.healingDone), deaths: Math.trunc(stat(p.deaths)),
unitsEnd, unitStats,
};
}
if (coinsSum > B.bountyCoins + 1e-6) return bad('coins');
const result = { time: raw.time, reason: raw.reason, perPlayer, killed: 0, total: 0, errors: Math.max(0, Math.trunc(Number(raw.errors) || 0)) };
for (const pid of pids) { result.killed += perPlayer[pid].killed; result.total += perPlayer[pid].total; }
if (spec.kind === 'unite' && Array.isArray(raw.unspawned)) {
const unspawned = [];
for (const u of raw.unspawned) {
if (!u || typeof u.enemyKey !== 'string' || !B.keyCounts.has(u.enemyKey)) return bad('unspawned');
const src = typeof u.sourcePlayerId === 'string' && B.sources.has(u.sourcePlayerId) ? u.sourcePlayerId : null;
if (!takeSource(u.enemyKey, src)) return bad('unspawned source');
unspawned.push({ enemyKey: u.enemyKey, sourcePlayerId: src, tag: typeof u.tag === 'string' ? u.tag : null, time: Number(u.time) || 0 });
}
if (unspawned.length > B.spawnCount) return bad('unspawned');
if (unspawned.length) result.unspawned = unspawned;
}
return { ok: true, result };
} catch (e) {
return bad(`exception: ${e && e.message}`);
}
}
+134
View File
@@ -0,0 +1,134 @@
// server/match/finalAssault.js — 最终攻势 (R14 / solo FUNNY R9) and 隐秘核心 (R15) helpers (DESIGN §6.1, research
// 00-INDEX §5, 06 §10).
//
// * Merged LP: teamLp = Σ LP of the alive players at the start of the Final Assault (no cap); the Hidden Core
// continues with what is left.
// * Pairing: alive players by seat → (1,2), (3,4); an odd player is alone on its own field with the `_s` template
// (solo modes always use `_s`). Field ids 'b1', 'b2'. In a pair the first player is the LEFT side, the second the
// RIGHT side (the sim mirrors the right side: board col c → field col 20 − c with the piece direction RIGHT ↔
// LEFT, UP / DOWN unchanged (DESIGN §3, research 09 §1.2 ConvertChessPositionInfoToBossMap); board rows 9–12 →
// boss rows 2–5, sim/constants BOSS_ROW_OFFSET). `bossFieldPlacement` gives that mapping for UIs / tools.
// * Shared boss HP pool (research 08 §6 #7, DESIGN §14 corrections): co-op = bloodPoint[difficulty] as is — split
// leaders share it, "敌方领袖的总生命值不变", no alive-player factor; solo = bloodPoint × config bossHpScale.solo (0.25,
// flagged unknown); × the tuning bossHpMul when data/tuning.json still has one (docs/BALANCE.md); shared by every
// boss field; bosses are never scaled by enemyScale.
// * Overtime: bossTurnHpReduceTime counts REAL seconds like the level's 120 s maxPlayTime (which runs out first; the
// battle goes on): from 150 real s (300 game s on the 2× field clock) the team loses bossOvertimeDrainPerSec (1) LP
// per real second (gamedata.js bossOvertimeDue); m.public.deadline = the 120 s countdown, m.public.overtimeAt = the
// drain start. Minion / boss leaks cost their `lpr`; team LP 0 ⇒ defeat (all fields force-ended); pool 0 ⇒ victory.
// * No IN_BATTLE layer gains (flags.layerGainsEnabled = false).
// * Hidden Core eligibility (after an R14 win): difficulty in hiddenCore.difficulties, the mode has a hidden round,
// Σ activated layers of the alive players measured at the end of the boss round's prep > threshold (solo 350 /
// co-op 1200) and team LP > minTeamLpExclusive (1).
import { BOSS_ROW_OFFSET, COLS } from '../sim/constants.js';
import { mirrorDir, normDir } from '../sim/dir.js';
export const BOSS_HIT_STEPS = [0.2, 0.5, 0.8];
/**
* Where a board piece (board row / col / dir) stands on a boss field for side 'L' | 'R' — the same mapping as the
* sim (Battle.mapTile / mapDir): rows 9–12 → 2–5; the right side mirrored col c → 20 − c, RIGHT ↔ LEFT.
* @returns {{ row: number, col: number, dir: 'UP'|'RIGHT'|'DOWN'|'LEFT' }}
*/
export function bossFieldPlacement(side, row, col, dir = 'RIGHT') {
const d = normDir(dir);
const r = row >= 7 ? row + BOSS_ROW_OFFSET : row;
return side === 'R' ? { row: r, col: COLS - 1 - col, dir: mirrorDir(d) } : { row: r, col, dir: d };
}
/** Pair alive players by seat: [[a, b], [c, d]] / [[a, b], [c]] / [[a]]. */
export function pairPlayers(alive) {
const sorted = alive.slice().sort((a, b) => a.seat - b.seat);
const groups = [];
for (let i = 0; i < sorted.length; i += 2) groups.push(sorted.slice(i, i + 2));
return groups;
}
/** Shared boss HP for a boss id. */
export function bossPoolHp(gd, bossId, aliveCount) {
const boss = gd.boss(bossId);
const diff = gd.difficulty;
let base = boss && boss.bloodPoint && Number.isFinite(boss.bloodPoint[diff]) ? boss.bloodPoint[diff] : null;
if (base == null && boss && boss.bloodPoint) base = Object.values(boss.bloodPoint).find((v) => Number.isFinite(v)) ?? null;
if (base == null) base = 500000;
const scale = gd.mode.bossHpScale && typeof gd.mode.bossHpScale === 'object' ? gd.mode.bossHpScale : {};
const cfg = gd.config.bossHpScale && typeof gd.config.bossHpScale === 'object' ? gd.config.bossHpScale : {};
const tune = typeof gd.bossHpMul === 'function' ? gd.bossHpMul(bossId) : 1;
if (gd.isSolo) {
const s = Number.isFinite(scale.solo) ? scale.solo : Number.isFinite(cfg.solo) ? cfg.solo : 0.25;
return Math.max(1, Math.round(base * s * tune));
}
void aliveCount; // co-op: the same pool whatever the number of alive players
return Math.max(1, Math.round(base * tune));
}
/** The one HP pool every boss field damages. `damage(playerId, amount)` is called by the sim. */
export class SharedBossPool {
constructor(hp, { onHit = null } = {}) {
this.maxHp = Math.max(1, hp);
this.hp = this.maxHp;
/** playerId → damage dealt */
this.byPlayer = new Map();
this.onHit = onHit;
}
damage(playerId, amount) {
const a = Number(amount);
if (!Number.isFinite(a) || a <= 0 || this.hp <= 0) return 0;
const dealt = Math.min(this.hp, a);
this.hp -= dealt;
if (playerId != null) this.byPlayer.set(playerId, (this.byPlayer.get(playerId) || 0) + dealt);
if (this.onHit) {
try { this.onHit(playerId, dealt); } catch { /* reported by the caller */ }
}
return dealt;
}
}
/**
* Hidden-core eligibility.
* @param {import('./gamedata.js').GameData} gd
* @param {{ layerSum: number, teamLp: number }} s
*/
export function hiddenEligible(gd, { layerSum, teamLp }) {
const hc = gd.hiddenCore;
if (!gd.hiddenRound || !hc.difficulties.includes(gd.difficulty)) return false;
const threshold = gd.isSolo ? hc.single : hc.multi;
return layerSum > threshold && teamLp > hc.minTeamLpExclusive;
}
/**
* A boss field the server takes over mid-fight (its client left, DESIGN §14) re-simulates its spec from t = 0. The
* damage its client already reported (`acked`, cumulative, per player in `ackedBy`) is in the shared pool, so this
* wrapper credits the real pool only with what the re-simulation deals beyond it. Reads (hp, maxHp) go to the pool.
*/
export class CreditPool {
/**
* @param {SharedBossPool} pool
* @param {{ acked?: number, ackedBy?: Record<string, number>, onCredit?: (playerId: string|null, amount: number) => void }} [o]
*/
constructor(pool, { acked = 0, ackedBy = {}, onCredit = null } = {}) {
this.pool = pool;
this.acked = Math.max(0, Number(acked) || 0);
this.ackedBy = { ...ackedBy };
this.cum = 0;
this.onCredit = onCredit;
}
get hp() { return this.pool.hp; }
set hp(v) { /* the sim's fallback path writes hp only when damage() threw; the real pool is never written here */ void v; }
get maxHp() { return this.pool.maxHp; }
damage(playerId, amount) {
const a = Number(amount);
if (!Number.isFinite(a) || a <= 0) return 0;
const before = this.cum;
this.cum += a;
const credit = Math.max(0, this.cum - Math.max(before, this.acked));
if (credit <= 0) return 0;
const dealt = this.pool.damage(playerId, credit);
if (dealt > 0 && this.onCredit) { try { this.onCredit(playerId, dealt); } catch { /* reported by the caller */ } }
return dealt;
}
}
+450
View File
@@ -0,0 +1,450 @@
// server/match/gamedata.js — typed, defaulted view of data/*.json for the match engine.
//
// Every lookup is an own-property lookup (ids come from client intents) that never throws and returns null for
// unknown ids. Tunables come from data/config.json with the documented defaults (research 00-INDEX §2–§8) when a
// key is missing, so a partial data set (tests, data being regenerated) still yields a working match.
//
// No custom balance (DESIGN §14 corrections, research 08 §6): enemy numbers are the official ones — the PRTS
// per-round enemyScale table of data/config.json, the leader pool = bloodPoint. data/tuning.json only overrides result
// titles:
// titles[titleId] { stat?, rule? } merged over config.titles
// (the former enemyHpMul / enemyAtkMul / enemySpeedMul / bossHpMul / flyPlaceholders knobs were removed; a tuning file
// that still carries them is ignored).
import { getConfig, getMode } from '../data.js';
const own = (map, id) => (map && typeof map === 'object' && typeof id === 'string' && Object.hasOwn(map, id) && map[id] && typeof map[id] === 'object' ? map[id] : null);
const numOr = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const posIntOr = (v, d) => (Number.isInteger(v) && v > 0 ? v : d);
export const DEFAULTS = Object.freeze({
income: [0, 4, 5, 6, 7, 8, 9, 10, 11, 12, 12, 12, 12, 12, 12, 12],
incomeCap: 12,
chessPrice: { 1: 2, 2: 3, 3: 3, 4: 3, 5: 4, 6: 4 },
sellPrice: 1,
refreshPrice: 1,
poolCopies: { 1: 12, 2: 14, 3: 18, 4: 16, 5: 8, 6: 5 },
mergeCount: 3,
goldenCopies: 3,
itemMergeCount: 2,
benchSize: 10,
tempSize: 5,
deployCap: 8,
equipPerChess: 2,
maxArtsPerRound: 2,
rewardOffer: { count: 3, tierOffset: 1, maxTier: 6, price: 0 },
upgradePrices: [5, 8, 11, 12, 13],
maxShopLevel: 6,
shopSlots: { 1: { chess: 3, item: 1 }, 2: { chess: 4, item: 1 }, 3: { chess: 4, item: 1 }, 4: { chess: 5, item: 1 }, 5: { chess: 5, item: 1 }, 6: { chess: 5, item: 1 } },
defaultBandId: 'band_bldsk',
defaultStartLp: 28,
lpCapPerRound: 10,
bossOvertimeAfter: 150,
bossOvertimeDrainPerSec: 1,
hiddenCore: { single: 350, multi: 1200, minTeamLpExclusive: 1, difficulties: ['NORMAL', 'HARD', 'ABYSS'] },
dp: { init: 10, perSec: 1, max: 99 },
unite: { maxHelpers: 2, templates: { 1: 'act1autochess_escaped_single', 2: 'act1autochess_escaped_multi' } },
timers: { infoCheck: 25, bandDraft: 50, bandTurn: 12, battleCheck: 3, spFirst: 30, spTurn: 16 },
bans: { FUNNY: { core: 0, addon: 1 }, NORMAL: { core: 3, addon: 4 }, HARD: { core: 3, addon: 4 }, ABYSS: { core: 3, addon: 4 } },
bandDraft: { skipsPerPlayer: 1, timeoutBandId: 'band_bldsk' },
leftoverFundsKeptByBands: ['band_cannot'],
});
/** Game seconds per real second of a battle (forced 2×): combat limits in data are real seconds (combatTimeLimit). */
export const COMBAT_TIME_SCALE = 2;
/** Strip the _a/_b suffix of an item id (the registry key of an item family). */
export const itemKey = (id) => (typeof id === 'string' ? id.replace(/_[ab]$/, '') : '');
export class GameData {
/**
* @param {Readonly<Record<string, any>>} data server/data.js getData() (may be partial)
* @param {string} modeId e.g. 'mode_multi_hard'
*/
constructor(data, modeId) {
this.raw = data && typeof data === 'object' ? data : {};
this.config = getConfig(this.raw) || {};
this.modeId = modeId;
this.mode = getMode(modeId, this.raw) || {};
this.economy = this.config.economy && typeof this.config.economy === 'object' ? this.config.economy : {};
const chess = this.raw.chess && typeof this.raw.chess === 'object' ? this.raw.chess : {};
this._chess = chess;
this._items = this.raw.items && typeof this.raw.items === 'object' ? this.raw.items : {};
this._bonds = this.raw.bonds && typeof this.raw.bonds === 'object' ? this.raw.bonds : {};
/** visible, shop-eligible base (normal) chess ids */
this.visibleChess = Object.keys(chess).filter((id) => {
const c = chess[id];
return c && c.visible && !c.isGolden && !c.isDiy && !c.isHidden && Number.isInteger(c.tier);
}).sort();
/** shop-eligible normal EQUIP item ids by tier */
this.shopItemsByTier = {};
for (const [id, it] of Object.entries(this._items)) {
if (!it || it.isGolden || it.hideInShop || it.itemType !== 'EQUIP' || !Number.isInteger(it.tier)) continue;
(this.shopItemsByTier[it.tier] ||= []).push(id);
}
for (const k of Object.keys(this.shopItemsByTier)) this.shopItemsByTier[k].sort();
this.bondIds = Object.keys(this._bonds).sort((a, b) => (numOr(this._bonds[a].identifier, 99) - numOr(this._bonds[b].identifier, 99)) || (a < b ? -1 : 1));
this.modeInactiveBonds = new Set(Array.isArray(this.mode.inactiveBondIds) ? this.mode.inactiveBondIds : []);
this.inactiveEnemies = new Set(Array.isArray(this.mode.inactiveEnemyKeys) ? this.mode.inactiveEnemyKeys : []);
/** data/tuning.json (titles only, see the header) */
this.tuning = this.raw.tuning && typeof this.raw.tuning === 'object' ? this.raw.tuning : {};
}
/**
* Leader HP pool multiplier — always 1 (no custom balance). Kept for callers written against the old tuning layer
* (finalAssault.bossPoolHp); use `bossPoolHp` for the official pool.
* @deprecated
*/
bossHpMul(bossId) { // eslint-disable-line no-unused-vars
return 1;
}
/**
* Official shared leader HP pool (research 08 §6 #7): co-op = bloodPoint[difficulty] as is — split leaders share one
* pool, "敌方领袖的总生命值不变", no alive-player scaling; solo = bloodPoint × config bossHpScale.solo (0.25,
* [ASSUMED], flagged in data). Leaders are never scaled by enemyScale.
* @param {string} bossId
* @returns {number}
*/
bossPoolHp(bossId) {
const boss = this.boss(bossId);
const diff = this.difficulty;
let base = boss && boss.bloodPoint && Number.isFinite(boss.bloodPoint[diff]) ? boss.bloodPoint[diff] : null;
if (base == null && boss && boss.bloodPoint) base = Object.values(boss.bloodPoint).find((v) => Number.isFinite(v)) ?? null;
if (base == null) base = 500000;
const ms = this.mode.bossHpScale && typeof this.mode.bossHpScale === 'object' ? this.mode.bossHpScale : {};
const cs = this.config.bossHpScale && typeof this.config.bossHpScale === 'object' ? this.config.bossHpScale : {};
const pick = (k, d) => (Number.isFinite(ms[k]) && ms[k] > 0 ? ms[k] : Number.isFinite(cs[k]) && cs[k] > 0 ? cs[k] : d);
const mul = this.isSolo ? pick('solo', 0.25) : pick('coop', 1);
return Math.max(1, Math.round(base * mul));
}
/** config.titles with the tuning overrides (stat / rule per title id) merged in. */
get titles() {
const list = Array.isArray(this.config.titles) ? this.config.titles : [];
const ov = this.tuning.titles && typeof this.tuning.titles === 'object' ? this.tuning.titles : {};
return list.map((t) => {
if (!t || typeof t.id !== 'string' || !Object.hasOwn(ov, t.id) || !ov[t.id] || typeof ov[t.id] !== 'object') return t;
const o = ov[t.id];
const out = { ...t };
if (typeof o.stat === 'string') out.stat = o.stat;
if (o.rule === 'max' || o.rule === 'min') out.rule = o.rule;
return out;
});
}
// ---- ids ------------------------------------------------------------------------------------------
chess(id) { return own(this._chess, id); }
item(id) { return own(this._items, id); }
bond(id) { return own(this._bonds, id); }
band(id) { return own(this.raw.bands, id); }
garrison(id) { return own(this.raw.garrisons, id); }
effect(id) { return own(this.raw.effects, id); }
enemy(key) { return own(this.raw.enemies, key); }
wave(id) { return own(this.raw.waves, id); }
stage(id) { return own(this.raw.stages, id); }
boss(id) { return own(this.raw.bosses, id); }
token(id) { return own(this.raw.tokens, id); }
get choices() { return this.raw.choices && typeof this.raw.choices === 'object' ? this.raw.choices : {}; }
get factions() { return this.raw.factions && typeof this.raw.factions === 'object' ? this.raw.factions : {}; }
/** Normal (base) chess id of a chess id (golden → base). */
baseIdOf(id) {
const c = this.chess(id);
if (!c) return typeof id === 'string' ? id.replace(/_b$/, '_a') : null;
return c.baseId || (c.isGolden ? id.replace(/_b$/, '_a') : id);
}
goldenIdOf(id) {
const c = this.chess(this.baseIdOf(id));
if (c && c.goldenId && this.chess(c.goldenId)) return c.goldenId;
const alt = typeof id === 'string' ? id.replace(/_a$/, '_b') : null;
return alt && this.chess(alt) ? alt : null;
}
isGolden(id) { const c = this.chess(id) || this.item(id); return !!(c && c.isGolden); }
tierOf(id) {
const c = this.chess(id) || this.item(id);
return c && Number.isInteger(c.tier) ? c.tier : 1;
}
// ---- economy --------------------------------------------------------------------------------------
income(round) {
const arr = Array.isArray(this.economy.income) ? this.economy.income : DEFAULTS.income;
const cap = numOr(this.economy.incomeCap, DEFAULTS.incomeCap);
const v = arr[round];
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v;
return Math.max(0, Math.min(cap, 3 + round));
}
chessPrice(id) {
const c = this.chess(id);
if (c && Number.isFinite(c.price) && c.price >= 0) return c.price;
const tier = this.tierOf(id);
const row = this.economy.chessPrice && this.economy.chessPrice[tier];
const golden = c && c.isGolden;
if (row && typeof row === 'object') return numOr(golden ? row.golden : row.normal, DEFAULTS.chessPrice[tier] ?? 3);
return DEFAULTS.chessPrice[tier] ?? 3;
}
sellPrice(id) {
const c = this.chess(id);
if (c && Number.isFinite(c.sellPrice) && c.sellPrice >= 0) return c.sellPrice;
const tier = this.tierOf(id);
const row = this.economy.chessSell && this.economy.chessSell[tier];
if (row && typeof row === 'object') return numOr(c && c.isGolden ? row.golden : row.normal, DEFAULTS.sellPrice);
return DEFAULTS.sellPrice;
}
itemPrice(id) {
const it = this.item(id);
return it && Number.isFinite(it.price) && it.price >= 0 ? it.price : 2;
}
get refreshPrice() { return Math.max(0, numOr(this.economy.refreshPrice, DEFAULTS.refreshPrice)); }
get benchSize() { return posIntOr(this.economy.benchSize, DEFAULTS.benchSize); }
get tempSize() { return posIntOr(this.economy.tempSize, DEFAULTS.tempSize); }
get deployCap() { return posIntOr(this.economy.deployCap, DEFAULTS.deployCap); }
get equipPerChess() { return posIntOr(this.economy.equipPerChess, DEFAULTS.equipPerChess); }
get maxArtsPerRound() { return posIntOr(this.economy.maxArtsPerRound, DEFAULTS.maxArtsPerRound); }
get goldenCopies() { return posIntOr(this.economy.goldenCopies, DEFAULTS.goldenCopies); }
get itemMergeCount() { return posIntOr(this.economy.itemMergeCount, DEFAULTS.itemMergeCount); }
get leftoverKeptBands() { return Array.isArray(this.economy.leftoverFundsKeptByBands) ? this.economy.leftoverFundsKeptByBands : DEFAULTS.leftoverFundsKeptByBands; }
get defaultBandId() { return typeof this.economy.defaultBandId === 'string' ? this.economy.defaultBandId : DEFAULTS.defaultBandId; }
get defaultStartLp() { return posIntOr(this.economy.defaultStartLp, DEFAULTS.defaultStartLp); }
rewardOffer() {
const r = this.economy.rewardOffer && typeof this.economy.rewardOffer === 'object' ? this.economy.rewardOffer : {};
return {
count: posIntOr(r.count, DEFAULTS.rewardOffer.count),
tierOffset: Number.isInteger(r.tierOffset) ? r.tierOffset : DEFAULTS.rewardOffer.tierOffset,
maxTier: posIntOr(r.maxTier, DEFAULTS.rewardOffer.maxTier),
price: Math.max(0, numOr(r.price, 0)),
};
}
/** Copies of a base chess in the shared pool. */
poolCopies(baseId) {
const ov = this.economy.poolCopiesOverrides;
if (ov && typeof ov === 'object' && Number.isInteger(ov[baseId]) && ov[baseId] >= 0) return ov[baseId];
const tier = this.tierOf(baseId);
const pc = this.economy.poolCopies;
const v = pc && typeof pc === 'object' ? pc[tier] : undefined;
return Number.isInteger(v) && v >= 0 ? v : (DEFAULTS.poolCopies[tier] ?? 10);
}
/** Copies needed to merge (0 = never merges: golden chess). */
mergeCount(id) {
const c = this.chess(id);
if (!c || c.isGolden) return 0;
if (Number.isInteger(c.upgradeNum) && c.upgradeNum > 0) return c.upgradeNum;
const ov = this.economy.mergeCountOverrides;
if (ov && Number.isInteger(ov[id])) return ov[id];
return posIntOr(this.economy.mergeCount, DEFAULTS.mergeCount);
}
// ---- mode -----------------------------------------------------------------------------------------
get isSolo() { return this.mode.type === 'SINGLE' || /^mode_single_/.test(this.modeId || ''); }
get difficulty() { return this.mode.difficulty || (this.modeId ? String(this.modeId).split('_').pop().toUpperCase() : 'NORMAL'); }
get lastRound() {
if (Number.isInteger(this.mode.lastRound) && this.mode.lastRound > 0) return this.mode.lastRound;
return this.modeId === 'mode_single_funny' ? 9 : 14;
}
get bossRound() { return Number.isInteger(this.mode.bossRound) && this.mode.bossRound > 0 ? this.mode.bossRound : this.lastRound; }
get hiddenRound() { return Number.isInteger(this.mode.hiddenRound) && this.mode.hiddenRound > 0 ? this.mode.hiddenRound : null; }
get maxShopLevel() { return posIntOr(this.mode.maxShopLevel, DEFAULTS.maxShopLevel); }
roundCfg(r) {
const rounds = this.mode.rounds;
return rounds && typeof rounds === 'object' && rounds[String(r)] && typeof rounds[String(r)] === 'object' ? rounds[String(r)] : null;
}
spRounds() { return Array.isArray(this.mode.spRounds) ? this.mode.spRounds.filter((n) => Number.isInteger(n)) : []; }
upgradePrices() {
const arr = Array.isArray(this.mode.upgradePrices) ? this.mode.upgradePrices : DEFAULTS.upgradePrices;
return arr.map((v) => Math.max(0, numOr(v, 99)));
}
/** Base price to go from `level` to level+1 (null at max). */
upgradeBase(level) {
if (level >= this.maxShopLevel) return null;
const arr = this.upgradePrices();
return arr[level - 1] ?? 99;
}
shopSlots(level) {
const s = this.mode.shopSlots && this.mode.shopSlots[String(level)];
const d = DEFAULTS.shopSlots[level] || DEFAULTS.shopSlots[6];
if (!s || typeof s !== 'object') return { ...d };
const chess = Number.isInteger(s.chess) && s.chess >= 0 ? s.chess : d.chess;
const item = Number.isInteger(s.item) && s.item >= 0 ? s.item : d.item;
return { chess: Math.min(chess, 8), item: Math.min(item, 4) };
}
/** Real-second prep timer for round r (null = untimed). */
prepTime(r) {
const rc = this.roundCfg(r);
if (!rc) return this.isSolo ? null : 90;
const v = rc.prepTime;
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null;
}
/** The round level's `maxPlayTime` (config rounds[r].combatTimeLimit) as data gives it — REAL seconds. */
combatTimeLimitReal(r) {
const rc = this.roundCfg(r);
const v = rc ? rc.combatTimeLimit : undefined;
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
const m = this.mode.combatTimeLimit && this.mode.combatTimeLimit[String(r)];
return typeof m === 'number' && Number.isFinite(m) && m > 0 ? m : 60;
}
/**
* Combat time limit of round r in GAME seconds (what the Battle and 联防 use): maxPlayTime × the forced battle speed
* (config.combatTimeScale, default COMBAT_TIME_SCALE 2). `maxPlayTime` counts real seconds of the 2× battle: read
* as game seconds, the rounds' own spawn schedules would not fit (R2 spawns its last flyer at 43 s of a 45 s limit,
* R3 at 62 s of 55 s — enemies that can never be killed, or never spawn), while × 2 every limit is ≈ the last spawn +
* one flyer crossing (R2 43 + 44 ≈ 90, R3 62 + 44 ≈ 110, R5 38 + 67 ≈ 110). docs/BALANCE.md §2.1.
*/
combatTimeLimit(r) {
return this.combatTimeLimitReal(r) * this.combatTimeScale;
}
/** Game seconds per real second of a battle (config.combatTimeScale, default COMBAT_TIME_SCALE 2). */
get combatTimeScale() {
const k = numOr(this.config.combatTimeScale, COMBAT_TIME_SCALE);
return k > 0 ? k : COMBAT_TIME_SCALE;
}
/**
* The boss round level's `maxPlayTime` (config rounds[r].levelMaxPlayTime, 120) in REAL seconds — the countdown of
* the Final Assault / Hidden Core. It is not a hard stop there ("计时结束后战斗仍然会继续", research 01 §10); null
* when the data has none.
*/
bossLevelTime(r) {
const rc = this.roundCfg(r);
const v = rc ? rc.levelMaxPlayTime : undefined;
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null;
}
/** Official enemy multipliers of round r (config enemyScale: the PRTS table + 终极 speed ×1.15 from R3). */
baseEnemyScale(r) {
const e = this.mode.enemyScale && this.mode.enemyScale[String(r)];
if (!e || typeof e !== 'object') return { hpMul: 1, atkMul: 1, speedMul: 1 };
return {
hpMul: Math.max(0.01, numOr(e.hp, 1)),
atkMul: Math.max(0, numOr(e.atk, 1)),
speedMul: Math.max(0.01, numOr(e.speed, 1)),
};
}
/** Enemy multipliers of round r = the official table (baseEnemyScale; no custom multiplier). */
enemyScale(r) {
return this.baseEnemyScale(r);
}
timer(key) {
const t = this.config.timers && this.config.timers[key];
return typeof t === 'number' && Number.isFinite(t) && t > 0 ? t : DEFAULTS.timers[key] ?? 10;
}
get lpCapPerRound() { return posIntOr(this.config.lpCapPerRound, DEFAULTS.lpCapPerRound); }
/**
* Boss overtime (`bossTurnHpReduceTime` 150 / 1 LP per second): a server turn timer of turnInfoDataDict like
* prepPhaseTime, so REAL seconds on the same clock as the boss level's 120 s maxPlayTime (combat limits are real
* seconds, docs/BALANCE.md §2.1) — the level countdown runs out first, the battle continues, and the merged team LP
* drains 1 per real second from the 150 s mark (research 01 §10, 06 §11.7). Read as game seconds the drain would
* start at 75 real s (45 s before the countdown ends) at 2 LP per real second.
*/
get bossOvertimeAfterReal() { return Math.max(0, numOr(this.config.bossOvertimeAfter, DEFAULTS.bossOvertimeAfter)); }
/** Team LP drained per REAL second of overtime. */
get bossOvertimeDrainReal() { return Math.max(0, numOr(this.config.bossOvertimeDrainPerSec, DEFAULTS.bossOvertimeDrainPerSec)); }
/** Overtime start in GAME seconds of a boss field clock (150 real s × the forced 2× = 300). */
get bossOvertimeAfter() { return this.bossOvertimeAfterReal * this.combatTimeScale; }
/** Team LP drained per GAME second of overtime (1 per real second = 0.5 per game second). */
get bossOvertimeDrain() { return this.bossOvertimeDrainReal / this.combatTimeScale; }
/**
* Team LP the overtime drain has taken when a boss field clock reads `gt` game seconds: bossOvertimeDrainReal per
* whole REAL second past bossOvertimeAfterReal (the first point at 151 real s).
*/
bossOvertimeDue(gt) {
const over = (Number(gt) || 0) / this.combatTimeScale - this.bossOvertimeAfterReal;
return over >= 1 ? Math.floor(over) * this.bossOvertimeDrainReal : 0;
}
get dp() {
const d = this.config.dp && typeof this.config.dp === 'object' ? this.config.dp : {};
return { dpInit: numOr(d.init, 10), dpPerSec: numOr(d.perSec, 1), dpMax: numOr(d.max, 99) };
}
get unite() {
const u = this.config.unite && typeof this.config.unite === 'object' ? this.config.unite : {};
return {
maxHelpers: posIntOr(u.maxHelpers, DEFAULTS.unite.maxHelpers),
templates: u.templates && typeof u.templates === 'object' ? u.templates : DEFAULTS.unite.templates,
};
}
get hiddenCore() {
const h = this.config.hiddenCore && typeof this.config.hiddenCore === 'object' ? this.config.hiddenCore : {};
return {
single: numOr(h.single, DEFAULTS.hiddenCore.single),
multi: numOr(h.multi, DEFAULTS.hiddenCore.multi),
minTeamLpExclusive: numOr(h.minTeamLpExclusive, DEFAULTS.hiddenCore.minTeamLpExclusive),
difficulties: Array.isArray(h.difficulties) ? h.difficulties : DEFAULTS.hiddenCore.difficulties,
};
}
bans(difficulty) {
const b = this.config.bans && this.config.bans[difficulty];
const d = DEFAULTS.bans[difficulty] || { core: 0, addon: 0 };
if (!b || typeof b !== 'object') return { ...d };
return { core: Number.isInteger(b.core) && b.core >= 0 ? b.core : d.core, addon: Number.isInteger(b.addon) && b.addon >= 0 ? b.addon : d.addon };
}
get bandDraft() {
const b = this.config.bandDraft && typeof this.config.bandDraft === 'object' ? this.config.bandDraft : {};
return {
skipsPerPlayer: Number.isInteger(b.skipsPerPlayer) && b.skipsPerPlayer >= 0 ? b.skipsPerPlayer : DEFAULTS.bandDraft.skipsPerPlayer,
timeoutBandId: typeof b.timeoutBandId === 'string' && this.band(b.timeoutBandId) ? b.timeoutBandId : this.defaultBandId,
};
}
/** Bosses weights for the boss round / hidden round. */
bossWeights(hidden = false) {
const w = hidden ? this.mode.hiddenBossWeights : this.mode.bossWeights;
return w && typeof w === 'object' ? Object.entries(w).filter(([id, v]) => this.boss(id) && Number(v) > 0) : [];
}
/** Band usable in this mode type. */
bandAllowed(bandId) {
const b = this.band(bandId);
if (!b) return false;
const list = Array.isArray(b.modeTypeList) ? b.modeTypeList : null;
if (!list) return true;
return list.includes(this.isSolo ? 'SINGLE' : 'MULTI');
}
bandIds() {
const bands = this.raw.bands && typeof this.raw.bands === 'object' ? this.raw.bands : {};
return Object.keys(bands).filter((id) => this.bandAllowed(id)).sort((a, b) => numOr(bands[a].sortId, 99) - numOr(bands[b].sortId, 99) || (a < b ? -1 : 1));
}
startLp(bandId) {
const b = this.band(bandId);
return b && Number.isInteger(b.totalHp) && b.totalHp > 0 ? b.totalHp : this.defaultStartLp;
}
/** Placeable (hand) tokens a chess sends to the hand when placed on the board: [{ tokenId, count }]. */
placeableTokens(chessId) {
const c = this.chess(chessId);
if (!c || !Array.isArray(c.tokens)) return [];
const out = [];
for (const tid of c.tokens) {
const t = this.token(tid);
if (!t || t.kind !== 'summon' || t.displayType !== 'DEFAULT' || t.placeable !== true) continue;
const count = posIntOr(t.deployLimit, 1);
out.push({ tokenId: tid, count: Math.min(count, 9) });
}
return out;
}
}
+166
View File
@@ -0,0 +1,166 @@
// server/match/invariants.js — engine invariants (DESIGN §11) as a non-throwing check. Used by the test harness
// (test/match/harness.js checkInvariants asserts the list is empty) and by tools/matchrun.mjs --check sweeps.
//
// collectViolations(m) → string[] (empty when every invariant holds):
// pool 0 ≤ left ≤ cap and left + Σ copies held by pieces == cap per base chess; non-pool chess hold 0 copies
// economy funds / pendingFunds non-negative integers, LP finite, shop level in range, prices ≥ 0
// pieces unique uids; hand 10 / temp 5 slots; chess carry ≤ equipPerChess known items; a normal piece holds ≤ 1
// copy, an elite ≤ goldenCopies; merges are immediate (never `mergeCount` normal copies of one chess, never
// two copies of a mergeable normal item); every token's owner chess is deployed
// board tiles inside the own region and legal for the piece; no items on the board; chess count ≤ deploy cap
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them)
// shop slot count follows the rolled layout; ids known; banned chess never offered by the shop / rewards
// elim. an eliminated player owns nothing (board, hand, temp, shop, offers, bounties, funds)
// match phase known; teamLp / boss pool within range; combat fields match the alive players
import { PHASE } from '../../shared/constants.js';
import { FIELD, canPlace, positionClass, parseKey } from './board.js';
import { computeBonds } from './bondsMeta.js';
const PHASES = new Set(Object.values(PHASE));
/**
* @param {import('./Match.js').Match} m
* @param {{ limit?: number }} [opts]
* @returns {string[]}
*/
export function collectViolations(m, { limit = 25 } = {}) {
const out = [];
const fail = (msg) => { if (out.length < limit) out.push(msg); };
const gd = m.gd;
const held = new Map();
const uids = new Set();
const banned = new Set(m.bannedChess || []);
const note = (ps, p) => {
if (!Number.isInteger(p.uid) || p.uid <= 0) fail(`${ps.playerId}: bad uid ${p.uid}`);
else if (uids.has(p.uid)) fail(`${ps.playerId}: duplicate uid ${p.uid}`);
uids.add(p.uid);
};
if (!PHASES.has(m.phase)) fail(`unknown phase ${m.phase}`);
if (m.teamLp != null && !(Number.isFinite(m.teamLp) && m.teamLp >= 0)) fail(`teamLp ${m.teamLp}`);
if (m.bossPool && !(m.bossPool.hp >= 0 && m.bossPool.hp <= m.bossPool.maxHp)) fail(`boss pool ${m.bossPool.hp}/${m.bossPool.maxHp}`);
for (const ps of m.players.values()) {
const id = ps.playerId;
if (!Number.isInteger(ps.funds) || ps.funds < 0) fail(`${id}: funds ${ps.funds}`);
if (!Number.isInteger(ps.pendingFunds) || ps.pendingFunds < 0) fail(`${id}: pendingFunds ${ps.pendingFunds}`);
if (!Number.isFinite(ps.lp)) fail(`${id}: lp ${ps.lp}`);
if (ps.alive && m.phase !== PHASE.SETTLE && ps.lp <= 0 && m.teamLp == null && m.round > 0 && ps.bandId) fail(`${id}: alive with lp ${ps.lp}`);
if (ps.hand.length !== gd.benchSize) fail(`${id}: hand has ${ps.hand.length} slots`);
if (ps.temp.length !== gd.tempSize) fail(`${id}: temp has ${ps.temp.length} slots`);
if (!(ps.shop.level >= 1 && ps.shop.level <= gd.maxShopLevel)) fail(`${id}: shop level ${ps.shop.level}`);
if (!(ps.shop.upgradePrice >= 0)) fail(`${id}: upgradePrice ${ps.shop.upgradePrice}`);
if (!Number.isInteger(ps.shop.freeRefreshes) || ps.shop.freeRefreshes < 0) fail(`${id}: freeRefreshes ${ps.shop.freeRefreshes}`);
if (!ps.alive) {
if (ps.board.size) fail(`${id}: eliminated but keeps ${ps.board.size} board pieces`);
if (ps.hand.some(Boolean) || ps.temp.some(Boolean)) fail(`${id}: eliminated but keeps hand/temp pieces`);
if (ps.shop.slots.length || ps.offers.length || ps.bounties.length) fail(`${id}: eliminated but keeps shop/offers/bounties`);
if (ps.funds || ps.pendingFunds) fail(`${id}: eliminated with funds ${ps.funds}+${ps.pendingFunds}`);
}
// pieces
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
const boardChessUids = new Set();
for (const p of ps.board.values()) if (p.kind === 'chess') boardChessUids.add(p.uid);
const itemCounts = new Map();
const countItem = (it) => itemCounts.set(it.id, (itemCounts.get(it.id) || 0) + 1);
for (const p of all) {
note(ps, p);
if (p.kind === 'chess') {
const rec = gd.chess(p.id);
if (!rec) { fail(`${id}: unknown chess ${p.id}`); continue; }
if (!Array.isArray(p.items) || p.items.length > gd.equipPerChess) fail(`${id}: ${p.id} carries ${p.items && p.items.length} items`);
for (const it of p.items || []) {
note(ps, it);
if (it.kind !== 'item' || !gd.item(it.id)) fail(`${id}: ${p.id} carries a bad item ${it.kind}:${it.id}`);
else if (gd.item(it.id).itemType !== 'EQUIP') fail(`${id}: ${p.id} carries a non-equipment item ${it.id}`);
countItem(it);
}
const maxCopies = rec.isGolden ? gd.goldenCopies : 1;
if (!Number.isInteger(p.poolCopies) || p.poolCopies < 0 || p.poolCopies > maxCopies) fail(`${id}: ${p.id} holds ${p.poolCopies} copies`);
const base = gd.baseIdOf(p.id);
held.set(base, (held.get(base) || 0) + (p.poolCopies || 0));
} else if (p.kind === 'item') {
if (!gd.item(p.id)) fail(`${id}: unknown item ${p.id}`);
countItem(p);
} else if (p.kind === 'token') {
if (!gd.token(p.id)) fail(`${id}: unknown token ${p.id}`);
if (!(p.count >= 1)) fail(`${id}: token stack count ${p.count}`);
// summons exist only while their owner is deployed (withdrawing / selling / merging it removes them)
if (!boardChessUids.has(p.ownerUid)) fail(`${id}: token ${p.uid} without a deployed owner (${p.ownerUid})`);
} else {
fail(`${id}: piece of unknown kind ${p.kind}`);
}
}
// board
let deployed = 0;
const dmap = ps.deployMap();
for (const [k, p] of ps.board) {
const [r, c] = parseKey(k);
if (!(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1)) fail(`${id}: piece outside the board at ${k}`);
if (p.kind === 'item') { fail(`${id}: item ${p.id} stands on the board`); continue; }
const rec = p.kind === 'token' ? gd.token(p.id) : gd.chess(p.id);
if (rec && !canPlace(dmap, positionClass(rec), r, c)) fail(`${id}: ${p.id} on an illegal tile ${k}`);
if (p.kind === 'chess') deployed++;
}
if (deployed > ps.deployCap) fail(`${id}: ${deployed} chess deployed > cap ${ps.deployCap}`);
// merges are immediate
const copies = new Map();
for (const p of all) if (p.kind === 'chess' && gd.chess(p.id) && !gd.isGolden(p.id)) { const b = gd.baseIdOf(p.id); copies.set(b, (copies.get(b) || 0) + 1); }
for (const [b, n] of copies) {
const need = gd.mergeCount(b);
if (need > 1 && gd.goldenIdOf(b) && n >= need) fail(`${id}: owns ${n} normal copies of ${b} (merges at ${need})`);
}
for (const [itemId, n] of itemCounts) {
const rec = gd.item(itemId);
if (!rec || rec.isGolden || rec.itemType !== 'EQUIP' || !rec.mergeable) continue;
const need = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : gd.itemMergeCount;
const gid = rec.upgradeChessId || rec.goldenId;
if (need > 1 && need < 100 && gid && gd.item(gid) && n >= need) fail(`${id}: owns ${n} copies of item ${itemId} (merges at ${need})`);
}
// bonds are up to date
try {
const fresh = computeBonds(gd, ps);
if (JSON.stringify(fresh) !== JSON.stringify(ps.bonds)) fail(`${id}: stale bonds (missing recompute)`);
} catch (e) {
fail(`${id}: computeBonds threw ${e && e.message}`);
}
// shop
if (ps.alive) {
const layout = ps.shop.layout;
if (layout && ps.shop.slots.length !== layout.chess + layout.item) fail(`${id}: ${ps.shop.slots.length} shop slots for layout ${layout.chess}+${layout.item}`);
ps.shop.slots.forEach((s, i) => {
if (!s) return;
if (s.kind === 'chess' ? !gd.chess(s.id) : !gd.item(s.id)) fail(`${id}: shop slot ${i} unknown ${s.kind} ${s.id}`);
if (!Number.isInteger(s.basePrice) || s.basePrice < 0) fail(`${id}: shop slot ${i} basePrice ${s.basePrice}`);
if (s.kind === 'chess' && banned.has(gd.baseIdOf(s.id))) fail(`${id}: banned chess ${s.id} in the shop`);
});
for (const o of ps.offers) {
for (const s of o.slots || []) {
if (s.kind === 'item' ? !gd.item(s.id) : !gd.chess(s.id)) fail(`${id}: bad reward slot ${s.kind} ${s.id}`);
if (s.kind !== 'item' && banned.has(gd.baseIdOf(s.id)) && o.source === 'merge') fail(`${id}: banned chess ${s.id} offered as a merge reward`);
}
if (!o.slots || !o.slots.length || o.slots.length > 6) fail(`${id}: reward offer with ${o.slots && o.slots.length} slots`);
}
}
}
// shared pool accounting
for (const [base, e] of m.pool.entries) {
if (!(e.left >= 0 && e.left <= e.cap)) fail(`pool ${base}: left ${e.left} cap ${e.cap}`);
const h = held.get(base) || 0;
if (e.left + h !== e.cap) fail(`pool ${base}: left ${e.left} + held ${h} != cap ${e.cap}`);
}
for (const [base, n] of held) if (!m.pool.has(base) && n !== 0) fail(`non-pool chess ${base} holds ${n} copies`);
// combat fields
if (m.phase === PHASE.COMBAT) {
// one field per alive player (+ the force-ended field of a player who quit during this combat)
const alive = m.order.filter((p) => p.alive || (p.left && m.fields.some((f) => f.fieldId === `n:${p.playerId}`))).map((p) => `n:${p.playerId}`).sort();
const ids = m.fields.map((f) => f.fieldId).sort();
if (JSON.stringify(alive) !== JSON.stringify(ids)) fail(`combat fields ${ids.join(',')} != alive ${alive.join(',')}`);
}
return out;
}
+172
View File
@@ -0,0 +1,172 @@
// server/match/pool.js — the SHARED chess pool (copies per base chess, across all players), per-match bans,
// copy-weighted shop rolls (research 00-INDEX §3, §6; DESIGN §6.2).
//
// Model:
// * Every visible (non-hidden, non-DIY) base chess that is not banned this match has `cap` copies
// (config.economy.poolCopies[tier], overrides e.g. 缪尔赛思 4). `left[baseId]` = copies not owned by anyone.
// * Owning a piece takes copies: a normal piece holds 1, an elite holds 3 (merge of 3 normals). Shop displays
// do NOT reserve copies; buying fails (SOLD_OUT) when left = 0.
// * Pieces remember how many copies they hold (`piece.poolCopies`), so selling / elimination / temp wipes return
// exactly what was taken — chess granted by effects while the pool is empty (or hidden/banned chess) hold 0.
// * Invariant (tests): 0 ≤ left ≤ cap and left + Σ held copies == cap for every base chess.
//
// Rolls: each chess slot draws ONE copy uniformly from all remaining copies of eligible chess with tier ≤ shop level
// ("copy-weighted"; duplicates within a roll allowed). The item slot picks a tier with the same tier shares, then a
// uniform shop-eligible item of that tier (falling back to lower tiers).
/**
* Per-match disabled bond set D and banned chess (research 01 A2): D = uniform sample of `core` core bonds and `addon`
* add-on bonds among weight > 0 bonds that are active in the mode. A visible chess is banned iff every one of its
* bonds is in D ∪ mode.inactiveBondIds.
* @param {import('./gamedata.js').GameData} gd
* @param {Function} rng seeded rng (createRng)
* @returns {{ drawn: string[], staticOff: string[], banned: string[] }}
*/
export function drawDisabledBonds(gd, rng) {
const { core: nCore, addon: nAddon } = gd.bans(gd.difficulty);
const staticOff = [...gd.modeInactiveBonds].filter((b) => gd.bond(b)).sort();
const eligible = gd.bondIds.filter((b) => {
const bond = gd.bond(b);
return bond && Number(bond.weight) > 0 && !gd.modeInactiveBonds.has(b);
});
const core = eligible.filter((b) => gd.bond(b).isCore);
const addon = eligible.filter((b) => !gd.bond(b).isCore);
const drawn = [...sample(core, nCore, rng), ...sample(addon, nAddon, rng)].sort();
const off = new Set([...drawn, ...staticOff]);
const banned = [];
for (const id of gd.visibleChess) {
const c = gd.chess(id);
const bonds = Array.isArray(c.bonds) ? c.bonds : [];
if (bonds.length > 0 && bonds.every((b) => off.has(b))) banned.push(id);
}
return { drawn, staticOff, banned };
}
function sample(arr, n, rng) {
const a = arr.slice();
rng.shuffle(a);
return a.slice(0, Math.max(0, Math.min(n, a.length)));
}
export class SharedPool {
/**
* @param {import('./gamedata.js').GameData} gd
* @param {{ banned?: Iterable<string> }} [opts]
*/
constructor(gd, { banned = [] } = {}) {
this.gd = gd;
const ban = new Set(banned);
/** @type {Map<string, { cap: number, left: number, tier: number }>} */
this.entries = new Map();
for (const id of gd.visibleChess) {
if (ban.has(id)) continue;
const cap = gd.poolCopies(id);
if (cap <= 0) continue;
this.entries.set(id, { cap, left: cap, tier: gd.tierOf(id) });
}
this.banned = [...ban].sort();
}
/** Whether a base chess is part of this match's pool (visible, not banned). */
has(baseId) { return this.entries.has(baseId); }
cap(baseId) { return this.entries.get(baseId)?.cap ?? 0; }
left(baseId) { return this.entries.get(baseId)?.left ?? 0; }
/** Take up to n copies; returns the number actually taken (0 when not in the pool / empty). */
take(baseId, n = 1) {
const e = this.entries.get(baseId);
if (!e || !(n > 0)) return 0;
const k = Math.min(e.left, Math.floor(n));
e.left -= k;
return k;
}
/** Return n copies (clamped at the cap). Returns the number actually returned. */
give(baseId, n = 1) {
const e = this.entries.get(baseId);
if (!e || !(n > 0)) return 0;
const k = Math.min(e.cap - e.left, Math.floor(n));
e.left += k;
return k;
}
/** Remaining copies of eligible chess (tier ≤ maxTier, or exactly `tier`). */
_eligible({ maxTier = 6, tier = null, filter = null } = {}) {
const out = [];
for (const [id, e] of this.entries) {
if (e.left <= 0) continue;
if (tier != null ? e.tier !== tier : e.tier > maxTier) continue;
if (filter && !filter(id, e)) continue;
out.push([id, e.left]);
}
return out;
}
/**
* Copy-weighted roll: one copy uniformly among remaining copies of eligible chess. Returns a base id or null.
* @param {Function} rng
* @param {{ maxTier?: number, tier?: number|null, filter?: (id: string, e: object) => boolean }} [opts]
*/
roll(rng, opts = {}) {
const el = this._eligible(opts);
let total = 0;
for (const [, n] of el) total += n;
if (total <= 0) return null;
let r = rng() * total;
for (const [id, n] of el) { r -= n; if (r < 0) return id; }
return el[el.length - 1][0];
}
/** Tier shares of a copy-weighted roll at shop level `maxTier` (current remaining copies). */
tierShares(maxTier) {
const t = {};
let total = 0;
for (const [, e] of this.entries) {
if (e.tier > maxTier || e.left <= 0) continue;
t[e.tier] = (t[e.tier] || 0) + e.left;
total += e.left;
}
const out = {};
for (const k of Object.keys(t)) out[k] = total > 0 ? t[k] / total : 0;
return out;
}
/**
* Item roll for the shop's item slot: tier by the chess tier shares at this level, uniform item within the tier,
* falling back to lower tiers when a tier has no item. Returns an item id or null.
*/
rollItem(rng, maxTier) {
const shares = this.tierShares(maxTier);
const tiers = Object.keys(shares).map(Number).sort((a, b) => a - b);
let tier = null;
if (tiers.length) {
let r = rng();
for (const t of tiers) { r -= shares[t]; if (r < 0) { tier = t; break; } }
if (tier == null) tier = tiers[tiers.length - 1];
} else {
tier = 1 + Math.floor(rng() * Math.max(1, maxTier));
}
for (let t = tier; t >= 1; t--) {
const list = this.gd.shopItemsByTier[t];
if (list && list.length) return list[Math.floor(rng() * list.length)];
}
for (let t = tier + 1; t <= 6; t++) {
const list = this.gd.shopItemsByTier[t];
if (list && list.length) return list[Math.floor(rng() * list.length)];
}
return null;
}
/** { baseId: left } snapshot (tests / diagnostics). */
snapshot() {
const o = {};
for (const [id, e] of this.entries) o[id] = e.left;
return o;
}
totalLeft() {
let n = 0;
for (const e of this.entries.values()) n += e.left;
return n;
}
}
+146
View File
@@ -0,0 +1,146 @@
// server/match/results.js — settlement (RESULT): per-player results, titles (评语), trophies and rewards
// (DESIGN §6.1, research 06 §10.4–§10.6, data/config.json titles/titleRule/trophies/rewards).
//
// roundsPassed (per player): eliminated in round k ⇒ k − 1; alive at the end ⇒ bossRound on a win (+1 when the
// Hidden Core was cleared), bossRound − 1 when the Final Assault was lost. Rewards and trophies follow each player's
// OWN rounds passed (research 06 §6 / §10.4): the Hidden-Core trophy row and a row's `victory` only for the players
// still in at the end — a teammate eliminated (or departed) earlier keeps the table value of its own rounds.
// Titles: every config.titles entry (with data/tuning.json `titles` overrides, gamedata.js) has { stat, rule:
// 'max'|'min', onlyOnWin? }. 'max' titles: candidates are the players with a positive stat, ranked high → low.
// 'min' titles (坚若磐石 "目标生命值损失最少" = least LP lost): every player still alive at the end is a candidate,
// ranked low → high (the eliminated lost everything). Candidates are ranked by the player's rank for that stat
// (0 = best), then by closeness to the best value, then title order, then seat; greedy assignment gives each player
// ≤ 1 title and uses each title ≤ once (config.titleRule [ASSUMED]).
import { bondList } from './bondsMeta.js';
import { boardOrder } from './board.js';
const STAT_OF = {
bossDamage: (ps) => ps.stats.bossDamage,
activatedLayers: (ps) => ps.activatedLayers(),
lpRemaining: (ps) => (ps.alive ? Math.max(0, ps.lpAtFinal ?? ps.lp) : 0),
lpLost: (ps) => Math.max(0, Number(ps.stats.lpLost) || 0),
merges: (ps) => ps.stats.merges,
itemsEquipped: (ps) => ps.stats.itemsEquipped,
fundsSpent: (ps) => ps.stats.gold,
};
export function assignTitles(gd, players, victory) {
const titles = Array.isArray(gd.titles) ? gd.titles : Array.isArray(gd.config.titles) ? gd.config.titles : [];
const cands = [];
titles.forEach((t, ti) => {
if (!t || typeof t.stat !== 'string' || !Object.hasOwn(STAT_OF, t.stat)) return;
if (t.onlyOnWin && !victory) return;
if (t.rule === 'min') {
const vals = players.filter((ps) => ps.alive).map((ps) => ({ ps, v: Math.max(0, Number(STAT_OF[t.stat](ps)) || 0) }));
if (!vals.length) return;
const best = Math.min(...vals.map((x) => x.v));
const sorted = vals.slice().sort((a, b) => a.v - b.v);
for (const { ps, v } of vals) cands.push({ ps, t, ti, rank: sorted.findIndex((x) => x.v === v), rel: (best + 1) / (v + 1) });
return;
}
const vals = players.map((ps) => ({ ps, v: Number(STAT_OF[t.stat](ps)) || 0 }));
const best = Math.max(0, ...vals.map((x) => x.v));
if (best <= 0) return;
const sorted = vals.slice().sort((a, b) => b.v - a.v);
for (const { ps, v } of vals) {
if (v <= 0) continue;
const rank = sorted.findIndex((x) => x.v === v);
cands.push({ ps, t, ti, rank, rel: v / best });
}
});
cands.sort((a, b) => a.rank - b.rank || b.rel - a.rel || a.ti - b.ti || a.ps.seat - b.ps.seat);
const out = new Map();
const used = new Set();
for (const c of cands) {
if (out.has(c.ps.playerId) || used.has(c.t.id)) continue;
out.set(c.ps.playerId, { id: c.t.id, name: c.t.name, picId: c.t.picId, text: c.t.text });
used.add(c.t.id);
}
return out;
}
function trophiesFor(gd, roundsPassed, hiddenCleared) {
const tr = gd.config.trophies;
if (!tr || gd.isSolo) return 0;
let n = 0;
if (Array.isArray(tr.byRoundsPassed)) {
for (const row of tr.byRoundsPassed) {
if (roundsPassed <= row.maxRound) { n = Number(row[gd.difficulty]) || 0; break; }
}
}
if (hiddenCleared && tr.hiddenCore && Number.isFinite(tr.hiddenCore[gd.difficulty])) n = tr.hiddenCore[gd.difficulty];
return n;
}
function rewardFor(gd, roundsPassed) {
const rw = gd.config.rewards;
if (!rw || !Array.isArray(rw.baseByRoundsPassed)) return 0;
const row = rw.baseByRoundsPassed.filter((r) => r.round <= roundsPassed).pop();
if (!row) return 0;
const df = (rw.difficultyFactor && rw.difficultyFactor[gd.difficulty]) || 1;
const mf = (rw.modeFactor && rw.modeFactor[gd.isSolo ? 'SINGLE' : 'MULTI']) || 1;
return Math.round(row.count * df * mf);
}
/**
* @param {import('./Match.js').Match} m
* @param {{ victory: boolean, hiddenReached: boolean, hiddenCleared: boolean, reason: string }} outcome
*/
export function buildResult(m, outcome) {
const gd = m.gd;
const { victory, hiddenReached, hiddenCleared } = outcome;
const players = [...m.players.values()].sort((a, b) => a.seat - b.seat);
const titles = assignTitles(gd, players, victory);
const teamRounds = victory ? gd.bossRound + (hiddenCleared ? 1 : 0) : Math.max(0, Math.min(m.round, gd.bossRound) - 1);
const rows = players.map((ps) => {
const roundsPassed = !ps.alive && ps.eliminatedRound != null ? Math.max(0, ps.eliminatedRound - 1) : teamRounds;
const lineup = boardOrder(ps.board).filter((x) => x.piece.kind === 'chess').map(({ r, c, piece }) => ({
id: piece.id, golden: gd.isGolden(piece.id), tier: gd.tierOf(piece.id), row: r, col: c, items: (piece.items || []).map((i) => i.id),
}));
// the team's clear counts for the players still in; an eliminated / departed teammate did not pass the boss round
const cleared = victory && ps.alive;
return {
playerId: ps.playerId,
seat: ps.seat,
name: ps.name,
isBot: ps.isBot,
left: ps.left,
alive: ps.alive,
victory: cleared,
roundsPassed,
eliminatedRound: ps.eliminatedRound,
lp: Math.max(0, ps.lp),
bandId: ps.bandId,
lineup,
bonds: bondList(gd, ps.bonds).filter((b) => b.active || b.layers > 0),
stats: {
dmgDealt: Math.round(ps.stats.dmgDealt), kills: ps.stats.kills, leaks: ps.stats.leaks, gold: ps.stats.gold,
refreshes: ps.stats.refreshes, merges: ps.stats.merges, itemsEquipped: ps.stats.itemsEquipped,
bossDamage: Math.round(ps.stats.bossDamage), activatedLayers: ps.activatedLayers(), lpLost: ps.stats.lpLost,
perfectRounds: ps.stats.perfectRounds,
},
title: titles.get(ps.playerId) ?? null,
trophies: trophiesFor(gd, roundsPassed, cleared && hiddenCleared),
reward: rewardFor(gd, roundsPassed),
};
});
return {
t: 'm.result',
victory,
roundsPassed: teamRounds,
hiddenReached,
hiddenCleared,
reason: outcome.reason,
// the merged Final Assault pool (null before round 14); players[].lp are the alive players' shares of it
teamLp: m.teamLp != null ? Math.max(0, Math.round(m.teamLp)) : null,
modeId: m.modeId,
difficulty: m.difficulty,
stageId: m.stageId,
bossId: m.bossId,
hiddenBossId: m.hiddenBossId,
seed: m.seed,
durationMs: Math.max(0, m.sched.now() - m.startedAt),
players: rows,
};
}
+211
View File
@@ -0,0 +1,211 @@
// server/match/scheduler.js — injectable time source + timers for the match engine.
//
// The match never touches setTimeout/Date.now directly: it asks a scheduler. Two implementations:
//
// RealScheduler({ now }) production: wall-clock timers (unref'd so a stray timer never keeps the process
// alive). `now` defaults to Date.now (the lobby passes its injectable clock).
// VirtualScheduler({ start, instantCombat })
// tests / tools: a priority queue of callbacks on a virtual clock. Nothing runs until
// the owner advances time (advance / runNext / runUntil / runAll). With
// `instantCombat: true` (default) the match steps battles to completion synchronously
// inside one callback instead of pacing them at 2× real time, so an entire match runs
// in milliseconds of wall time.
//
// Common interface: now(), setTimeout(fn, ms) → handle, clearTimeout(handle), setInterval(fn, ms) → handle,
// clearInterval(handle), dispose(). Flags: `virtual` (bool), `instant` (bool: battles run synchronously).
// Callbacks are expected to guard themselves; the schedulers still catch and report (onError) so one faulty
// callback never breaks the queue.
const MAX_DELAY = 2 ** 31 - 1;
const clampDelay = (ms) => {
const n = Number(ms);
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.min(MAX_DELAY, n);
};
export class RealScheduler {
/** @param {{ now?: () => number, onError?: (e: unknown) => void }} [opts] */
constructor({ now = Date.now, onError = null } = {}) {
this.virtual = false;
this.instant = false;
this._now = typeof now === 'function' ? now : Date.now;
this._onError = onError;
/** @type {Set<any>} */
this._timeouts = new Set();
/** @type {Set<any>} */
this._intervals = new Set();
this.disposed = false;
}
now() {
try { return Number(this._now()) || Date.now(); } catch { return Date.now(); }
}
_wrap(fn) {
return () => {
try { fn(); } catch (e) { if (this._onError) this._onError(e); else console.error('[scheduler] callback threw', e); }
};
}
setTimeout(fn, ms) {
if (this.disposed || typeof fn !== 'function') return null;
const run = this._wrap(fn);
const h = setTimeout(() => { this._timeouts.delete(h); run(); }, clampDelay(ms));
h.unref?.();
this._timeouts.add(h);
return h;
}
clearTimeout(h) {
if (!h) return;
clearTimeout(h);
this._timeouts.delete(h);
}
setInterval(fn, ms) {
if (this.disposed || typeof fn !== 'function') return null;
const h = setInterval(this._wrap(fn), Math.max(1, clampDelay(ms)));
h.unref?.();
this._intervals.add(h);
return h;
}
clearInterval(h) {
if (!h) return;
clearInterval(h);
this._intervals.delete(h);
}
dispose() {
this.disposed = true;
for (const h of this._timeouts) clearTimeout(h);
for (const h of this._intervals) clearInterval(h);
this._timeouts.clear();
this._intervals.clear();
}
}
export class VirtualScheduler {
/**
* @param {{ start?: number, instantCombat?: boolean, onError?: (e: unknown) => void }} [opts]
*/
constructor({ start = 1_700_000_000_000, instantCombat = true, onError = null } = {}) {
this.virtual = true;
this.instant = instantCombat !== false;
this.t = Number(start) || 0;
this._seq = 0;
/** @type {Array<{ id: number, at: number, seq: number, fn: Function, every: number, cancelled: boolean }>} */
this._q = [];
this._onError = onError;
this.disposed = false;
/** callbacks executed so far (diagnostics) */
this.executed = 0;
/** errors thrown by callbacks (tests assert this stays empty) */
this.errors = [];
}
now() { return this.t; }
_push(fn, ms, every) {
if (this.disposed || typeof fn !== 'function') return null;
const e = { id: ++this._seq, at: this.t + clampDelay(ms), seq: this._seq, fn, every, cancelled: false };
// binary insertion keeps the queue sorted by (at, seq)
let lo = 0;
let hi = this._q.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
const o = this._q[mid];
if (o.at < e.at || (o.at === e.at && o.seq < e.seq)) lo = mid + 1; else hi = mid;
}
this._q.splice(lo, 0, e);
return e;
}
setTimeout(fn, ms) { return this._push(fn, ms, 0); }
clearTimeout(h) { if (h && typeof h === 'object') h.cancelled = true; }
setInterval(fn, ms) { return this._push(fn, Math.max(1, clampDelay(ms)), Math.max(1, clampDelay(ms))); }
clearInterval(h) { if (h && typeof h === 'object') h.cancelled = true; }
/** Number of live (not cancelled) queued callbacks. */
pending() {
let n = 0;
for (const e of this._q) if (!e.cancelled) n++;
return n;
}
/** Time of the next live callback, or null. */
nextAt() {
while (this._q.length && this._q[0].cancelled) this._q.shift();
return this._q.length ? this._q[0].at : null;
}
/** Run the earliest callback (advancing the clock to it). Returns false when the queue is empty. */
runNext() {
while (this._q.length && this._q[0].cancelled) this._q.shift();
const e = this._q.shift();
if (!e) return false;
if (e.at > this.t) this.t = e.at;
if (e.every > 0) {
// re-arm before running so the callback may clear itself
e.at = this.t + e.every;
e.seq = ++this._seq;
this._reinsert(e);
}
this.executed++;
try { e.fn(); } catch (err) {
this.errors.push(err);
if (this._onError) this._onError(err); else console.error('[virtual scheduler] callback threw', err);
}
return true;
}
_reinsert(e) {
let lo = 0;
let hi = this._q.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
const o = this._q[mid];
if (o.at < e.at || (o.at === e.at && o.seq < e.seq)) lo = mid + 1; else hi = mid;
}
this._q.splice(lo, 0, e);
}
/** Advance the clock by `ms`, running every callback that falls due (in order). */
advance(ms) {
const target = this.t + Math.max(0, Number(ms) || 0);
for (;;) {
const at = this.nextAt();
if (at == null || at > target) break;
this.runNext();
}
if (target > this.t) this.t = target;
}
/**
* Run callbacks until `pred()` is true (checked before each callback), the queue is empty, or a limit is hit.
* Returns true when the predicate held.
*/
runUntil(pred, { maxSteps = 1e7, maxTime = Infinity } = {}) {
const limitT = this.t + maxTime;
for (let i = 0; i < maxSteps; i++) {
if (pred()) return true;
const at = this.nextAt();
if (at == null || at > limitT) return !!pred();
this.runNext();
}
return !!pred();
}
/** Run until the queue is empty (or maxSteps). Returns the number of callbacks run. */
runAll({ maxSteps = 1e7 } = {}) {
let n = 0;
while (n < maxSteps && this.runNext()) n++;
return n;
}
dispose() {
this.disposed = true;
for (const e of this._q) e.cancelled = true;
this._q = [];
}
}
+142
View File
@@ -0,0 +1,142 @@
// server/match/unite.js — 联防 (Unite) planning and LP attribution (DESIGN §6.1, research 06 §5, research 08 §5).
//
// Trigger (co-op only): after the normal combats, ≥ 1 alive player leaked (a counted leak ⇒ not perfect) and
// ≥ 1 alive player was perfect. Helpers (PRTS 卫戍协议/帮助 §联防阶段): up to config.unite.maxHelpers (2) perfect
// players chosen by most units on the field (downed included) > has an active bond (存疑) > most undowned units, then
// seat; with 2 helpers the one ranked first by most units > active bond > Σ active bond layers (存疑) > most undowned
// units "率先迎敌" on the RIGHT-hand field (colOffset +8, where the escaped_multi routes enter), the other keeps the
// left half (colOffset 0); a lone helper plays escaped_single on its own field. Their operators keep HP%, SP and a
// running timed skill from the end of their own combat (BattleResult.unitsEnd → PlayerBattleInput.units[].carryState,
// "阵地以其当前状态"); an operator dead at the end of its own combat (HP ratio 0) and its summons stay out of the 联防
// battle [ASSUMED: the sim has no "start undeployed with a running redeploy timer" input, so it does not come back
// mid-联防 either]. Enemies = the union of every leaker's counted leaks (same stats: the SpawnSpec mods travel with
// the leak), routed on the escaped template (`escaped_single` for 1 helper, `escaped_multi` for 2): walkers on its
// `lrsldr` action, flyers on `yokai`, tokens on `gopro_2` / `lazerd` (waves.js buildUniteWave); kill bounties keep
// paying the killer (a helper). No IN_BATTLE layer gains. Time limit = the round's combat limit.
// LP: an enemy still alive at the end (leaked in the unite battle, or never spawned before the limit) costs its
// SOURCE player 1 LP; each player's round loss = min(lpCap, survivors attributed to them + leaks that could not
// re-enter) — the same 10 cap as a normal round.
import { buildUniteWave } from './waves.js';
/**
* @param {import('./Match.js').Match} m
* @param {Map<string, object>} results playerId → BattleResult.perPlayer entry of the player's own combat
* @returns {null | { helpers: any[], leakers: any[], leaked: object[], notReentered: Map<string, number> }}
*/
export function planUnite(m, results) {
if (m.isSolo) return null;
const alive = m.alivePlayers();
const leakers = [];
const perfects = [];
for (const ps of alive) {
const r = results.get(ps.playerId);
if (!r) continue;
const counted = (r.leaked || []).filter((l) => l && l.counted !== false);
if (counted.length > 0) leakers.push(ps);
else if (r.perfect !== false) perfects.push(ps);
}
if (!leakers.length || !perfects.length) return null;
const helpers = helperOrder(m, perfects, results);
const leaked = [];
const notReentered = new Map();
for (const ps of leakers) {
const r = results.get(ps.playerId);
for (const l of r.leaked || []) {
if (!l || l.counted === false) continue;
if (!m.gd.enemy(l.enemyKey)) { notReentered.set(ps.playerId, (notReentered.get(ps.playerId) || 0) + 1); continue; }
// kill bounties keep paying in 联防: a 悬赏 card (mods.bountyId) or a bounty set on the SpawnSpec by content
// (copied into mods.bountyCoins by the match)
const bountyId = l.mods && l.mods.bountyId;
const b = bountyId ? ps.bounties.find((x) => x.id === bountyId) : null;
let bounty = b && b.card && b.card.payout !== 'perfect' && Number(b.card.coin) > 0 ? { coins: Math.trunc(b.card.coin), ownerPlayerId: ps.playerId } : null;
const extra = !bountyId && l.mods ? Math.trunc(Number(l.mods.bountyCoins) || 0) : 0;
if (!bounty && extra > 0) bounty = { coins: extra, ownerPlayerId: ps.playerId };
leaked.push({ enemyKey: l.enemyKey, mods: l.mods ? { ...l.mods } : null, lpr: l.lpr ?? 1, sourcePlayerId: ps.playerId, tag: l.tag ?? null, bounty });
}
}
return { helpers, leakers, leaked, notReentered };
}
/**
* Helper metrics of a perfect player: units on the field (board operators, downed included), whether a bond is
* active, Σ layers of the active bonds, operators still standing at the end of the player's own combat.
*/
export function helperStats(m, ps, results) {
const units = ps.deployCount;
let active = false;
let layers = 0;
for (const [id, b] of Object.entries(ps.bonds || {})) {
if (!b || !b.active) continue;
active = true;
layers += Number(ps.layers && ps.layers[id]) || Number(b.layers) || 0;
}
const r = results && typeof results.get === 'function' ? results.get(ps.playerId) : null;
const opUids = new Set();
for (const p of ps.board.values()) if (p && p.kind === 'chess') opUids.add(p.uid);
let standing = units;
if (r && Array.isArray(r.unitsEnd) && r.unitsEnd.length) {
standing = 0;
for (const u of r.unitsEnd) if (u && opUids.has(u.uid) && u.alive) standing++;
}
return { units, active, layers, standing };
}
/**
* The ≤ maxHelpers helpers, first = the one that meets the enemies first (right-hand field): selection by units >
* active bond > standing units > seat, then the pair ordered by units > active bond > layers > standing > seat.
*/
export function helperOrder(m, perfects, results) {
const st = new Map(perfects.map((ps) => [ps.playerId, helperStats(m, ps, results)]));
const S = (ps) => st.get(ps.playerId);
const select = perfects.slice().sort((a, b) => S(b).units - S(a).units || (S(b).active - S(a).active) || S(b).standing - S(a).standing || a.seat - b.seat)
.slice(0, m.gd.unite.maxHelpers);
return select.sort((a, b) => S(b).units - S(a).units || (S(b).active - S(a).active) || S(b).layers - S(a).layers || S(b).standing - S(a).standing || a.seat - b.seat);
}
/** Battle options for the unite field (without data/logger, added by the match). */
export function uniteBattleOpts(m, plan, timeLimit) {
const wave = buildUniteWave(m.gd, plan.leaked, plan.helpers.length, timeLimit);
const players = plan.helpers.map((ps, i) => {
const carry = new Map();
const dead = new Set();
const r = m.lastResults.get(ps.playerId);
for (const u of (r && r.unitsEnd) || []) {
if (!u || u.uid == null) continue;
if (!u.alive) { dead.add(u.uid); continue; }
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0.01, Math.min(1, u.hpPct)) : 1, sp: Number.isFinite(u.sp) ? u.sp : 0, skillActive: !!u.skillActive });
}
// 2 helpers: the first one meets the enemies first on the right-hand field (escaped_multi enters at col 18)
const colOffset = plan.helpers.length > 1 && i === 0 ? 8 : 0;
const input = ps.battleInput({ side: 'L', colOffset, carry });
// "阵地以其当前状态": an operator knocked out in the helper's own combat is not on that position (HP ratio 0) — it
// stays out of the 联防 battle together with its summons instead of redeploying at full HP
if (dead.size) input.units = input.units.filter((u) => !(u.kind === 'token' ? dead.has(u.ownerUid) : dead.has(u.uid)));
const ev = { input, kind: 'unite', round: m.round, spawns: wave.spawns };
m.dispatch(ps, 'onBattleStart', ev);
return ev.input && typeof ev.input === 'object' ? ev.input : input;
});
return { wave, players };
}
/**
* LP loss per leaker after the unite battle: survivors by source (+ unspawned re-entries + not re-entered leaks),
* capped per round.
* @returns {Map<string, number>} playerId → survivors (uncapped)
*/
export function uniteSurvivors(plan, uniteResult) {
const out = new Map();
for (const [pid, n] of plan.notReentered) out.set(pid, (out.get(pid) || 0) + n);
const perPlayer = (uniteResult && uniteResult.perPlayer) || {};
for (const pp of Object.values(perPlayer)) {
for (const l of (pp && pp.leaked) || []) {
if (!l || l.counted === false || !l.sourcePlayerId) continue;
out.set(l.sourcePlayerId, (out.get(l.sourcePlayerId) || 0) + 1);
}
}
for (const u of (uniteResult && uniteResult.unspawned) || []) {
if (!u || !u.sourcePlayerId) continue;
out.set(u.sourcePlayerId, (out.get(u.sourcePlayerId) || 0) + 1);
}
return out;
}
+662
View File
@@ -0,0 +1,662 @@
// 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. Multi-round bounties ("之后的每场作战", `rounds` > 1) 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 }` — `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).
*/
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,
_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 };
}