Prepare game updates and single-process PM2 deployment
This commit is contained in:
+121
-9
@@ -32,7 +32,7 @@ class Wire {
|
||||
close() { this.connected = false; }
|
||||
disconnect() { this.connected = false; for (const h of this.states) h('disconnected'); }
|
||||
}
|
||||
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0) {
|
||||
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0) {
|
||||
const clock = new ManualClock(); clock.time = 1000;
|
||||
const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a;
|
||||
const records: ResultSubmission[][] = [[], []];
|
||||
@@ -40,7 +40,8 @@ function setup(onRematch?: () => Promise<void>, guestClockOffset = 0) {
|
||||
const controllers = [a, b].map((peer, slot) => {
|
||||
const memory = new Map<string, string>();
|
||||
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: clock.setTimeout, clearTimeout: clock.clearTimeout } : clock;
|
||||
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,
|
||||
reporter: { submit: async data => { records[slot].push(data); return { status: 'applied', stats: emptyPlayerStats() }; } },
|
||||
storage,
|
||||
@@ -51,9 +52,12 @@ function setup(onRematch?: () => Promise<void>, guestClockOffset = 0) {
|
||||
test('two peers agree turns and record complementary results without server turn events', async () => {
|
||||
const h = setup(); const [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000);
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'selecting');
|
||||
await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(4000);
|
||||
await a.submitAction('charge'); await h.clock.advance(1);
|
||||
assert.equal(a.view().phase, 'selecting');
|
||||
await b.submitAction('charge'); await h.clock.advance(1);
|
||||
assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing');
|
||||
assert.deepEqual(a.view().energy, [1, 1]); assert.deepEqual(b.view().energy, [1, 1]);
|
||||
await h.clock.advance(1200); await a.submitAction('blast'); await b.submitAction('charge'); await h.clock.advance(4000);
|
||||
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
|
||||
@@ -61,11 +65,28 @@ test('two peers agree turns and record complementary results without server turn
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('an immediate choice survives the other browser starting the announced turn late', async () => {
|
||||
const h = setup(undefined, 0, 100), [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(0); await h.clock.advance(4000);
|
||||
assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'countdown');
|
||||
await a.submitAction('charge'); await h.clock.advance(0); await h.clock.advance(100);
|
||||
await b.submitAction('charge'); await h.clock.advance(1);
|
||||
assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing');
|
||||
assert.deepEqual(a.view().energy, [1, 1]); assert.deepEqual(b.view().energy, [1, 1]);
|
||||
await h.clock.advance(1200);
|
||||
assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'revealing');
|
||||
await a.submitAction('blast'); await h.clock.advance(0); await h.clock.advance(100);
|
||||
await b.submitAction('charge'); await h.clock.advance(1);
|
||||
assert.equal(a.view().finished?.reason, 'hit'); assert.equal(b.view().finished?.reason, 'hit');
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('loss of final presentation after digest acknowledgements preserves both agreed results', async () => {
|
||||
const h = setup(); const [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000);
|
||||
await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(5200);
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200);
|
||||
await a.submitAction('blast'); await b.submitAction('charge');
|
||||
h.a.intercept = m => { if (m.type !== 'turn:present') return false; queueMicrotask(() => { h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); }); return true; };
|
||||
await h.clock.advance(14000);
|
||||
@@ -73,6 +94,28 @@ test('loss of final presentation after digest acknowledgements preserves both ag
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
for (const leavingSlot of [0, 1] as const) test(`leaving a finished match as slot ${leavingSlot} never restarts the other player's final reveal`, async () => {
|
||||
const h = setup(), [a, b] = h.controllers;
|
||||
const remainingSlot = leavingSlot === 0 ? 1 : 0;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200);
|
||||
await a.submitAction('blast'); await b.submitAction('charge'); await h.clock.advance(4000);
|
||||
const remaining = h.controllers[remainingSlot], before = remaining.view();
|
||||
assert.equal(before.finished?.reason, 'hit'); assert.equal(before.finished?.saved, true);
|
||||
assert.ok(before.reveal!.revealEndsAt < h.clock.now());
|
||||
h.controllers[leavingSlot].destroy();
|
||||
const wire = remainingSlot === 0 ? h.a : h.b;
|
||||
wire.disconnect(); await h.clock.advance(1);
|
||||
assert.equal(remaining.view().opponentPresent, false);
|
||||
assert.deepEqual(remaining.view().reveal, before.reveal);
|
||||
assert.deepEqual(remaining.view().finished, before.finished);
|
||||
wire.disconnect(); await h.clock.advance(1);
|
||||
assert.deepEqual(remaining.view().reveal, before.reveal);
|
||||
assert.deepEqual(h.records.map(records => records.length), [1, 1]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('crossed explicit leave requests produce one draw per player instead of two wins', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
@@ -84,7 +127,7 @@ test('crossed explicit leave requests produce one draw per player instead of two
|
||||
test('recovery starts a fresh epoch and ignores messages from the previous connection', async () => {
|
||||
const h = setup(); const [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000); await a.submitAction('charge');
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); await a.submitAction('charge');
|
||||
h.a.disconnect(); h.b.disconnect(); await h.clock.advance(100);
|
||||
assert.equal(h.a.epoch, 1); assert.equal(h.b.epoch, 1);
|
||||
assert.equal(a.view().ownAction, null); assert.equal(b.view().ownAction, null);
|
||||
@@ -97,7 +140,7 @@ test('recovery starts a fresh epoch and ignores messages from the previous conne
|
||||
test('peers whose wall clocks differ by five seconds agree on deadline and timeout result', async () => {
|
||||
const h = setup(undefined, 5000); const [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000);
|
||||
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);
|
||||
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
|
||||
@@ -107,7 +150,7 @@ test('peers whose wall clocks differ by five seconds agree on deadline and timeo
|
||||
test('unrecoverable transport interruption aborts rather than inventing two wins', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000);
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect();
|
||||
await new Promise(r => setImmediate(r)); // drain already-delivered messages before advancing the recovery clock
|
||||
await h.clock.advance(10000);
|
||||
@@ -130,3 +173,72 @@ test('refresh of a mutually confirmed final result saves without reconnecting to
|
||||
finally { restored.destroy(); }
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('silent packet loss near a deadline enters recovery before declaring a protocol error', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
await Promise.all(h.controllers.map(c => c.submitAction('charge'))); await h.clock.advance(1); await h.clock.advance(1200);
|
||||
// Last heartbeat was just delivered; no RTC state event arrives when packets stop.
|
||||
h.a.drop = h.b.drop = true;
|
||||
await h.clock.advance(17000);
|
||||
assert.ok(h.a.epoch > 0 && h.b.epoch > 0);
|
||||
assert.deepEqual(h.records.map(r => r.map(x => [x.outcome, x.reason])), [[['aborted', 'connection_failed']], [['aborted', 'connection_failed']]]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('transport send failure at selection deadline attempts recovery', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
await h.clock.advance(2900);
|
||||
h.a.connected = h.b.connected = false;
|
||||
h.a.restart = h.b.restart = async () => { throw new Error('offline'); };
|
||||
await h.clock.advance(100);
|
||||
assert.deepEqual(h.records.map(r => r.map(x => x.reason)), [['connection_failed'], ['connection_failed']]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('recovery blocks actions, ignores repeated disconnects and records interruption only once', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect();
|
||||
await h.clock.advance(1);
|
||||
assert.equal(await h.controllers[0].submitAction('charge'), false);
|
||||
h.a.disconnect(); h.b.disconnect(); await h.clock.advance(1);
|
||||
assert.equal(h.a.epoch, 1); assert.equal(h.b.epoch, 1);
|
||||
await h.clock.advance(10000);
|
||||
assert.deepEqual(h.records.map(r => r.length), [1, 1]);
|
||||
assert.deepEqual(h.records.map(r => r[0].outcome), ['aborted', 'aborted']);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('leaving during unavailable recovery settles and records once', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); await h.clock.advance(1);
|
||||
const leaving = h.controllers[0].leave(); await h.clock.advance(3000); await leaving;
|
||||
await h.clock.advance(10000);
|
||||
assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('loss of result agreement cannot display or save a speculative winner during recovery', async () => {
|
||||
const h = setup(); const [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200);
|
||||
await a.submitAction('blast'); await b.submitAction('charge');
|
||||
h.a.intercept = h.b.intercept = message => message.type === 'turn:resolved';
|
||||
h.a.restart = h.b.restart = () => new Promise<void>(() => {});
|
||||
await h.clock.advance(7000);
|
||||
assert.deepEqual(h.controllers.map(c => c.view().finished), [undefined, undefined]);
|
||||
assert.deepEqual(h.controllers.map(c => c.view().opponentPresent), [false, false]);
|
||||
await Promise.all(h.controllers.map(c => c.retrySave()));
|
||||
assert.deepEqual(h.records.map(r => r.length), [0, 0]);
|
||||
await h.clock.advance(10000);
|
||||
assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user