c96a1627ec
Build and Publish Docker Image / build-and-push-image (push) Successful in 1m0s
Navigation : - Suppression du menu hamburger, remplacé par le logo de l'app dans l'en-tête - Barre du bas à 5 onglets : Accueil, Mes jeux, Parties, Stats, Joueurs - Menu profil sur l'avatar (haut-droite) : profil/switch, Emplacements, Jeux importés, Paramètres - Nouvelle page "Mes jeux" (/jeux) : bibliothèque de tous les jeux Format d'import enrichi + gabarits : - Champ icon (lucide) au niveau du jeu, et icon/color sur les catégories - Gabarits JSON des 5 jeux natifs dans public/game-templates/ (référence, au format d'import) + doc à jour Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
4.6 KiB
TypeScript
156 lines
4.6 KiB
TypeScript
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;
|
|
}
|