chore: address code quality audit findings
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled

This commit is contained in:
Tom Boullay
2026-05-28 08:31:42 +02:00
parent 947025cbf5
commit d654565f87
73 changed files with 890 additions and 1457 deletions
@@ -445,11 +445,14 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
Voix
<select
value={selectedDialogue.voice}
onChange={(event) =>
updateSelectedDialogue({
voice: event.target.value as DialogueVoiceId,
})
}
onChange={(event) => {
const selectedVoice = voices.find(
(voice) => voice.id === event.target.value,
);
if (!selectedVoice) return;
updateSelectedDialogue({ voice: selectedVoice.id });
}}
>
{voices.map((voice) => (
<option key={voice.id} value={voice.id}>
+36 -26
View File
@@ -1,14 +1,19 @@
import { useEffect, useRef, useState } from "react";
import { Download, RefreshCw, Save } from "lucide-react";
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
import type { SubtitleLanguage } from "@/types/settings/settings";
import type {
DialogueDefinition,
DialogueManifest,
DialogueSpeaker,
DialogueVoiceId,
} from "@/types/dialogues/dialogues";
import { logger } from "@/utils/core/Logger";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { parseSrt } from "@/utils/subtitles/parseSrt";
import {
parseSrt,
parseSrtTime,
parseSrtWithDiagnostics,
} from "@/utils/subtitles/parseSrt";
interface SrtVoiceOption {
id: DialogueVoiceId;
@@ -88,21 +93,6 @@ function formatPreviewTime(totalSeconds: number): string {
return `${Math.max(0, totalSeconds).toFixed(1)}s`;
}
function parseSrtTime(value: string): number | null {
const match = value.match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/);
if (!match) return null;
const [, hours, minutes, seconds, milliseconds] = match;
if (!hours || !minutes || !seconds || !milliseconds) return null;
return (
Number(hours) * 3600 +
Number(minutes) * 60 +
Number(seconds) +
Number(milliseconds) / 1000
);
}
function padTime(value: number): string {
return value.toString().padStart(2, "0");
}
@@ -120,7 +110,7 @@ function getSrtDiagnostic(
.trim()
.split(/\n{2,}/)
.filter(Boolean);
const cues = parseSrt(content);
const { cues, diagnostics } = parseSrtWithDiagnostics(content);
const errors: string[] = [];
const indexes = new Set<number>();
@@ -164,6 +154,10 @@ function getSrtDiagnostic(
);
}
for (const diagnostic of diagnostics) {
errors.push(`Bloc ${diagnostic.blockIndex + 1}: ${diagnostic.reason}.`);
}
const cueIndexes = new Set(cues.map((cue) => cue.index));
const missingCueIndexes = expectedCueIndexes.filter(
(cueIndex) => !cueIndexes.has(cueIndex),
@@ -470,8 +464,14 @@ export function EditorSrtPanel(): React.JSX.Element {
.then((loadedManifest) => {
if (mounted) setManifest(loadedManifest);
})
.catch(() => {
if (mounted) setManifest(null);
.catch((error) => {
if (!mounted) return;
setManifest(null);
setStatus("Erreur de chargement du manifeste dialogues");
logger.error("EditorSrt", "Failed to load dialogue manifest", {
error: error instanceof Error ? error : String(error),
});
});
return () => {
@@ -519,9 +519,14 @@ export function EditorSrtPanel(): React.JSX.Element {
Voix
<select
value={voice}
onChange={(event) =>
setVoice(event.target.value as DialogueVoiceId)
}
onChange={(event) => {
const selectedVoice = SRT_VOICES.find(
(item) => item.id === event.target.value,
);
if (selectedVoice) {
setVoice(selectedVoice.id);
}
}}
>
{SRT_VOICES.map((item) => (
<option key={item.id} value={item.id}>
@@ -535,9 +540,14 @@ export function EditorSrtPanel(): React.JSX.Element {
Langue
<select
value={language}
onChange={(event) =>
setLanguage(event.target.value as SubtitleLanguage)
}
onChange={(event) => {
const selectedLanguage = SRT_LANGUAGES.find(
(item) => item === event.target.value,
);
if (selectedLanguage) {
setLanguage(selectedLanguage);
}
}}
>
{SRT_LANGUAGES.map((item) => (
<option key={item} value={item}>
@@ -5,7 +5,7 @@ import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
interface RepairCompletionStepProps {
config: RepairMissionConfig;
+4 -8
View File
@@ -7,21 +7,17 @@ import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspec
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
import {
RepairScanSequence,
type RepairScannedBrokenPart,
} from "@/components/three/gameplay/RepairScanSequence";
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
import {
REPAIR_MISSIONS,
type RepairMissionConfig,
} from "@/data/gameplay/repairMissions";
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import type {
MissionStep,
RepairMissionConfig,
RepairMissionId,
RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { useGameStore } from "@/managers/stores/useGameStore";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
@@ -2,7 +2,7 @@ import { InteractableObject } from "@/components/three/interaction/InteractableO
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairInspectionObjectProps {
@@ -10,7 +10,7 @@ import {
REPAIR_CASE_MODEL_PATH,
} from "@/data/gameplay/repairCaseConfig";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairMissionCaseProps {
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
interface RepairReassemblyStepProps {
config: RepairMissionConfig;
@@ -3,7 +3,6 @@ import * as THREE from "three";
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import {
@@ -15,7 +14,9 @@ import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type {
RepairMissionConfig,
RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions";
RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { logger } from "@/utils/core/Logger";
import type { Vector3Tuple } from "@/types/three/three";
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
@@ -34,6 +35,7 @@ const REPAIR_INSTALL_RADIUS = 1.1;
const VALID_PART_COLOR = "#22c55e";
const INVALID_PART_COLOR = "#ef4444";
const STORED_BROKEN_PART_COLOR = "#38bdf8";
let hasWarnedMissingPlaceholders = false;
interface RepairRepairingStepProps {
brokenParts: readonly RepairScannedBrokenPart[];
@@ -400,6 +402,14 @@ function getPlaceholderTargets(
return placeholders;
}
if (!hasWarnedMissingPlaceholders) {
hasWarnedMissingPlaceholders = true;
logger.warn(
"RepairGame",
"Repair case placeholders missing, using fallback slots",
);
}
return FALLBACK_PLACEHOLDER_OFFSETS.map(
(offset, index): RepairCasePlaceholder => ({
name: `placeholder_${index + 1}`,
@@ -416,12 +426,12 @@ function getBrokenPartTargetPositions(
part: RepairScannedBrokenPart,
placeholderTargets: readonly RepairCasePlaceholder[],
): readonly Vector3Tuple[] {
if (!part.placeholderName) {
if (!part.caseSlotName) {
return placeholderTargets.map((placeholder) => placeholder.position);
}
const matchingPlaceholder = placeholderTargets.find(
(placeholder) => placeholder.name === part.placeholderName,
(placeholder) => placeholder.name === part.caseSlotName,
);
return matchingPlaceholder
@@ -475,6 +485,6 @@ function getBrokenPartsToDeposit(
id: part.id,
label: part.label,
modelPath: part.modelPath ?? config.modelPath,
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}),
...(part.caseSlotName ? { caseSlotName: part.caseSlotName } : {}),
}));
}
@@ -8,7 +8,9 @@ import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
import type {
RepairMissionConfig,
RepairMissionPartConfig,
} from "@/data/gameplay/repairMissions";
RepairScannedBrokenPart,
} from "@/types/gameplay/repairMission";
import { logger } from "@/utils/core/Logger";
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
interface RepairScanSequenceProps {
@@ -16,13 +18,13 @@ interface RepairScanSequenceProps {
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
}
export interface RepairScannedBrokenPart {
id: string;
label: string;
modelPath: string;
placeholderName?: string;
interface RepairBrokenPartMatch {
config: RepairMissionPartConfig;
partIndex: number;
}
const warnedMissingScanParts = new Set<string>();
export function RepairScanSequence({
config,
onComplete,
@@ -31,9 +33,9 @@ export function RepairScanSequence({
const [activePartIndex, setActivePartIndex] = useState(0);
const activePart = parts[activePartIndex];
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
const visibleBrokenPartIndexes = brokenPartIndexes.filter(
(partIndex) => partIndex <= activePartIndex,
const brokenPartMatches = getBrokenPartMatches(parts, config);
const visibleBrokenPartMatches = brokenPartMatches.filter(
(match) => match.partIndex <= activePartIndex,
);
useEffect(() => {
@@ -65,8 +67,8 @@ export function RepairScanSequence({
onPartsReady={setParts}
/>
<RepairScanVisual target={activePart?.object} />
{visibleBrokenPartIndexes.map((partIndex) => {
const part = parts[partIndex];
{visibleBrokenPartMatches.map((match) => {
const part = parts[match.partIndex];
if (!part) return null;
return (
@@ -87,29 +89,25 @@ function getScannedBrokenParts(
parts: readonly ExplodedPart[],
config: RepairMissionConfig,
): readonly RepairScannedBrokenPart[] {
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
return brokenPartIndexes.map((_, index) => {
const configuredPart = config.brokenParts[index] ?? config.brokenParts[0];
return getBrokenPartMatches(parts, config).map((match) => {
return {
id: configuredPart?.id ?? `${config.id}-broken-part-${index}`,
label: configuredPart?.label ?? `${config.label} broken part`,
modelPath: configuredPart?.modelPath ?? config.modelPath,
...(configuredPart?.placeholderName
? { placeholderName: configuredPart.placeholderName }
id: match.config.id,
label: match.config.label,
modelPath: match.config.modelPath ?? config.modelPath,
...(match.config.caseSlotName
? { caseSlotName: match.config.caseSlotName }
: {}),
};
});
}
function getBrokenPartIndexes(
function getBrokenPartMatches(
parts: readonly ExplodedPart[],
brokenParts: readonly RepairMissionPartConfig[],
): number[] {
if (parts.length === 0 || brokenParts.length === 0) return [];
config: RepairMissionConfig,
): RepairBrokenPartMatch[] {
if (parts.length === 0 || config.brokenParts.length === 0) return [];
const matchedIndexes = brokenParts.flatMap((brokenPart) => {
const matches = config.brokenParts.flatMap((brokenPart) => {
const { nodeName } = brokenPart;
if (!nodeName) return [];
@@ -117,12 +115,30 @@ function getBrokenPartIndexes(
objectContainsNodeName(part.object, nodeName),
);
return index >= 0 ? [index] : [];
return index >= 0 ? [{ config: brokenPart, partIndex: index }] : [];
});
if (matchedIndexes.length > 0) return [...new Set(matchedIndexes)];
if (matches.length !== config.brokenParts.length) {
const matchedIds = new Set(matches.map((match) => match.config.id));
const missingIds = config.brokenParts
.filter((brokenPart) => !matchedIds.has(brokenPart.id))
.map((brokenPart) => brokenPart.id);
return parts.slice(0, brokenParts.length).map((_, index) => index);
const warningKey = `${config.id}:${missingIds.join(",")}`;
if (!warnedMissingScanParts.has(warningKey)) {
warnedMissingScanParts.add(warningKey);
logger.warn("RepairScan", "Broken parts missing from exploded model", {
missionId: config.id,
missingIds,
});
}
}
return matches.filter(
(match, index, allMatches) =>
allMatches.findIndex((item) => item.partIndex === match.partIndex) ===
index,
);
}
function objectContainsNodeName(
+17 -5
View File
@@ -7,8 +7,9 @@ import {
} from "@/hooks/animation/useAnimatedModel";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import type { ModelTransformProps } from "@/types/three/three";
import { logger } from "@/utils/core/Logger";
export interface AnimatedModelConfig extends ModelTransformProps {
interface AnimatedModelConfig extends ModelTransformProps {
modelPath: string;
animations?: string[];
defaultAnimation?: string;
@@ -121,17 +122,28 @@ export function AnimatedModel({
return;
}
let defaultAction = actions[defaultAnimation as string];
let defaultAction = actions[defaultAnimation];
if (!defaultAction && names.length > 0) {
defaultAction = actions[names[0] as string];
const fallbackAnimation = names[0];
if (!defaultAction && fallbackAnimation) {
logger.warn(
"AnimatedModel",
"Default animation missing, using fallback",
{
modelPath,
defaultAnimation,
fallbackAnimation,
availableAnimations: names,
},
);
defaultAction = actions[fallbackAnimation];
}
if (defaultAction) {
defaultAction.play();
onLoaded?.();
}
}, [actions, defaultAnimation, names, autoPlay, onLoaded]);
}, [actions, defaultAnimation, modelPath, names, autoPlay, onLoaded]);
const contextValue: AnimatedModelContextValue = {
play,
+1 -1
View File
@@ -17,7 +17,7 @@ function applyShadowSettings(
});
}
export interface SimpleModelConfig extends ModelTransformProps {
interface SimpleModelConfig extends ModelTransformProps {
modelPath: string;
castShadow?: boolean;
receiveShadow?: boolean;
@@ -93,8 +93,11 @@ function createMergedMeshes(scene: THREE.Group): MergedMeshData[] {
return [...groups.values()]
.map((group) => {
if (group.geometries.length === 1) {
const [geometry] = group.geometries;
if (!geometry) return null;
return {
geometry: group.geometries[0] as THREE.BufferGeometry,
geometry,
material: group.material,
};
}
+1 -28
View File
@@ -2,10 +2,7 @@ import { useEffect } from "react";
import { RotateCcw, X } from "lucide-react";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type {
RepairRuntime,
SubtitleLanguage,
} from "@/managers/stores/useSettingsStore";
import type { SubtitleLanguage } from "@/types/settings/settings";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
function formatPercent(value: number): string {
@@ -62,14 +59,12 @@ export function GameSettingsMenu(): React.JSX.Element | null {
dialogueVolume,
subtitlesEnabled,
subtitleLanguage,
repairRuntime,
setMusicVolume,
setSfxVolume,
setDialogueVolume,
setSettingsMenuOpen,
setSubtitlesEnabled,
setSubtitleLanguage,
setRepairRuntime,
} = useSettingsStore();
useEffect(() => {
@@ -178,28 +173,6 @@ export function GameSettingsMenu(): React.JSX.Element | null {
</div>
</section>
<section
className="game-settings-menu__section"
aria-labelledby="repair-settings-heading"
>
<h3 id="repair-settings-heading">Repair game</h3>
<div className="game-settings-menu__choice-group game-settings-menu__choice-group--stacked">
{(["js", "python"] satisfies RepairRuntime[]).map((runtime) => (
<button
key={runtime}
type="button"
className={repairRuntime === runtime ? "active" : undefined}
onClick={() => setRepairRuntime(runtime)}
aria-pressed={repairRuntime === runtime}
>
{runtime === "js"
? "Repair game en JS (local)"
: "Repair game en Python (server)"}
</button>
))}
</div>
</section>
{showDebugRestart ? (
<button
className="game-settings-menu__restart"
+1 -1
View File
@@ -2,7 +2,7 @@ import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
export type SubtitleSpeaker = DialogueSpeaker;
type SubtitleSpeaker = DialogueSpeaker;
interface SubtitlesProps {
speaker?: SubtitleSpeaker | null;
+11 -15
View File
@@ -1,18 +1,12 @@
import { RotateCcw, StepBack, StepForward } from "lucide-react";
import {
type MainGameState,
useGameStore,
} from "@/managers/stores/useGameStore";
import { useGameStore } from "@/managers/stores/useGameStore";
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
import { GAME_STEPS, type GameStep } from "@/types/game";
const MAIN_STATES: MainGameState[] = [
"intro",
"bike",
"pylone",
"ferme",
"outro",
];
import {
GAME_STEPS,
isGameStep,
MAIN_GAME_STATES,
type MainGameState,
} from "@/types/game";
function toPascalCase(value: string): string {
return value
@@ -60,7 +54,9 @@ export function GameStateDebugPanel(): React.JSX.Element {
function setSubState(nextSubState: string): void {
if (mainState === "intro") {
setIntroStep(nextSubState as GameStep);
if (isGameStep(nextSubState)) {
setIntroStep(nextSubState);
}
return;
}
@@ -124,7 +120,7 @@ export function GameStateDebugPanel(): React.JSX.Element {
aria-label="Main states"
role="group"
>
{MAIN_STATES.map((state) => (
{MAIN_GAME_STATES.map((state) => (
<button
key={state}
aria-pressed={state === mainState}