feat(debug): persist and reset debug game state

This commit is contained in:
tom-boullay
2026-05-21 12:09:12 +02:00
parent 072dec03b4
commit 4f25b33d3b
4 changed files with 251 additions and 5 deletions
+22 -1
View File
@@ -1,10 +1,12 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { X } from "lucide-react"; import { RotateCcw, X } from "lucide-react";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type { import type {
RepairRuntime, RepairRuntime,
SubtitleLanguage, SubtitleLanguage,
} from "@/managers/stores/useSettingsStore"; } from "@/managers/stores/useSettingsStore";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
function formatPercent(value: number): string { function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`; return `${Math.round(value * 100)}%`;
@@ -52,6 +54,7 @@ function VolumeSlider({
} }
export function GameSettingsMenu(): React.JSX.Element | null { export function GameSettingsMenu(): React.JSX.Element | null {
const resetGame = useGameStore((state) => state.resetGame);
const { const {
isSettingsMenuOpen, isSettingsMenuOpen,
musicVolume, musicVolume,
@@ -93,6 +96,13 @@ export function GameSettingsMenu(): React.JSX.Element | null {
window.location.assign("/"); window.location.assign("/");
}; };
const handleRestart = (): void => {
resetGame();
window.location.reload();
};
const showDebugRestart = isDebugEnabled();
return ( return (
<div className="game-settings-menu" role="dialog" aria-modal="true"> <div className="game-settings-menu" role="dialog" aria-modal="true">
<div className="game-settings-menu__panel"> <div className="game-settings-menu__panel">
@@ -190,6 +200,17 @@ export function GameSettingsMenu(): React.JSX.Element | null {
</div> </div>
</section> </section>
{showDebugRestart ? (
<button
className="game-settings-menu__restart"
type="button"
onClick={handleRestart}
>
<RotateCcw size={14} aria-hidden="true" />
Recommencer
</button>
) : null}
<button <button
className="game-settings-menu__quit" className="game-settings-menu__quit"
type="button" type="button"
+11
View File
@@ -662,6 +662,7 @@ canvas {
} }
.game-settings-menu__choice-group button, .game-settings-menu__choice-group button,
.game-settings-menu__restart,
.game-settings-menu__quit { .game-settings-menu__quit {
width: 100%; width: 100%;
padding: 11px 12px; padding: 11px 12px;
@@ -680,6 +681,16 @@ canvas {
color: #050505; color: #050505;
} }
.game-settings-menu__restart {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
border-color: rgba(96, 165, 250, 0.35);
color: #bfdbfe;
}
.game-settings-menu__quit { .game-settings-menu__quit {
margin-top: 8px; margin-top: 8px;
border-color: rgba(248, 113, 113, 0.35); border-color: rgba(248, 113, 113, 0.35);
+179 -4
View File
@@ -1,12 +1,19 @@
import { create } from "zustand"; import { create } from "zustand";
import type { GameStep } from "@/types/game"; import { GAME_STEPS, type GameStep } from "@/types/game";
import { import {
isRepairMissionId, isRepairMissionId,
isMissionStep,
getNextMissionStep, getNextMissionStep,
getPreviousMissionStep, getPreviousMissionStep,
type MissionStep, type MissionStep,
type RepairMissionId, type RepairMissionId,
} from "@/types/gameplay/repairMission"; } from "@/types/gameplay/repairMission";
import {
clearDebugGameStateCookie,
readDebugGameStateCookie,
writeDebugGameStateCookie,
} from "@/utils/debug/debugGameStateCookie";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro"; export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
export type { MissionStep, RepairMissionId }; export type { MissionStep, RepairMissionId };
@@ -30,7 +37,7 @@ interface MissionFlowState {
playerName: string; playerName: string;
} }
interface GameState { export interface GameState {
mainState: MainGameState; mainState: MainGameState;
isCinematicPlaying: boolean; isCinematicPlaying: boolean;
missionFlow: MissionFlowState; missionFlow: MissionFlowState;
@@ -79,6 +86,34 @@ interface GameActions {
type GameStore = GameState & GameActions; type GameStore = GameState & GameActions;
type GameStateUpdate = Partial<GameState>; type GameStateUpdate = Partial<GameState>;
const MAIN_GAME_STATES: readonly MainGameState[] = [
"intro",
"bike",
"pylone",
"ferme",
"outro",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isStringOrNull(value: unknown): value is string | null {
return typeof value === "string" || value === null;
}
function isBoolean(value: unknown): value is boolean {
return typeof value === "boolean";
}
function isMainGameState(value: unknown): value is MainGameState {
return MAIN_GAME_STATES.includes(value as MainGameState);
}
function isGameStep(value: unknown): value is GameStep {
return GAME_STEPS.includes(value as GameStep);
}
function completeIntroState(state: GameState): GameStateUpdate { function completeIntroState(state: GameState): GameStateUpdate {
return { return {
mainState: "bike", mainState: "bike",
@@ -237,8 +272,139 @@ function createInitialGameState(): GameState {
}; };
} }
function hydrateIntroState(initial: IntroState, value: unknown): IntroState {
if (!isRecord(value)) return initial;
return {
currentStep: isGameStep(value.currentStep)
? value.currentStep
: initial.currentStep,
dialogueAudio: isStringOrNull(value.dialogueAudio)
? value.dialogueAudio
: initial.dialogueAudio,
hasCompleted: isBoolean(value.hasCompleted)
? value.hasCompleted
: initial.hasCompleted,
isBikeUnlocked: isBoolean(value.isBikeUnlocked)
? value.isBikeUnlocked
: initial.isBikeUnlocked,
};
}
function hydrateMissionState(
initial: MissionState,
value: unknown,
): MissionState {
if (!isRecord(value)) return initial;
return {
currentStep:
typeof value.currentStep === "string" && isMissionStep(value.currentStep)
? value.currentStep
: initial.currentStep,
dialogueAudio: isStringOrNull(value.dialogueAudio)
? value.dialogueAudio
: initial.dialogueAudio,
};
}
function hydrateMissionFlowState(
initial: MissionFlowState,
value: unknown,
): MissionFlowState {
if (!isRecord(value)) return initial;
return {
activityCity: isBoolean(value.activityCity)
? value.activityCity
: initial.activityCity,
canMove: isBoolean(value.canMove) ? value.canMove : initial.canMove,
dialogMessage: isStringOrNull(value.dialogMessage)
? value.dialogMessage
: initial.dialogMessage,
playerName:
typeof value.playerName === "string"
? value.playerName
: initial.playerName,
};
}
function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
if (!isRecord(value)) return initial;
const bike = hydrateMissionState(initial.bike, value.bike);
const pylone = hydrateMissionState(initial.pylone, value.pylone);
const ferme = hydrateMissionState(initial.ferme, value.ferme);
const outro = isRecord(value.outro) ? value.outro : null;
return {
mainState: isMainGameState(value.mainState)
? value.mainState
: initial.mainState,
isCinematicPlaying: isBoolean(value.isCinematicPlaying)
? value.isCinematicPlaying
: initial.isCinematicPlaying,
missionFlow: hydrateMissionFlowState(
initial.missionFlow,
value.missionFlow,
),
intro: hydrateIntroState(initial.intro, value.intro),
bike: {
...bike,
isRepaired:
isRecord(value.bike) && isBoolean(value.bike.isRepaired)
? value.bike.isRepaired
: initial.bike.isRepaired,
},
pylone: {
...pylone,
isPowered:
isRecord(value.pylone) && isBoolean(value.pylone.isPowered)
? value.pylone.isPowered
: initial.pylone.isPowered,
},
ferme: {
...ferme,
irrigationFixed:
isRecord(value.ferme) && isBoolean(value.ferme.irrigationFixed)
? value.ferme.irrigationFixed
: initial.ferme.irrigationFixed,
},
outro: {
dialogueAudio:
outro && isStringOrNull(outro.dialogueAudio)
? outro.dialogueAudio
: initial.outro.dialogueAudio,
hasStarted:
outro && isBoolean(outro.hasStarted)
? outro.hasStarted
: initial.outro.hasStarted,
},
};
}
function createInitialDebugGameState(): GameState {
const initialState = createInitialGameState();
if (!isDebugEnabled()) return initialState;
return hydrateDebugGameState(initialState, readDebugGameStateCookie());
}
function pickGameState(state: GameStore): GameState {
return {
mainState: state.mainState,
isCinematicPlaying: state.isCinematicPlaying,
missionFlow: state.missionFlow,
intro: state.intro,
bike: state.bike,
pylone: state.pylone,
ferme: state.ferme,
outro: state.outro,
};
}
export const useGameStore = create<GameStore>()((set) => ({ export const useGameStore = create<GameStore>()((set) => ({
...createInitialGameState(), ...createInitialDebugGameState(),
setMainState: (mainState) => set({ mainState }), setMainState: (mainState) => set({ mainState }),
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }), setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
hideDialog: () => hideDialog: () =>
@@ -302,9 +468,18 @@ export const useGameStore = create<GameStore>()((set) => ({
return { outro: { ...state.outro, hasStarted: false } }; return { outro: { ...state.outro, hasStarted: false } };
}), }),
resetGame: () => set(createInitialGameState()), resetGame: () => {
set(createInitialGameState());
clearDebugGameStateCookie();
},
showDialog: (dialogMessage) => showDialog: (dialogMessage) =>
set((state) => ({ set((state) => ({
missionFlow: { ...state.missionFlow, dialogMessage }, missionFlow: { ...state.missionFlow, dialogMessage },
})), })),
})); }));
if (isDebugEnabled()) {
useGameStore.subscribe((state) => {
writeDebugGameStateCookie(pickGameState(state));
});
}
+39
View File
@@ -0,0 +1,39 @@
import type { GameState } from "@/managers/stores/useGameStore";
const DEBUG_GAME_STATE_COOKIE_NAME = "la-fabrik-debug-game-state";
const DEBUG_GAME_STATE_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
function getCookieValue(name: string): string | null {
if (typeof document === "undefined") return null;
const cookie = document.cookie
.split(";")
.map((item) => item.trim())
.find((item) => item.startsWith(`${name}=`));
return cookie ? cookie.slice(name.length + 1) : null;
}
export function readDebugGameStateCookie(): unknown {
const value = getCookieValue(DEBUG_GAME_STATE_COOKIE_NAME);
if (!value) return null;
try {
return JSON.parse(decodeURIComponent(value));
} catch {
return null;
}
}
export function writeDebugGameStateCookie(state: GameState): void {
if (typeof document === "undefined") return;
const value = encodeURIComponent(JSON.stringify(state));
document.cookie = `${DEBUG_GAME_STATE_COOKIE_NAME}=${value}; max-age=${DEBUG_GAME_STATE_COOKIE_MAX_AGE}; path=/; SameSite=Lax`;
}
export function clearDebugGameStateCookie(): void {
if (typeof document === "undefined") return;
document.cookie = `${DEBUG_GAME_STATE_COOKIE_NAME}=; max-age=0; path=/; SameSite=Lax`;
}