mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-17 18:14:28 +00:00
feat(web): add online 1v1 play
Create a room and share a six-character code or an invite link; join by either. The waiting room shows the code until an opponent arrives. During a game the opponent's disconnection raises a banner with the grace countdown, and their return clears it. When the game ends, the rematch prompt appears only while an offer is actually open, so there is never a button that can only produce an error. The board is now one component both modes render, which is what makes online play the same screen as the bot game rather than a second implementation of it. Room codes are normalized by stripping separators and reading the code out of a pasted invite link, and nothing else. Filtering out every character outside the alphabet would turn any sentence into six plausible letters and send the player to a room that was never theirs. The socket now opens when the player asks for a room, not on arrival: the handshake carries the nickname once, and a socket opened before they typed it introduced them under whatever was stored beforehand. A tab that already holds a session reconnects immediately instead, so refreshing mid-game returns to the game. A resume the server cannot honour is dropped quietly rather than opening the lobby with an error the player did nothing to cause. The client also notices a socket that fails without closing. Nothing did before, so the page kept showing a live connection and a running countdown over a socket nothing could reach. A tick that fired late is ignored, because a backgrounded tab has its timers throttled and would otherwise close a healthy connection every time the player switched away.
This commit is contained in:
1 parent
4277c2014c
commit
ffa13a0f6e
23 files changed
+1369
-164
No files matched your search
@@ -0,0 +1,46 @@
|
||||
// The upstream dictionary is pinned in two places: the Makefile, which builds
|
||||
// it for a developer, and the Dockerfile, which builds it for the image. They
|
||||
// have to agree, or the container ships a wordlist nobody tested against.
|
||||
//
|
||||
// This lives in the JavaScript suite for no better reason than that it is the
|
||||
// suite that already reads other files in the repository. It is checking two
|
||||
// build files, not the frontend.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const makefile = readFileSync(fileURLToPath(new URL('../../Makefile', import.meta.url)), 'utf8');
|
||||
const dockerfile = readFileSync(fileURLToPath(new URL('../../Dockerfile', import.meta.url)), 'utf8');
|
||||
|
||||
/**
|
||||
* @param {string} source
|
||||
* @param {RegExp} pattern
|
||||
* @param {string} what
|
||||
*/
|
||||
function pin(source, pattern, what) {
|
||||
const match = source.match(pattern);
|
||||
expect(match, `${what} not found`).not.toBeNull();
|
||||
return match?.[1].trim();
|
||||
}
|
||||
|
||||
describe('the pinned upstream dictionary', () => {
|
||||
const makeUrl = pin(makefile, /DICT_URL\s*:?=\s*(\S+)/, 'DICT_URL in the Makefile');
|
||||
const makeSha = pin(makefile, /DICT_SHA256\s*:?=\s*(\S+)/, 'DICT_SHA256 in the Makefile');
|
||||
const dockerUrl = pin(dockerfile, /ARG DICT_URL=(\S+)/, 'DICT_URL in the Dockerfile');
|
||||
const dockerSha = pin(dockerfile, /ARG DICT_SHA256=(\S+)/, 'DICT_SHA256 in the Dockerfile');
|
||||
|
||||
it('is the same release in both build files', () => {
|
||||
expect(dockerUrl).toBe(makeUrl);
|
||||
});
|
||||
|
||||
it('is pinned to the same checksum in both build files', () => {
|
||||
expect(dockerSha).toBe(makeSha);
|
||||
});
|
||||
|
||||
it('is a checksum, not a placeholder', () => {
|
||||
// A guard that only compared the two would pass happily if both were
|
||||
// blanked, which is the one way this pin can silently stop pinning.
|
||||
expect(makeSha).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
@@ -227,3 +227,71 @@ describe('pong', () => {
|
||||
expect(JSON.stringify(store.state)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('online rooms', () => {
|
||||
it('waits once the room exists but has one seat filled', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(msg('roomCreated', { roomCode: 'K7M2QP' }));
|
||||
|
||||
expect(store.state.phase).toBe('waiting');
|
||||
expect(store.state.roomCode).toBe('K7M2QP');
|
||||
});
|
||||
|
||||
it('does not drop a live game back into waiting', () => {
|
||||
// A resumed session is told its room again. Treating that as "waiting"
|
||||
// would replace the board with the invite screen mid-game.
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(msg('roomCreated', { roomCode: 'K7M2QP' }));
|
||||
|
||||
expect(store.state.phase).toBe('playing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rematch offers', () => {
|
||||
function finished(store) {
|
||||
store.apply(started());
|
||||
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED }));
|
||||
}
|
||||
|
||||
it('records both sides of the answer as the server rendered them', () => {
|
||||
const store = createGameStore();
|
||||
finished(store);
|
||||
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: false, expiresInMs: 27500 }));
|
||||
|
||||
expect(store.state.rematch).toEqual({
|
||||
iAccepted: true,
|
||||
opponentAccepted: false,
|
||||
expiresInMs: 27500
|
||||
});
|
||||
});
|
||||
|
||||
it('ends the offer when the opponent cannot come back', () => {
|
||||
// The countdown would otherwise keep running with nobody to answer it.
|
||||
const store = createGameStore();
|
||||
finished(store);
|
||||
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: false, expiresInMs: 27500 }));
|
||||
store.apply(msg('opponentLeft', { canReconnect: false, graceMs: 0 }));
|
||||
|
||||
expect(store.state.rematch).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the offer while the opponent is only disconnected', () => {
|
||||
const store = createGameStore();
|
||||
finished(store);
|
||||
store.apply(msg('rematchState', { iAccepted: false, opponentAccepted: true, expiresInMs: 20000 }));
|
||||
store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30000 }));
|
||||
|
||||
expect(store.state.rematch).not.toBeNull();
|
||||
});
|
||||
|
||||
it('clears the offer once the next game starts', () => {
|
||||
const store = createGameStore();
|
||||
finished(store);
|
||||
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: true, expiresInMs: 15000 }));
|
||||
store.apply(started({ openingWord: 'thí sinh' }));
|
||||
|
||||
expect(store.state.rematch).toBeNull();
|
||||
expect(store.state.phase).toBe('playing');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// Room codes cross a gap the type system cannot see: the server generates them
|
||||
// from a Go constant and the client validates them against a JavaScript copy.
|
||||
// The first test here reads the Go source so the copy cannot quietly drift.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ROOM_CODE_ALPHABET,
|
||||
ROOM_CODE_LENGTH,
|
||||
isRoomCode,
|
||||
normalizeRoomCode
|
||||
} from '../src/lib/room-code.js';
|
||||
|
||||
const hubSource = fileURLToPath(new URL('../../server/internal/wsapi/hub.go', import.meta.url));
|
||||
|
||||
describe('the alphabet', () => {
|
||||
const go = readFileSync(hubSource, 'utf8');
|
||||
|
||||
it('matches the one the server generates codes from', () => {
|
||||
const match = go.match(/roomCodeAlphabet\s*=\s*"([^"]+)"/);
|
||||
expect(match, 'roomCodeAlphabet not found in hub.go').not.toBeNull();
|
||||
expect(ROOM_CODE_ALPHABET).toBe(match?.[1]);
|
||||
});
|
||||
|
||||
it('matches the length the server generates', () => {
|
||||
const match = go.match(/roomCodeLen\s*=\s*(\d+)/);
|
||||
expect(match, 'roomCodeLen not found in hub.go').not.toBeNull();
|
||||
expect(ROOM_CODE_LENGTH).toBe(Number(match?.[1]));
|
||||
});
|
||||
|
||||
it('omits the characters people confuse when reading a code aloud', () => {
|
||||
for (const ch of ['0', 'O', '1', 'I', 'L']) {
|
||||
expect(ROOM_CODE_ALPHABET.includes(ch), `${ch} should not be in the alphabet`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRoomCode', () => {
|
||||
it('upper-cases what was typed in lower case', () => {
|
||||
expect(normalizeRoomCode('k7m2qp')).toBe('K7M2QP');
|
||||
});
|
||||
|
||||
it('accepts a code with punctuation or spacing around it', () => {
|
||||
expect(normalizeRoomCode(' K7M2-QP ')).toBe('K7M2QP');
|
||||
});
|
||||
|
||||
it('takes the code out of a pasted invite link', () => {
|
||||
expect(normalizeRoomCode('https://noitu.example/online?code=K7M2QP')).toBe('K7M2QP');
|
||||
});
|
||||
|
||||
it('reads the code from a link that carries other parameters too', () => {
|
||||
expect(normalizeRoomCode('https://noitu.example/online?ref=chat&code=K7M2QP#top')).toBe(
|
||||
'K7M2QP'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not try to find a code buried in a sentence', () => {
|
||||
// Guessing would occasionally produce a plausible code for the wrong
|
||||
// room, which is worse than asking the player to paste the code itself.
|
||||
expect(isRoomCode(normalizeRoomCode('vào phòng của tôi nhé'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps characters outside the alphabet so the code can be refused', () => {
|
||||
// A code misread as O for 0 should be reported as malformed, not
|
||||
// quietly rewritten into a different six-character code.
|
||||
expect(normalizeRoomCode('K0MOQP')).toBe('K0MOQP');
|
||||
expect(isRoomCode(normalizeRoomCode('K0MOQP'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not truncate a long paste into a different code', () => {
|
||||
expect(isRoomCode(normalizeRoomCode('K7M2QPZZZZ'))).toBe(false);
|
||||
});
|
||||
|
||||
it('handles nothing at all', () => {
|
||||
expect(normalizeRoomCode('')).toBe('');
|
||||
expect(normalizeRoomCode(/** @type {any} */ (undefined))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRoomCode', () => {
|
||||
it('accepts a well-formed code', () => {
|
||||
expect(isRoomCode('K7M2QP')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects the wrong length', () => {
|
||||
expect(isRoomCode('K7M2Q')).toBe(false);
|
||||
expect(isRoomCode('K7M2QPZ')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects characters outside the alphabet', () => {
|
||||
expect(isRoomCode('K7M2Q0')).toBe(false);
|
||||
expect(isRoomCode('k7m2qp')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../src/lib/proto/noitu/v1/game_pb.js';
|
||||
import {
|
||||
BACKOFF_MS,
|
||||
LIVENESS_TIMEOUT_MS,
|
||||
PING_INTERVAL_MS,
|
||||
Status,
|
||||
createClient,
|
||||
@@ -460,3 +461,69 @@ describe('backoff and the handshake', () => {
|
||||
expect(h.reconnectDelays().at(-1)).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a socket that dies without closing', () => {
|
||||
/**
|
||||
* Fires the ping timer `ticks` times, advancing the clock by one interval
|
||||
* each time — a healthy tab whose timers are running on schedule.
|
||||
*
|
||||
* @param {any} h
|
||||
* @param {number} ticks
|
||||
* @param {number} start
|
||||
*/
|
||||
function tick(h, ticks, start) {
|
||||
let clock = start;
|
||||
for (let i = 0; i < ticks; i += 1) {
|
||||
clock += PING_INTERVAL_MS;
|
||||
h.setClock(clock);
|
||||
h.timers.flush();
|
||||
}
|
||||
return clock;
|
||||
}
|
||||
|
||||
it('is closed by the client once frames stop arriving', () => {
|
||||
// A connection that fails without closing leaves onclose unfired, so the
|
||||
// page would keep showing a live connection and a running countdown over
|
||||
// a socket nothing can reach.
|
||||
const h = setup();
|
||||
h.client.connect();
|
||||
h.last().open();
|
||||
const socket = h.last();
|
||||
|
||||
tick(h, LIVENESS_TIMEOUT_MS / PING_INTERVAL_MS + 1, 1000);
|
||||
|
||||
expect(socket.closed).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a socket alone while frames are still arriving', () => {
|
||||
const h = setup();
|
||||
h.client.connect();
|
||||
h.last().open();
|
||||
const socket = h.last();
|
||||
|
||||
let clock = 1000;
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
clock += PING_INTERVAL_MS;
|
||||
h.setClock(clock);
|
||||
socket.deliver(serverMsg('pong', { clientTimeMs: 1n, serverTimeMs: 2n }));
|
||||
h.timers.flush();
|
||||
}
|
||||
|
||||
expect(socket.closed).toBe(false);
|
||||
});
|
||||
|
||||
it('does not punish a tab whose timers were throttled', () => {
|
||||
// A backgrounded tab fires its timers minutes late through no fault of
|
||||
// the connection. Judging silence on a tick that was itself late would
|
||||
// close a healthy socket every time the player switched away.
|
||||
const h = setup();
|
||||
h.client.connect();
|
||||
h.last().open();
|
||||
const socket = h.last();
|
||||
|
||||
h.setClock(1000 + 10 * 60_000);
|
||||
h.timers.flush();
|
||||
|
||||
expect(socket.closed).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user