Prepare game updates and single-process PM2 deployment

This commit is contained in:
2026-10-14 19:13:36 +09:00
parent 4f24129e29
commit 22792bc2b4
50 changed files with 1546 additions and 197 deletions
+33 -11
View File
@@ -1,4 +1,4 @@
import { MATCH_COUNTDOWN_MS, RULES_VERSION } from '../../constants/gameplay.js';
import { RULES_VERSION } from '../../constants/gameplay.js';
import { RECONNECT_GRACE_MS } from '../../constants/session.js';
import { PEER_PING_MS, PEER_SILENCE_MS, PROTOCOL_TIMEOUT_MS, PROTOCOL_VERSION, RESUME_START_DELAY_MS } from '../../constants/webrtc.js';
import type { MatchEngine } from '../shared/match-engine.js';
@@ -7,6 +7,7 @@ import type { MatchCheckpoint, PeerMessage, PublicCheckpoint } from '../shared/p
import type { ActionId, Pair } from '../shared/types.js';
import type { GameClock } from './match-controller.js';
import type { PeerConnection } from './peer.js';
import { matchIntroductionDuration, matchPreparationDuration } from '../shared/match-introduction.js';
export type GamePeer = Pick<PeerConnection, 'connect' | 'connected' | 'epoch' | 'send' | 'onMessage' | 'onState' | 'restart' | 'close'>;
interface FriendConfig {
@@ -19,6 +20,7 @@ export function createFriendMatch(config: FriendConfig) {
let stopped = false, recovering = false, offset = 0, localReady = false, peerReady = false, begun = false;
let confirmed = config.saved?.confirmed ?? engine.checkpoint();
let exchange: TurnExchange | undefined, ownDigest = '', remoteDigest = '', remoteAck = '', sentAck = false, presented = false;
let pendingTurnId: number | undefined, pendingCommit: Extract<PeerMessage, { type: 'turn:commit' }> | undefined;
let presentAt: number | undefined, privateChoice: { action: ActionId | null; nonce: string } | undefined;
let resumedPeer = false, resumedAck = false, lastPeerAt = clock.now(), queue = Promise.resolve();
let leaveResolve: (() => void) | undefined;
@@ -29,7 +31,12 @@ export function createFriendMatch(config: FriendConfig) {
function clear(name: string) { clock.clearTimeout(timers.get(name)); timers.delete(name); }
function later(name: string, fn: () => void, ms: number) { clear(name); timers.set(name, clock.setTimeout(() => { timers.delete(name); if (!stopped) fn(); }, Math.max(0, ms))); }
function clearAll() { for (const name of [...timers.keys()]) clear(name); }
function run(fn: () => unknown | Promise<unknown>) { void Promise.resolve().then(fn).catch(() => { if (!stopped) abort('protocol_error'); }); }
function handleError() {
if (stopped) return;
if (!peer.connected) { void recover(); return; }
abort('protocol_error');
}
function run(fn: () => unknown | Promise<unknown>) { void Promise.resolve().then(fn).catch(handleError); }
function send(type: PeerMessage['type'], fields: Record<string, unknown> = {}) {
peer.send({ protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: config.matchId,
connectionEpoch: peer.epoch, turnId: engine.view().turnId, type, ...fields } as PeerMessage);
@@ -42,7 +49,7 @@ export function createFriendMatch(config: FriendConfig) {
engine.abort(reason); config.changed(); config.finished();
stopped = true; peer.close(); leaveResolve?.();
}
function armProtocol() { later('protocol', () => abort('protocol_error'), PROTOCOL_TIMEOUT_MS); }
function armProtocol() { later('protocol', () => { void recover(); }, PROTOCOL_TIMEOUT_MS); }
function publicCheckpoint(): PublicCheckpoint {
const state = confirmed.state;
return { turnId: state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId), energy: [...state.energy],
@@ -90,6 +97,7 @@ export function createFriendMatch(config: FriendConfig) {
else if (presentAt !== undefined) present(presentAt);
}
async function resolved(actions: Pair<ActionId | null>) {
clear('deadline');
engine.resolve(actions);
const state = engine.view();
ownDigest = await digestValue([config.matchId, peer.epoch, state.turnId, state.energy, actions, state.finished?.winnerIndex ?? null, state.finished?.reason ?? null]);
@@ -97,20 +105,24 @@ export function createFriendMatch(config: FriendConfig) {
send('turn:resolved', { digest: ownDigest }); agree();
}
function selection() {
const earlyCommit = pendingCommit; pendingTurnId = undefined; pendingCommit = undefined;
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
const state = engine.view();
exchange = createTurnExchange({ matchId: config.matchId, epoch: peer.epoch, turnId: state.turnId, slot: config.slot, energy: state.energy,
send: message => peer.send(message), onResolved: actions => run(() => resolved(actions)),
onPrivateState: value => { privateChoice = value; config.changed(); }, onPhase: () => armProtocol() });
onPrivateState: value => { privateChoice = value; config.changed(); },
onPhase: phase => { if (phase !== 'commit' || now() >= state.deadline) armProtocol(); } });
if (earlyCommit) void exchange.receive(earlyCommit).catch(handleError);
config.changed();
later('deadline', () => run(() => exchange!.commit(engine.view().ownAction)), state.deadline - now());
later('deadline', () => { armProtocol(); run(() => exchange!.commit(engine.view().ownAction)); }, state.deadline - now());
}
function applyStart(startsAt: number, resume = false) {
if (!Number.isFinite(startsAt)) { abort('protocol_error'); return; }
if (resume) {
engine.resumeTurn(confirmed, startsAt); recovering = false; clear('recovery'); engine.presence(true); selection(); heartbeat(); return;
}
if (!begun) { begun = true; engine.start(startsAt - MATCH_COUNTDOWN_MS); confirmed = engine.checkpoint(); config.changed(); }
pendingTurnId = engine.view().turnId + 1; pendingCommit = undefined;
if (!begun) { begun = true; engine.start(startsAt - matchPreparationDuration(engine.view().settings)); confirmed = engine.checkpoint(); config.changed(); }
later('start', () => { if (engine.beginTurn(startsAt)) selection(); }, startsAt - now());
}
function startTurn(startsAt: number, resume = false) {
@@ -118,7 +130,7 @@ export function createFriendMatch(config: FriendConfig) {
send('turn:start', { startsAt, resume, turnId }); applyStart(startsAt, resume);
}
function maybeStart() {
if (localReady && peerReady && config.slot === 0 && !begun && !recovering) startTurn(now() + MATCH_COUNTDOWN_MS);
if (localReady && peerReady && config.slot === 0 && !begun && !recovering) startTurn(now() + matchPreparationDuration(engine.view().settings) + matchIntroductionDuration(engine.view().settings, false));
}
function maybeResume() {
if (!recovering || !resumedPeer || !resumedAck) return;
@@ -129,9 +141,11 @@ export function createFriendMatch(config: FriendConfig) {
if (stopped || recovering) return;
if (confirmed.state.phase === 'finished') {
clearAll(); if (engine.view().phase !== 'finished') engine.restore(confirmed);
engine.present(now()); engine.presence(false); config.changed(); config.finished(); return;
// A confirmed result keeps its original reveal window when the peer leaves.
engine.presence(false); config.changed(); config.finished(); return;
}
recovering = true; resumedPeer = resumedAck = false; clearAll(); exchange = undefined;
recovering = true; resumedPeer = resumedAck = false; clearAll(); exchange = undefined; pendingTurnId = undefined; pendingCommit = undefined;
engine.restore(confirmed);
engine.presence(false); config.changed();
later('recovery', () => abort('connection_failed'), RECONNECT_GRACE_MS - (clock.now() - disconnectedAt));
try {
@@ -165,13 +179,20 @@ export function createFriendMatch(config: FriendConfig) {
}
if (message.type === 'match:leave-ack') { leaveResolve?.(); return; }
if (message.type === 'match:rematch') { engine.requestRematch(config.slot === 0 ? 1 : 0); config.changed(); return; }
if (recovering || message.turnId !== engine.view().turnId) return;
if (recovering) return;
// A peer may select before this browser's scheduled turn-start callback runs.
if (message.type === 'turn:commit' && message.turnId === pendingTurnId) {
if (message.slot === config.slot) throw new Error('INVALID_SLOT');
if (pendingCommit && pendingCommit.digest !== message.digest) throw new Error('COMMITMENT_CHANGED');
pendingCommit = message; return;
}
if (message.turnId !== engine.view().turnId) return;
if (message.type === 'turn:commit' || message.type === 'turn:reveal') { await exchange?.receive(message); return; }
if (message.type === 'turn:resolved') { if (remoteDigest && remoteDigest !== message.digest) throw new Error('RESULT_CHANGED'); remoteDigest = message.digest; agree(); }
else if (message.type === 'turn:ack') { remoteAck = message.digest; agree(); }
else if (message.type === 'turn:present') { if (config.slot !== 1 || !Number.isFinite(message.revealedAt)) throw new Error('INVALID_PRESENT'); presentAt = message.revealedAt; agree(); }
}
const offMessage = peer.onMessage(message => { queue = queue.then(() => receive(message)).catch(() => abort('protocol_error')); });
const offMessage = peer.onMessage(message => { queue = queue.then(() => receive(message)).catch(handleError); });
const offState = peer.onState(state => {
if (state === 'disconnected' || state === 'failed') { const disconnectedAt = clock.now(); void queue.then(() => recover(disconnectedAt)); }
});
@@ -184,6 +205,7 @@ export function createFriendMatch(config: FriendConfig) {
catch { abort('connection_failed'); }
},
get recovering() { return recovering; },
submitAction() { if (!recovering && !stopped) run(() => exchange!.commit(engine.view().ownAction)); },
requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); },
snapshot: () => ({ confirmed: structuredClone(confirmed), epoch: peer.epoch, privateChoice }),
async leave() {