248 lines
8.0 KiB
TypeScript
248 lines
8.0 KiB
TypeScript
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<Difficulte, number> = {
|
||
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/<uuid>) : 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
|
||
}));
|
||
}
|