feat: allow three cumulative selection timeouts per match

This commit is contained in:
2026-10-15 11:51:08 +09:00
parent fc5e475f24
commit 293ed7bcc4
14 changed files with 228 additions and 37 deletions
+94 -4
View File
@@ -1,3 +1,4 @@
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createMatchController } from '../src/client/match-controller.js';
@@ -51,7 +52,7 @@ test('peer emotes reach the correct slot without exposing choices or changing th
assert.equal(b.sendEmote('threaten'), false);
} finally { h.close(); }
});
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0) {
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0, turnDurationMs = DEFAULT_GLOBAL_SETTINGS.turnDurationMs) {
const clock = new ManualClock(); clock.time = 1000;
const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a;
const records: ResultSubmission[][] = [[], []];
@@ -61,7 +62,7 @@ function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStart
const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; storages.push(storage);
const localClock = slot === 1 ? { now: () => clock.now() + guestClockOffset,
setTimeout: (fn: () => void, ms: number) => clock.setTimeout(fn, ms + (ms === 4000 || ms === 0 ? guestStartDelay : 0)), clearTimeout: clock.clearTimeout } : clock;
return createMatchController({ activeMatch: { matchId: 'friend-match', seq: 1, mode: 'friend', roomId: 'r' }, gameSessionId: `session-${slot}`, selfIndex: slot as 0 | 1, clock: localClock, peer, onRematch,
return createMatchController({ activeMatch: { matchId: 'friend-match', seq: 1, mode: 'friend', roomId: 'r', settings: { ...DEFAULT_GLOBAL_SETTINGS, turnDurationMs } }, gameSessionId: `session-${slot}`, selfIndex: slot as 0 | 1, clock: localClock, peer, onRematch,
reporter: { submit: async data => { records[slot].push(data); return { status: 'applied', stats: emptyPlayerStats() }; } },
storage,
});
@@ -161,7 +162,7 @@ test('peers whose wall clocks differ by five seconds agree on deadline and timeo
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
assert.equal(a.view().deadline, b.view().deadline); assert.equal(a.view().clockNow, b.view().clockNow);
await a.submitAction('charge'); await h.clock.advance(4000);
await a.submitAction('charge'); await h.clock.advance(9000);
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout');
} finally { h.close(); }
@@ -180,7 +181,7 @@ test('unrecoverable transport interruption aborts rather than inventing two wins
test('refresh of a mutually confirmed final result saves without reconnecting to the opponent', async () => {
const h = setup();
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(7000);
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(13000);
const { GAME_CHECKPOINT_KEY } = await import('../constants/recording.js');
const saved = JSON.parse(h.storages[0].getItem(GAME_CHECKPOINT_KEY));
saved.checkpoint.state.finished.saved = false; saved.checkpoint.state.saveStatus = 'pending';
@@ -261,3 +262,92 @@ test('loss of result agreement cannot display or save a speculative winner durin
assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]);
} finally { h.close(); }
});
test('peer timeout extensions preserve hidden choices and cumulative chances across turns', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await a.submitAction('charge');
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.deepEqual(a.view().timeouts, [0, 1]);
assert.deepEqual(b.view().timeouts, [0, 1]);
assert.equal(a.view().ownAction, 'charge');
assert.equal(b.view().reveal, undefined);
assert.equal(await b.submitAction('guard'), true); await h.clock.advance(1);
assert.equal(a.view().phase, 'revealing');
await h.clock.advance(1200);
await a.submitAction('guard');
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.deepEqual(b.view().timeouts, [0, 2]);
assert.equal(b.view().phase, 'selecting');
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.equal(a.view().finished?.winnerIndex, 0);
assert.equal(b.view().finished?.winnerIndex, 0);
} finally { h.close(); }
});
test('a guest commitment arriving after the deadline keeps a valid last-moment choice', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await a.submitAction('charge'); await h.clock.advance(1);
const held: PeerMessage[] = [];
h.b.intercept = message => { if (['turn:commit', 'turn:reveal', 'turn:timeout'].includes(message.type)) { held.push(message); return true; } return false; };
await h.clock.advance(b.view().deadline - h.clock.now() - 1);
await b.submitAction('guard'); await h.clock.advance(2);
assert.equal(a.view().phase, 'selecting');
h.b.intercept = undefined; for (const message of held) h.b.send(message);
await h.clock.advance(1);
assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing');
assert.deepEqual(a.view().timeouts, [0, 0]); assert.deepEqual(b.view().timeouts, [0, 0]);
assert.deepEqual(a.view().reveal?.actions, ['charge', 'guard']);
} finally { h.close(); }
});
test('recovery reconciles a timeout report lost before the other peer receives it', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
h.a.intercept = message => message.type === 'turn:timeout';
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.deepEqual(a.view().timeouts, [1, 1]); assert.deepEqual(b.view().timeouts, [0, 0]);
h.a.intercept = undefined; h.a.disconnect(); h.b.disconnect();
await h.clock.advance(1000);
assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'selecting');
assert.deepEqual(a.view().timeouts, [1, 1]); assert.deepEqual(b.view().timeouts, [1, 1]);
assert.equal(a.view().aborted, false); assert.equal(b.view().aborted, false);
} finally { h.close(); }
});
test('selecting during a ten-second extension does not start the protocol timeout early', async () => {
const h = setup(undefined, 0, 0, 10000), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await h.clock.advance(a.view().deadline - h.clock.now() + 1000);
assert.deepEqual(a.view().timeouts, [1, 1]);
await a.submitAction('guard'); await h.clock.advance(4000);
assert.equal(h.a.epoch, 0); assert.equal(h.b.epoch, 0);
assert.equal(a.view().ownAction, 'guard');
assert.equal(await b.submitAction('charge'), true); await h.clock.advance(1);
assert.equal(a.view().phase, 'revealing');
} finally { h.close(); }
});
test('disconnect before terminal timeout agreement cannot refund the third chance', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await a.submitAction('guard');
const holdAgreement = (message: PeerMessage) => ['turn:resolved', 'turn:ack', 'turn:present'].includes(message.type);
h.a.intercept = h.b.intercept = holdAgreement;
await h.clock.advance(a.view().deadline - h.clock.now() + 6001);
assert.deepEqual(a.view().timeouts, [0, 3]); assert.deepEqual(b.view().timeouts, [0, 3]);
assert.deepEqual(h.records.map(records => records.length), [0, 0]);
h.a.intercept = h.b.intercept = undefined; h.a.disconnect(); h.b.disconnect();
await h.clock.advance(1000);
assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout');
assert.equal(a.view().finished?.winnerIndex, 0); assert.equal(b.view().finished?.winnerIndex, 0);
assert.deepEqual(a.view().timeouts, [0, 3]);
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
} finally { h.close(); }
});