import { browser } from "$app/environment"; import type { Team, Round, FinalRound, GameSettings, GamePhase, QuestionResult } from "$lib/types/kuldvillak"; // Game session state that syncs across tabs export interface GameSessionState { // Game data name: string; settings: GameSettings; teams: Team[]; rounds: Round[]; finalRound: FinalRound | null; // Current game state phase: GamePhase; currentRoundIndex: number; activeTeamId: string | null; // Intro animation state introCategoryIndex: number; // Which category is being shown during intro categoriesIntroduced: boolean; // Have all categories been introduced for this round boardRevealed: boolean; // Has the board been revealed (prices faded in) for this round // Question state currentQuestion: { roundIndex: number; categoryIndex: number; questionIndex: number; } | null; showAnswer: boolean; wrongTeamIds: string[]; // Teams that answered wrong for current question lastAnsweredTeamId: string | null; // Track who answered last lastAnswerCorrect: boolean | null; // Was it correct or wrong // Daily Double dailyDoubleWager: number | null; // Final Round finalCategoryRevealed: boolean; // Has the final category been revealed finalWagers: Record; finalAnswers: Record; finalRevealed: string[]; // Team IDs that have been revealed // Timer timerRunning: boolean; timerSeconds: number; timerMax: number; // Question tracking questionsAnswered: number; // How many questions have been answered currentQuestionNumber: number; // Which question number is this (1-30) questionResults: QuestionResult[]; // Results of answered questions } const CHANNEL_NAME = "kuldvillak-game-session"; const STORAGE_KEY = "kuldvillak-game-session"; class GameSessionStore { private channel: BroadcastChannel | null = null; private timerInterval: ReturnType | null = null; private isTimerOwner = false; // Only one tab should own the timer state = $state(null); constructor() { if (browser) { // Setup broadcast channel for cross-tab sync this.channel = new BroadcastChannel(CHANNEL_NAME); this.channel.onmessage = (event) => { if (event.data.type === "STATE_UPDATE") { this.state = event.data.state; } else if (event.data.type === "REQUEST_STATE") { // Another tab is requesting the current state if (this.state) { this.broadcast("STATE_UPDATE", this.state); } } else if (event.data.type === "TIMER_OWNER_CHECK") { // Another tab is checking who owns the timer if (this.isTimerOwner) { this.channel?.postMessage({ type: "TIMER_OWNER_EXISTS" }); } } else if (event.data.type === "TIMER_OWNER_EXISTS") { // Another tab owns the timer, don't start ours this.isTimerOwner = false; } }; // Try to load from localStorage const saved = localStorage.getItem(STORAGE_KEY); if (saved) { try { this.state = JSON.parse(saved); // Timer will be started by moderator view via enableTimerControl() } catch { // Invalid data } } // Request state from other tabs this.channel.postMessage({ type: "REQUEST_STATE" }); } } private broadcast(type: string, state: GameSessionState) { if (this.channel) { // Use $state.snapshot to get plain object from Proxy const plainState = $state.snapshot(state); this.channel.postMessage({ type, state: plainState }); } } private persist() { if (browser && this.state) { // Use $state.snapshot to get plain object from Proxy const plainState = $state.snapshot(this.state); localStorage.setItem(STORAGE_KEY, JSON.stringify(plainState)); this.broadcast("STATE_UPDATE", this.state); } } // Initialize a new game session startGame(data: { name: string; settings: GameSettings; teams: Team[]; rounds: Round[]; finalRound: FinalRound | null; }) { // Deep clone the data to remove any Proxy objects const plainData = JSON.parse(JSON.stringify(data)); this.state = { ...plainData, phase: "intro" as const, currentRoundIndex: 0, activeTeamId: null, introCategoryIndex: -1, categoriesIntroduced: false, boardRevealed: false, currentQuestion: null, showAnswer: false, wrongTeamIds: [], lastAnsweredTeamId: null, lastAnswerCorrect: null, dailyDoubleWager: null, finalCategoryRevealed: false, finalWagers: {}, finalAnswers: {}, finalRevealed: [], timerRunning: false, timerSeconds: 0, timerMax: plainData.settings.defaultTimerSeconds ?? 10, questionsAnswered: 0, currentQuestionNumber: 0, questionResults: [], }; // Timer will be started by moderator view via enableTimerControl() this.persist(); } // ============================================ // Intro Phase Management // ============================================ startCategoryIntro() { if (!this.state) return; this.state.phase = "intro-categories"; this.state.introCategoryIndex = 0; this.persist(); } nextIntroCategory() { if (!this.state) return; const currentRound = this.state.rounds[this.state.currentRoundIndex]; if (!currentRound) return; this.state.introCategoryIndex++; // Check if we've shown all categories - stay on villak screen if (this.state.introCategoryIndex >= currentRound.categories.length) { this.state.phase = "intro"; this.state.introCategoryIndex = -1; this.state.categoriesIntroduced = true; // Mark categories as introduced } this.persist(); } startBoard() { if (!this.state) return; this.state.phase = "board"; this.persist(); } markBoardRevealed() { if (!this.state) return; this.state.boardRevealed = true; this.persist(); } // End the game session endGame() { this.stopInternalTimer(); this.state = null; if (browser) { localStorage.removeItem(STORAGE_KEY); this.broadcast("STATE_UPDATE", null as any); } } // ============================================ // Question Management // ============================================ selectQuestion(roundIndex: number, categoryIndex: number, questionIndex: number) { if (!this.state) return; const question = this.state.rounds[roundIndex]?.categories[categoryIndex]?.questions[questionIndex]; if (!question || question.isRevealed) return; this.state.currentQuestion = { roundIndex, categoryIndex, questionIndex }; this.state.wrongTeamIds = []; this.state.activeTeamId = null; this.state.currentQuestionNumber = this.state.questionsAnswered + 1; if (question.isDailyDouble) { this.state.phase = "daily-double"; this.state.dailyDoubleWager = null; } else { this.state.phase = "question"; } this.state.showAnswer = false; // Reset timer this.state.timerSeconds = this.state.timerMax; this.state.timerRunning = false; this.persist(); } setDailyDoubleWager(teamId: string, wager: number) { if (!this.state) return; this.state.activeTeamId = teamId; this.state.dailyDoubleWager = wager; this.state.phase = "question"; this.persist(); } toggleAnswer() { if (!this.state) return; this.state.showAnswer = !this.state.showAnswer; this.persist(); } revealAnswer() { if (!this.state) return; this.state.showAnswer = true; this.persist(); } // Mark answer correct - awards points, shows answer, closes after delay markCorrect(teamId: string) { if (!this.state || !this.state.currentQuestion) return; const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion; const question = this.state.rounds[roundIndex].categories[categoryIndex].questions[questionIndex]; // Award points const team = this.state.teams.find(t => t.id === teamId); if (team) { const points = this.state.dailyDoubleWager ?? question.points; team.score += points; } // Track last answer this.state.lastAnsweredTeamId = teamId; this.state.lastAnswerCorrect = true; // Show answer and close after configured delay this.state.showAnswer = true; this.persist(); const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000; setTimeout(() => this.finalizeQuestion(), revealMs); } // Mark answer wrong - deducts points, adds to wrong list markWrong(teamId: string) { if (!this.state || !this.state.currentQuestion) return; const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion; const question = this.state.rounds[roundIndex].categories[categoryIndex].questions[questionIndex]; // Deduct points if allowed const team = this.state.teams.find(t => t.id === teamId); if (team && this.state.settings.allowNegativeScores) { const points = this.state.dailyDoubleWager ?? question.points; team.score -= points; } // Track last answer this.state.lastAnsweredTeamId = teamId; this.state.lastAnswerCorrect = false; // Add to wrong list if (!this.state.wrongTeamIds.includes(teamId)) { this.state.wrongTeamIds.push(teamId); } // Clear active team for next selection this.state.activeTeamId = null; // Check if all teams have answered wrong const allTeamsWrong = this.state.teams.every(t => this.state!.wrongTeamIds.includes(t.id)); if (allTeamsWrong) { // Everyone wrong - show answer and close this.state.showAnswer = true; this.persist(); const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000; setTimeout(() => this.finalizeQuestion(), revealMs); } else { this.persist(); } } // Skip question - shows answer, closes after delay skipQuestion() { if (!this.state || !this.state.currentQuestion) return; this.state.showAnswer = true; this.persist(); const revealMs = (this.state.settings.answerRevealSeconds ?? 5) * 1000; setTimeout(() => this.finalizeQuestion(), revealMs); } // Actually close the question and return to board private finalizeQuestion() { if (!this.state || !this.state.currentQuestion) return; const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion; const question = this.state.rounds[roundIndex].categories[categoryIndex].questions[questionIndex]; // Save question result before resetting state const points = this.state.dailyDoubleWager ?? question.points; let pointsChange = 0; if (this.state.lastAnswerCorrect === true) { pointsChange = points; } else if (this.state.lastAnswerCorrect === false) { pointsChange = -points; } this.state.questionResults.push({ categoryIndex, questionIndex, points: question.points, teamId: this.state.lastAnsweredTeamId, pointsChange, isDailyDouble: question.isDailyDouble, wager: this.state.dailyDoubleWager ?? undefined, }); // Mark as revealed question.isRevealed = true; // Increment questions answered counter this.state.questionsAnswered++; // Reset state this.state.currentQuestion = null; this.state.showAnswer = false; this.state.wrongTeamIds = []; this.state.dailyDoubleWager = null; this.state.activeTeamId = null; this.state.phase = "board"; // Check if round is complete this.checkRoundComplete(); this.persist(); } // Legacy method for compatibility closeQuestion(correct: boolean | null, teamId?: string | null) { if (correct === true && teamId) { this.markCorrect(teamId); } else if (correct === false && teamId) { this.markWrong(teamId); } else { this.skipQuestion(); } } private checkRoundComplete() { if (!this.state) return; const currentRound = this.state.rounds[this.state.currentRoundIndex]; const allRevealed = currentRound.categories.every(cat => cat.questions.every(q => q.isRevealed) ); if (allRevealed) { // Move to next round or final if (this.state.currentRoundIndex < this.state.rounds.length - 1) { this.state.currentRoundIndex++; } else if (this.state.settings.enableFinalRound && this.state.finalRound) { this.state.phase = "final-category"; } else { this.state.phase = "finished"; } } } // ============================================ // Team & Score Management // ============================================ setActiveTeam(teamId: string | null) { if (!this.state) return; this.state.activeTeamId = teamId; this.persist(); } adjustScore(teamId: string, delta: number) { if (!this.state) return; const team = this.state.teams.find(t => t.id === teamId); if (team) { team.score += delta; this.persist(); } } setScore(teamId: string, score: number) { if (!this.state) return; const team = this.state.teams.find(t => t.id === teamId); if (team) { team.score = score; this.persist(); } } // ============================================ // Round Management // ============================================ nextRound() { if (!this.state) return; if (this.state.currentRoundIndex < this.state.rounds.length - 1) { this.state.currentRoundIndex++; this.state.phase = "intro"; this.state.introCategoryIndex = -1; this.state.categoriesIntroduced = false; // Reset for new round this.state.boardRevealed = false; // Reset for new round this.state.questionResults = []; this.persist(); } } goToFinalRound() { if (!this.state || !this.state.finalRound) return; this.state.phase = "final-intro"; this.persist(); } startFinalCategoryReveal() { if (!this.state) return; this.state.phase = "final-category"; this.persist(); } finishFinalCategoryReveal() { if (!this.state) return; // Go back to Kuldvillak screen, waiting for moderator to start question this.state.phase = "final-intro"; this.state.finalCategoryRevealed = true; this.persist(); } // ============================================ // Final Round // ============================================ setFinalWager(teamId: string, wager: number) { if (!this.state) return; this.state.finalWagers[teamId] = wager; this.persist(); } showFinalQuestion() { if (!this.state) return; this.state.phase = "final-question"; this.state.timerMax = 30; // Set 30 second timer for final round this.state.timerSeconds = 30; this.persist(); } showFinalScores() { if (!this.state) return; this.state.phase = "final-scores"; this.persist(); } setFinalAnswer(teamId: string, answer: string) { if (!this.state) return; this.state.finalAnswers[teamId] = answer; this.persist(); } revealFinalAnswer(teamId: string, correct: boolean) { if (!this.state) return; const team = this.state.teams.find(t => t.id === teamId); const wager = this.state.finalWagers[teamId] ?? 0; if (team) { if (correct) { team.score += wager; } else { team.score -= wager; } } this.state.finalRevealed.push(teamId); // Check if all revealed if (this.state.finalRevealed.length === this.state.teams.length) { this.state.phase = "finished"; } this.persist(); } // ============================================ // Timer // ============================================ private startInternalTimer() { // Only start if not already running if (this.timerInterval) return; this.timerInterval = setInterval(() => { if (this.state?.timerRunning) { if (this.state.timerSeconds > 0) { this.state.timerSeconds--; this.persist(); } else { this.state.timerRunning = false; this.persist(); } } }, 1000); } private stopInternalTimer() { if (this.timerInterval) { clearInterval(this.timerInterval); this.timerInterval = null; } } setTimerMax(seconds: number) { if (!this.state) return; this.state.timerMax = seconds; this.persist(); } // Call this from moderator view only enableTimerControl() { this.startInternalTimer(); } startTimer() { if (!this.state) return; this.state.timerRunning = true; this.state.timerSeconds = this.state.timerMax; this.persist(); } stopTimer() { if (!this.state) return; this.state.timerRunning = false; this.persist(); } // Called externally - no longer needed but kept for compatibility tickTimer() { // Timer now runs internally, this is a no-op } resetTimer() { if (!this.state) return; this.state.timerSeconds = this.state.timerMax; this.state.timerRunning = false; this.persist(); } // ============================================ // Helpers // ============================================ get currentRound(): Round | null { if (!this.state) return null; return this.state.rounds[this.state.currentRoundIndex] ?? null; } get currentQuestionData() { if (!this.state?.currentQuestion) return null; const { roundIndex, categoryIndex, questionIndex } = this.state.currentQuestion; const category = this.state.rounds[roundIndex]?.categories[categoryIndex]; const question = category?.questions[questionIndex]; return question ? { category, question } : null; } get sortedTeams(): Team[] { if (!this.state) return []; return [...this.state.teams].sort((a, b) => b.score - a.score); } getQuestionResult(categoryIndex: number, questionIndex: number): QuestionResult | null { if (!this.state) return null; return this.state.questionResults.find( r => r.categoryIndex === categoryIndex && r.questionIndex === questionIndex ) ?? null; } } export const gameSession = new GameSessionStore();