This commit is contained in:
@@ -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