feat: allow three cumulative selection timeouts per match
This commit is contained in:
+60
-10
@@ -1,4 +1,4 @@
|
||||
import { RULES_VERSION } from '../../constants/gameplay.js';
|
||||
import { RULES_VERSION, TIMEOUT_CHANCES } 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';
|
||||
@@ -12,7 +12,7 @@ import { matchIntroductionDuration, matchPreparationDuration } from '../shared/m
|
||||
export type GamePeer = Pick<PeerConnection, 'connect' | 'connected' | 'epoch' | 'send' | 'onMessage' | 'onState' | 'restart' | 'close'>;
|
||||
interface FriendConfig {
|
||||
matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock;
|
||||
setOffset(offset: number): void; changed(): void; finished(): void;
|
||||
setOffset(offset: number): void; changed(): void; checkpointChanged(): void; finished(): void;
|
||||
saved?: { confirmed?: MatchCheckpoint; epoch: number };
|
||||
onEmote?(id: unknown, variant: unknown): void;
|
||||
}
|
||||
@@ -21,6 +21,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 timeoutReports: Pair<boolean | null> = [null, null];
|
||||
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();
|
||||
@@ -53,7 +54,7 @@ export function createFriendMatch(config: FriendConfig) {
|
||||
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],
|
||||
return { turnId: state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId), energy: [...state.energy], timeouts: state.timeouts ?? [0, 0],
|
||||
finished: state.phase === 'finished', winnerIndex: state.finished?.winnerIndex ?? null, ...(state.finished ? { reason: state.finished.reason } : {}) };
|
||||
}
|
||||
function sameCheckpoint(a: PublicCheckpoint, b: PublicCheckpoint) {
|
||||
@@ -99,28 +100,65 @@ export function createFriendMatch(config: FriendConfig) {
|
||||
}
|
||||
async function resolved(actions: Pair<ActionId | null>) {
|
||||
clear('deadline');
|
||||
engine.resolve(actions);
|
||||
if (engine.view().phase !== 'finished') 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]);
|
||||
ownDigest = await digestValue([config.matchId, peer.epoch, state.turnId, state.energy, state.timeouts, actions, state.finished?.winnerIndex ?? null, state.finished?.reason ?? null]);
|
||||
if (stopped || recovering) return;
|
||||
send('turn:resolved', { digest: ownDigest }); agree();
|
||||
}
|
||||
function applyTimeout(deadline: number) {
|
||||
const state = engine.view();
|
||||
if (state.phase !== 'selecting' || deadline !== state.deadline || timeoutReports.includes(null)) return;
|
||||
const missing = timeoutReports as Pair<boolean>;
|
||||
if (!missing.some(Boolean)) { armProtocol(); return; }
|
||||
clear('protocol');
|
||||
const extended = engine.expireSelection(missing, deadline);
|
||||
// Persist timeout evidence even before terminal digest agreement. Recovery
|
||||
// must complete the third timeout, never restore another selection window.
|
||||
confirmed.state.timeouts = [...engine.view().timeouts!];
|
||||
config.checkpointChanged();
|
||||
if (extended) {
|
||||
config.changed(); armDeadline();
|
||||
} else if (engine.view().phase === 'finished') {
|
||||
run(() => resolved([null, null])); armProtocol();
|
||||
}
|
||||
}
|
||||
function armDeadline() {
|
||||
const deadline = engine.view().deadline;
|
||||
timeoutReports = [null, null];
|
||||
later('deadline', () => {
|
||||
if (!peer.connected) { void recover(); return; }
|
||||
armProtocol();
|
||||
run(() => {
|
||||
const missing = engine.view().ownAction === null;
|
||||
timeoutReports[config.slot] = missing;
|
||||
// Ordered delivery puts a last-moment commitment before this report.
|
||||
send('turn:timeout', { deadline, missing, slot: config.slot }); applyTimeout(deadline);
|
||||
});
|
||||
}, deadline - now());
|
||||
}
|
||||
function resetAgreement() {
|
||||
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
|
||||
}
|
||||
function selection() {
|
||||
const earlyCommit = pendingCommit; pendingTurnId = undefined; pendingCommit = undefined;
|
||||
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
|
||||
resetAgreement();
|
||||
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: phase => { if (phase !== 'commit' || now() >= state.deadline) armProtocol(); } });
|
||||
onPhase: phase => { if (phase !== 'commit' || now() >= engine.view().deadline) armProtocol(); } });
|
||||
if (earlyCommit) void exchange.receive(earlyCommit).catch(handleError);
|
||||
config.changed();
|
||||
later('deadline', () => { armProtocol(); run(() => exchange!.commit(engine.view().ownAction)); }, state.deadline - now());
|
||||
armDeadline();
|
||||
}
|
||||
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;
|
||||
engine.resumeTurn(confirmed, startsAt); recovering = false; clear('recovery'); engine.presence(true);
|
||||
if (engine.view().phase === 'finished') { resetAgreement(); run(() => resolved([null, null])); armProtocol(); }
|
||||
else selection();
|
||||
heartbeat(); return;
|
||||
}
|
||||
pendingTurnId = engine.view().turnId + 1; pendingCommit = undefined;
|
||||
if (!begun) { begun = true; engine.start(startsAt - matchPreparationDuration(engine.view().settings)); confirmed = engine.checkpoint(); config.changed(); }
|
||||
@@ -162,13 +200,19 @@ export function createFriendMatch(config: FriendConfig) {
|
||||
if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; }
|
||||
if (message.type === 'pong') return;
|
||||
if (message.type === 'emote') {
|
||||
if (!recovering && engine.view().phase !== 'finished') config.onEmote?.(message.id, message.variant);
|
||||
if (!recovering && engine.view().phase !== 'finished' && (message.variant === 0 || message.variant === 1)) config.onEmote?.(message.id, message.variant);
|
||||
return;
|
||||
}
|
||||
if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; }
|
||||
if (message.type === 'resume' || message.type === 'resume:ack') {
|
||||
if (!recovering) return;
|
||||
if (!sameCheckpoint(message.checkpoint, publicCheckpoint())) { abort('protocol_error'); return; }
|
||||
const counts = message.checkpoint.timeouts ?? [0, 0];
|
||||
if (!Array.isArray(counts) || counts.length !== 2 || counts.some(count => !Number.isInteger(count) || count < 0 || count > TIMEOUT_CHANCES)) throw new Error('INVALID_TIMEOUT');
|
||||
// A disconnect can lose one timeout report. Keep the highest observed count
|
||||
// while discarding unconfirmed choices, so reconnect never refunds a chance.
|
||||
confirmed.state.timeouts = counts.map((count, slot) => Math.max(count, confirmed.state.timeouts?.[slot] ?? 0)) as Pair<number>;
|
||||
config.checkpointChanged();
|
||||
if (message.type === 'resume') { resumedPeer = true; send('resume:ack', { checkpoint: publicCheckpoint() }); }
|
||||
else resumedAck = true;
|
||||
maybeResume(); return;
|
||||
@@ -192,6 +236,12 @@ export function createFriendMatch(config: FriendConfig) {
|
||||
pendingCommit = message; return;
|
||||
}
|
||||
if (message.turnId !== engine.view().turnId) return;
|
||||
if (message.type === 'turn:timeout') {
|
||||
if (message.slot !== (config.slot === 0 ? 1 : 0) || typeof message.missing !== 'boolean') throw new Error('INVALID_TIMEOUT');
|
||||
if (message.deadline !== engine.view().deadline) return;
|
||||
if (timeoutReports[message.slot] !== null && timeoutReports[message.slot] !== message.missing) throw new Error('TIMEOUT_CHANGED');
|
||||
timeoutReports[message.slot] = message.missing; applyTimeout(message.deadline); 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(); }
|
||||
|
||||
Reference in New Issue
Block a user