feat(app): mode street 'An lari a' - visionneuse mapillary-js lazy, /api/round?mode=street, i18n FR/GCF (ADR-003)

This commit is contained in:
sucupira
2026-07-18 20:15:03 -04:00
parent fab020ba75
commit e4a0bfd045
15 changed files with 546 additions and 27 deletions
+60 -5
View File
@@ -1,4 +1,5 @@
import rawLieux from './data/lieux.json';
import rawStreet from './data/street.json';
import type {
Categorie,
Difficulte,
@@ -6,10 +7,15 @@ import type {
GuessResult,
Lieu,
Region,
RoundPlace
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'];
@@ -43,7 +49,13 @@ function parseCategorie(value: string | null): Categorie | null {
return value && CATEGORIES.includes(value as Categorie) ? (value as Categorie) : null;
}
export function getLieuById(id: string): Lieu | undefined {
/** 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);
}
@@ -74,6 +86,28 @@ export function getRandomLieu(options: {
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;
@@ -103,6 +137,27 @@ export function sanitizeRound(lieu: Lieu): RoundPlace {
};
}
/**
* 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²) :
@@ -111,7 +166,7 @@ export function sanitizeRound(lieu: Lieu): RoundPlace {
* 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: Lieu): number {
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) {
@@ -126,7 +181,7 @@ export function rayonEffectifKm(lieu: Lieu): number {
* bonus temps ≤ +20 % ; total plafonné à 5000.
*/
export function scoreGuess(
lieu: Lieu,
lieu: Scoreable,
guess: { lat: number; lon: number },
hintsUsed: number,
timeMs?: number
@@ -154,7 +209,7 @@ export function scoreGuess(
}
/** Réponse « Passer » : la vérité est révélée, score 0, pas de distance. */
export function skipResult(lieu: Lieu): GuessResult {
export function skipResult(lieu: Scoreable): GuessResult {
return {
distanceKm: null,
score: 0,