Prepare game updates and single-process PM2 deployment
This commit is contained in:
+33
-11
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user