Triviaverse / src /lib /game /engine.ts
Simeon Garratt
fix: leaderboard tracking, score bugs, mobile admin, OG image
c05d6e8
Raw
History Blame Contribute Delete
4.28 kB
import type { GameMode, GamePhase, ShuffledQuestion } from "./types";
export type ChallengePhase = "easy" | "medium" | "hard";
const CHALLENGE_PROGRESSION: ChallengePhase[] = ["easy", "medium", "hard"];
/**
* The complete state of an in-progress game session.
*/
export interface GameState {
mode: GameMode;
phase: GamePhase;
questions: ShuffledQuestion[];
currentQuestionIndex: number;
score: number;
streak: number;
streakMax: number;
questionsCorrect: number;
startedAt: number | null;
finishedAt: number | null;
challengePhase: ChallengePhase;
lastAnswerCorrect: boolean | null;
}
/**
* All actions that can be dispatched to the game reducer.
*/
export type GameAction =
| { type: "START" }
| { type: "ANSWER_CORRECT"; points: number }
| { type: "ANSWER_WRONG" }
| { type: "TIME_UP" }
| { type: "NEXT_PHASE" }
| { type: "ADD_QUESTIONS"; questions: ShuffledQuestion[] }
| { type: "SET_QUESTIONS"; questions: ShuffledQuestion[] };
/**
* Creates the initial idle GameState for the given mode and question list.
*/
export function createInitialState(mode: GameMode, questions: ShuffledQuestion[]): GameState {
return {
mode, phase: "idle", questions, currentQuestionIndex: 0,
score: 0, streak: 0, streakMax: 0, questionsCorrect: 0,
startedAt: null, finishedAt: null, challengePhase: "easy", lastAnswerCorrect: null,
};
}
/**
* Pure reducer for the game state machine. Returns a new GameState given the
* current state and an action — never mutates the input state.
*/
export function gameReducer(state: GameState, action: GameAction): GameState {
switch (action.type) {
case "START":
return { ...state, phase: "playing", startedAt: Date.now() };
case "ANSWER_CORRECT": {
const newStreak = state.streak + 1;
const newIndex = state.currentQuestionIndex + 1;
const isFinished = newIndex >= state.questions.length;
// For challenge-me, only truly finish on the last phase (hard) or if they hit 30 questions
const isChallengeComplete = state.mode === "challenge-me" && (state.challengePhase === "hard" || newIndex >= 30);
const shouldFinish = isFinished && !(state.mode === "challenge-me" && !isChallengeComplete);
return {
...state,
score: state.score + action.points,
streak: newStreak,
streakMax: Math.max(state.streakMax, newStreak),
questionsCorrect: state.questionsCorrect + 1,
currentQuestionIndex: newIndex,
lastAnswerCorrect: true,
phase: shouldFinish ? "finished" : state.phase,
finishedAt: shouldFinish ? Date.now() : null,
};
}
case "ANSWER_WRONG": {
if (state.mode === "streak") {
return {
...state,
streak: 0,
currentQuestionIndex: state.currentQuestionIndex + 1,
lastAnswerCorrect: false,
phase: "finished",
finishedAt: Date.now(),
};
}
const newIndex = state.currentQuestionIndex + 1;
const isFinished = newIndex >= state.questions.length;
// For challenge-me, only truly finish on the last phase (hard) or if they hit 30 questions
const isChallengeComplete = state.mode === "challenge-me" && (state.challengePhase === "hard" || newIndex >= 30);
const shouldFinish = isFinished && !(state.mode === "challenge-me" && !isChallengeComplete);
return {
...state,
streak: 0,
currentQuestionIndex: newIndex,
lastAnswerCorrect: false,
phase: shouldFinish ? "finished" : state.phase,
finishedAt: shouldFinish ? Date.now() : null,
};
}
case "TIME_UP":
return { ...state, phase: "finished", finishedAt: Date.now() };
case "NEXT_PHASE": {
const idx = CHALLENGE_PROGRESSION.indexOf(state.challengePhase);
if (idx >= CHALLENGE_PROGRESSION.length - 1) {
return { ...state, phase: "finished", finishedAt: Date.now() };
}
return { ...state, challengePhase: CHALLENGE_PROGRESSION[idx + 1]! };
}
case "ADD_QUESTIONS":
return { ...state, questions: [...state.questions, ...action.questions] };
case "SET_QUESTIONS":
return createInitialState(state.mode, action.questions);
default:
return state;
}
}