import rawLieux from './data/lieux.json'; import rawStreet from './data/street.json'; import type { Categorie, Difficulte, ExplorerLieu, GuessResult, Lieu, Region, RoundPlace, StreetLieu } from '$lib/types'; const lieux = rawLieux as Lieu[]; const street = rawStreet as StreetLieu[]; /** Lieu « scorable » : les deux corpus partagent les champs du scoring. */ type Scoreable = Lieu | StreetLieu; const REGIONS: Region[] = ['GUADELOUPE', 'MARTINIQUE', 'GUYANE', 'REUNION']; const CATEGORIES: Categorie[] = ['MONUMENT', 'LIEU']; export const MAX_POINTS = 5000; /** Rayon (km) de la courbe de score selon la difficulté du lieu. */ export const RAYONS: Record = { FACILE: 50, MOYEN: 15, DIFFICILE: 5, EXPERT: 1 }; /** Distance haversine en km. */ export function haversineKm(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number { const R = 6371; const dLat = ((b.lat - a.lat) * Math.PI) / 180; const dLon = ((b.lon - a.lon) * Math.PI) / 180; const s = Math.sin(dLat / 2) ** 2 + Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLon / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)); } function parseRegion(value: string | null): Region | null { return value && REGIONS.includes(value as Region) ? (value as Region) : null; } function parseCategorie(value: string | null): Categorie | null { return value && CATEGORIES.includes(value as Categorie) ? (value as Categorie) : null; } /** Recherche dans les deux corpus (photos classiques + street Mapillary). */ export function getLieuById(id: string): Scoreable | undefined { return lieux.find((l) => l.id === id) ?? street.find((l) => l.id === id); } /** Recherche limitée au corpus photo classique (défi, proxy photo). */ export function getLieuPhotoById(id: string): Lieu | undefined { return lieux.find((l) => l.id === id); } /** Sélection aléatoire d'un lieu, avec filtres optionnels. */ export function getRandomLieu(options: { region?: string | null; categorie?: string | null; exclude?: string | null; }): Lieu | null { const region = parseRegion(options.region ?? null); const categorie = parseCategorie(options.categorie ?? null); const excluded = new Set( (options.exclude ?? '') .split(',') .map((s) => s.trim()) .filter(Boolean) ); let pool = lieux.filter((l) => !excluded.has(l.id)); if (region) pool = pool.filter((l) => l.region === region); if (categorie) pool = pool.filter((l) => l.categorie === categorie); // Si tout a été exclu, on réessaie sans les exclusions (le jeu doit continuer). if (pool.length === 0) { pool = lieux; if (region) pool = pool.filter((l) => l.region === region); if (categorie) pool = pool.filter((l) => l.categorie === categorie); } if (pool.length === 0) return null; return pool[Math.floor(Math.random() * pool.length)]; } /** Sélection aléatoire d'un lieu street (mode « An lari a », corpus Mapillary). */ export function getRandomStreet(options: { region?: string | null; exclude?: string | null; }): StreetLieu | null { const region = parseRegion(options.region ?? null); const excluded = new Set( (options.exclude ?? '') .split(',') .map((s) => s.trim()) .filter(Boolean) ); let pool = street.filter((l) => !excluded.has(l.id)); if (region) pool = pool.filter((l) => l.region === region); // Si tout a été exclu, on réessaie sans les exclusions (le jeu doit continuer). if (pool.length === 0) { pool = region ? street.filter((l) => l.region === region) : street; } if (pool.length === 0) return null; return pool[Math.floor(Math.random() * pool.length)]; } /** Largeurs (px) servies par le proxy photo /api/photo/[id]?w=. */ export const PHOTO_LARGEURS = [400, 800, 1200] as const; /** * Anti-triche : ne garde que les champs publiques pour /api/round. * L'URL photo passe par le proxy serveur (/api/photo/) : l'URL Wikimedia * Commons d'origine contient le nom du fichier, qui révèle souvent le lieu (C4). */ export function sanitizeRound(lieu: Lieu): RoundPlace { return { id: lieu.id, nom: lieu.nom, nom_creole: lieu.nom_creole, categorie: lieu.categorie, difficulte: lieu.difficulte, photo: { url: `/api/photo/${lieu.id}`, srcset: { '400': `/api/photo/${lieu.id}?w=400`, '800': `/api/photo/${lieu.id}?w=800`, '1200': `/api/photo/${lieu.id}?w=1200` }, credit: lieu.photo.credit, alt: lieu.photo.alt }, indices: lieu.indices }; } /** * Anti-triche, mode street : ne garde que les champs publics. * Seul `mapillaryId` part au client (la visionneuse doit bien appeler Mapillary) ; * coords/commune/région/wikidata_id ne partent jamais avant le guess. * Limitation documentée (architecture.md §10) : l'image_key est publique côté * Mapillary, un joueur techniquement équipé peut retrouver la position — même * propriété que GeoGuessr/Street View, acceptable en solo sans leaderboard. */ export function sanitizeRoundStreet(lieu: StreetLieu): RoundPlace { return { id: lieu.id, nom: lieu.nom, nom_creole: lieu.nom_creole, categorie: 'LIEU', difficulte: lieu.difficulte, mapillaryId: lieu.mapillary_id, credit: `© ${lieu.credit.username} / Mapillary (CC BY-SA 4.0)`, indices: lieu.indices }; } /** * Rayon effectif (km) de la courbe de score (audit A1). * Les communes guyanaises sont immenses (Saint-Laurent-du-Maroni ≈ 4 321 km²) : * avec le seul rayon de difficulté, un guess à quelques dizaines de km y est * injustement quasi nul. Quand `aire_commune_km2` est renseigné, le rayon est * au moins celui du disque de même aire que la commune : sqrt(aire / π). * Champ optionnel : sans lui, on retombe sur le rayon de difficulté. */ export function rayonEffectifKm(lieu: Scoreable): number { const base = RAYONS[lieu.difficulte]; const aire = lieu.aire_commune_km2; if (typeof aire === 'number' && Number.isFinite(aire) && aire > 0) { return Math.max(base, Math.sqrt(aire / Math.PI)); } return base; } /** * Scoring recalculé côté serveur : * score = round(5000 × e^(−d/r_eff)) ; pénalité −10 % par indice ; * bonus temps ≤ +20 % ; total plafonné à 5000. */ export function scoreGuess( lieu: Scoreable, guess: { lat: number; lon: number }, hintsUsed: number, timeMs?: number ): GuessResult { const distanceKm = haversineKm(guess, lieu.coordonnees); const radiusEff = rayonEffectifKm(lieu); const base = Math.round(MAX_POINTS * Math.exp(-distanceKm / radiusEff)); const penalises = base * Math.max(0, 1 - 0.1 * Math.max(0, hintsUsed)); let total = penalises; if (typeof timeMs === 'number' && Number.isFinite(timeMs) && timeMs >= 0) { total += penalises * 0.2 * Math.max(0, 1 - timeMs / 60000); } const score = Math.min(MAX_POINTS, Math.round(total)); return { distanceKm: Math.round(distanceKm * 10) / 10, score, radiusEffKm: Math.round(radiusEff * 10) / 10, coordonnees: lieu.coordonnees, commune: lieu.commune, region: lieu.region, nom: lieu.nom, nom_creole: lieu.nom_creole, wikidata_id: lieu.wikidata_id }; } /** Réponse « Passer » : la vérité est révélée, score 0, pas de distance. */ export function skipResult(lieu: Scoreable): GuessResult { return { distanceKm: null, score: 0, radiusEffKm: null, coordonnees: lieu.coordonnees, commune: lieu.commune, region: lieu.region, nom: lieu.nom, nom_creole: lieu.nom_creole, wikidata_id: lieu.wikidata_id }; } /** * Catalogue Explorer : coords arrondies à 0.05° (mode sans score, fiches autorisées). * L'`id` (uuid) n'est volontairement PAS exposé : il permettrait de corréler * l'id d'un round avec la commune du lieu (fuite de réponse, audit C4). * L'Explorer n'a pas besoin d'ids stables (le front utilise l'indice du tableau). */ export function catalogueExplorer(): ExplorerLieu[] { return lieux.map((l) => ({ nom: l.nom, nom_creole: l.nom_creole, region: l.region, commune: l.commune, coordonnees: { lat: Math.round(l.coordonnees.lat / 0.05) * 0.05, lon: Math.round(l.coordonnees.lon / 0.05) * 0.05 }, categorie: l.categorie, difficulte: l.difficulte, wikidata_id: l.wikidata_id, photo: l.photo })); }