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
+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;
}