This commit is contained in:
@@ -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() {
|
||||
<Route path="/locations" element={<Locations />} />
|
||||
<Route path="/profiles" element={<Profiles />} />
|
||||
<Route path="/friends" element={<Friends />} />
|
||||
<Route path="/games" element={<ImportedGames />} />
|
||||
<Route path="/login" element={<Auth />} />
|
||||
<Route path="/stats" element={<Statistics />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
|
||||
@@ -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<Location, string>;
|
||||
profiles!: Table<Profile, string>;
|
||||
syncState!: Table<SyncState, string>;
|
||||
importedGames!: Table<ImportedGame, [string, string]>;
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+44
-3
@@ -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<ImportedGame[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
<div className="p-4 space-y-6 pb-20 relative z-50">
|
||||
<header className="py-1 px-1 flex items-center bg-background/40 backdrop-blur-md rounded-full shadow-sm w-max pr-6 sticky top-4 z-50 border border-black/5 dark:border-white/5">
|
||||
<NavigationMenu />
|
||||
<h1 className="text-2xl font-black tracking-tighter ml-2 drop-shadow-sm">
|
||||
Jeux importés
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<p className="text-sm text-muted-foreground px-2">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{feedback && (
|
||||
<p
|
||||
className={`text-sm font-medium rounded-xl p-3 ${
|
||||
feedback.type === "ok"
|
||||
? "text-emerald-700 bg-emerald-500/10"
|
||||
: "text-destructive bg-destructive/10"
|
||||
}`}
|
||||
>
|
||||
{feedback.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
className="h-14 rounded-2xl font-black"
|
||||
onClick={handleImport}
|
||||
>
|
||||
<Upload className="w-5 h-5 mr-2" />
|
||||
Importer
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-14 rounded-2xl font-bold"
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
<FileDown className="w-5 h-5 mr-2" />
|
||||
Modèle
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{!importedGames || importedGames.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 flex flex-col items-center bg-background/60 backdrop-blur-sm rounded-[2rem]">
|
||||
<Puzzle className="w-16 h-16 mb-4 opacity-20" />
|
||||
<p className="font-bold text-lg">Aucun jeu importé.</p>
|
||||
<p className="text-sm mt-1 max-w-[260px]">
|
||||
Téléchargez le modèle pour voir le format attendu.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
importedGames.map((game, index) => {
|
||||
const Icon = game.iconImage
|
||||
? null
|
||||
: (Icons as any)["Puzzle"];
|
||||
return (
|
||||
<motion.div
|
||||
key={game.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Card className="border-0 shadow-lg bg-background/90 backdrop-blur-md">
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center min-w-0">
|
||||
<div
|
||||
className="w-12 h-12 rounded-2xl flex items-center justify-center mr-3 shrink-0 overflow-hidden shadow-sm"
|
||||
style={{ backgroundColor: `${game.colors.primary}20` }}
|
||||
>
|
||||
{game.iconImage ? (
|
||||
<img
|
||||
src={game.iconImage}
|
||||
alt={game.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : Icon ? (
|
||||
<Icon
|
||||
className="w-6 h-6"
|
||||
style={{ color: game.colors.primary }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-black text-lg truncate">
|
||||
{game.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{game.minPlayers}–{game.maxPlayers} joueurs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(game.id, game.name)}
|
||||
className="text-destructive hover:bg-destructive/10 shrink-0"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-bold px-2 tracking-tight">Jeux</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start h-auto py-4 px-6 border-0 shadow-md bg-background/90 backdrop-blur-md rounded-[1.5rem] hover:scale-[1.02] transition-transform"
|
||||
onClick={() => navigate("/games")}
|
||||
>
|
||||
<div className="flex items-center font-black text-lg w-full">
|
||||
<div className="w-10 h-10 rounded-full bg-indigo-500/20 flex items-center justify-center mr-4">
|
||||
<Puzzle className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
Gérer les jeux importés
|
||||
</div>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-bold px-2 tracking-tight">Données</h2>
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user