Données
diff --git a/src/pages/Stats/index.tsx b/src/pages/Stats/index.tsx
index a585e36..a5479ec 100644
--- a/src/pages/Stats/index.tsx
+++ b/src/pages/Stats/index.tsx
@@ -2,7 +2,7 @@ import { useState, useMemo } from "react";
import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../../database/db";
import { useProfileStore } from "../../stores/profileStore";
-import { games } from "../../games";
+import { useAllGames } from "../../games";
import { Card, CardContent } from "../../components/ui/card";
import { Badge } from "../../components/ui/badge";
import { NavigationMenu } from "../../components/NavigationMenu";
@@ -12,6 +12,7 @@ import { motion } from "framer-motion";
export default function Statistics() {
const activeProfileId = useProfileStore((s) => s.activeProfileId);
+ const games = useAllGames();
const allSessions =
useLiveQuery(
async () => {
diff --git a/src/types/gameImport.ts b/src/types/gameImport.ts
new file mode 100644
index 0000000..6cd93a2
--- /dev/null
+++ b/src/types/gameImport.ts
@@ -0,0 +1,58 @@
+import { ScoreType, ScoringCategory, GameOption } from "./index";
+
+// How a player's total is computed from their per-round scores. Declarative so
+// imported game files never carry executable code.
+export type ScoreFormula =
+ | { strategy: "sum_rounds" }
+ | {
+ strategy: "sum_rounds_with_reset";
+ resetThreshold: number;
+ resetTo: number;
+ oncePerPlayer?: boolean;
+ };
+
+// How the end-of-game target score is resolved.
+export type TargetScoreSpec =
+ | { type: "fixed"; value: number }
+ | {
+ type: "fromOption";
+ optionId: string;
+ customOptionId?: string;
+ customSentinel?: string;
+ };
+
+// The importable game definition (a single JSON file, images embedded as data URIs).
+export interface ImportedGameDefinition {
+ schemaVersion: 1;
+ id: string;
+ name: string;
+ minPlayers: number;
+ maxPlayers: number;
+ scoreType: ScoreType;
+ scoreFormula: ScoreFormula;
+ targetScore?: TargetScoreSpec;
+ targetScoreCondition?: "reaches" | "exceeds";
+ fixedRounds?: number;
+ scoringCategories?: ScoringCategory[];
+ options?: GameOption[];
+ description?: string;
+ colors: { primary: string; secondary?: string };
+ iconImage?: string; // data URI (png/svg/webp) -> imagePath
+ bannerImage?: string; // data URI -> bannerPath
+}
+
+// As stored in Dexie, scoped to a local profile.
+export interface ImportedGame extends ImportedGameDefinition {
+ profileId: string;
+ createdAt: number;
+ updatedAt: number;
+}
+
+// Ids reserved by the built-in games; imported games may not reuse them.
+export const RESERVED_GAME_IDS = [
+ "azul",
+ "cabo",
+ "skyjo",
+ "harmonie",
+ "odin_cards",
+];
diff --git a/src/utils/gameImportAdapter.ts b/src/utils/gameImportAdapter.ts
new file mode 100644
index 0000000..8034f63
--- /dev/null
+++ b/src/utils/gameImportAdapter.ts
@@ -0,0 +1,151 @@
+import { z } from "zod";
+import { GameConfig } from "../types";
+import { ImportedGameDefinition, RESERVED_GAME_IDS } from "../types/gameImport";
+
+const scoringCategorySchema = z.object({
+ id: z.string(),
+ label: z.string(),
+});
+
+const gameOptionSchema = z.object({
+ id: z.string(),
+ label: z.string(),
+ type: z.enum(["boolean", "number", "select"]),
+ defaultValue: z.any(),
+ options: z
+ .array(z.object({ label: z.string(), value: z.union([z.string(), z.number()]) }))
+ .optional(),
+});
+
+const scoreFormulaSchema = z.discriminatedUnion("strategy", [
+ z.object({ strategy: z.literal("sum_rounds") }),
+ z.object({
+ strategy: z.literal("sum_rounds_with_reset"),
+ resetThreshold: z.number(),
+ resetTo: z.number(),
+ oncePerPlayer: z.boolean().optional(),
+ }),
+]);
+
+const targetScoreSchema = z.discriminatedUnion("type", [
+ z.object({ type: z.literal("fixed"), value: z.number() }),
+ z.object({
+ type: z.literal("fromOption"),
+ optionId: z.string(),
+ customOptionId: z.string().optional(),
+ customSentinel: z.string().optional(),
+ }),
+]);
+
+export const importedGameSchema = z.object({
+ schemaVersion: z.literal(1),
+ id: z
+ .string()
+ .min(1)
+ .regex(/^[a-z0-9_-]+$/i, "id: lettres, chiffres, _ ou - uniquement")
+ .refine((id) => !RESERVED_GAME_IDS.includes(id), {
+ message: "cet identifiant est réservé à un jeu natif",
+ }),
+ name: z.string().min(1).max(60),
+ minPlayers: z.number().int().min(1).max(20),
+ maxPlayers: z.number().int().min(1).max(20),
+ scoreType: z.enum([
+ "positive",
+ "negative",
+ "target",
+ "fixed_rounds",
+ "sudden_death",
+ ]),
+ scoreFormula: scoreFormulaSchema,
+ targetScore: targetScoreSchema.optional(),
+ targetScoreCondition: z.enum(["reaches", "exceeds"]).optional(),
+ fixedRounds: z.number().int().positive().optional(),
+ scoringCategories: z.array(scoringCategorySchema).optional(),
+ options: z.array(gameOptionSchema).optional(),
+ description: z.string().max(400).optional(),
+ colors: z.object({
+ primary: z.string(),
+ secondary: z.string().optional(),
+ }),
+ iconImage: z.string().optional(),
+ bannerImage: z.string().optional(),
+});
+
+// Parse + validate a raw JSON string into a game definition (throws on error).
+export function parseImportedGame(raw: string): ImportedGameDefinition {
+ let data: unknown;
+ try {
+ data = JSON.parse(raw);
+ } catch {
+ throw new Error("Fichier JSON invalide.");
+ }
+ const result = importedGameSchema.safeParse(data);
+ if (!result.success) {
+ const first = result.error.issues[0];
+ throw new Error(
+ `Définition invalide : ${first.path.join(".") || "?"} — ${first.message}`,
+ );
+ }
+ return result.data as ImportedGameDefinition;
+}
+
+// Translate a declarative definition into a runtime GameConfig. The generated
+// closures come from a fixed, reviewed set of strategies — never user code.
+export function toGameConfig(def: ImportedGameDefinition): GameConfig {
+ const config: GameConfig = {
+ id: def.id,
+ name: def.name,
+ minPlayers: def.minPlayers,
+ maxPlayers: def.maxPlayers,
+ scoreType: def.scoreType,
+ targetScoreCondition: def.targetScoreCondition,
+ fixedRounds: def.fixedRounds,
+ scoringCategories: def.scoringCategories,
+ options: def.options,
+ description: def.description,
+ colors: def.colors,
+ imagePath: def.iconImage,
+ bannerPath: def.bannerImage,
+ };
+
+ // Target score
+ if (def.targetScore) {
+ const spec = def.targetScore;
+ if (spec.type === "fixed") {
+ config.targetScore = spec.value;
+ } else {
+ config.getTargetScore = (options) => {
+ const raw = options?.[spec.optionId];
+ if (
+ spec.customSentinel !== undefined &&
+ raw === spec.customSentinel &&
+ spec.customOptionId
+ ) {
+ return parseInt(options?.[spec.customOptionId] ?? "0", 10);
+ }
+ return parseInt(raw ?? "0", 10);
+ };
+ }
+ }
+
+ // Score aggregation
+ if (def.scoreFormula.strategy === "sum_rounds_with_reset") {
+ const { resetThreshold, resetTo, oncePerPlayer } = def.scoreFormula;
+ config.calculatePlayerTotal = (rounds, playerId) => {
+ let total = 0;
+ let used = false;
+ for (const round of rounds) {
+ const pScore = round.scores.find((s) => s.playerId === playerId);
+ total += pScore?.score || 0;
+ if (total === resetThreshold && (!oncePerPlayer || !used)) {
+ total = resetTo;
+ used = true;
+ }
+ }
+ return total;
+ };
+ }
+ // "sum_rounds" -> leave calculatePlayerTotal undefined (default sum)
+
+ return config;
+}