Kuldvillak MVP ei forki, Randel, fork you Randel Mandre SASS license peal
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

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();