You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
623 lines
20 KiB
623 lines
20 KiB
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<string, number>; |
|
finalAnswers: Record<string, string>; |
|
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<typeof setInterval> | null = null; |
|
private isTimerOwner = false; // Only one tab should own the timer |
|
state = $state<GameSessionState | null>(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();
|
|
|