Simeon Garratt commited on
Commit
b5ae9b2
·
1 Parent(s): 4ce4246

feat: game engine state machine with reducer pattern

Browse files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

_._ _..._ .-', _.._(`))
'-. ` ' /-._.-' ',/
) \ '.
/ _ _ | \
| a a / |
\ .-. ;
'-('' ).-' ,' ;
'-; | .'
\ \ /
| 7 .__ _.-\ \
| | | `` | |
/,_| | /,_/ /
/,_/ '----'

Piggy Benissy

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Simeon Garratt | www.simeongarratt.com | @SimeonGarratt
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Files changed (2) hide show
  1. src/lib/game/engine.test.ts +94 -0
  2. src/lib/game/engine.ts +112 -0
src/lib/game/engine.test.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import { gameReducer, createInitialState } from "./engine";
3
+ import type { ShuffledQuestion } from "./types";
4
+
5
+ const mockQuestion = (id: string, difficulty: "easy" | "medium" | "hard" = "medium"): ShuffledQuestion => ({
6
+ id, category: "General", difficulty, questionText: `Question ${id}?`,
7
+ correctAnswer: "Correct", incorrectAnswers: ["Wrong1", "Wrong2", "Wrong3"],
8
+ allAnswers: ["Correct", "Wrong1", "Wrong2", "Wrong3"],
9
+ });
10
+
11
+ describe("createInitialState", () => {
12
+ it("creates idle state for quick-fire", () => {
13
+ const state = createInitialState("quick-fire", [mockQuestion("1")]);
14
+ expect(state.phase).toBe("idle");
15
+ expect(state.mode).toBe("quick-fire");
16
+ expect(state.score).toBe(0);
17
+ expect(state.currentQuestionIndex).toBe(0);
18
+ });
19
+ });
20
+
21
+ describe("gameReducer", () => {
22
+ it("START transitions from idle to playing", () => {
23
+ const state = createInitialState("quick-fire", [mockQuestion("1")]);
24
+ const next = gameReducer(state, { type: "START" });
25
+ expect(next.phase).toBe("playing");
26
+ expect(next.startedAt).not.toBeNull();
27
+ });
28
+
29
+ it("ANSWER_CORRECT increments score and streak", () => {
30
+ let state = createInitialState("quick-fire", [mockQuestion("1"), mockQuestion("2")]);
31
+ state = gameReducer(state, { type: "START" });
32
+ state = gameReducer(state, { type: "ANSWER_CORRECT", points: 200 });
33
+ expect(state.score).toBe(200);
34
+ expect(state.streak).toBe(1);
35
+ expect(state.questionsCorrect).toBe(1);
36
+ expect(state.currentQuestionIndex).toBe(1);
37
+ });
38
+
39
+ it("ANSWER_WRONG resets streak to 0", () => {
40
+ let state = createInitialState("quick-fire", [mockQuestion("1"), mockQuestion("2")]);
41
+ state = gameReducer(state, { type: "START" });
42
+ state = gameReducer(state, { type: "ANSWER_CORRECT", points: 100 });
43
+ state = gameReducer(state, { type: "ANSWER_WRONG" });
44
+ expect(state.streak).toBe(0);
45
+ expect(state.questionsCorrect).toBe(1);
46
+ expect(state.currentQuestionIndex).toBe(2);
47
+ });
48
+
49
+ it("ANSWER_WRONG in streak mode ends the game", () => {
50
+ let state = createInitialState("streak", [mockQuestion("1"), mockQuestion("2")]);
51
+ state = gameReducer(state, { type: "START" });
52
+ state = gameReducer(state, { type: "ANSWER_WRONG" });
53
+ expect(state.phase).toBe("finished");
54
+ });
55
+
56
+ it("answering last question finishes the game", () => {
57
+ let state = createInitialState("quick-fire", [mockQuestion("1")]);
58
+ state = gameReducer(state, { type: "START" });
59
+ state = gameReducer(state, { type: "ANSWER_CORRECT", points: 100 });
60
+ expect(state.phase).toBe("finished");
61
+ });
62
+
63
+ it("TIME_UP finishes the game", () => {
64
+ let state = createInitialState("quick-fire", [mockQuestion("1")]);
65
+ state = gameReducer(state, { type: "START" });
66
+ state = gameReducer(state, { type: "TIME_UP" });
67
+ expect(state.phase).toBe("finished");
68
+ });
69
+
70
+ it("tracks streakMax across the game", () => {
71
+ let state = createInitialState("quick-fire", [
72
+ mockQuestion("1"), mockQuestion("2"), mockQuestion("3"), mockQuestion("4"),
73
+ ]);
74
+ state = gameReducer(state, { type: "START" });
75
+ state = gameReducer(state, { type: "ANSWER_CORRECT", points: 100 });
76
+ state = gameReducer(state, { type: "ANSWER_CORRECT", points: 100 });
77
+ state = gameReducer(state, { type: "ANSWER_WRONG" });
78
+ state = gameReducer(state, { type: "ANSWER_CORRECT", points: 100 });
79
+ expect(state.streakMax).toBe(2);
80
+ expect(state.streak).toBe(1);
81
+ });
82
+
83
+ it("NEXT_PHASE transitions challenge-me phases", () => {
84
+ let state = createInitialState("challenge-me", [mockQuestion("1")]);
85
+ state = gameReducer(state, { type: "START" });
86
+ expect(state.challengePhase).toBe("easy");
87
+ state = gameReducer(state, { type: "NEXT_PHASE" });
88
+ expect(state.challengePhase).toBe("medium");
89
+ state = gameReducer(state, { type: "NEXT_PHASE" });
90
+ expect(state.challengePhase).toBe("hard");
91
+ state = gameReducer(state, { type: "NEXT_PHASE" });
92
+ expect(state.phase).toBe("finished");
93
+ });
94
+ });
src/lib/game/engine.ts ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { GameMode, GamePhase, ShuffledQuestion } from "./types";
2
+
3
+ export type ChallengePhase = "easy" | "medium" | "hard";
4
+ const CHALLENGE_PROGRESSION: ChallengePhase[] = ["easy", "medium", "hard"];
5
+
6
+ /**
7
+ * The complete state of an in-progress game session.
8
+ */
9
+ export interface GameState {
10
+ mode: GameMode;
11
+ phase: GamePhase;
12
+ questions: ShuffledQuestion[];
13
+ currentQuestionIndex: number;
14
+ score: number;
15
+ streak: number;
16
+ streakMax: number;
17
+ questionsCorrect: number;
18
+ startedAt: number | null;
19
+ finishedAt: number | null;
20
+ challengePhase: ChallengePhase;
21
+ lastAnswerCorrect: boolean | null;
22
+ }
23
+
24
+ /**
25
+ * All actions that can be dispatched to the game reducer.
26
+ */
27
+ export type GameAction =
28
+ | { type: "START" }
29
+ | { type: "ANSWER_CORRECT"; points: number }
30
+ | { type: "ANSWER_WRONG" }
31
+ | { type: "TIME_UP" }
32
+ | { type: "NEXT_PHASE" }
33
+ | { type: "ADD_QUESTIONS"; questions: ShuffledQuestion[] };
34
+
35
+ /**
36
+ * Creates the initial idle GameState for the given mode and question list.
37
+ */
38
+ export function createInitialState(mode: GameMode, questions: ShuffledQuestion[]): GameState {
39
+ return {
40
+ mode, phase: "idle", questions, currentQuestionIndex: 0,
41
+ score: 0, streak: 0, streakMax: 0, questionsCorrect: 0,
42
+ startedAt: null, finishedAt: null, challengePhase: "easy", lastAnswerCorrect: null,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Pure reducer for the game state machine. Returns a new GameState given the
48
+ * current state and an action — never mutates the input state.
49
+ */
50
+ export function gameReducer(state: GameState, action: GameAction): GameState {
51
+ switch (action.type) {
52
+ case "START":
53
+ return { ...state, phase: "playing", startedAt: Date.now() };
54
+
55
+ case "ANSWER_CORRECT": {
56
+ const newStreak = state.streak + 1;
57
+ const newIndex = state.currentQuestionIndex + 1;
58
+ const isFinished = newIndex >= state.questions.length;
59
+ return {
60
+ ...state,
61
+ score: state.score + action.points,
62
+ streak: newStreak,
63
+ streakMax: Math.max(state.streakMax, newStreak),
64
+ questionsCorrect: state.questionsCorrect + 1,
65
+ currentQuestionIndex: newIndex,
66
+ lastAnswerCorrect: true,
67
+ phase: isFinished ? "finished" : state.phase,
68
+ finishedAt: isFinished ? Date.now() : null,
69
+ };
70
+ }
71
+
72
+ case "ANSWER_WRONG": {
73
+ if (state.mode === "streak") {
74
+ return {
75
+ ...state,
76
+ streak: 0,
77
+ currentQuestionIndex: state.currentQuestionIndex + 1,
78
+ lastAnswerCorrect: false,
79
+ phase: "finished",
80
+ finishedAt: Date.now(),
81
+ };
82
+ }
83
+ const newIndex = state.currentQuestionIndex + 1;
84
+ const isFinished = newIndex >= state.questions.length;
85
+ return {
86
+ ...state,
87
+ streak: 0,
88
+ currentQuestionIndex: newIndex,
89
+ lastAnswerCorrect: false,
90
+ phase: isFinished ? "finished" : state.phase,
91
+ finishedAt: isFinished ? Date.now() : null,
92
+ };
93
+ }
94
+
95
+ case "TIME_UP":
96
+ return { ...state, phase: "finished", finishedAt: Date.now() };
97
+
98
+ case "NEXT_PHASE": {
99
+ const idx = CHALLENGE_PROGRESSION.indexOf(state.challengePhase);
100
+ if (idx >= CHALLENGE_PROGRESSION.length - 1) {
101
+ return { ...state, phase: "finished", finishedAt: Date.now() };
102
+ }
103
+ return { ...state, challengePhase: CHALLENGE_PROGRESSION[idx + 1]! };
104
+ }
105
+
106
+ case "ADD_QUESTIONS":
107
+ return { ...state, questions: [...state.questions, ...action.questions] };
108
+
109
+ default:
110
+ return state;
111
+ }
112
+ }