diff --git a/app/src/lib/server/tirage.ts b/app/src/lib/server/tirage.ts new file mode 100644 index 0000000..2d33ce6 --- /dev/null +++ b/app/src/lib/server/tirage.ts @@ -0,0 +1,86 @@ +import rawLieux from './data/lieux.json'; +import type { Categorie, Lieu } from '$lib/types'; + +const lieux = rawLieux as Lieu[]; + +/** Nombre de manches d'un défi. */ +const NB_MANCHES = 5; + +/** Seed acceptée : alphanumérique + tiret/underscore, 4 à 40 caractères. */ +const SEED_REGEX = /^[A-Za-z0-9_-]{4,40}$/; + +export function seedValide(seed: string): boolean { + return SEED_REGEX.test(seed); +} + +/** + * Hash cyrb53 (domaine public) réduit à 32 bits. + * Arithmétique entière via Math.imul : résultat strictement identique sur + * toutes les plateformes — prérequis du déterminisme inter-machines (ADR-001 V1). + */ +function hashSeed(seed: string): number { + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < seed.length; i++) { + const ch = seed.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return (h1 ^ h2) >>> 0; +} + +/** PRNG mulberry32 (domaine public) : flottants dans [0, 1), déterministes. */ +function mulberry32(graine: number): () => number { + let a = graine; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Tire les 5 lieux d'un défi à partir de la seed : mêmes lieux, même ordre, + * pour tous les joueurs et sur toutes les machines (ADR-001 v1). + * PRNG seedé exclusivement — jamais Math.random. + * Contraintes : ≥ 1 MONUMENT et ≥ 1 LIEU, et si possible ≥ 2 régions distinctes. + * Fonction pure : aucune I/O réseau, aucun état ; même entrée → même sortie. + */ +export function idsPourSeed(seed: string): string[] { + const rng = mulberry32(hashSeed(seed)); + + // Mélange de Fisher-Yates déterministe des indices du corpus. + const indices = lieux.map((_, i) => i); + for (let i = indices.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [indices[i], indices[j]] = [indices[j], indices[i]]; + } + const melanges = indices.map((i) => lieux[i]); + const tires = melanges.slice(0, NB_MANCHES); + + // ≥ 1 de chaque catégorie : si l'une manque, on échange un tiré contre le + // premier non-tiré de la catégorie manquante (l'autre catégorie reste présente). + const assurerCategorie = (categorie: Categorie): void => { + if (tires.some((l) => l.categorie === categorie)) return; + const candidat = melanges.find((l) => l.categorie === categorie && !tires.includes(l)); + if (candidat) tires[0] = candidat; + }; + assurerCategorie('MONUMENT'); + assurerCategorie('LIEU'); + + // ≥ 2 régions distinctes si possible : on échange un tiré contre un non-tiré + // d'une autre région et de MÊME catégorie (contrainte catégories préservée, + // les deux étant présentes après l'étape précédente). + const regions = new Set(tires.map((l) => l.region)); + if (regions.size < 2) { + const candidat = melanges.find((l) => !tires.includes(l) && !regions.has(l.region)); + const i = candidat ? tires.findIndex((l) => l.categorie === candidat.categorie) : -1; + if (candidat && i >= 0) tires[i] = candidat; + } + + return tires.map((l) => l.id); +} diff --git a/app/src/routes/api/defi/[seed]/+server.ts b/app/src/routes/api/defi/[seed]/+server.ts new file mode 100644 index 0000000..d59add6 --- /dev/null +++ b/app/src/routes/api/defi/[seed]/+server.ts @@ -0,0 +1,25 @@ +import { json } from '@sveltejs/kit'; +import { getLieuById, sanitizeRound } from '$lib/server/lieux'; +import { idsPourSeed, seedValide } from '$lib/server/tirage'; +import type { RequestHandler } from './$types'; + +/** + * GET /api/defi/[seed] → { rounds: [5 rounds SANITISÉS] }. + * Même seed → mêmes lieux, même ordre, pour tous les joueurs (ADR-001 v1). + * Même format que /api/round : jamais de coordonnées, commune, région ni wikidata_id ; + * les photos passent par le proxy opaque /api/photo/. + */ +export const GET: RequestHandler = ({ params }) => { + if (!seedValide(params.seed)) { + return json( + { erreur: 'Seed invalide (4–40 caractères : lettres, chiffres, tiret, underscore)' }, + { status: 400 } + ); + } + const rounds = idsPourSeed(params.seed).map((id) => { + const lieu = getLieuById(id); + if (!lieu) throw new Error(`Tirage incohérent : id inconnu (${id})`); + return sanitizeRound(lieu); + }); + return json({ rounds }, { headers: { 'Cache-Control': 'no-store' } }); +}; diff --git a/app/src/routes/defi/[seed]/+page.svelte b/app/src/routes/defi/[seed]/+page.svelte new file mode 100644 index 0000000..56f7528 --- /dev/null +++ b/app/src/routes/defi/[seed]/+page.svelte @@ -0,0 +1,589 @@ + + + + {t().app.titre} — {t().modes.defi} — seed {seed} + + +
+ {#if bilan} +
+

{t().defi.bilan}

+

{t().defi.titreSeed} {seed}

+

+ {defi.total} + {t().defi.scoreTotal} +

+
+ +
+
    + {#each defi.manches as m, i (m.place.id)} +
  1. + {i + 1}. {m.result.nom} + {t().regions[m.result.region]} + {m.result.score} {t().resultat.points} +
  2. + {/each} +
+
+ + + + {t().resultat.retourAccueil} +
+

+ {t().defi.taPosition} — + {t().defi.positionReelle} +

+
+ {:else if erreur} +
+

{t().jeu.erreur}

+ +
+ {:else if place} +
+
+ {#if result} +
+ +
+ {:else} + + {/if} +
+ +
+ void soumettre(false)} + /> + {#if !defiTermine} + {t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5 + {/if} + {t().defi.titreSeed} {seed} + {#if timerActif && !result} +
+ +
+ {/if} +
+
+ + {#if !result} +
+
+ + +
+ +
+ {#if indicesMontres.length > 0} + + {/if} + {#if !guess} +

{t().jeu.poserMarqueur}

+ {/if} + {/if} + {:else} +

{t().jeu.charger}

+ {/if} +
+ + diff --git a/app/src/routes/defi/[seed]/+page.ts b/app/src/routes/defi/[seed]/+page.ts new file mode 100644 index 0000000..31be79b --- /dev/null +++ b/app/src/routes/defi/[seed]/+page.ts @@ -0,0 +1,12 @@ +import { error } from '@sveltejs/kit'; +import type { RoundPlace } from '$lib/types'; +import type { PageLoad } from './$types'; + +/** Charge les 5 rounds du défi seedé via l'API (mêmes lieux pour tous les joueurs). */ +export const load: PageLoad = async ({ params, fetch }) => { + const res = await fetch(`/api/defi/${params.seed}`); + if (res.status === 400) error(400, 'Seed de défi invalide'); + if (!res.ok) error(500, 'Impossible de charger ce défi'); + const data = (await res.json()) as { rounds: RoundPlace[] }; + return { seed: params.seed, rounds: data.rounds }; +};