'use strict'; const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); // Must be set before ../server is required. const STORIES_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'yarn-socket-stories-')); process.env.YARN_STORIES_DIR = STORIES_DIR; const test = require('node:test'); const assert = require('node:assert/strict'); const { io: createClient } = require('socket.io-client'); const { server, games } = require('../server'); function waitForEvent(socket, event, { timeout = 8000 } = {}) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { socket.off(event, onEvent); reject(new Error(`timed out waiting for "${event}"`)); }, timeout); function onEvent(payload) { clearTimeout(timer); socket.off(event, onEvent); resolve(payload); } socket.on(event, onEvent); }); } test('two humans and a bot can play a complete game end to end', async (t) => { await new Promise((resolve) => server.listen(0, resolve)); const { port } = server.address(); const url = `http://127.0.0.1:${port}`; const roomName = 'smoke-room'; const sockets = []; t.after(async () => { for (const socket of sockets) socket.disconnect(); games[roomName]?.destroy(); delete games[roomName]; await new Promise((resolve) => server.close(resolve)); }); const connect = async (uuid, name) => { const socket = createClient(url, { transports: ['websocket'], forceNew: true, reconnection: false }); sockets.push(socket); const joined = waitForEvent(socket, 'gameState'); socket.on('connect', () => { socket.emit('joinRoom', { roomName, playerUUID: uuid, playerName: name }); }); await joined; return socket; }; const alice = await connect('alice-0001', 'Alice'); const bob = await connect('bob-0002', 'Bob'); // The first player is the host. const lobbyState = await new Promise((resolve) => { alice.emit('setName', { name: 'Alice' }); alice.once('gameState', resolve); }); assert.equal(lobbyState.gameHost, 'alice-0001'); const roundStarted = waitForEvent(alice, 'newRound'); alice.emit('startGame', { botCount: 1, timeLimit: 30, roundLimit: 1, scoreLimit: 0, startText: 'It was a dark and stormy night...' }); const roundState = await roundStarted; assert.equal(roundState.currentPhase, 'writing'); assert.equal(roundState.players.length, 3, 'two humans plus one bot'); const votingForAlice = waitForEvent(alice, 'votingPhase'); const votingForBob = waitForEvent(bob, 'votingPhase'); alice.emit('submitEntry', { entry: 'Alice bravely opened the door.' }); bob.emit('submitEntry', { entry: 'Bob hid behind the sofa.' }); const aliceVote = await votingForAlice; const bobVote = await votingForBob; assert.equal(aliceVote.entries.length, 3, 'bots submit entries too'); const aliceOwn = aliceVote.entries.find( (entry) => entry.index === aliceVote.ownEntryIndex ); const bobOwn = bobVote.entries.find( (entry) => entry.index === bobVote.ownEntryIndex ); assert.equal(aliceOwn.entry_text, 'Alice bravely opened the door.'); assert.equal(bobOwn.entry_text, 'Bob hid behind the sofa.'); const roundResults = waitForEvent(alice, 'roundResults'); const gameOver = waitForEvent(alice, 'gameOver'); // Cross-vote so no one votes for themselves and the bot cannot win alone. alice.emit('submitVote', { entryIndex: bobVote.ownEntryIndex }); bob.emit('submitVote', { entryIndex: aliceVote.ownEntryIndex }); const round = await roundResults; assert.equal(round.results.length, 3, 'every entry is scored'); assert.ok(round.winner); assert.ok(round.winner.points > 0); assert.equal(round.results.reduce((sum, entry) => sum + entry.votes, 0), 3); const result = await gameOver; assert.ok(result.winner); assert.equal(typeof result.winner.score, 'number'); assert.ok( ['alice-0001', 'bob-0002'].includes(result.winner.player_id), 'a human wins when the humans vote for each other' ); assert.ok(result.winner.score > 0); assert.equal(result.story.length, 2, 'start text plus the winning entry'); assert.equal(result.story[0].player, 'Narrator'); // The story is persisted when the game finishes. const saved = fs.readdirSync(STORIES_DIR).filter((name) => name.endsWith('.txt')); assert.equal(saved.length, 1); const savedText = fs.readFileSync(path.join(STORIES_DIR, saved[0]), 'utf8'); assert.ok(savedText.includes(round.winner.entry_text), 'saved story contains the winning line'); assert.ok(savedText.startsWith('It was a dark and stormy night...')); // And the room is listed for the lobby browser. const state = games[roomName].getState(); assert.equal(state.currentPhase, 'gameOver'); assert.equal(state.inProgress, false); }); test('when the last unsubmitted player leaves, voting starts immediately', async (t) => { await new Promise((resolve) => server.listen(0, resolve)); const { port } = server.address(); const url = `http://127.0.0.1:${port}`; const roomName = 'disconnect-room'; const sockets = []; t.after(async () => { for (const socket of sockets) socket.disconnect(); games[roomName]?.destroy(); delete games[roomName]; await new Promise((resolve) => server.close(resolve)); }); const connect = async (uuid, name) => { const socket = createClient(url, { transports: ['websocket'], forceNew: true, reconnection: false }); sockets.push(socket); const joined = waitForEvent(socket, 'gameState'); socket.on('connect', () => { socket.emit('joinRoom', { roomName, playerUUID: uuid, playerName: name }); }); await joined; return socket; }; const alice = await connect('alice-1001', 'Alice'); const bob = await connect('bob-1002', 'Bob'); const carla = await connect('carla-1003', 'Carla'); alice.emit('startGame', { botCount: 0, timeLimit: 60, roundLimit: 5, scoreLimit: 0 }); await waitForEvent(alice, 'newRound'); alice.emit('submitEntry', { entry: 'Alice wrote this.' }); bob.emit('submitEntry', { entry: 'Bob wrote this.' }); const voting = waitForEvent(alice, 'votingPhase'); carla.disconnect(); const payload = await voting; assert.equal(payload.entries.length, 2, 'empty/absent entries are excluded'); assert.equal(games[roomName].currentPhase, 'voting'); });