Batch of LLM-assisted fixes/feature updates

This commit is contained in:
Cam Spry committed 2026-10-30 23:02:45 -04:00
1 parent 7c6453160a
commit 05aaf394df
16 files changed
+5597 -912

No files matched your search

+466
View File
@@ -0,0 +1,466 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM } = require('jsdom');
const GAME_HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'game.html'), 'utf8');
const MENU_HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'menu.html'), 'utf8');
const flush = async (rounds = 4) => {
for (let i = 0; i < rounds; i += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
};
function createFakeSocket() {
return {
handlers: new Map(),
emitted: [],
on(event, handler) {
const list = this.handlers.get(event) || [];
list.push(handler);
this.handlers.set(event, list);
return this;
},
off(event, handler) {
this.handlers.set(
event,
(this.handlers.get(event) || []).filter((fn) => fn !== handler)
);
return this;
},
emit(event, payload) {
this.emitted.push({ event, payload });
},
trigger(event, payload) {
for (const handler of this.handlers.get(event) || []) handler(payload);
},
lastEmitted(event) {
return [...this.emitted].reverse().find((entry) => entry.event === event);
}
};
}
function makeState(playerId, overrides = {}) {
return {
roomName: 'testroom',
inProgress: true,
gameHost: playerId,
gameSettings: {
scoreLimit: 0,
roundLimit: 15,
timeLimit: 60,
botCount: 0,
startText: 'It was a dark and stormy night...'
},
players: [
{
player_id: playerId,
name: 'Alice',
score: 0,
isBot: false,
hasSubmitted: false,
hasVoted: false
},
{
player_id: 'bob-2',
name: 'Bob',
score: 0,
isBot: false,
hasSubmitted: false,
hasVoted: false
}
],
yarnStory: [{ player: 'Narrator', str: 'It was a dark and stormy night...' }],
currentPhase: 'writing',
currentRound: 1,
roundLimit: 15,
scoreLimit: 0,
timeRemaining: 42,
round_data: [],
lastResults: null,
gameWinner: null,
ownEntryIndex: -1,
...overrides
};
}
function loadGame() {
const socket = createFakeSocket();
const dom = new JSDOM(GAME_HTML, {
url: 'http://localhost/game/testroom',
runScripts: 'dangerously',
beforeParse(window) {
window.io = () => socket;
window.alert = () => {};
window.confirm = () => true;
window.document.cookie = 'yarn_player_name=Alice';
}
});
socket.trigger('connect');
const join = socket.lastEmitted('joinRoom').payload;
const playerId = join.playerUUID;
const send = (state) => {
const { ownEntryIndex, ...rest } = state;
socket.trigger('gameState', { ...rest, ownEntryIndex });
};
return { dom, window: dom.window, document: dom.window.document, socket, playerId, send };
}
// ---------------------------------------------------------------------------
// Lobby & settings
// ---------------------------------------------------------------------------
test('scoreLimit 0 is sent as 0 instead of silently becoming 100', async (t) => {
const { dom, document, socket, playerId, send } = loadGame();
t.after(() => dom.window.close());
send(makeState(playerId, { inProgress: false, currentPhase: 'lobby', round_data: [] }));
const scoreInput = document.getElementById('score-limit');
assert.equal(scoreInput.value, '0');
document.getElementById('start-game-btn').click();
const start = socket.lastEmitted('startGame').payload;
assert.equal(start.scoreLimit, 0, 'zero score limit must survive');
assert.equal(start.roundLimit, 15);
assert.equal(start.timeLimit, 60);
assert.equal(start.botCount, 0);
assert.equal(start.startText, 'It was a dark and stormy night...');
});
test('non-host lobby sees a read-only settings summary and no start button', (t) => {
const { dom, document, playerId, send } = loadGame();
t.after(() => dom.window.close());
send(
makeState(playerId, {
inProgress: false,
currentPhase: 'lobby',
gameHost: 'someone-else',
gameSettings: {
scoreLimit: 20,
roundLimit: 10,
timeLimit: 45,
botCount: 2,
startText: 'Start here.'
}
})
);
assert.equal(document.getElementById('start-game-btn'), null);
const summary = document.querySelector('.settings-summary').textContent;
assert.match(summary, /Rounds: 10/);
assert.match(summary, /Score limit: 20/);
assert.match(summary, /Time: 45s/);
assert.match(summary, /Bots: 2/);
});
// ---------------------------------------------------------------------------
// Writing phase & input preservation
// ---------------------------------------------------------------------------
test('in-progress typing survives gameState and playersUpdate broadcasts', (t) => {
const { dom, document, playerId, send, socket } = loadGame();
t.after(() => dom.window.close());
const state = makeState(playerId);
send(state);
const input = document.getElementById('entry-input');
assert.ok(input, 'writing input is rendered');
input.value = 'Half a sentence, still typing';
// Someone else submits (playersUpdate) and a player joins (gameState).
const bobSubmitted = {
...state,
players: state.players.map((player) =>
player.player_id === 'bob-2' ? { ...player, hasSubmitted: true } : player
)
};
socket.trigger('playersUpdate', bobSubmitted.players);
send(bobSubmitted);
socket.trigger('timerUpdate', 41);
const sameInput = document.getElementById('entry-input');
assert.equal(sameInput, input, 'the input node is not rebuilt');
assert.equal(sameInput.value, 'Half a sentence, still typing');
assert.equal(document.getElementById('timer').textContent, '41');
});
test('let someone join mid-typing without losing text', (t) => {
const { dom, document, playerId, send, socket } = loadGame();
t.after(() => dom.window.close());
send(makeState(playerId));
const input = document.getElementById('entry-input');
input.value = 'Draft';
// Simulate a new player joining: server broadcasts a full gameState.
socket.trigger('gameState', makeState(playerId, {
players: [
...makeState(playerId).players,
{ player_id: 'carol-3', name: 'Carol', score: 0, isBot: false, hasSubmitted: false, hasVoted: false }
]
}));
assert.equal(document.getElementById('entry-input').value, 'Draft');
});
test('refresh restores the submitted state from the server', (t) => {
const { dom, document, playerId, send } = loadGame();
t.after(() => dom.window.close());
const state = makeState(playerId, {
players: [
{ player_id: playerId, name: 'Alice', score: 0, isBot: false, hasSubmitted: true, hasVoted: false },
{ player_id: 'bob-2', name: 'Bob', score: 0, isBot: false, hasSubmitted: false, hasVoted: false }
]
});
send(state);
assert.equal(document.getElementById('entry-input'), null, 'no input after refresh');
assert.match(document.getElementById('game-bar').textContent, /Entry submitted/);
});
test('the header shows round progress and the score target', (t) => {
const { dom, document, playerId, send } = loadGame();
t.after(() => dom.window.close());
send(makeState(playerId, { currentRound: 3, roundLimit: 15, scoreLimit: 25 }));
const meta = document.getElementById('room-meta').textContent;
assert.match(meta, /Round 3\/15/);
assert.match(meta, /Target: 25 pts/);
});
// ---------------------------------------------------------------------------
// Voting
// ---------------------------------------------------------------------------
test('your own entry cannot be voted for, and votes are confirmed by the server', (t) => {
const { dom, document, playerId, send, socket } = loadGame();
t.after(() => dom.window.close());
send(
makeState(playerId, {
currentPhase: 'voting',
round_data: [
{ index: 0, entry_text: 'Mine.', voteCount: 0 },
{ index: 1, entry_text: 'Theirs.', voteCount: 0 }
],
timeRemaining: 20,
ownEntryIndex: 0
})
);
const buttons = document.querySelectorAll('.vote-btn');
assert.equal(buttons.length, 2);
assert.equal(buttons[0].disabled, true);
assert.equal(buttons[0].textContent.trim(), 'Yours');
assert.equal(buttons[1].disabled, false);
buttons[1].click();
assert.equal(socket.lastEmitted('submitVote').payload.entryIndex, 1);
// The server confirms with playersUpdate; the bar switches to "Vote cast".
socket.trigger('playersUpdate', [
{ player_id: playerId, name: 'Alice', score: 0, isBot: false, hasSubmitted: true, hasVoted: true },
{ player_id: 'bob-2', name: 'Bob', score: 0, isBot: false, hasSubmitted: true, hasVoted: false }
]);
assert.match(document.getElementById('game-bar').textContent, /Vote cast/);
});
test('refresh during voting restores the voted state', (t) => {
const { dom, document, playerId, send } = loadGame();
t.after(() => dom.window.close());
send(
makeState(playerId, {
currentPhase: 'voting',
round_data: [{ index: 0, entry_text: 'Mine.', voteCount: 0 }],
timeRemaining: 20,
ownEntryIndex: 0,
players: [
{ player_id: playerId, name: 'Alice', score: 0, isBot: false, hasSubmitted: true, hasVoted: true },
{ player_id: 'bob-2', name: 'Bob', score: 0, isBot: false, hasSubmitted: true, hasVoted: true }
]
})
);
assert.equal(document.querySelectorAll('.vote-btn').length, 0);
assert.match(document.getElementById('game-bar').textContent, /Vote cast/);
});
// ---------------------------------------------------------------------------
// Results & game over
// ---------------------------------------------------------------------------
test('round results list every entry with author, votes and points', (t) => {
const { dom, document, playerId, send, socket } = loadGame();
t.after(() => dom.window.close());
send(makeState(playerId, { currentPhase: 'voting', round_data: [] }));
const winner = {
index: 1,
entry_text: 'The winning line.',
player_id: 'bob-2',
player_name: 'Bob',
votes: 2,
points: 5,
bonus: 3,
wasTie: false
};
socket.trigger('roundResults', {
winner,
reason: 'winner',
round: 1,
players: makeState(playerId).players,
story: [
{ player: 'Narrator', str: 'It was a dark and stormy night...' },
{ player: 'Bob', str: 'The winning line.' }
],
results: [
{ index: 0, entry_text: 'A losing line.', player_id: playerId, player_name: 'Alice', votes: 0, points: 0, bonus: 0 },
winner
]
});
const rows = document.querySelectorAll('#results-table tbody tr');
assert.equal(rows.length, 2);
assert.match(rows[0].textContent, /A losing line\./);
assert.match(rows[0].textContent, /Alice/);
assert.match(rows[1].textContent, /The winning line\./);
assert.match(rows[1].textContent, /Bob/);
assert.match(rows[1].textContent, /\+5/);
assert.ok(rows[1].classList.contains('winner'));
});
test('game over shows the final story with attribution and export controls', (t) => {
const { dom, document, playerId, send, socket } = loadGame();
t.after(() => dom.window.close());
send(makeState(playerId, { currentPhase: 'voting', round_data: [] }));
socket.trigger('gameOver', {
winner: { player_id: playerId, name: 'Alice', score: 12 },
players: makeState(playerId).players,
story: [
{ player: 'Narrator', str: 'It was a dark and stormy night...' },
{ player: 'Alice', str: 'A heroic ending.' }
]
});
assert.match(document.getElementById('main-text').textContent, /Alice is victorious with 12 points/);
const segment = [...document.querySelectorAll('.story-segment')].at(-1);
assert.equal(segment.dataset.player, 'Alice');
assert.match(segment.textContent, /A heroic ending\./);
assert.ok(document.getElementById('download-story-btn'));
assert.ok(document.getElementById('copy-story-btn'));
assert.ok(document.getElementById('play-again-btn'));
document.getElementById('play-again-btn').click();
assert.ok(socket.lastEmitted('playAgain'));
});
test('host controls: skip and kick are wired to the server', (t) => {
const { dom, document, playerId, send, socket } = loadGame();
t.after(() => dom.window.close());
send(makeState(playerId));
document.getElementById('skip-btn').click();
assert.ok(socket.lastEmitted('skipPhase'));
document.querySelector('[data-kick="bob-2"]').click();
assert.equal(socket.lastEmitted('kickPlayer').payload.playerId, 'bob-2');
});
test('being kicked shows a notice to the player', (t) => {
const { dom, window, socket } = loadGame();
t.after(() => dom.window.close());
socket.trigger('kicked', { message: 'Removed by the host.' });
assert.match(window.document.getElementById('toast').textContent, /Removed by the host/);
});
// ---------------------------------------------------------------------------
// Menu
// ---------------------------------------------------------------------------
function loadMenu(fetchImpl) {
const dom = new JSDOM(MENU_HTML, {
url: 'http://localhost/',
runScripts: 'dangerously',
beforeParse(window) {
window.fetch = fetchImpl(window);
window.alert = () => {};
}
});
return dom;
}
test('menu renders rooms and the past-stories archive', async (t) => {
const requests = [];
const dom = loadMenu((window) => async (url) => {
requests.push(url);
if (url === '/api/rooms') {
return {
ok: true,
json: async () => ({
rooms: [
{ name: 'lobby', playerCount: 2, inProgress: false, phase: 'lobby' },
{ name: 'storytime', playerCount: 3, inProgress: true, phase: 'voting' }
]
})
};
}
if (url === '/api/stories') {
return {
ok: true,
json: async () => ({ stories: ['storytime - 2026-10-30 12-00-00.txt'] })
};
}
if (url.startsWith('/api/stories/')) {
return { ok: true, text: async () => 'Once upon a time.' };
}
void window;
return { ok: false };
});
t.after(() => dom.window.close());
const { document } = dom.window;
document.getElementById('find-btn').click();
await flush();
const rooms = document.querySelectorAll('.room-row');
assert.equal(rooms.length, 2);
assert.match(rooms[1].textContent, /storytime/);
assert.match(rooms[1].textContent, /voting/);
assert.match(rooms[1].textContent, /3 players/);
document.getElementById('stories-btn').click();
await flush();
const stories = document.querySelectorAll('.story-row');
assert.equal(stories.length, 1);
assert.match(stories[0].textContent, /storytime - 2026-10-30/);
stories[0].click();
await flush();
assert.equal(document.getElementById('story-viewer').textContent, 'Once upon a time.');
assert.ok(requests.includes('/api/rooms'));
assert.ok(requests.includes('/api/stories'));
// Stop the room-polling interval so the test process can exit.
document.getElementById('find-btn').click();
});