feat: allow three cumulative selection timeouts per match
This commit is contained in:
@@ -34,7 +34,7 @@ test('local bot runs countdown, turns, result and persistence without Socket.IO'
|
||||
});
|
||||
test('timeout records a loss once and local leave records forfeit', async () => {
|
||||
const h = harness();
|
||||
try { await h.controller.start(); await h.clock.advance(7000); assert.equal(h.recorded[0].outcome, 'loss'); assert.equal(h.recorded[0].reason, 'timeout'); }
|
||||
try { await h.controller.start(); await h.clock.advance(12000); assert.equal(h.recorded[0].outcome, 'loss'); assert.equal(h.recorded[0].reason, 'timeout'); }
|
||||
finally { h.controller.destroy(); }
|
||||
const other = harness();
|
||||
try { await other.controller.start(); await other.controller.leave(); assert.equal(other.recorded[0].reason, 'forfeit'); assert.equal(other.recorded[0].outcome, 'loss'); }
|
||||
|
||||
@@ -39,10 +39,15 @@ test('a turn resolves once and a hit ends the game', () => {
|
||||
test('only verified missing choices count as timeouts; technical abort leaves outcome separate', () => {
|
||||
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000);
|
||||
engine.resolve([null, 'charge']);
|
||||
assert.equal(engine.view().phase, 'selecting');
|
||||
assert.deepEqual(engine.view().timeouts, [1, 0]);
|
||||
at(engine.view().deadline); engine.resolve([null, 'charge']);
|
||||
assert.equal(engine.view().phase, 'selecting');
|
||||
at(engine.view().deadline); engine.resolve([null, 'charge']);
|
||||
assert.equal(engine.view().finished?.winnerIndex, 1);
|
||||
assert.equal(engine.view().finished?.reason, 'timeout');
|
||||
const other = setup(); other.engine.start(1000); other.at(4000); other.engine.beginTurn(); other.at(8000);
|
||||
other.engine.resolve([null, null]);
|
||||
for (let i = 0; i < 3; i++) { other.at(other.engine.view().deadline); other.engine.resolve([null, null]); }
|
||||
assert.equal(other.engine.view().finished?.reason, 'double_timeout');
|
||||
const aborted = setup(); aborted.engine.abort('connection_failed');
|
||||
assert.equal(aborted.engine.view().aborted, true);
|
||||
@@ -86,3 +91,20 @@ test('rollback and recovery keep published revisions increasing so the view acce
|
||||
other.engine.resumeTurn(start, 5000);
|
||||
assert.ok(other.engine.view().version > previous);
|
||||
});
|
||||
|
||||
test('timeout chances persist across turns and restore while locked actions stay locked', () => {
|
||||
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); engine.lockAction('guard');
|
||||
at(engine.view().deadline); engine.resolve(['guard', null]);
|
||||
assert.equal(engine.view().ownAction, 'guard');
|
||||
assert.equal(engine.lockAction('charge'), false);
|
||||
assert.deepEqual(engine.view().timeouts, [0, 1]);
|
||||
engine.resolve(['guard', 'charge']);
|
||||
at(engine.view().reveal!.revealEndsAt); engine.beginTurn();
|
||||
const saved = engine.checkpoint(); engine.restore(saved);
|
||||
assert.deepEqual(engine.view().timeouts, [0, 1]);
|
||||
at(engine.view().deadline); engine.resolve([null, null]);
|
||||
assert.deepEqual(engine.view().timeouts, [1, 2]);
|
||||
at(engine.view().deadline); engine.resolve([null, null]);
|
||||
assert.deepEqual(engine.view().timeouts, [2, 3]);
|
||||
assert.equal(engine.view().finished?.winnerIndex, 0);
|
||||
});
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
@@ -414,7 +414,7 @@ test('finished matches retain results on reload then expire when abandoned', int
|
||||
try {
|
||||
await f.service.join('player-a', f.a.gameSessionId);
|
||||
const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS);
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
|
||||
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.finished?.reason, 'double_timeout');
|
||||
await f.clock.advance(RANKED_FINISHED_TTL_MS - 1);
|
||||
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.matchId, id);
|
||||
@@ -458,7 +458,7 @@ test('deadline rejects late actions and duplicate terminal writes survive an amb
|
||||
await s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
|
||||
assert.equal((await s.leaderboard('player-a')).self, undefined);
|
||||
// Jump to the deadline without executing timers: the request itself must settle it.
|
||||
f.clock.time += TURN_DURATION_MS;
|
||||
f.clock.time += TURN_DURATION_MS * 3;
|
||||
await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /response lost after write/);
|
||||
assert.equal((await s.profile('player-a')).stats.wins, 1);
|
||||
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
|
||||
@@ -509,7 +509,7 @@ test('timeouts progress without polling, unlock colors only from authoritative r
|
||||
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId;
|
||||
await clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
|
||||
await s.action('player-a', a.gameSessionId, id, 1, 'guard');
|
||||
await clock.advance(TURN_DURATION_MS);
|
||||
await clock.advance(TURN_DURATION_MS * 3);
|
||||
assert.equal((await s.status('player-b', b.gameSessionId)).match?.finished?.reason, 'timeout');
|
||||
await s.leave('player-a', a.gameSessionId, id); await s.leave('player-b', b.gameSessionId, id);
|
||||
}
|
||||
@@ -778,7 +778,7 @@ test('ranked timer reports only a safe failure code and retries persistence even
|
||||
try {
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
await s.join('player-b', f.b.gameSessionId);
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS);
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
|
||||
await logged;
|
||||
assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']);
|
||||
assert.equal(await records.countDocuments(), 0);
|
||||
|
||||
Reference in New Issue
Block a user