Fix stuff

This commit is contained in:
Joey Yakimowich-Payne 2026-01-14 09:07:20 -07:00
commit 32696ad33d
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
13 changed files with 2194 additions and 110 deletions

View file

@ -0,0 +1,521 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* Test suite for game reconnection scenarios
*
* These tests verify the reconnection logic works correctly for:
* 1. Player reconnection with score/answer preservation
* 2. Host reconnection with game state preservation
* 3. State synchronization between host and reconnecting players
*/
describe('Game Reconnection Logic', () => {
describe('Player Matching on Reconnection', () => {
const createMockPlayer = (overrides = {}) => ({
id: 'player-123',
name: 'TestPlayer',
score: 500,
previousScore: 0,
streak: 2,
lastAnswerCorrect: true,
selectedShape: 'diamond' as const,
pointsBreakdown: { basePoints: 450, streakBonus: 50, comebackBonus: 0, firstCorrectBonus: 0, penalty: 0, total: 500 },
isBot: false,
avatarSeed: 0.5,
color: '#ff0000',
...overrides
});
it('should find existing player by previousId', () => {
const players = [createMockPlayer({ id: 'old-peer-id' })];
const payload = { name: 'TestPlayer', reconnect: true, previousId: 'old-peer-id' };
const existingByPreviousId = payload.previousId
? players.find(p => p.id === payload.previousId)
: null;
expect(existingByPreviousId).toBeDefined();
expect(existingByPreviousId?.score).toBe(500);
expect(existingByPreviousId?.lastAnswerCorrect).toBe(true);
});
it('should find existing player by name when previousId not found', () => {
const players = [createMockPlayer({ id: 'different-id' })];
const payload = { name: 'TestPlayer', reconnect: true, previousId: 'non-existent-id' };
const existingByPreviousId = payload.previousId
? players.find(p => p.id === payload.previousId)
: null;
const existingByName = players.find(p => p.name === payload.name && p.id !== 'host');
const existingPlayer = existingByPreviousId || existingByName;
expect(existingByPreviousId).toBeUndefined();
expect(existingByName).toBeDefined();
expect(existingPlayer?.score).toBe(500);
});
it('should preserve player score when reconnecting', () => {
const players = [createMockPlayer({ score: 1500, streak: 3 })];
const payload = { name: 'TestPlayer', reconnect: true, previousId: 'player-123' };
const existingPlayer = players.find(p => p.id === payload.previousId);
const reconnectedPlayer = existingPlayer
? { ...existingPlayer, id: 'new-peer-id' }
: null;
expect(reconnectedPlayer?.score).toBe(1500);
expect(reconnectedPlayer?.streak).toBe(3);
expect(reconnectedPlayer?.id).toBe('new-peer-id');
});
it('should preserve lastAnswerCorrect when reconnecting', () => {
const players = [createMockPlayer({ lastAnswerCorrect: true, pointsBreakdown: { total: 500 } as any })];
const payload = { name: 'TestPlayer', reconnect: true, previousId: 'player-123' };
const existingPlayer = players.find(p => p.id === payload.previousId);
expect(existingPlayer?.lastAnswerCorrect).toBe(true);
expect(existingPlayer?.pointsBreakdown?.total).toBe(500);
});
it('should NOT create duplicate player when reconnecting by name', () => {
const players = [createMockPlayer({ id: 'old-id', name: 'TestPlayer' })];
const payload = { name: 'TestPlayer', reconnect: true, previousId: 'wrong-id' };
const existingByName = players.find(p => p.name === payload.name && p.id !== 'host');
// Should find existing player, not create new one
expect(existingByName).toBeDefined();
expect(players.length).toBe(1);
});
it('should update player ID when reconnecting', () => {
const oldId = 'old-peer-id';
const newId = 'new-peer-id';
const players = [createMockPlayer({ id: oldId })];
const existingPlayer = players.find(p => p.id === oldId);
const updatedPlayers = players.map(p =>
p.id === oldId ? { ...p, id: newId } : p
);
expect(updatedPlayers[0].id).toBe(newId);
expect(updatedPlayers[0].score).toBe(existingPlayer?.score);
});
});
describe('WELCOME Message Construction', () => {
it('should include hasAnswered when player already answered', () => {
const reconnectedPlayer = {
lastAnswerCorrect: true,
pointsBreakdown: { total: 500 }
};
const welcomePayload: any = {};
if (reconnectedPlayer) {
welcomePayload.hasAnswered = reconnectedPlayer.lastAnswerCorrect !== null;
welcomePayload.lastAnswerCorrect = reconnectedPlayer.lastAnswerCorrect;
if (reconnectedPlayer.pointsBreakdown) {
welcomePayload.lastPointsEarned = reconnectedPlayer.pointsBreakdown.total;
}
}
expect(welcomePayload.hasAnswered).toBe(true);
expect(welcomePayload.lastAnswerCorrect).toBe(true);
expect(welcomePayload.lastPointsEarned).toBe(500);
});
it('should include hasAnswered=false when player has not answered', () => {
const reconnectedPlayer = {
lastAnswerCorrect: null,
pointsBreakdown: null
};
const welcomePayload: any = {};
if (reconnectedPlayer) {
welcomePayload.hasAnswered = reconnectedPlayer.lastAnswerCorrect !== null;
}
expect(welcomePayload.hasAnswered).toBe(false);
});
it('should include question data for mid-game reconnection', () => {
const currentQuestion = {
text: 'What is 2+2?',
options: [
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
]
};
const welcomePayload: any = {
gameState: 'QUESTION',
currentQuestionIndex: 1,
totalQuestions: 5,
timeLeft: 15000,
};
if (currentQuestion) {
welcomePayload.questionText = currentQuestion.text;
const correctOpt = currentQuestion.options.find(o => o.isCorrect);
welcomePayload.correctShape = correctOpt?.shape;
// Mask correct answer from client
welcomePayload.options = currentQuestion.options.map(o => ({
...o,
isCorrect: false
}));
}
expect(welcomePayload.questionText).toBe('What is 2+2?');
expect(welcomePayload.correctShape).toBe('diamond');
expect(welcomePayload.options.every((o: any) => o.isCorrect === false)).toBe(true);
});
it('should include player scores and streak', () => {
const reconnectedPlayer = {
score: 1500,
streak: 3,
lastAnswerCorrect: true,
pointsBreakdown: { total: 500 }
};
const welcomePayload: any = {};
if (reconnectedPlayer) {
welcomePayload.score = reconnectedPlayer.score;
welcomePayload.streak = reconnectedPlayer.streak;
}
expect(welcomePayload.score).toBe(1500);
expect(welcomePayload.streak).toBe(3);
});
});
describe('Client State Sync on WELCOME', () => {
it('should set QUESTION state when host is in QUESTION and player answered', () => {
const payload = {
gameState: 'QUESTION' as const,
hasAnswered: true,
};
let resultState: string;
if (payload.gameState === 'QUESTION' || payload.gameState === 'COUNTDOWN') {
resultState = payload.gameState;
} else {
resultState = 'LOBBY';
}
expect(resultState).toBe('QUESTION');
});
it('should set REVEAL state when host is in REVEAL', () => {
const payload = {
gameState: 'REVEAL' as const,
hasAnswered: true,
};
let resultState: string;
if (payload.gameState === 'REVEAL') {
resultState = 'REVEAL';
} else {
resultState = 'LOBBY';
}
expect(resultState).toBe('REVEAL');
});
it('should set SCOREBOARD state when host is in SCOREBOARD', () => {
const payload = {
gameState: 'SCOREBOARD' as const,
};
let resultState: string;
if (payload.gameState === 'SCOREBOARD') {
resultState = 'SCOREBOARD';
} else {
resultState = 'LOBBY';
}
expect(resultState).toBe('SCOREBOARD');
});
it('should reconstruct quiz from WELCOME payload', () => {
const payload = {
quizTitle: 'Test Quiz',
totalQuestions: 5,
currentQuestionIndex: 2,
questionText: 'What is 2+2?',
options: [
{ text: '4', shape: 'diamond', color: 'blue', isCorrect: false }
]
};
const questions: any[] = [];
for (let i = 0; i < payload.totalQuestions; i++) {
if (i === payload.currentQuestionIndex) {
questions.push({
id: `q-${i}`,
text: payload.questionText,
options: payload.options,
timeLimit: 30
});
} else {
questions.push({ id: `loading-${i}`, text: '', options: [], timeLimit: 0 });
}
}
const quiz = { title: payload.quizTitle, questions };
expect(quiz.questions.length).toBe(5);
expect(quiz.questions[2].text).toBe('What is 2+2?');
expect(quiz.questions[0].text).toBe('');
});
});
describe('Host Reconnection - Game Resume', () => {
it('should set HOST_RECONNECTED state when game was mid-play', () => {
const hostData = {
gameState: 'QUESTION',
currentQuestionIndex: 2,
};
let resultState: string;
if (hostData.gameState === 'LOBBY') {
resultState = 'LOBBY';
} else if (hostData.gameState === 'PODIUM') {
resultState = 'PODIUM';
} else {
resultState = 'HOST_RECONNECTED';
}
expect(resultState).toBe('HOST_RECONNECTED');
});
it('should preserve currentQuestionIndex on host reconnect', () => {
const hostData = {
currentQuestionIndex: 3,
quiz: { questions: [{}, {}, {}, {}, {}] }
};
expect(hostData.currentQuestionIndex).toBe(3);
});
it('should restore host player score on reconnect', () => {
const hostData = {
players: [
{ id: 'host', name: 'Host', score: 1200, streak: 4 }
]
};
const hostPlayer = hostData.players.find(p => p.id === 'host');
expect(hostPlayer?.score).toBe(1200);
expect(hostPlayer?.streak).toBe(4);
});
it('should clear non-host players on host reconnect (they will rejoin)', () => {
const hostData = {
players: [
{ id: 'host', name: 'Host', score: 1200 },
{ id: 'player1', name: 'Player1', score: 800 },
{ id: 'player2', name: 'Player2', score: 600 },
]
};
const hostPlayer = hostData.players.find(p => p.id === 'host');
const restoredPlayers = hostPlayer ? [hostPlayer] : [];
// Only host should be in players array after reconnect
// Other players will rejoin and be matched by name
expect(restoredPlayers.length).toBe(1);
expect(restoredPlayers[0].id).toBe('host');
});
});
describe('Host Answer State on Resume', () => {
it('should preserve hasAnswered=true if host already answered before disconnect', () => {
const hostPlayer = {
id: 'host',
lastAnswerCorrect: true,
pointsBreakdown: { total: 500 }
};
const hostAlreadyAnswered = hostPlayer.lastAnswerCorrect !== null;
expect(hostAlreadyAnswered).toBe(true);
});
it('should allow host to answer if they had not answered before disconnect', () => {
const hostPlayer = {
id: 'host',
lastAnswerCorrect: null,
pointsBreakdown: null
};
const hostAlreadyAnswered = hostPlayer.lastAnswerCorrect !== null;
expect(hostAlreadyAnswered).toBe(false);
});
it('startQuestion with isResume=true should preserve answer state', () => {
const players = [
{ id: 'host', lastAnswerCorrect: true, score: 500 },
{ id: 'player1', lastAnswerCorrect: false, score: 0 },
{ id: 'player2', lastAnswerCorrect: null, score: 300 }, // Didn't answer yet
];
const isResume = true;
// When resuming, players who already answered should keep their state
const updatedPlayers = isResume
? players // Keep all states on resume
: players.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null }));
expect(updatedPlayers[0].lastAnswerCorrect).toBe(true);
expect(updatedPlayers[1].lastAnswerCorrect).toBe(false);
});
it('startQuestion with isResume=false should reset all answer states', () => {
const players = [
{ id: 'host', lastAnswerCorrect: true, score: 500 },
{ id: 'player1', lastAnswerCorrect: false, score: 0 },
];
const isResume = false;
const updatedPlayers = isResume
? players
: players.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null }));
expect(updatedPlayers[0].lastAnswerCorrect).toBe(null);
expect(updatedPlayers[1].lastAnswerCorrect).toBe(null);
});
});
describe('Disconnection State Transitions', () => {
it('should transition to DISCONNECTED state on connection close', () => {
const currentGameState: string = 'QUESTION';
let newState = currentGameState;
// Simulating conn.on('close') handler
if (currentGameState !== 'PODIUM') {
newState = 'DISCONNECTED';
}
expect(newState).toBe('DISCONNECTED');
});
it('should NOT transition to DISCONNECTED if game is at PODIUM', () => {
const currentGameState: string = 'PODIUM';
let newState = currentGameState;
if (currentGameState !== 'PODIUM') {
newState = 'DISCONNECTED';
}
expect(newState).toBe('PODIUM');
});
it('should preserve player name and game PIN when disconnected', () => {
const gameState = {
currentPlayerName: 'TestPlayer',
gamePin: '123456',
};
// On disconnect, these should NOT be cleared - only gameState changes to DISCONNECTED
expect(gameState.currentPlayerName).toBe('TestPlayer');
expect(gameState.gamePin).toBe('123456');
});
});
});
describe('Session Storage Logic', () => {
const SESSION_STORAGE_KEY = 'kaboot_session';
const getStoredSession = (storage: Map<string, string>) => {
const stored = storage.get(SESSION_STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
};
const storeSession = (storage: Map<string, string>, session: any) => {
storage.set(SESSION_STORAGE_KEY, JSON.stringify(session));
};
const clearStoredSession = (storage: Map<string, string>) => {
storage.delete(SESSION_STORAGE_KEY);
};
it('should store session with all required fields for client', () => {
const storage = new Map<string, string>();
const session = {
pin: '123456',
role: 'CLIENT' as const,
playerName: 'TestPlayer',
playerId: 'peer-123'
};
storeSession(storage, session);
const stored = getStoredSession(storage);
expect(stored.pin).toBe('123456');
expect(stored.role).toBe('CLIENT');
expect(stored.playerName).toBe('TestPlayer');
expect(stored.playerId).toBe('peer-123');
});
it('should store session with hostSecret for host', () => {
const storage = new Map<string, string>();
const session = {
pin: '123456',
role: 'HOST' as const,
hostSecret: 'secret-abc123'
};
storeSession(storage, session);
const stored = getStoredSession(storage);
expect(stored.hostSecret).toBe('secret-abc123');
});
it('should update playerId on reconnection', () => {
const storage = new Map<string, string>();
const oldSession = {
pin: '123456',
role: 'CLIENT' as const,
playerName: 'TestPlayer',
playerId: 'old-peer-id'
};
storeSession(storage, oldSession);
// Simulate reconnection - update playerId while preserving other fields
const newSession = { ...oldSession, playerId: 'new-peer-id' };
storeSession(storage, newSession);
const stored = getStoredSession(storage);
expect(stored.playerId).toBe('new-peer-id');
expect(stored.playerName).toBe('TestPlayer'); // Name should be preserved
});
it('should clear session on goHomeFromDisconnected', () => {
const storage = new Map<string, string>();
const session = {
pin: '123456',
role: 'CLIENT' as const,
playerName: 'TestPlayer',
};
storeSession(storage, session);
expect(getStoredSession(storage)).not.toBeNull();
// Simulate goHomeFromDisconnected
clearStoredSession(storage);
expect(getStoredSession(storage)).toBeNull();
});
});