diff --git a/src/App.tsx b/src/App.tsx index 8324bab..0009df8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import Players from "./pages/Players"; import Locations from "./pages/Locations"; import Profiles from "./pages/Profiles"; import Friends from "./pages/Friends"; +import ImportedGames from "./pages/ImportedGames"; import Auth from "./pages/Auth"; import Settings from "./pages/Settings"; import Statistics from "./pages/Stats"; @@ -18,6 +19,7 @@ import { useAppStore } from "./stores/appStore"; import { useProfileStore } from "./stores/profileStore"; import { useAuthStore } from "./stores/authStore"; import { startSyncTriggers } from "./sync/syncEngine"; +import { startGamesRegistry } from "./games"; function App() { const { loadSettings } = useAppStore(); @@ -25,6 +27,7 @@ function App() { const restoreAuth = useAuthStore((s) => s.restore); useEffect(() => { + startGamesRegistry(); // Order matters: profiles must be loaded before auth restore triggers a sync. loadProfiles().then(() => { restoreAuth(); @@ -43,6 +46,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/database/db.ts b/src/database/db.ts index 28ab656..926d8a1 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -7,6 +7,7 @@ import { Profile, SyncState, } from "../types"; +import { ImportedGame } from "../types/gameImport"; import { generateId } from "../utils/id"; // When the sync engine applies records pulled/acked from the server, it flips @@ -25,6 +26,7 @@ export class BoardScoreDatabase extends Dexie { locations!: Table; profiles!: Table; syncState!: Table; + importedGames!: Table; constructor() { super("BoardScoreDatabase"); @@ -141,6 +143,18 @@ export class BoardScoreDatabase extends Dexie { }); } }); + + // Version 6: imported game definitions (per profile) + this.version(6).stores({ + sessions: + "id, gameId, dateStart, status, locationId, updatedAt, profileId, dirty", + settings: "id", + players: "id, name, createdAt, updatedAt, profileId, dirty", + locations: "id, name, createdAt, updatedAt, profileId, dirty", + profiles: "id, name, createdAt, updatedAt", + syncState: "profileId", + importedGames: "[profileId+id], profileId, id, name", + }); } } diff --git a/src/games/index.ts b/src/games/index.ts index 8f3aa6c..5f331d9 100644 --- a/src/games/index.ts +++ b/src/games/index.ts @@ -1,10 +1,16 @@ +import { liveQuery } from "dexie"; +import { useLiveQuery } from "dexie-react-hooks"; import { GameConfig } from "../types"; +import { ImportedGame } from "../types/gameImport"; +import { db } from "../database/db"; +import { toGameConfig } from "../utils/gameImportAdapter"; import { azulConfig } from "./azul/config"; import { caboConfig } from "./cabo/config"; import { harmonieConfig } from "./harmonie/config"; import { odinCardsConfig } from "./odin_cards/config"; import { skyjoConfig } from "./skyjo/config"; +// Built-in games (compiled into the app). export const games: GameConfig[] = [ azulConfig, caboConfig, @@ -13,6 +19,41 @@ export const games: GameConfig[] = [ odinCardsConfig, ]; -export const getGameConfig = (id: string): GameConfig | undefined => { - return games.find((g) => g.id === id); -}; +// Imported games for the active profile, kept in sync in-memory so the +// synchronous getGameConfig() below keeps working for non-React callers. +let importedRegistry: GameConfig[] = []; + +async function fetchImportedGames(): Promise { + const settings = await db.settings.get(1); + const profileId = settings?.activeProfileId; + if (!profileId) return []; + return db.importedGames.where("profileId").equals(profileId).toArray(); +} + +// Start a background subscription that keeps the in-memory registry current +// across imports and profile switches. Call once at app boot. +export function startGamesRegistry() { + liveQuery(fetchImportedGames).subscribe({ + next: (rows) => { + importedRegistry = rows.map(toGameConfig); + }, + error: (err) => console.error("[games] registry subscription error", err), + }); +} + +// Synchronous lookup (built-in + imported). Safe for event handlers / non-React +// code; the registry is populated shortly after boot. +export const getGameConfig = (id: string): GameConfig | undefined => + games.find((g) => g.id === id) ?? importedRegistry.find((g) => g.id === id); + +// Reactive merged list (built-in + imported for the active profile). +export function useAllGames(): GameConfig[] { + const imported = useLiveQuery(fetchImportedGames, []); + return [...games, ...(imported ?? []).map(toGameConfig)]; +} + +// Reactive single-game lookup. +export function useGameConfig(id: string | undefined): GameConfig | undefined { + const all = useAllGames(); + return id ? all.find((g) => g.id === id) : undefined; +} diff --git a/src/pages/GameOver.tsx b/src/pages/GameOver.tsx index 8387aba..0bc6cf9 100644 --- a/src/pages/GameOver.tsx +++ b/src/pages/GameOver.tsx @@ -5,7 +5,7 @@ import * as Icons from "lucide-react"; import { useLiveQuery } from "dexie-react-hooks"; import { db } from "../database/db"; import { GameSession } from "../types"; -import { getGameConfig } from "../games"; +import { useGameConfig } from "../games"; import { useGameStore } from "../stores/gameStore"; import { useGameTheme } from "../hooks/useGameTheme"; import { Button } from "../components/ui/button"; @@ -23,7 +23,7 @@ export default function GameOver() { const [showCelebration, setShowCelebration] = useState(true); const startNewGame = useGameStore((state) => state.startNewGame); - const gameConfig = getGameConfig(session?.gameId || ""); + const gameConfig = useGameConfig(session?.gameId); useGameTheme(gameConfig); const location = useLiveQuery( diff --git a/src/pages/History.tsx b/src/pages/History.tsx index 795121b..5638b5f 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -1,7 +1,7 @@ import { useNavigate } from "react-router-dom"; import { useLiveQuery } from "dexie-react-hooks"; import { db } from "../database/db"; -import { games } from "../games"; +import { useAllGames } from "../games"; import { Card, CardContent } from "../components/ui/card"; import { NavigationMenu } from "../components/NavigationMenu"; import { useProfileStore } from "../stores/profileStore"; @@ -21,6 +21,7 @@ function formatDate(ms: number) { export default function History() { const navigate = useNavigate(); const activeProfileId = useProfileStore((s) => s.activeProfileId); + const games = useAllGames(); const sessions = useLiveQuery( async () => { if (!activeProfileId) return []; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 4f33dd0..367342d 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,7 +1,7 @@ import { useNavigate } from "react-router-dom"; import { useLiveQuery } from "dexie-react-hooks"; import * as Icons from "lucide-react"; -import { games } from "../games"; +import { useAllGames } from "../games"; import { db } from "../database/db"; import { motion } from "framer-motion"; import { NavigationMenu } from "../components/NavigationMenu"; @@ -33,6 +33,7 @@ const PlanetLogo = () => ( export default function Home() { const navigate = useNavigate(); const activeProfileId = useProfileStore((s) => s.activeProfileId); + const games = useAllGames(); // Load unfinished games for the active profile const activeSessions = useLiveQuery( diff --git a/src/pages/ImportedGames.tsx b/src/pages/ImportedGames.tsx new file mode 100644 index 0000000..18fa424 --- /dev/null +++ b/src/pages/ImportedGames.tsx @@ -0,0 +1,212 @@ +import { useState } from "react"; +import { useLiveQuery } from "dexie-react-hooks"; +import * as Icons from "lucide-react"; +import { Upload, Trash2, FileDown, Puzzle } from "lucide-react"; +import { db } from "../database/db"; +import { useProfileStore } from "../stores/profileStore"; +import { parseImportedGame } from "../utils/gameImportAdapter"; +import { ImportedGameDefinition } from "../types/gameImport"; +import { Card, CardContent } from "../components/ui/card"; +import { Button } from "../components/ui/button"; +import { NavigationMenu } from "../components/NavigationMenu"; +import { motion } from "framer-motion"; + +const TEMPLATE: ImportedGameDefinition = { + schemaVersion: 1, + id: "mon-jeu", + name: "Mon Jeu", + minPlayers: 2, + maxPlayers: 6, + scoreType: "negative", + scoreFormula: { strategy: "sum_rounds" }, + targetScore: { type: "fixed", value: 100 }, + targetScoreCondition: "reaches", + description: + "Le score le plus bas gagne. La partie s'arrête quand un joueur atteint 100 points.", + colors: { primary: "#3b82f6" }, +}; + +export default function ImportedGames() { + const activeProfileId = useProfileStore((s) => s.activeProfileId); + const [feedback, setFeedback] = useState< + { type: "ok" | "err"; message: string } | null + >(null); + + const importedGames = useLiveQuery( + async () => { + if (!activeProfileId) return []; + return db.importedGames + .where("profileId") + .equals(activeProfileId) + .toArray(); + }, + [activeProfileId], + ); + + const handleImport = () => { + setFeedback(null); + const input = document.createElement("input"); + input.type = "file"; + input.accept = "application/json,.json"; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file || !activeProfileId) return; + try { + const text = await file.text(); + const def = parseImportedGame(text); + const existing = await db.importedGames.get([activeProfileId, def.id]); + const now = Date.now(); + await db.importedGames.put({ + ...def, + profileId: activeProfileId, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }); + setFeedback({ + type: "ok", + message: existing + ? `« ${def.name} » mis à jour.` + : `« ${def.name} » importé !`, + }); + } catch (err) { + setFeedback({ + type: "err", + message: err instanceof Error ? err.message : "Import impossible.", + }); + } + }; + input.click(); + }; + + const handleDelete = async (id: string, name: string) => { + if (!activeProfileId) return; + if (window.confirm(`Supprimer le jeu importé « ${name} » ?`)) { + await db.importedGames.delete([activeProfileId, id]); + } + }; + + const handleDownloadTemplate = () => { + const blob = new Blob([JSON.stringify(TEMPLATE, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "skori-jeu-modele.json"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+ +

+ Jeux importés +

+
+ +

+ Ajoutez de nouveaux jeux à l'app via un fichier de définition JSON + (icône, règles de score, options). Les jeux importés sont propres à ce + profil. +

+ + {feedback && ( +

+ {feedback.message} +

+ )} + +
+ + +
+ +
+ {!importedGames || importedGames.length === 0 ? ( +
+ +

Aucun jeu importé.

+

+ Téléchargez le modèle pour voir le format attendu. +

+
+ ) : ( + importedGames.map((game, index) => { + const Icon = game.iconImage + ? null + : (Icons as any)["Puzzle"]; + return ( + + + +
+
+ {game.iconImage ? ( + {game.name} + ) : Icon ? ( + + ) : null} +
+
+

+ {game.name} +

+

+ {game.minPlayers}–{game.maxPlayers} joueurs +

+
+
+ +
+
+
+ ); + }) + )} +
+
+ ); +} diff --git a/src/pages/NewGame.tsx b/src/pages/NewGame.tsx index bcca862..28c3a33 100644 --- a/src/pages/NewGame.tsx +++ b/src/pages/NewGame.tsx @@ -14,7 +14,7 @@ import * as Icons from "lucide-react"; import { useLiveQuery } from "dexie-react-hooks"; import { Reorder, useDragControls } from "framer-motion"; import { db } from "../database/db"; -import { getGameConfig } from "../games"; +import { useGameConfig } from "../games"; import { useGameStore } from "../stores/gameStore"; import { useProfileStore } from "../stores/profileStore"; import { useAuthStore } from "../stores/authStore"; @@ -82,7 +82,7 @@ function DraggablePlayerItem({ export default function NewGame() { const { gameId } = useParams<{ gameId: string }>(); const navigate = useNavigate(); - const gameConfig = getGameConfig(gameId || ""); + const gameConfig = useGameConfig(gameId); useGameTheme(gameConfig); const startNewGame = useGameStore((state) => state.startNewGame); diff --git a/src/pages/PlayGame.tsx b/src/pages/PlayGame.tsx index 56c802f..c0bc71e 100644 --- a/src/pages/PlayGame.tsx +++ b/src/pages/PlayGame.tsx @@ -5,7 +5,7 @@ import * as Icons from "lucide-react"; import { useLiveQuery } from "dexie-react-hooks"; import { db } from "../database/db"; import { useGameStore } from "../stores/gameStore"; -import { getGameConfig } from "../games"; +import { useGameConfig } from "../games"; import { useGameTheme } from "../hooks/useGameTheme"; import { RoundScore } from "../types"; import { Button } from "../components/ui/button"; @@ -36,7 +36,7 @@ export default function PlayGame() { // Keep active session players updated if a global player rename happens const playersInDb = useLiveQuery(() => db.players.toArray()); - const gameConfig = getGameConfig(activeSession?.gameId || ""); + const gameConfig = useGameConfig(activeSession?.gameId); useGameTheme(gameConfig); useEffect(() => { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index dd396bc..4944df2 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -13,6 +13,7 @@ import { RefreshCw, Check, CloudOff, + Puzzle, } from "lucide-react"; import { useAppStore } from "../stores/appStore"; import { useProfileStore } from "../stores/profileStore"; @@ -41,12 +42,14 @@ export default function Settings() { const players = await db.players.toArray(); const locations = await db.locations.toArray(); const profiles = await db.profiles.toArray(); + const importedGames = await db.importedGames.toArray(); const data = JSON.stringify({ sessions, settings, players, locations, profiles, + importedGames, }); const blob = new Blob([data], { type: "application/json" }); const url = URL.createObjectURL(blob); @@ -79,6 +82,7 @@ export default function Settings() { } const locations = data.locations || []; + const importedGames = data.importedGames || []; let profiles = data.profiles || []; // Rétrocompatibilité : un ancien backup n'a pas de profils. @@ -117,6 +121,7 @@ export default function Settings() { db.locations, db.profiles, db.syncState, + db.importedGames, ], async () => { await db.sessions.clear(); @@ -125,6 +130,7 @@ export default function Settings() { await db.locations.clear(); await db.profiles.clear(); await db.syncState.clear(); + await db.importedGames.clear(); if (data.sessions.length > 0) await db.sessions.bulkAdd(data.sessions); @@ -136,6 +142,8 @@ export default function Settings() { await db.locations.bulkAdd(locations); if (profiles.length > 0) await db.profiles.bulkAdd(profiles); + if (importedGames.length > 0) + await db.importedGames.bulkAdd(importedGames); }, ); @@ -346,6 +354,22 @@ export default function Settings() { +
+

Jeux

+ +
+

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; +}