import game option
Build and Publish Docker Image / build-and-push-image (push) Successful in 58s

This commit is contained in:
Zed
2026-07-11 02:37:40 +02:00
parent 9a63240113
commit bb07d7ef30
13 changed files with 519 additions and 12 deletions
+4
View File
@@ -11,6 +11,7 @@ import Players from "./pages/Players";
import Locations from "./pages/Locations"; import Locations from "./pages/Locations";
import Profiles from "./pages/Profiles"; import Profiles from "./pages/Profiles";
import Friends from "./pages/Friends"; import Friends from "./pages/Friends";
import ImportedGames from "./pages/ImportedGames";
import Auth from "./pages/Auth"; import Auth from "./pages/Auth";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
import Statistics from "./pages/Stats"; import Statistics from "./pages/Stats";
@@ -18,6 +19,7 @@ import { useAppStore } from "./stores/appStore";
import { useProfileStore } from "./stores/profileStore"; import { useProfileStore } from "./stores/profileStore";
import { useAuthStore } from "./stores/authStore"; import { useAuthStore } from "./stores/authStore";
import { startSyncTriggers } from "./sync/syncEngine"; import { startSyncTriggers } from "./sync/syncEngine";
import { startGamesRegistry } from "./games";
function App() { function App() {
const { loadSettings } = useAppStore(); const { loadSettings } = useAppStore();
@@ -25,6 +27,7 @@ function App() {
const restoreAuth = useAuthStore((s) => s.restore); const restoreAuth = useAuthStore((s) => s.restore);
useEffect(() => { useEffect(() => {
startGamesRegistry();
// Order matters: profiles must be loaded before auth restore triggers a sync. // Order matters: profiles must be loaded before auth restore triggers a sync.
loadProfiles().then(() => { loadProfiles().then(() => {
restoreAuth(); restoreAuth();
@@ -43,6 +46,7 @@ function App() {
<Route path="/locations" element={<Locations />} /> <Route path="/locations" element={<Locations />} />
<Route path="/profiles" element={<Profiles />} /> <Route path="/profiles" element={<Profiles />} />
<Route path="/friends" element={<Friends />} /> <Route path="/friends" element={<Friends />} />
<Route path="/games" element={<ImportedGames />} />
<Route path="/login" element={<Auth />} /> <Route path="/login" element={<Auth />} />
<Route path="/stats" element={<Statistics />} /> <Route path="/stats" element={<Statistics />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
+14
View File
@@ -7,6 +7,7 @@ import {
Profile, Profile,
SyncState, SyncState,
} from "../types"; } from "../types";
import { ImportedGame } from "../types/gameImport";
import { generateId } from "../utils/id"; import { generateId } from "../utils/id";
// When the sync engine applies records pulled/acked from the server, it flips // 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>; locations!: Table<Location, string>;
profiles!: Table<Profile, string>; profiles!: Table<Profile, string>;
syncState!: Table<SyncState, string>; syncState!: Table<SyncState, string>;
importedGames!: Table<ImportedGame, [string, string]>;
constructor() { constructor() {
super("BoardScoreDatabase"); 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
View File
@@ -1,10 +1,16 @@
import { liveQuery } from "dexie";
import { useLiveQuery } from "dexie-react-hooks";
import { GameConfig } from "../types"; 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 { azulConfig } from "./azul/config";
import { caboConfig } from "./cabo/config"; import { caboConfig } from "./cabo/config";
import { harmonieConfig } from "./harmonie/config"; import { harmonieConfig } from "./harmonie/config";
import { odinCardsConfig } from "./odin_cards/config"; import { odinCardsConfig } from "./odin_cards/config";
import { skyjoConfig } from "./skyjo/config"; import { skyjoConfig } from "./skyjo/config";
// Built-in games (compiled into the app).
export const games: GameConfig[] = [ export const games: GameConfig[] = [
azulConfig, azulConfig,
caboConfig, caboConfig,
@@ -13,6 +19,41 @@ export const games: GameConfig[] = [
odinCardsConfig, odinCardsConfig,
]; ];
export const getGameConfig = (id: string): GameConfig | undefined => { // Imported games for the active profile, kept in sync in-memory so the
return games.find((g) => g.id === id); // 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;
}
+2 -2
View File
@@ -5,7 +5,7 @@ import * as Icons from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db"; import { db } from "../database/db";
import { GameSession } from "../types"; import { GameSession } from "../types";
import { getGameConfig } from "../games"; import { useGameConfig } from "../games";
import { useGameStore } from "../stores/gameStore"; import { useGameStore } from "../stores/gameStore";
import { useGameTheme } from "../hooks/useGameTheme"; import { useGameTheme } from "../hooks/useGameTheme";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
@@ -23,7 +23,7 @@ export default function GameOver() {
const [showCelebration, setShowCelebration] = useState(true); const [showCelebration, setShowCelebration] = useState(true);
const startNewGame = useGameStore((state) => state.startNewGame); const startNewGame = useGameStore((state) => state.startNewGame);
const gameConfig = getGameConfig(session?.gameId || ""); const gameConfig = useGameConfig(session?.gameId);
useGameTheme(gameConfig); useGameTheme(gameConfig);
const location = useLiveQuery( const location = useLiveQuery(
+2 -1
View File
@@ -1,7 +1,7 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db"; import { db } from "../database/db";
import { games } from "../games"; import { useAllGames } from "../games";
import { Card, CardContent } from "../components/ui/card"; import { Card, CardContent } from "../components/ui/card";
import { NavigationMenu } from "../components/NavigationMenu"; import { NavigationMenu } from "../components/NavigationMenu";
import { useProfileStore } from "../stores/profileStore"; import { useProfileStore } from "../stores/profileStore";
@@ -21,6 +21,7 @@ function formatDate(ms: number) {
export default function History() { export default function History() {
const navigate = useNavigate(); const navigate = useNavigate();
const activeProfileId = useProfileStore((s) => s.activeProfileId); const activeProfileId = useProfileStore((s) => s.activeProfileId);
const games = useAllGames();
const sessions = useLiveQuery( const sessions = useLiveQuery(
async () => { async () => {
if (!activeProfileId) return []; if (!activeProfileId) return [];
+2 -1
View File
@@ -1,7 +1,7 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import * as Icons from "lucide-react"; import * as Icons from "lucide-react";
import { games } from "../games"; import { useAllGames } from "../games";
import { db } from "../database/db"; import { db } from "../database/db";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { NavigationMenu } from "../components/NavigationMenu"; import { NavigationMenu } from "../components/NavigationMenu";
@@ -33,6 +33,7 @@ const PlanetLogo = () => (
export default function Home() { export default function Home() {
const navigate = useNavigate(); const navigate = useNavigate();
const activeProfileId = useProfileStore((s) => s.activeProfileId); const activeProfileId = useProfileStore((s) => s.activeProfileId);
const games = useAllGames();
// Load unfinished games for the active profile // Load unfinished games for the active profile
const activeSessions = useLiveQuery( const activeSessions = useLiveQuery(
+212
View File
@@ -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>
);
}
+2 -2
View File
@@ -14,7 +14,7 @@ import * as Icons from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { Reorder, useDragControls } from "framer-motion"; import { Reorder, useDragControls } from "framer-motion";
import { db } from "../database/db"; import { db } from "../database/db";
import { getGameConfig } from "../games"; import { useGameConfig } from "../games";
import { useGameStore } from "../stores/gameStore"; import { useGameStore } from "../stores/gameStore";
import { useProfileStore } from "../stores/profileStore"; import { useProfileStore } from "../stores/profileStore";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
@@ -82,7 +82,7 @@ function DraggablePlayerItem({
export default function NewGame() { export default function NewGame() {
const { gameId } = useParams<{ gameId: string }>(); const { gameId } = useParams<{ gameId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const gameConfig = getGameConfig(gameId || ""); const gameConfig = useGameConfig(gameId);
useGameTheme(gameConfig); useGameTheme(gameConfig);
const startNewGame = useGameStore((state) => state.startNewGame); const startNewGame = useGameStore((state) => state.startNewGame);
+2 -2
View File
@@ -5,7 +5,7 @@ import * as Icons from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../database/db"; import { db } from "../database/db";
import { useGameStore } from "../stores/gameStore"; import { useGameStore } from "../stores/gameStore";
import { getGameConfig } from "../games"; import { useGameConfig } from "../games";
import { useGameTheme } from "../hooks/useGameTheme"; import { useGameTheme } from "../hooks/useGameTheme";
import { RoundScore } from "../types"; import { RoundScore } from "../types";
import { Button } from "../components/ui/button"; 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 // Keep active session players updated if a global player rename happens
const playersInDb = useLiveQuery(() => db.players.toArray()); const playersInDb = useLiveQuery(() => db.players.toArray());
const gameConfig = getGameConfig(activeSession?.gameId || ""); const gameConfig = useGameConfig(activeSession?.gameId);
useGameTheme(gameConfig); useGameTheme(gameConfig);
useEffect(() => { useEffect(() => {
+24
View File
@@ -13,6 +13,7 @@ import {
RefreshCw, RefreshCw,
Check, Check,
CloudOff, CloudOff,
Puzzle,
} from "lucide-react"; } from "lucide-react";
import { useAppStore } from "../stores/appStore"; import { useAppStore } from "../stores/appStore";
import { useProfileStore } from "../stores/profileStore"; import { useProfileStore } from "../stores/profileStore";
@@ -41,12 +42,14 @@ export default function Settings() {
const players = await db.players.toArray(); const players = await db.players.toArray();
const locations = await db.locations.toArray(); const locations = await db.locations.toArray();
const profiles = await db.profiles.toArray(); const profiles = await db.profiles.toArray();
const importedGames = await db.importedGames.toArray();
const data = JSON.stringify({ const data = JSON.stringify({
sessions, sessions,
settings, settings,
players, players,
locations, locations,
profiles, profiles,
importedGames,
}); });
const blob = new Blob([data], { type: "application/json" }); const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -79,6 +82,7 @@ export default function Settings() {
} }
const locations = data.locations || []; const locations = data.locations || [];
const importedGames = data.importedGames || [];
let profiles = data.profiles || []; let profiles = data.profiles || [];
// Rétrocompatibilité : un ancien backup n'a pas de profils. // Rétrocompatibilité : un ancien backup n'a pas de profils.
@@ -117,6 +121,7 @@ export default function Settings() {
db.locations, db.locations,
db.profiles, db.profiles,
db.syncState, db.syncState,
db.importedGames,
], ],
async () => { async () => {
await db.sessions.clear(); await db.sessions.clear();
@@ -125,6 +130,7 @@ export default function Settings() {
await db.locations.clear(); await db.locations.clear();
await db.profiles.clear(); await db.profiles.clear();
await db.syncState.clear(); await db.syncState.clear();
await db.importedGames.clear();
if (data.sessions.length > 0) if (data.sessions.length > 0)
await db.sessions.bulkAdd(data.sessions); await db.sessions.bulkAdd(data.sessions);
@@ -136,6 +142,8 @@ export default function Settings() {
await db.locations.bulkAdd(locations); await db.locations.bulkAdd(locations);
if (profiles.length > 0) if (profiles.length > 0)
await db.profiles.bulkAdd(profiles); await db.profiles.bulkAdd(profiles);
if (importedGames.length > 0)
await db.importedGames.bulkAdd(importedGames);
}, },
); );
@@ -346,6 +354,22 @@ export default function Settings() {
</Card> </Card>
</section> </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"> <section className="space-y-4">
<h2 className="text-xl font-bold px-2 tracking-tight">Données</h2> <h2 className="text-xl font-bold px-2 tracking-tight">Données</h2>
<div className="space-y-3"> <div className="space-y-3">
+2 -1
View File
@@ -2,7 +2,7 @@ import { useState, useMemo } from "react";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { db } from "../../database/db"; import { db } from "../../database/db";
import { useProfileStore } from "../../stores/profileStore"; import { useProfileStore } from "../../stores/profileStore";
import { games } from "../../games"; import { useAllGames } from "../../games";
import { Card, CardContent } from "../../components/ui/card"; import { Card, CardContent } from "../../components/ui/card";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { NavigationMenu } from "../../components/NavigationMenu"; import { NavigationMenu } from "../../components/NavigationMenu";
@@ -12,6 +12,7 @@ import { motion } from "framer-motion";
export default function Statistics() { export default function Statistics() {
const activeProfileId = useProfileStore((s) => s.activeProfileId); const activeProfileId = useProfileStore((s) => s.activeProfileId);
const games = useAllGames();
const allSessions = const allSessions =
useLiveQuery( useLiveQuery(
async () => { async () => {
+58
View File
@@ -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",
];
+151
View File
@@ -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;
}