feat(defi): lien a seed /defi/[seed] - memes 5 lieux pour tous, PRNG seede serveur deterministe (ADR-001 v1)

This commit is contained in:
sucupira
2026-07-16 23:21:31 -04:00
parent 8178e87f87
commit 3f4f284c0a
4 changed files with 712 additions and 0 deletions
+86
View File
@@ -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);
}
+25
View File
@@ -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/<uuid>.
*/
export const GET: RequestHandler = ({ params }) => {
if (!seedValide(params.seed)) {
return json(
{ erreur: 'Seed invalide (440 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' } });
};
+589
View File
@@ -0,0 +1,589 @@
<script lang="ts">
import { page } from '$app/state';
import GameMap from '$lib/components/GameMap.svelte';
import CarteRegions, { type MarqueurCarte } from '$lib/components/CarteRegions.svelte';
import PhotoPanel from '$lib/components/PhotoPanel.svelte';
import ResultPanel from '$lib/components/ResultPanel.svelte';
import TimerRing from '$lib/components/TimerRing.svelte';
import { t } from '$lib/i18n/store.svelte';
import { defi } from '$lib/stores/defi.svelte';
import type { Coordonnees, GuessResult, RoundPlace } from '$lib/types';
let { data } = $props();
const seed = $derived(data.seed);
const rounds = $derived(data.rounds);
const timerActif = $derived(page.url.searchParams.get('timer') === '1');
let place = $state<RoundPlace | null>(null);
let guess = $state<Coordonnees | null>(null);
let result = $state<GuessResult | null>(null);
let indicesMontres = $state<string[]>([]);
let erreur = $state(false);
let enCours = $state(false); // requête guess en vol
let timerRef: ReturnType<typeof TimerRing> | undefined = $state();
let bilan = $state(false); // écran final du défi
let lienCopie = $state(false); // retour visuel du bouton « Copier le lien »
const mancheCourante = $derived(defi.manches.length + 1);
const defiTermine = $derived(defi.termine);
// Marqueurs du récap SVG : position réelle (vert) + guess du joueur (corail),
// numérotés par manche ; une manche passée (guess null) n'a que le point réel.
const marqueursRecap = $derived(
defi.manches.flatMap((m, i): MarqueurCarte[] => {
const reel: MarqueurCarte = {
lat: m.result.coordonnees.lat,
lon: m.result.coordonnees.lon,
type: 'reel',
label: m.result.nom,
paire: i + 1
};
if (!m.guess) return [reel];
return [
reel,
{ lat: m.guess.lat, lon: m.guess.lon, type: 'guess', label: m.result.nom, paire: i + 1 }
];
})
);
// Les 5 lieux sont préchargés par le load : la manche courante suit l'état du store.
function chargerRound(): void {
guess = null;
result = null;
indicesMontres = [];
place = rounds[defi.manches.length] ?? null;
erreur = !place;
}
async function soumettre(skip: boolean): Promise<void> {
if (!place || result || enCours) return;
if (!skip && !guess) return;
enCours = true;
try {
const body: Record<string, unknown> = { id: place.id };
if (skip) {
body.skip = true;
} else {
body.lat = guess!.lat;
body.lon = guess!.lon;
body.hintsUsed = indicesMontres.length;
if (timerActif && timerRef) body.timeMs = timerRef.tempsMs();
}
const res = await fetch('/api/guess', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(String(res.status));
result = (await res.json()) as GuessResult;
defi.ajouter({ place, guess: skip ? null : guess, result });
} catch {
erreur = true;
} finally {
enCours = false;
}
}
function montrerIndice(): void {
if (!place) return;
const prochain = place.indices.find((i) => !indicesMontres.includes(i.texte));
if (prochain) indicesMontres = [...indicesMontres, prochain.texte];
}
function suivant(): void {
chargerRound();
}
function montrerBilan(): void {
bilan = true;
}
function rejouerDefi(): void {
defi.reinitialiser();
bilan = false;
lienCopie = false;
chargerRound();
}
/** Copie l'URL du défi dans le presse-papiers (repli : champ temporaire + sélection). */
async function copierLien(): Promise<void> {
const url = `${window.location.origin}/defi/${seed}`;
try {
await navigator.clipboard.writeText(url);
} catch {
// Contexte non sécurisé (HTTP) ou navigateur sans Clipboard API.
const champ = document.createElement('textarea');
champ.value = url;
document.body.appendChild(champ);
champ.select();
document.execCommand('copy');
champ.remove();
}
lienCopie = true;
setTimeout(() => (lienCopie = false), 2500);
}
/** Génère une image canvas téléchargeable : score total + lieux déjà révélés (V3). */
function partager(): void {
const canvas = document.createElement('canvas');
canvas.width = 1080;
canvas.height = 1080;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Fond sable + bandeau vert profond
ctx.fillStyle = '#f5efe2';
ctx.fillRect(0, 0, 1080, 1080);
ctx.fillStyle = '#0b3d2e';
ctx.fillRect(0, 0, 1080, 240);
ctx.fillStyle = '#fffdf8';
ctx.textAlign = 'center';
ctx.font = '800 96px system-ui, sans-serif';
ctx.fillText('JWE', 540, 130);
ctx.font = '600 44px system-ui, sans-serif';
ctx.fillText(t().modes.defi, 540, 200);
// Score total
ctx.fillStyle = '#0b3d2e';
ctx.font = '800 150px system-ui, sans-serif';
ctx.fillText(`${defi.total}`, 540, 420);
ctx.fillStyle = '#5a6b62';
ctx.font = '500 40px system-ui, sans-serif';
ctx.fillText(t().defi.scoreTotal, 540, 480);
// Manches : lieu, région, score
let y = 580;
for (const [i, m] of defi.manches.entries()) {
ctx.fillStyle = i % 2 === 0 ? '#e3efe7' : '#f5efe2';
ctx.fillRect(90, y - 44, 900, 74);
ctx.fillStyle = '#22302a';
ctx.textAlign = 'left';
ctx.font = '600 34px system-ui, sans-serif';
ctx.fillText(`${i + 1}. ${m.result.nom}`, 120, y);
ctx.fillStyle = '#17624a';
ctx.font = '500 30px system-ui, sans-serif';
ctx.fillText(t().regions[m.result.region], 120, y + 34);
ctx.textAlign = 'right';
ctx.fillStyle = '#0b3d2e';
ctx.font = '800 40px system-ui, sans-serif';
ctx.fillText(`${m.result.score}`, 960, y + 8);
y += 92;
}
ctx.fillStyle = '#5a6b62';
ctx.textAlign = 'center';
ctx.font = '500 30px system-ui, sans-serif';
ctx.fillText('labola.o-k-i.net', 540, 1010);
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = 'jwe-defi.png';
a.click();
}
// (Ré)initialise le défi au montage et à chaque changement de seed :
// en navigation client d'un /defi/<seed> vers un autre, le composant est conservé.
let seedJouee = '';
$effect(() => {
if (seed === seedJouee) return;
seedJouee = seed;
defi.reinitialiser();
bilan = false;
lienCopie = false;
chargerRound();
});
</script>
<svelte:head>
<title>{t().app.titre}{t().modes.defi} — seed {seed}</title>
</svelte:head>
<div class="jeu">
{#if bilan}
<div class="bilan">
<h1>{t().defi.bilan}</h1>
<p class="seed-discret">{t().defi.titreSeed} <strong>{seed}</strong></p>
<p class="total">
<span class="valeur">{defi.total}</span>
<span class="unite">{t().defi.scoreTotal}</span>
</p>
<div class="bilan-carte">
<CarteRegions selectionnable={false} marqueurs={marqueursRecap} />
</div>
<ol class="recap">
{#each defi.manches as m, i (m.place.id)}
<li>
<span class="r-nom">{i + 1}. {m.result.nom}</span>
<span class="r-region">{t().regions[m.result.region]}</span>
<span class="r-score">{m.result.score} {t().resultat.points}</span>
</li>
{/each}
</ol>
<div class="bilan-actions">
<button type="button" class="btn btn-primaire" onclick={partager}>
{t().defi.partager}
</button>
<button type="button" class="btn btn-secondaire" onclick={() => void copierLien()}>
{lienCopie ? t().defi.lienCopie : t().defi.copierLien}
</button>
<button type="button" class="btn btn-secondaire" onclick={rejouerDefi}>
{t().resultat.rejouer}
</button>
<a href="/" class="btn btn-tertiaire">{t().resultat.retourAccueil}</a>
</div>
<p class="legende">
<span class="puce joueur" aria-hidden="true"></span> {t().defi.taPosition}
<span class="puce reelle" aria-hidden="true"></span> {t().defi.positionReelle}
</p>
</div>
{:else if erreur}
<div class="etat">
<p>{t().jeu.erreur}</p>
<button type="button" class="btn btn-secondaire" onclick={chargerRound}>
{t().jeu.reessayer}
</button>
</div>
{:else if place}
<div class="split" class:resultat={!!result}>
<section class="panneau-photo">
{#if result}
<div class="resultat-scroll">
<ResultPanel
{result}
creditPhoto={place.photo.credit}
labelBouton={defiTermine ? t().resultat.voirBilan : t().resultat.suivant}
onsuivant={defiTermine ? montrerBilan : suivant}
/>
</div>
{:else}
<PhotoPanel photo={place.photo} />
{/if}
</section>
<section class="panneau-carte">
<GameMap
bind:guess
real={result ? result.coordonnees : null}
interactive={!result}
onvalidate={() => void soumettre(false)}
/>
{#if !defiTermine}
<span class="badge-manche">{t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5</span>
{/if}
<span class="badge-seed">{t().defi.titreSeed} {seed}</span>
{#if timerActif && !result}
<div class="badge-timer">
<TimerRing bind:this={timerRef} actif={!result} />
</div>
{/if}
</section>
</div>
{#if !result}
<div class="barre-actions">
<div class="actions-secondaires">
<button
type="button"
class="btn btn-tertiaire"
onclick={montrerIndice}
disabled={indicesMontres.length >= place.indices.length}
>
{t().jeu.indice}
<span class="penalite">{t().jeu.indicePenalite}</span>
</button>
<button type="button" class="btn btn-secondaire" onclick={() => void soumettre(true)}>
{t().jeu.passer}
</button>
</div>
<button
type="button"
class="btn btn-primaire valider"
disabled={!guess || enCours}
onclick={() => void soumettre(false)}
>
{t().jeu.valider}
</button>
</div>
{#if indicesMontres.length > 0}
<aside class="indices" aria-live="polite">
{#each indicesMontres as texte, i (texte)}
<p class="indice"><strong>{t().jeu.indice} {i + 1}</strong>{texte}</p>
{/each}
</aside>
{/if}
{#if !guess}
<p class="aide-carte">{t().jeu.poserMarqueur}</p>
{/if}
{/if}
{:else}
<div class="etat"><p>{t().jeu.charger}</p></div>
{/if}
</div>
<style>
.jeu {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.etat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
color: var(--oki-encre-doux);
}
.split {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.6rem;
min-height: 0;
}
/* Mobile : photo 60 % haut, carte 40 % bas */
.panneau-photo {
flex: 0 0 58%;
min-height: 0;
}
.split.resultat .panneau-photo {
flex: 1 1 55%;
overflow-y: auto;
}
.panneau-carte {
position: relative;
flex: 1;
min-height: 220px;
}
.resultat-scroll {
height: 100%;
overflow-y: auto;
background: var(--oki-blanc);
border-radius: var(--oki-radius);
box-shadow: var(--oki-ombre);
padding: 1rem;
}
.badge-manche {
position: absolute;
top: 0.6rem;
right: 0.6rem;
background: rgba(255, 253, 248, 0.94);
color: var(--oki-vert);
border-radius: 999px;
padding: 0.25rem 0.8rem;
font-size: 0.82rem;
font-weight: 700;
box-shadow: var(--oki-ombre);
}
/* Seed visible mais discrète : les joueurs vérifient qu'ils jouent le même défi */
.badge-seed {
position: absolute;
top: 2.7rem;
right: 0.6rem;
background: rgba(255, 253, 248, 0.8);
color: var(--oki-encre-doux);
border-radius: 999px;
padding: 0.15rem 0.7rem;
font-size: 0.72rem;
font-weight: 600;
box-shadow: var(--oki-ombre);
}
.badge-timer {
position: absolute;
top: 0.6rem;
left: 3.4rem; /* à droite des boutons zoom */
}
/* Barre d'actions : fixe en zone du pouce (tiers inférieur) */
.barre-actions {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.7rem 0.8rem calc(0.7rem + env(safe-area-inset-bottom));
background: var(--oki-blanc);
border-top: 1px solid var(--oki-sable-fonce);
}
.actions-secondaires {
display: flex;
gap: 0.5rem;
}
.actions-secondaires .btn {
padding: 0.7rem 1rem;
font-size: 0.92rem;
}
.penalite {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.8;
}
.valider {
flex: 0 0 auto;
padding: 0.9rem 1.4rem;
}
.indices {
padding: 0 1rem 0.5rem;
}
.indice {
margin: 0.25rem 0;
background: var(--oki-bleu-doux);
border-left: 4px solid var(--oki-bleu);
border-radius: var(--oki-radius-petit);
padding: 0.5rem 0.8rem;
font-size: 0.95rem;
}
.aide-carte {
margin: 0;
padding: 0 1rem 0.6rem;
text-align: center;
font-size: 0.88rem;
color: var(--oki-encre-doux);
}
/* Desktop : 50/50 */
@media (min-width: 768px) {
.split {
flex-direction: row;
}
.panneau-photo,
.split.resultat .panneau-photo {
flex: 1 1 50%;
}
.panneau-carte {
flex: 1 1 50%;
}
.barre-actions {
justify-content: center;
gap: 1.5rem;
}
}
/* — Écran bilan du défi — */
.bilan {
flex: 1;
width: 100%;
max-width: 860px;
margin: 0 auto;
padding: 1.2rem 1rem 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.bilan h1 {
margin: 0;
text-align: center;
color: var(--oki-vert);
}
.seed-discret {
margin: 0;
text-align: center;
font-size: 0.9rem;
color: var(--oki-encre-doux);
}
.total {
margin: 0;
text-align: center;
}
.total .valeur {
font-size: 2.8rem;
font-weight: 800;
color: var(--oki-vert);
}
.total .unite {
margin-left: 0.4rem;
color: var(--oki-encre-doux);
}
.bilan-carte {
width: 100%;
max-width: 520px;
margin: 0 auto;
}
.recap {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.recap li {
display: flex;
align-items: baseline;
gap: 0.6rem;
background: var(--oki-blanc);
border-radius: var(--oki-radius-petit);
box-shadow: var(--oki-ombre);
padding: 0.55rem 0.9rem;
}
.r-nom {
font-weight: 700;
color: var(--oki-vert);
}
.r-region {
flex: 1;
color: var(--oki-encre-doux);
font-size: 0.9rem;
}
.r-score {
font-weight: 800;
}
.bilan-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.7rem;
}
.legende {
margin: 0;
text-align: center;
font-size: 0.85rem;
color: var(--oki-encre-doux);
}
.puce.joueur {
color: var(--oki-corail);
}
.puce.reelle {
color: var(--oki-vert);
}
</style>
+12
View File
@@ -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 };
};