265 lines
13 KiB
JavaScript
265 lines
13 KiB
JavaScript
// 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):全员确认本局信息或倒计时结束后将直接结算。', // 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 };
|