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(), icon: z.string().max(40).optional(), color: z.string().max(30).optional(), }); 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(), }), icon: z.string().max(40).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, icon: def.icon, 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; }