// 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} */ 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):全员确认本局信息或倒计时结束后将直接结算。', // i18n-ignore: development placeholder }; } /** 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 };