153 lines
4.4 KiB
TypeScript
153 lines
4.4 KiB
TypeScript
|
|
import rawLieux from './data/lieux.json';
|
|||
|
|
import type {
|
|||
|
|
Categorie,
|
|||
|
|
Difficulte,
|
|||
|
|
ExplorerLieu,
|
|||
|
|
GuessResult,
|
|||
|
|
Lieu,
|
|||
|
|
Region,
|
|||
|
|
RoundPlace
|
|||
|
|
} from '$lib/types';
|
|||
|
|
|
|||
|
|
const lieux = rawLieux as Lieu[];
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getLieuById(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)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Anti-triche : ne garde que les champs publiques pour /api/round. */
|
|||
|
|
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: lieu.photo,
|
|||
|
|
indices: lieu.indices
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Scoring recalculé côté serveur :
|
|||
|
|
* score = round(5000 × e^(−d/r)), r selon difficulté ;
|
|||
|
|
* pénalité −10 % par indice ; bonus temps ≤ +20 % ; total plafonné à 5000.
|
|||
|
|
*/
|
|||
|
|
export function scoreGuess(
|
|||
|
|
lieu: Lieu,
|
|||
|
|
guess: { lat: number; lon: number },
|
|||
|
|
hintsUsed: number,
|
|||
|
|
timeMs?: number
|
|||
|
|
): GuessResult {
|
|||
|
|
const distanceKm = haversineKm(guess, lieu.coordonnees);
|
|||
|
|
const base = Math.round(MAX_POINTS * Math.exp(-distanceKm / RAYONS[lieu.difficulte]));
|
|||
|
|
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,
|
|||
|
|
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: Lieu): GuessResult {
|
|||
|
|
return {
|
|||
|
|
distanceKm: null,
|
|||
|
|
score: 0,
|
|||
|
|
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). */
|
|||
|
|
export function catalogueExplorer(): ExplorerLieu[] {
|
|||
|
|
return lieux.map((l) => ({
|
|||
|
|
id: l.id,
|
|||
|
|
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
|
|||
|
|
}));
|
|||
|
|
}
|