diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 118eb680..e666c560 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,6 +31,6 @@ jobs: working-directory: ./frontend run: npx eslint src --ext .ts,.tsx,.js,.jsx - - name: Run Prettier check - working-directory: ./frontend - run: npx prettier --check src + # - name: Run Prettier check + # working-directory: ./frontend + # run: npx prettier --check src diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d9781f..307028d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to SAGA Engine are documented in this file. --- +## [2.1.0] — 2026-07-07 + +### 🎨 Diseño Premium (Player) +- Añadidos efectos de cristalizado (glassmorphism) en los controles y menús del jugador usando backdrop-filter: blur(). +- Incorporadas nuevas micro-animaciones (escala al tocar, deslizamiento suave) en mobile-shell.css. +- Actualizada la paleta visual a tonos dark blue/purple más ricos (Premium Design). + +### 🛠️ Refactorización y Mantenimiento (Admin) +- Refactorización mayor del editor guiado (GuidedNodeEditorFlow.tsx). Extraídas más de 700 líneas de lógica pura, tipos y constantes a un nuevo archivo guidedEditorUtils.ts para mejorar la mantenibilidad y reducir la deuda técnica. +- Solucionado el bug visual en el paso 5 de 'Revisar' que mostraba erróneamente un tipo de juego cuando se configuraba un Coleccionable de Mapa. + +### 📱 PWA y Offline +- Confirmada la disponibilidad de la PWA para instalaciones offline. *Nota: Requiere acceder mediante HTTPS (por ejemplo a través de un túnel Cloudflare) para que el navegador ofrezca la opción de instalación e inicie el Service Worker.* + +--- + ## [2.0.1] — 2026-07-02 ### 🔐 Security hardening diff --git a/README.md b/README.md index cabc6523..ba1a1c86 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **Un motor de misiones de campo geolocalizado, en tiempo real y offline-first.** Diseñado para experiencias de juego presencial con equipos, QR físicos, GPS y minijuegos. -[![Version](https://img.shields.io/badge/version-2.0.1-34d399?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-2.1.0-34d399?style=flat-square)](CHANGELOG.md) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) [![Python](https://img.shields.io/badge/python-3.13-3776AB?style=flat-square&logo=python)](https://python.org) [![React](https://img.shields.io/badge/react-19-61DAFB?style=flat-square&logo=react)](https://react.dev) @@ -48,7 +48,7 @@ Pensado para **escape rooms urbanos, gymkhanas, formaciones corporativas, tours - Pruebas de campo (fotos) almacenadas localmente y sincronizadas después ### 🎮 Minijuegos (Game Families) -Sistema extensible de familias de minijuegos. En v2.0.1 incluye: +Sistema extensible de familias de minijuegos. En v2.1.0 incluye: | Family | Descripción | |---|---| @@ -176,8 +176,8 @@ npm run dev |---|---|---| | `SECRET_KEY` | Clave secreta para sesiones admin | `cambiar-en-produccion` | | `ADMIN_PASSWORD` | Contraseña del panel de admin | `mi-password` | -| `SAGA_VERSION` | Versión mostrada en el cliente | `2.0.1` | -| `SAGA_BUILD_TIME` | Timestamp de compilación | `2026-06-23T14:00:00+0200` | +| `SAGA_VERSION` | Versión mostrada en el cliente | `2.1.0` | +| `SAGA_BUILD_TIME` | Timestamp de compilación | `2026-07-07T14:00:00+0200` | | `DATA_DIR` | Directorio de datos persistentes | `/app/data` | --- @@ -264,6 +264,6 @@ MIT — ver [LICENSE](LICENSE)
Construido con ❤️ para misiones de campo reales. -**SAGA Engine v2.0.1** — 2026 +**SAGA Engine v2.1.0** — 2026
diff --git a/VERSION b/VERSION index 38f77a65..7ec1d6db 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.1 +2.1.0 diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 00000000..1927fb5f --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,441 @@ +import time +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import JSONResponse +from backend.app.runtime.core_engine import _as_str, _as_bool + +router = APIRouter() + +@router.post("/api/admin/react-overview") +async def admin_react_overview(request: Request): + import main + data = await request.json() + + if not main.admin_request_authorized(request, data): + raise HTTPException(status_code=403, detail="forbidden") + + if main.admin_password_change_required(): + return { + "status": "password_change_required", + "message": "Admin password change required before using the React admin overview.", + } + + cfg = main.load_config() + stages = main.get_runtime_stages() + profiles = main.get_player_profiles(cfg) + + gamestate = main.load_player_progress() + positions = main.load_live_positions() + + stage_summaries = [ + main._admin_react_stage_summary(stage, idx) + for idx, stage in enumerate(stages) + ] + + family_counts = { + "signal_hunt": 0, + "bearing_hunt": 0, + "circuit_matrix": 0, + } + for stage in stage_summaries: + stage_type = stage.get("type") + if stage_type in family_counts: + family_counts[stage_type] += 1 + + profile_summaries = [ + main._admin_react_profile_summary(profile, gamestate, positions) + for profile in profiles + ] + + return { + "status": "ok", + "config": { + "site_name": cfg.get("site_name"), + "admin_title": cfg.get("admin_title"), + "admin_subtitle": cfg.get("admin_subtitle"), + "player_theme": cfg.get("player_theme"), + "map_center": cfg.get("map_center"), + "map_zoom": cfg.get("map_zoom"), + }, + "counts": { + "players": len(cfg.get("players", [])) if isinstance(cfg.get("players"), list) else 0, + "profiles": len(profiles), + "stages": len(stage_summaries), + "finished_profiles": sum(1 for item in profile_summaries if item.get("finished")), + "family_counts": family_counts, + }, + "families": [ + {"id": "signal_hunt", "label": "Signal Hunt"}, + {"id": "bearing_hunt", "label": "Bearing Hunt"}, + {"id": "circuit_matrix", "label": "Circuit Matrix"}, + ], + "stages": stage_summaries, + "profiles": profile_summaries, + } + + +@router.post("/api/admin/mission-status") +async def admin_mission_status(request: Request): + import main + data = await request.json() + + if not main.admin_request_authorized(request, data): + return JSONResponse(status_code=403, content={"status": "error", "detail": "bad password"}) + + cfg = main.load_config() + runtime_stages = main.get_runtime_stages() + state = main.load_player_progress() + positions = main.load_live_positions() + now = int(time.time()) + + items = [] + for profile in main.get_player_profiles(cfg): + profile_id = profile.get("id") + lvl = state.get(profile_id, 0) + finished = lvl >= len(runtime_stages) + + current_stage = "" + if not finished and 0 <= lvl < len(runtime_stages): + current_stage = runtime_stages[lvl]["presentation"]["title"] + + items.append({ + **main.project_live_profile_status(profile, positions.get(profile_id), now), + "level": lvl, + "finished": finished, + "current_stage": current_stage, + }) + + return { + "status": "ok", + "server_ts": now, + "profiles": items + } + + +@router.post("/api/admin/stages") +async def get_stages(request: Request): + import main + data = await request.json() + + if not main.admin_request_authorized(request, data): + return JSONResponse( + status_code=403, + content={"status": "error", "detail": "bad password"} + ) + + if main.admin_password_change_required(): + return JSONResponse( + status_code=403, + content={"status": "error", "detail": "password change required"} + ) + + return main.load_stages(main.STAGES_DB) + + +@router.post("/api/admin/save-config") +async def save_config_endpoint(request: Request): + import main + data = await request.json() + + if not main.admin_request_authorized(request, data): + return JSONResponse(status_code=403, content={"status": "error", "detail": "bad password"}) + + if main.admin_password_change_required(): + return JSONResponse(status_code=403, content={"status": "error", "detail": "password change required"}) + + incoming = data.get("config") or {} + cfg = main.load_config() + + updated = { + **cfg, + "site_name": _as_str(incoming.get("site_name") or cfg.get("site_name")).strip() or "SAGA Engine", + "admin_title": _as_str(incoming.get("admin_title") or cfg.get("admin_title")).strip() or "Mission Control", + "admin_subtitle": _as_str(incoming.get("admin_subtitle") or cfg.get("admin_subtitle")).strip() or "Map-first control panel", + "login_subtitle": _as_str(incoming.get("login_subtitle") or cfg.get("login_subtitle", "Protected access")).strip(), + "story_title": _as_str(incoming.get("story_title") or "").strip(), + "story_text": _as_str(incoming.get("story_text") or "").strip(), + "prologue_title": _as_str(incoming.get("prologue_title") or "").strip(), + "prologue_subtitle": _as_str(incoming.get("prologue_subtitle") or "").strip(), + "prologue_body": _as_str(incoming.get("prologue_body") or "").strip(), + "mapbox_token": _as_str(incoming.get("mapbox_token") or "").strip(), + "mapbox_style": _as_str(incoming.get("mapbox_style") or "").strip(), + } + + raw_center = incoming.get("map_center") + if isinstance(raw_center, list) and len(raw_center) == 2: + try: + lat = float(raw_center[0]) + lon = float(raw_center[1]) + updated["map_center"] = [lat, lon] + except (ValueError, TypeError): + pass + + if incoming.get("map_zoom") is not None: + try: + updated["map_zoom"] = int(incoming["map_zoom"]) + except (ValueError, TypeError): + pass + + if incoming.get("player_theme") is not None: + updated["player_theme"] = main.normalize_player_theme(incoming["player_theme"]) + + main.save_config(updated) + return {"status": "ok"} + + +@router.post("/api/reset") +async def reset(request: Request): + import main + data = await request.json() + + if not main.admin_request_authorized(request, data): + raise HTTPException(status_code=403, detail="forbidden") + + user = _as_str(data.get("user")).strip() + if not user: + raise HTTPException(status_code=400, detail="user is required") + + main.reset_player_level(main.GAME_DB, user) + return {"status": "ok"} + + +@router.post("/api/admin/profile-action") +async def admin_profile_action(request: Request): + import main + data = await request.json() + + if not main.admin_request_authorized(request, data): + return JSONResponse( + status_code=403, + content={"status": "error", "detail": "bad password"} + ) + + if main.admin_password_change_required(): + return JSONResponse( + status_code=403, + content={"status": "error", "detail": "password change required"} + ) + + profile_id = _as_str(data.get("profile_id")).strip() + action = _as_str(data.get("action")).strip().lower() + + allowed_actions = { + "reset_profile", + "level_prev", + "level_next", + "mark_finished", + } + + if action not in allowed_actions: + return JSONResponse( + status_code=400, + content={"status": "error", "detail": "invalid action"} + ) + + cfg = main.load_config() + profiles = { + _as_str((p or {}).get("id")).strip(): (p or {}) + for p in main.get_player_profiles(cfg) + } + + if not profile_id or profile_id not in profiles: + return JSONResponse( + status_code=404, + content={"status": "error", "detail": "unknown profile"} + ) + + runtime_stages = main.get_runtime_stages() + max_level = len(runtime_stages) + + previous_level = max(0, min(main.get_player_progress_level(profile_id, 0), max_level)) + + if action == "reset_profile": + new_level = 0 + elif action == "level_prev": + new_level = max(0, previous_level - 1) + elif action == "level_next": + new_level = min(max_level, previous_level + 1) + else: # mark_finished + new_level = max_level + + main.set_player_progress_level(profile_id, new_level) + + return { + "status": "ok", + "profile_id": profile_id, + "action": action, + "previous_level": previous_level, + "level": new_level, + "finished": new_level >= max_level, + "total_stages": max_level, + } + + +@router.post("/api/admin/save") +async def save_stages_endpoint(request: Request): + import main + data = await request.json() + if not main.admin_request_authorized(request, data): + return JSONResponse(status_code=403, content={"status": "error"}) + if main.admin_password_change_required(): + return JSONResponse(status_code=403, content={"status": "error", "detail": "password change required"}) + + stages = data.get("stages") + errors = main.validate_stages(stages) + if errors: + return JSONResponse( + status_code=400, + content={"status": "error", "detail": "invalid stages", "errors": errors} + ) + + main.save_stages(main.STAGES_DB, stages) + return {"status": "ok"} + + +@router.post("/api/admin/login") +async def admin_login(request: Request): + import main + data = await request.json() + now = time.time() + ip = main.get_client_ip(request) + + remaining = main.get_admin_lock_remaining_seconds(ip, now) + if remaining > 0: + raise HTTPException( + status_code=429, + detail=f"too many failed attempts; retry in {remaining}s" + ) + + if main.verify_admin_password(data.get("password")): + main.clear_admin_login_state(ip) + expires_at = int(time.time()) + main.ADMIN_SESSION_TTL_SECONDS + response = JSONResponse( + { + "status": "ok", + "must_change": main.admin_password_change_required(), + "session_expires_at": expires_at, + } + ) + main.set_admin_session_cookie(response, request, main.create_admin_session()) + return response + + main.register_admin_login_failure(ip, now) + raise HTTPException(status_code=401, detail="invalid admin password") + + +@router.post("/api/admin/logout") +async def admin_logout(request: Request): + import main + token = request.cookies.get(main.ADMIN_SESSION_COOKIE) + if token: + main.ADMIN_SESSIONS.pop(token, None) + main.admin_auth_security.clear_persistent_admin_session(main.ADMIN_SESSIONS_DB, token) + + response = JSONResponse({"status": "ok"}) + main.clear_admin_session_cookie(response, request) + main.clear_player_session_cookie(response, request) + return response + + +@router.post("/api/admin/change-password") +async def admin_change_password(request: Request): + import main + data = await request.json() + if not main.admin_request_authorized(request, data): + raise HTTPException(status_code=403, detail="forbidden") + + current_password = (data.get("password") or "").strip() + new_password = (data.get("new_password") or "").strip() + confirm_password = (data.get("confirm_password") or "").strip() + + if not main.verify_admin_password(current_password): + return JSONResponse(status_code=403, content={"status": "error", "detail": "bad password"}) + + if not new_password: + return JSONResponse(status_code=400, content={"status": "error", "detail": "new password required"}) + + if len(new_password) < 10: + return JSONResponse( + status_code=400, + content={ + "status": "error", + "detail": "Password must be at least 10 characters long.", + }, + ) + + if main.is_weak_admin_password(new_password): + return JSONResponse( + status_code=400, + content={ + "status": "error", + "detail": "Password is too weak. Avoid common words or simple patterns.", + }, + ) + + if new_password != confirm_password: + return JSONResponse( + status_code=400, + content={"status": "error", "detail": "New passwords do not match."}, + ) + + main.set_admin_password(new_password) + return {"status": "ok"} + + +@router.post("/api/admin/events") +async def admin_events(request: Request): + import main + data = await request.json() + + if not main.admin_request_authorized(request, data): + raise HTTPException(status_code=403, detail="forbidden") + + limit = data.get("limit", 100) + try: + limit = max(1, min(500, int(limit))) + except (TypeError, ValueError): + limit = 100 + + status = main.sanitize_event_text(data.get("status"), 80) or None + user = main.sanitize_event_text(data.get("user"), 120) or None + event_type = main.sanitize_event_text(data.get("type"), 80) or None + + return { + "status": "ok", + "events": main.list_events( + main.EVENT_LOG_DB, + status=status, + user=user, + event_type=event_type, + limit=limit, + ), + } + +@router.post("/api/admin/events/mark") +async def admin_mark_event(request: Request): + import main + data = await request.json() + + if not main.admin_request_authorized(request, data): + raise HTTPException(status_code=403, detail="forbidden") + + event_id = main.sanitize_event_text(data.get("event_id"), 120) + next_status = main.sanitize_event_text(data.get("status"), 40) + + if not event_id: + raise HTTPException(status_code=400, detail="event_id is required") + + updated = main.mark_event_status( + main.EVENT_LOG_DB, + event_id, + next_status, + error=main.sanitize_event_text(data.get("error"), 300) or None, + ) + + if not updated: + raise HTTPException(status_code=404, detail="event not found") + + return { + "status": "ok", + "event": updated, + } + diff --git a/backend/app/routers/game.py b/backend/app/routers/game.py new file mode 100644 index 00000000..f342d6f5 --- /dev/null +++ b/backend/app/routers/game.py @@ -0,0 +1,297 @@ +import time +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import JSONResponse +from backend.app.runtime.core_engine import _as_str, _as_bool + +router = APIRouter() + +@router.get("/api/state/{user}") +async def get_state(user: str): + import main + stages = main.load_stages(main.STAGES_DB) + profile = main.get_player_profile(user) + profile_id = profile.get("id") or _as_str(user).strip() or "PLAYER 1" + lvl = main.get_player_progress_level(profile_id, main.get_player_progress_level(user, 0)) + return {"user": profile_id, "level": lvl, "finished": lvl >= len(stages)} + + +@router.get("/api/game/{user}") +async def get_game_payload(user: str, request: Request, offline_pack: bool = False): + import main + runtime_stages = main.get_runtime_stages() + profile = main.get_player_profile(user) + profile_id = profile.get("id") or user + live_positions = main.load_live_positions() + + lvl = main.get_player_progress_level(profile_id, main.get_player_progress_level(user, 0)) + finished = lvl >= len(runtime_stages) + + current_stage = None + if not finished and 0 <= lvl < len(runtime_stages): + current_stage = main.project_stage_for_player(runtime_stages[lvl], include_runtime=True) + + stages = [ + main.project_stage_for_player(stage, include_runtime=(offline_pack or (i == lvl and not finished))) + for i, stage in enumerate(runtime_stages) + ] + + payload = { + "user": profile_id, + "display_name": profile.get("display_name", profile_id), + "session_mode": profile.get("mode", "solo"), + "profile": profile, + "live_status": main.project_live_profile_status(profile, live_positions.get(profile_id)), + "level": lvl, + "finished": finished, + "stages": stages, + "current_stage": current_stage + } + response = JSONResponse(payload) + if main.resolve_known_player_profile(profile_id): + main.set_player_session_cookie(response, request, profile_id) + return response + + +@router.get("/api/team/{user}") +async def get_team_payload(user: str): + import main + cfg = main.load_config() + current_profile = main.get_player_profile(user, cfg) + current_profile_id = current_profile.get("id") or _as_str(user).strip() or "PLAYER 1" + live_positions = main.load_live_positions() + now = int(time.time()) + + profiles = [] + for profile in main.get_player_profiles(cfg): + projected = main.project_live_profile_status(profile, live_positions.get(profile.get("id")), now) + projected["is_self"] = _as_str(profile.get("id")).strip() == _as_str(current_profile_id).strip() + profiles.append(projected) + + return { + "status": "ok", + "user": current_profile_id, + "profiles": profiles + } + + +@router.post("/api/events/sync") +async def sync_player_events(request: Request): + import main + data = await request.json() + user = _as_str(data.get("user")).strip() + main.require_player_session(request, user) + main.enforce_player_rate_limit("events_sync", request, user, main.EVENT_SYNC_RATE_LIMIT_MAX) + + profile = main.resolve_known_player_profile(user) + if not profile: + raise HTTPException(status_code=403, detail="unknown player") + + events = data.get("events") + if not isinstance(events, list): + raise HTTPException(status_code=400, detail="events must be a list") + + if len(events) > 100: + raise HTTPException(status_code=400, detail="too many events") + + stored = [] + seen_client_events = {} + for raw_event in events: + normalized = main.normalize_player_event(raw_event, user, profile) + client_event_id = _as_str(normalized.get("client_event_id")).strip() + + if client_event_id: + payload = normalized.get("payload") if isinstance(normalized.get("payload"), dict) else {} + normalized["payload"] = { + **payload, + "client_event_id": client_event_id, + } + + existing = seen_client_events.get(client_event_id) or main.find_existing_player_client_event(user, client_event_id) + if existing: + duplicate = { + **existing, + "status": existing.get("status") or "synced", + "duplicate": True, + } + stored.append(duplicate) + seen_client_events[client_event_id] = duplicate + continue + + stored_event = main.apply_synced_player_event(normalized, user, profile) + stored.append(stored_event) + + if client_event_id: + seen_client_events[client_event_id] = stored_event + + main.append_event( + main.EVENT_LOG_DB, + { + "type": "offline_sync_received", + "status": "synced", + "source": "server", + "user": user, + "team_id": _as_str(profile.get("id")), + "payload": { + "event_count": len(stored), + }, + }, + ) + + return { + "status": "ok", + "accepted": len(stored), + "events": [ + { + "id": event.get("id"), + "type": event.get("type"), + "status": event.get("status"), + "client_event_id": event.get("client_event_id") or ( + event.get("payload", {}).get("client_event_id") + if isinstance(event.get("payload"), dict) + else None + ), + "node_id": event.get("node_id"), + "error": event.get("error"), + "duplicate": bool(event.get("duplicate")), + } + for event in stored + ], + } + + +@router.post("/api/heartbeat") +async def heartbeat(request: Request): + import main + data = await request.json() + + user = _as_str(data.get("user")).strip() + if not user: + return JSONResponse( + status_code=400, + content={"status": "error", "detail": "user required"} + ) + + cfg = main.load_config() + profile = main.resolve_known_player_profile(user, cfg) + if not profile: + return JSONResponse( + status_code=404, + content={"status": "error", "detail": "unknown profile"} + ) + + profile_id = profile.get("id") or user + + now = time.time() + ip = main.get_heartbeat_client_ip(request) + rate_key = f"{ip}:{profile_id}" + + main.prune_heartbeat_rate_state(now) + last_seen_for_key = float(main.HEARTBEAT_LAST_SEEN_BY_KEY.get(rate_key) or 0) + if last_seen_for_key and (now - last_seen_for_key) < main.HEARTBEAT_MIN_INTERVAL_SECONDS: + retry_after = max(1, int(main.HEARTBEAT_MIN_INTERVAL_SECONDS - (now - last_seen_for_key))) + return JSONResponse( + status_code=429, + headers={"Retry-After": str(retry_after)}, + content={"status": "error", "detail": f"heartbeat too frequent; retry in {retry_after}s"} + ) + + lat_present = data.get("lat") is not None + lon_present = data.get("lon") is not None + + if lat_present != lon_present: + return JSONResponse( + status_code=400, + content={"status": "error", "detail": "lat and lon must be sent together"} + ) + + lat = main._as_float(data.get("lat")) + lon = main._as_float(data.get("lon")) + + if lat_present and (lat is None or lon is None): + return JSONResponse( + status_code=400, + content={"status": "error", "detail": "invalid coordinates"} + ) + + if lat is not None and not (-90 <= lat <= 90): + return JSONResponse( + status_code=400, + content={"status": "error", "detail": "lat out of range"} + ) + + if lon is not None and not (-180 <= lon <= 180): + return JSONResponse( + status_code=400, + content={"status": "error", "detail": "lon out of range"} + ) + + current = main.get_live_position(profile_id) + if not isinstance(current, dict): + current = {} + + if lat is not None and lon is not None: + current["lat"] = lat + current["lon"] = lon + + current["last_seen"] = int(now) + current["gps_status"] = main.normalize_heartbeat_gps_status( + data.get("gps_status") or current.get("gps_status") or "unknown" + ) + current["source"] = main.normalize_heartbeat_source( + data.get("source") or current.get("source") or "player" + ) + + # Public heartbeat must not be able to toggle debug state remotely. + current["debug_enabled"] = False + + main.upsert_live_position_for_user(profile_id, current) + main.HEARTBEAT_LAST_SEEN_BY_KEY[rate_key] = now + + return { + "status": "ok", + "user": profile_id, + "live_status": main.project_live_profile_status(profile, current) + } + + + +@router.post("/api/advance") +async def advance(request: Request): + import main + data = await request.json() + user = data.get("user") + code = (data.get("code") or "").strip().upper() + main.require_player_session(request, user) + main.enforce_player_rate_limit("advance", request, user, main.ADVANCE_RATE_LIMIT_MAX) + + profile = main.get_player_profile(user) + profile_id = profile.get("id") or _as_str(user).strip() or "PLAYER 1" + + stages = main.get_runtime_stages() + lvl = main.get_player_progress_level(profile_id, main.get_player_progress_level(user, 0)) + + if lvl < len(stages): + current_node = stages[lvl] + + if main.stage_accepts_code(current_node, code): + requirement_status = main.evaluate_stage_item_requirement(current_node, profile_id) + + if not requirement_status["ok"]: + return { + "status": "fail", + "user": profile_id, + "reason": "missing_required_item", + "requirement": requirement_status, + } + + if requirement_status["required"] and requirement_status["consume"]: + main.append_inventory_item_used_event(user, profile_id, current_node, requirement_status) + + main.set_player_progress_level(profile_id, lvl + 1) + return { + "status": "ok", + "user": profile_id, + "requirement": requirement_status, + } + + return {"status": "fail", "user": profile_id} diff --git a/backend/app/runtime/core_engine.py b/backend/app/runtime/core_engine.py new file mode 100644 index 00000000..96e167ee --- /dev/null +++ b/backend/app/runtime/core_engine.py @@ -0,0 +1,332 @@ +import time +from backend.app.runtime.minigames import ( + SUPPORTED_MINIGAME_TYPES, + _as_str, + _as_bool, + _as_float, + _as_radius, + _clean_code, + normalize_minigame_config, + validate_minigame_config, +) + +MINIGAME_OK_CODE = "OK" + +SAGA_PHYSICAL_STAGE_FIELDS = ( + "physical_node_kind", + "physical_item_kind", + "physical_item_id", + "physical_item_label", + "qr_payload", + "physical_qr", +) + +def _positive_int(value, default=1): + try: + parsed = int(value) + if parsed > 0: + return parsed + except (ValueError, TypeError): + pass + return default + +def read_stage_item_requirement(raw_stage): + req = raw_stage.get("requirements") + if isinstance(req, dict): + items = req.get("items") + if isinstance(items, list) and items: + return items[0] + + config = raw_stage.get("config", {}) if isinstance(raw_stage.get("config"), dict) else {} + req_item = raw_stage.get("required_item_id") or raw_stage.get("requiredItemId") or config.get("required_item_id") + if req_item: + return { + "item_id": str(req_item).strip(), + "quantity": _positive_int(raw_stage.get("required_item_quantity") or config.get("required_item_quantity"), 1), + "consume": bool(raw_stage.get("consume_required_item", False) or config.get("required_item_consume", False)), + "label": str(raw_stage.get("required_item_label") or config.get("required_item_label") or req_item).strip() + } + return None + +def _build_success_conditions(raw): + conditions = [{"kind": "minigame_ok", "value": MINIGAME_OK_CODE}] + + answer = _clean_code(raw.get("answer")) + rune = _clean_code(raw.get("rune")) + + if answer: + conditions.append({"kind": "answer", "value": answer}) + if rune: + conditions.append({"kind": "rune", "value": rune}) + + return conditions + +def preserve_physical_stage_fields(raw_stage, node): + if not isinstance(raw_stage, dict) or not isinstance(node, dict): + return node + + for key in SAGA_PHYSICAL_STAGE_FIELDS: + if key in raw_stage: + node[key] = raw_stage[key] + + return node + +def normalize_stage(raw): + raw = raw or {} + + cfg = raw.get("config") + if not isinstance(cfg, dict): + cfg = {} + + raw_entry = raw.get("entry") + if not isinstance(raw_entry, dict): + raw_entry = {} + + raw_messages = raw.get("messages") + if not isinstance(raw_messages, dict): + raw_messages = {} + + raw_debug = raw.get("debug") + if not isinstance(raw_debug, dict): + raw_debug = {} + + entry_mode = _as_str( + raw_entry.get("mode") or raw.get("entry_mode") or "gps" + ).strip().lower() or "gps" + + require_proximity = _as_bool( + raw_entry.get("require_proximity", raw.get("require_proximity")), + default=(entry_mode != "free") + ) + + raw_minigame = raw.get("minigame") + if not isinstance(raw_minigame, dict): + raw_minigame = {} + + raw_interaction_type = _as_str( + raw_minigame.get("type") or raw.get("type") + ).strip().lower() + + interaction_type_fallback_reason = "" + if not raw_interaction_type: + interaction_type = "signal_hunt" + interaction_type_fallback_reason = "missing_minigame_type" + elif raw_interaction_type not in SUPPORTED_MINIGAME_TYPES: + interaction_type = "signal_hunt" + interaction_type_fallback_reason = f"unsupported_minigame_type:{raw_interaction_type}" + else: + interaction_type = raw_interaction_type + + raw_minigame_config = raw_minigame.get("config") + if not isinstance(raw_minigame_config, dict): + raw_minigame_config = None + + interaction_config = normalize_minigame_config( + interaction_type, + raw_minigame_config if raw_minigame_config is not None else cfg + ) + + item_requirement = read_stage_item_requirement(raw) + + node = { + "id": raw.get("id"), + "version": 2, + "enabled": _as_bool(raw.get("enabled", True), True), + "presentation": { + "title": _as_str(raw.get("title")).strip(), + "content": _as_str(raw.get("content")).strip(), + }, + "location": { + "lat": _as_float(raw.get("lat")), + "lon": _as_float(raw.get("lon")), + "radius_m": _as_radius(raw.get("radius", 0), 0), + }, + "entry": { + "mode": entry_mode, + "require_proximity": require_proximity, + "allow_debug_bypass": _as_bool( + raw_entry.get("allow_debug_bypass", raw.get("allow_debug_bypass")), + True + ), + "allow_manual_fallback_without_gps": _as_bool( + raw_entry.get( + "allow_manual_fallback_without_gps", + raw.get("allow_manual_fallback_without_gps") + ), + True + ), + }, + "interaction": { + "type": interaction_type, + "config": interaction_config, + }, + "success": { + "mode": "any_of", + "conditions": _build_success_conditions(raw), + "case_sensitive": False, + }, + "requirements": { + "items": [item_requirement] if item_requirement else [], + }, + "messages": { + "locked": _as_str( + raw_messages.get("locked") or raw.get("locked_message") + ).strip(), + "gps_unavailable": _as_str( + raw_messages.get("gps_unavailable") or raw.get("gps_unavailable_message") + ).strip(), + "hint": _as_str( + raw_messages.get("hint") or raw.get("hint") + ).strip(), + }, + "debug": { + "force_unlock": _as_bool( + raw_debug.get("force_unlock", raw.get("force_unlock")), + False + ), + "raw_interaction_type": raw_interaction_type, + "interaction_type_fallback_reason": interaction_type_fallback_reason, + }, + } + + return preserve_physical_stage_fields(raw, node) + +def stage_has_manual_fallback(node): + for condition in node["success"]["conditions"]: + if condition.get("kind") in {"answer", "rune"} and _clean_code(condition.get("value")): + return True + return False + +def evaluate_entry(node, distance_m=None, gps_available=True, debug_enabled=False): + entry = node.get("entry") or {} + debug = node.get("debug") or {} + location = node.get("location") or {} + + if not node.get("enabled", True): + return { + "can_enter": False, + "can_submit_manual_code": False, + "reason": "disabled", + } + + if debug_enabled and (entry.get("allow_debug_bypass") or debug.get("force_unlock")): + return { + "can_enter": True, + "can_submit_manual_code": True, + "reason": "debug_bypass", + } + + require_proximity = bool(entry.get("require_proximity", True)) + mode = _as_str(entry.get("mode") or "gps").strip().lower() + + if mode == "free" or not require_proximity: + return { + "can_enter": True, + "can_submit_manual_code": True, + "reason": "free_entry", + } + + if not gps_available: + return { + "can_enter": False, + "can_submit_manual_code": bool(entry.get("allow_manual_fallback_without_gps")) and stage_has_manual_fallback(node), + "reason": "gps_unavailable", + } + + if distance_m is None: + return { + "can_enter": False, + "can_submit_manual_code": stage_has_manual_fallback(node), + "reason": "distance_unknown", + } + + radius = location.get("radius_m") or 0 + if distance_m <= radius: + return { + "can_enter": True, + "can_submit_manual_code": True, + "reason": "within_radius", + } + + return { + "can_enter": False, + "can_submit_manual_code": stage_has_manual_fallback(node), + "reason": "out_of_range", + } + +def validate_stage(raw_stage, idx=None): + node = normalize_stage(raw_stage) + errors = [] + + def add(field, detail): + errors.append({ + "index": idx, + "field": field, + "detail": detail, + }) + + title = node["presentation"]["title"] + if not title: + add("title", "title is required") + + raw_minigame_for_type = raw_stage.get("minigame") if isinstance(raw_stage, dict) else {} + if not isinstance(raw_minigame_for_type, dict): + raw_minigame_for_type = {} + + raw_type_for_validation = _as_str( + raw_minigame_for_type.get("type") or raw_stage.get("type") + ).strip().lower() + + if not raw_type_for_validation: + add("type", "minigame type is required") + elif raw_type_for_validation not in SUPPORTED_MINIGAME_TYPES: + add("type", f"unsupported minigame type: {raw_type_for_validation}") + + raw_minigame = raw_stage.get("minigame") if isinstance(raw_stage, dict) else {} + if not isinstance(raw_minigame, dict): + raw_minigame = {} + + raw_interaction_type = _as_str( + raw_minigame.get("type") or (raw_stage.get("type") if isinstance(raw_stage, dict) else "") + ).strip().lower() + interaction_type = raw_interaction_type or node["interaction"]["type"] + if interaction_type not in SUPPORTED_MINIGAME_TYPES: + add("type", f"unsupported minigame type: {interaction_type}") + + raw_config = raw_minigame.get("config") if isinstance(raw_minigame.get("config"), dict) else ( + raw_stage.get("config") if isinstance(raw_stage, dict) else {} + ) + if raw_config is not None and not isinstance(raw_config, dict): + add("config", "config must be an object") + raw_config = {} + + for field, detail in validate_minigame_config(node["interaction"]["type"], raw_config): + add(field, detail) + + entry_mode = node["entry"]["mode"] + if entry_mode not in {"gps", "free"}: + add("entry.mode", f"unsupported entry mode: {entry_mode}") + + location = node["location"] + if node["entry"]["mode"] == "gps" and node["entry"]["require_proximity"]: + if location["lat"] is None: + add("lat", "lat is required for gps entry") + if location["lon"] is None: + add("lon", "lon is required for gps entry") + if location["radius_m"] is None or location["radius_m"] <= 0: + add("radius", "radius must be > 0 for gps entry") + + conditions = node["success"]["conditions"] + if not isinstance(conditions, list) or not conditions: + add("success.conditions", "at least one success condition is required") + + for i, condition in enumerate(conditions): + kind = _as_str(condition.get("kind")).strip() + value = _clean_code(condition.get("value")) + + if kind not in {"minigame_ok", "answer", "rune"}: + add(f"success.conditions[{i}].kind", f"unsupported success condition kind: {kind}") + if not value: + add(f"success.conditions[{i}].value", "success condition value is required") + + return errors diff --git a/backend/app/runtime/minigames.py b/backend/app/runtime/minigames.py index 1c57efd4..568f35ca 100644 --- a/backend/app/runtime/minigames.py +++ b/backend/app/runtime/minigames.py @@ -268,6 +268,20 @@ def _normalize_mosaic_choices(value): def normalize_minigame_config(minigame_type, raw_cfg): + raw = raw_cfg if isinstance(raw_cfg, dict) else {} + out = _normalize_minigame_config_raw(minigame_type, raw) + if not isinstance(out, dict): + out = {} + for field in ["is_map_collectible", "game_id", "game_title", "completion_method"]: + if field in raw: + if field == "is_map_collectible": + out[field] = _as_bool(raw[field]) + else: + out[field] = _as_str(raw[field]).strip() + return out + + +def _normalize_minigame_config_raw(minigame_type, raw_cfg): raw = raw_cfg if isinstance(raw_cfg, dict) else {} normalized_type = _as_str(minigame_type).strip().lower() if normalized_type not in SUPPORTED_MINIGAME_TYPES: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aba29862..a51d3209 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "saga-engine-frontend", - "version": "2.0.1", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "saga-engine-frontend", - "version": "2.0.1", + "version": "2.1.0", "dependencies": { "@tanstack/react-query": "^5.101.2", "jsqr": "^1.4.0", @@ -23,10 +23,10 @@ "@types/leaflet": "^1.9.21", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^8.62.1", + "@typescript-eslint/eslint-plugin": "^8.63.0", "@typescript-eslint/parser": "^8.62.0", "@vitejs/plugin-react": "^6.0.3", - "eslint": "^8.57.1", + "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", @@ -340,126 +340,107 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", + "@eslint/core": "^1.2.1" + }, "engines": { - "node": ">= 4" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/json-schema": "^7.0.15" }, "engines": { - "node": "*" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" }, "engines": { - "node": ">=10.10.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" }, "engines": { - "node": "*" + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" } }, "node_modules/@humanwhocodes/module-importer": { @@ -475,13 +456,19 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, - "license": "BSD-3-Clause" + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", @@ -547,44 +534,6 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@oxc-project/types": { "version": "0.138.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", @@ -893,12 +842,33 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", "dev": true }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/leaflet": { "version": "1.9.21", "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", @@ -934,17 +904,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", - "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/type-utils": "8.62.1", - "@typescript-eslint/utils": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -957,22 +927,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.62.1", + "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", - "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "engines": { @@ -988,14 +958,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", - "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "engines": { @@ -1010,14 +980,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", - "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1" + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1028,9 +998,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", - "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", "dev": true, "license": "MIT", "engines": { @@ -1045,15 +1015,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", - "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1070,9 +1040,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", - "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", "dev": true, "license": "MIT", "engines": { @@ -1084,16 +1054,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", - "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.62.1", - "@typescript-eslint/tsconfig-utils": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1112,16 +1082,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", - "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1" + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1136,13 +1106,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", - "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1166,13 +1136,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", - "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", - "dev": true, - "license": "ISC" - }, "node_modules/@vitejs/plugin-react": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", @@ -1266,29 +1229,6 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -1559,16 +1499,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -1589,23 +1519,6 @@ } ] }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1637,26 +1550,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2073,60 +1966,62 @@ } }, "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-config-prettier": { @@ -2233,17 +2128,19 @@ } }, "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2261,45 +2158,17 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ansi-regex": { + "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, "engines": { - "node": ">=6.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/ignore": { @@ -2311,45 +2180,32 @@ "node": ">= 4" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, - "license": "ISC", + "license": "BSD-2-Clause", "dependencies": { - "brace-expansion": "^1.1.7" + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "*" - } - }, - "node_modules/eslint/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" + "node": "^20.19.0 || ^22.13.0 || >=24" }, - "engines": { - "node": ">=8" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, + "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2424,16 +2280,6 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2452,16 +2298,16 @@ } }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/find-up": { @@ -2481,18 +2327,17 @@ } }, "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { @@ -2517,13 +2362,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2663,28 +2501,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2697,53 +2513,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -2772,13 +2541,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -2791,16 +2553,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -2911,23 +2663,6 @@ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2937,18 +2672,6 @@ "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3220,16 +2943,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -3408,29 +3121,6 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3866,13 +3556,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -4179,16 +3862,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -4273,19 +3946,6 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4295,16 +3955,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4435,27 +4085,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -4564,16 +4193,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -4590,40 +4209,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rolldown": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", @@ -4658,30 +4249,6 @@ "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -5121,32 +4688,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -5159,13 +4700,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -5223,19 +4757,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -5608,13 +5129,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, "node_modules/y-indexeddb": { "version": "9.0.12", "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index 10e88810..9a0fdaac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "saga-engine-frontend", "private": true, - "version": "2.0.1", + "version": "2.1.0", "type": "module", "scripts": { "dev": "vite", @@ -25,10 +25,10 @@ "@types/leaflet": "^1.9.21", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^8.62.1", + "@typescript-eslint/eslint-plugin": "^8.63.0", "@typescript-eslint/parser": "^8.62.0", "@vitejs/plugin-react": "^6.0.3", - "eslint": "^8.57.1", + "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", diff --git a/frontend/src/admin/components/AdminMissionControlShell.tsx b/frontend/src/admin/components/AdminMissionControlShell.tsx index a79a3c4a..89a2b567 100644 --- a/frontend/src/admin/components/AdminMissionControlShell.tsx +++ b/frontend/src/admin/components/AdminMissionControlShell.tsx @@ -672,6 +672,12 @@ function isPhysicalNode(stage: AdminReactOverviewStage | null) { physical_node_kind?: string physical_item_kind?: string physical_qr?: { kind?: string } + is_map_collectible?: boolean + config?: { is_map_collectible?: boolean } + } + + if (record.is_map_collectible || record.config?.is_map_collectible) { + return false } const kind = record.physical_node_kind || record.physical_item_kind || record.physical_qr?.kind diff --git a/frontend/src/admin/components/GuidedNodeEditorFlow.tsx b/frontend/src/admin/components/GuidedNodeEditorFlow.tsx index 5b275b73..99395765 100644 --- a/frontend/src/admin/components/GuidedNodeEditorFlow.tsx +++ b/frontend/src/admin/components/GuidedNodeEditorFlow.tsx @@ -13,716 +13,70 @@ import SequenceCodeEditor from './sequenceCode/SequenceCodeEditor' import PlaceMosaicEditor from './placeMosaic/PlaceMosaicEditor' import TiltMazeEditor from './tiltMaze/TiltMazeEditor' -type StageLike = Record - -type GuidedNodeEditorFlowProps = { +import { + type StageLike, + type StepKey, + type EditorMode, + STEPS, + READY_STATUSES, + TECHNICAL_CONFIG_KEYS, + LEGACY_MESSAGE_FALLBACKS, + QR_KIND_BY_GAME_ID, + QR_GAME_BY_KIND, + CONFIG_FIELD_META, + CONFIG_ORDER, + configOf, + titleOf, + nodeNumber, + displayTitle, + normalizeQrKind, + hasExplicitQrMarker, + gameFromStage, + isMapCollectibleStage, + isQrStage, + gameOptions, + qrOptions, + statusLabel, + offlineLabel, + isPlayableNow, + usesLocationRadius, + normalizeDifficultyForEditor, + isValidFixedCircuitConfig, + isValidSequenceCodeConfig, + isValidTiltMazeConfig, + isValidPlaceMosaicConfig, + normalizeCopy, + shouldReplaceGeneratedGameTitle, + shouldReplaceSequenceTitle, + shouldReplacePlaceMosaicTitle, + isLegacySequenceCopy, + isLegacySequenceHint, + isExperimentalOrPlanned, + normalizeMessage, + CUSTOM_GAME_EDITOR_IDS, + hasCustomGameEditor, + guidedConfigKeysForGame, + slugOf, + fallbackCode, + qrKindForGame, + qrGameForKind, + qrLabel, + qrItemId, + qrPayload, + qrDesignFromConfig, + formatConfigValue, + parseConfigValue, + copyText +} from './guided-editor/guidedEditorUtils' + +export interface GuidedNodeEditorFlowProps { stage: StageLike - onPatch: (patch: Record) => void + onPatch: (updates: Record) => void onClose: () => void onDelete: () => void onRequestChangeType?: () => void } -type StepKey = 'type' | 'subtype' | 'config' | 'content' | 'rules' | 'review' -type EditorMode = 'game' | 'qr' | 'map_collectible' - -const STEPS: Array<{ key: StepKey; label: string }> = [ - { key: 'subtype', label: 'Modo' }, - { key: 'config', label: 'Ajustes' }, - { key: 'content', label: 'Textos' }, - { key: 'rules', label: 'Reglas' }, - { key: 'review', label: 'Revisar' }, -] - -const READY_STATUSES = new Set(['runtime_ready']) - -const TECHNICAL_CONFIG_KEYS = new Set([ - 'completion_method', - 'game_id', - 'game_title', - 'objective', - 'source_lat', - 'source_lon', - 'max_signal', - 'noise_floor', - 'jitter', - 'decay_curve', - 'timeout_ms', - 'update_rate_ms', - 'use_audio', - 'use_vibration', - 'use_direction_hint', - 'false_peaks', - 'dead_zones', - 'seed', - 'path_cells', - 'pattern_mode', - 'shuffle_choices', - 'hint_text', - 'max_attempts', - 'image_data_url', - 'image_alt', - 'grid_size', - 'preview_ms', - 'max_moves', - 'require_final_question', - 'final_question', - 'final_choices', - 'final_correct_index', -]) - -const LEGACY_MESSAGE_FALLBACKS: Record = { - 'GPS unavailable message.': 'Activa GPS para localizar la señal.', - 'Move closer to unlock this node.': 'Acércate más al punto para desbloquear el nodo.', - 'Complete this node to continue.': 'Completa este nodo para continuar.', -} - -const QR_KIND_BY_GAME_ID: Partial> = { - qr_collectible: 'collectible', - qr_key_gate: 'requirement', - clue_card: 'clue', - bonus_cache: 'bonus', -} - -const QR_GAME_BY_KIND: Record = { - collectible: 'qr_collectible', - requirement: 'qr_key_gate', - clue: 'clue_card', - bonus: 'bonus_cache', -} - -const CONFIG_FIELD_META: Record< - string, - { - label: string - help: string - type: 'text' | 'number' | 'select' | 'sequence' - options?: Array<{ value: string; label: string }> - } -> = { - objective: { - label: 'Objetivo interno', - help: 'Define que intenta resolver el juego. Normalmente viene de la plantilla.', - type: 'text', - }, - completion_method: { - label: 'Cómo se completa', - help: 'Forma principal de cerrar el nodo en el móvil del jugador.', - type: 'select', - options: [ - { value: 'proximity', label: 'Llegar a la zona' }, - { value: 'hold', label: 'Mantenerse en la zona' }, - { value: 'bearing', label: 'Rumbo / brújula' }, - { value: 'puzzle', label: 'Puzzle visual' }, - { value: 'manual_code', label: 'Código manual' }, - { value: 'sequence', label: 'Secuencia' }, - { value: 'qr_complete', label: 'QR completa el nodo' }, - { value: 'photo', label: 'Foto' }, - { value: 'inventory_only', label: 'Objeto/mochila' }, - { value: 'team', label: 'Equipo' }, - { value: 'motion', label: 'Movimiento / sensor' }, - ], - }, - source_radius_m: { - label: 'Radio de señal', - help: 'Zona aproximada donde la señal empieza a funcionar.', - type: 'number', - }, - lock_threshold: { - label: 'Umbral de bloqueo', - help: 'Valor de señal o precisión necesario para dar el nodo por válido.', - type: 'number', - }, - hold_ms: { - label: 'Tiempo de espera', - help: 'Milisegundos que debe mantenerse la condición antes de completar.', - type: 'number', - }, - target_bearing_deg: { - label: 'Rumbo objetivo', - help: 'Dirección en grados: 0 norte, 90 este, 180 sur, 270 oeste.', - type: 'number', - }, - tolerance_deg: { - label: 'Tolerancia de rumbo', - help: 'Margen permitido alrededor del rumbo objetivo.', - type: 'number', - }, - grid_cols: { - label: 'Columnas', - help: 'Tamaño horizontal del puzzle lógico.', - type: 'number', - }, - grid_rows: { - label: 'Filas', - help: 'Tamaño vertical del puzzle lógico.', - type: 'number', - }, - difficulty: { - label: 'Dificultad', - help: 'Nivel de dificultad del reto.', - type: 'select', - options: [ - { value: 'easy', label: 'Fácil' }, - { value: 'normal', label: 'Normal' }, - { value: 'hard', label: 'Difícil' }, - ], - }, - expected_code: { - label: 'Código esperado', - help: 'Palabra o código que deberá introducir el jugador.', - type: 'text', - }, - sequence: { - label: 'Secuencia', - help: 'Lista de valores separados por coma.', - type: 'sequence', - }, - game_id: { - label: 'ID de juego', - help: 'Identificador del catálogo. No suele hacer falta tocarlo.', - type: 'text', - }, - game_title: { - label: 'Nombre de juego', - help: 'Nombre de referencia de la plantilla.', - type: 'text', - }, -} - -const CONFIG_ORDER = [ - 'objective', - 'completion_method', - 'source_radius_m', - 'lock_threshold', - 'hold_ms', - 'target_bearing_deg', - 'tolerance_deg', - 'grid_cols', - 'grid_rows', - 'difficulty', - 'expected_code', - 'sequence', - 'game_id', - 'game_title', -] - -function configOf(stage: StageLike): Record { - return stage.config && typeof stage.config === 'object' ? stage.config : {} -} - -function titleOf(stage: StageLike) { - return String(stage.title || stage.name || 'NEW NODE') -} - -function nodeNumber(stage: StageLike) { - if (typeof stage.index === 'number') return String(stage.index + 1) - const raw = String(stage.title || stage.name || stage.id || stage.node_id || '') - return raw.match(/\d+/)?.[0] || '' -} - -function displayTitle(stage: StageLike) { - const n = nodeNumber(stage) - const title = titleOf(stage) - if (n && !title.trim().startsWith(`${n}.`)) return `${n}. ${title}` - return title -} - -function normalizeQrKind(value: unknown): PhysicalQrKind { - const raw = String(value || 'collectible') - if (raw === 'object') return 'collectible' - if (raw === 'key') return 'requirement' - if (raw === 'requirement' || raw === 'clue' || raw === 'bonus' || raw === 'collectible') - return raw - return 'collectible' -} - -function hasExplicitQrMarker(stage: StageLike): boolean { - return Boolean( - stage.physical_qr || - stage.physical_node_kind || - stage.physical_item_kind || - stage.physical_item_id || - stage.physical_item_label || - stage.qr_payload || - String(stage.game_family || '').includes('physical') || - String(stage.game_type || '').includes('qr_') || - String(stage.game_template_id || '').includes('qr_') - ) -} - -function gameFromStage(stage: StageLike): AdminGameCatalogItem { - const config = configOf(stage) - - const configId = typeof config.game_id === 'string' ? config.game_id : '' - - const gameTypeId = typeof stage.game_type === 'string' ? stage.game_type : '' - - const templateId = typeof stage.game_template_id === 'string' ? stage.game_template_id : '' - - const byConfig = configId ? adminGameCatalog.find((game) => game.id === configId) : null - - const byGameType = gameTypeId ? adminGameCatalog.find((game) => game.id === gameTypeId) : null - - const byTemplate = templateId ? adminGameCatalog.find((game) => game.id === templateId) : null - - // Al cambiar de juego, los dos identificadores superiores - // se actualizan juntos. Si coinciden, son la identidad más - // reciente y evitan mostrar un editor antiguo por config obsoleta. - if (byGameType && byTemplate && byGameType.id === byTemplate.id) { - return byGameType - } - - if (byConfig) return byConfig - if (byTemplate) return byTemplate - if (byGameType) return byGameType - - // Legacy: los nodos antiguos signal_hunt sin game_id eran GPS/señal. - // Como hemos quitado GPS del catálogo visible, NO deben caer en el primer signal_hunt físico/QR. - if (stage.type === 'signal_hunt' && !hasExplicitQrMarker(stage)) { - return ( - adminGameCatalog.find((game) => game.id === 'shake_antenna_charge') || adminGameCatalog[0] - ) - } - - return ( - adminGameCatalog.find((game) => game.family === stage.type && game.category !== 'physical') || - adminGameCatalog.find((game) => game.family === stage.type) || - adminGameCatalog[0] - ) -} - -function isMapCollectibleStage(stage: StageLike): boolean { - const config = configOf(stage) - return Boolean(config.is_map_collectible) -} - -function isQrStage(stage: StageLike): boolean { - if (isMapCollectibleStage(stage)) return false - const config = configOf(stage) - const gameId = - typeof config.game_id === 'string' - ? config.game_id - : typeof stage.game_type === 'string' - ? stage.game_type - : typeof stage.game_template_id === 'string' - ? stage.game_template_id - : '' - const explicit = gameId ? adminGameCatalog.find((game) => game.id === gameId) : null - - return Boolean(hasExplicitQrMarker(stage) || explicit?.category === 'physical') -} - -function gameOptions(showExperimental = false): AdminGameCatalogItem[] { - return adminGameCatalog.filter((game) => { - if (game.category === 'physical') return false - if (showExperimental) return true - return isPlayableNow(game) - }) -} - -function qrOptions(): AdminGameCatalogItem[] { - return adminGameCatalog.filter((game) => game.category === 'physical') -} - -function statusLabel(game: AdminGameCatalogItem) { - if (game.runtimeStatus === 'runtime_ready') return 'Jugable' - if (game.runtimeStatus === 'runtime_partial') return 'Experimental' - if (game.runtimeStatus === 'preset_only') return 'Plantilla' - return 'No listo' -} - -function offlineLabel(game: AdminGameCatalogItem) { - if (game.offlineStatus === 'offline_ready') return 'Offline listo' - if (game.offlineStatus === 'offline_partial') return 'Offline parcial' - return 'Offline pendiente' -} - -function isPlayableNow(game: AdminGameCatalogItem) { - return READY_STATUSES.has(game.runtimeStatus) -} - -function usesLocationRadius(game: AdminGameCatalogItem) { - return ( - game.category === 'gps' || - game.category === 'compass' || - game.category === 'photo' || - game.category === 'team' || - game.completionMethod === 'proximity' || - game.completionMethod === 'hold' || - game.completionMethod === 'bearing' - ) -} - -function normalizeDifficultyForEditor(value: unknown) { - const raw = String(value ?? '') - .trim() - .toLowerCase() - - if (raw === 'easy' || raw === 'facil' || raw === 'fácil' || raw === '1') { - return 'easy' - } - - if ( - raw === 'hard' || - raw === 'dificil' || - raw === 'difícil' || - raw === '3' || - raw === '4' || - raw === '5' - ) { - return 'hard' - } - - return 'normal' -} - -function isValidFixedCircuitConfig(config: Record) { - if (config.pattern_mode !== 'fixed') return true - if (!Array.isArray(config.path_cells)) return false - if (config.path_cells.length < 4) return false - - const rows = Math.max(4, Math.min(6, Number(config.grid_rows || 5))) - - const cols = Math.max(4, Math.min(6, Number(config.grid_cols || 5))) - - const seen = new Set() - let previous: [number, number] | null = null - - for (const rawCell of config.path_cells) { - const cell = String(rawCell) - - if (!/^\d+:\d+$/.test(cell)) return false - if (seen.has(cell)) return false - - const [row, col] = cell.split(':').map(Number) - - if (row < 0 || row >= rows || col < 0 || col >= cols) { - return false - } - - if (previous && Math.abs(row - previous[0]) + Math.abs(col - previous[1]) !== 1) { - return false - } - - seen.add(cell) - previous = [row, col] - } - - return true -} - -function isValidSequenceCodeConfig(config: Record) { - if (!Array.isArray(config.sequence)) { - return false - } - - const sequence = config.sequence.map((item) => String(item).trim()) - - if (sequence.length < 3 || sequence.length > 10) { - return false - } - - if (sequence.some((item) => !item || item.length > 32)) { - return false - } - - const unique = new Set(sequence.map((item) => item.toLocaleLowerCase())) - - if (unique.size !== sequence.length) { - return false - } - - const maxAttempts = Number(config.max_attempts ?? 3) - - return Number.isInteger(maxAttempts) && maxAttempts >= 1 && maxAttempts <= 8 -} - -function isValidTiltMazeConfig(config: Record) { - const rows = Number(config.grid_rows ?? 9) - - const cols = Number(config.grid_cols ?? 9) - - const timeLimit = Number(config.time_limit_s ?? 75) - - const lives = Number(config.lives ?? 3) - - return ( - Number.isInteger(rows) && - rows >= 5 && - rows <= 13 && - Number.isInteger(cols) && - cols >= 5 && - cols <= 13 && - Number.isInteger(timeLimit) && - timeLimit >= 20 && - timeLimit <= 180 && - Number.isInteger(lives) && - lives >= 1 && - lives <= 5 - ) -} - -function isValidPlaceMosaicConfig(config: Record) { - const image = String(config.image_data_url || '').trim() - - const imageValid = - image.length <= 600000 && - (image.startsWith('data:image/jpeg;base64,') || - image.startsWith('data:image/png;base64,') || - image.startsWith('data:image/webp;base64,')) - - const gridSize = Number(config.grid_size ?? config.grid_cols ?? 3) - - if (!imageValid || !Number.isInteger(gridSize) || gridSize < 2 || gridSize > 4) { - return false - } - - if (config.require_final_question !== true) { - return true - } - - const question = String(config.final_question || '').trim() - - const choices = Array.isArray(config.final_choices) - ? config.final_choices.map((item) => String(item).trim()).filter(Boolean) - : [] - - const correctIndex = Number(config.final_correct_index ?? 0) - - return ( - question.length >= 3 && - choices.length >= 2 && - choices.length <= 4 && - Number.isInteger(correctIndex) && - correctIndex >= 0 && - correctIndex < choices.length - ) -} - -function normalizeCopy(value: unknown) { - return String(value || '') - .trim() - .toLocaleLowerCase() -} - -function shouldReplaceGeneratedGameTitle(value: unknown) { - const title = normalizeCopy(value) - - if (!title) return true - if (/^new node(?:\s+\d+)?$/.test(title)) return true - if (/^nuevo nodo(?:\s+\d+)?$/.test(title)) return true - - return new Set([ - 'restaurar el circuito', - 'matriz de circuitos', - 'código secuencial', - 'codigo secuencial', - 'la clave del tríptico', - 'la clave del triptico', - 'mosaico del lugar', - 'laberinto de equilibrio', - ]).has(title) -} - -function shouldReplaceSequenceTitle(value: unknown) { - return shouldReplaceGeneratedGameTitle(value) -} - -function shouldReplacePlaceMosaicTitle(value: unknown) { - return shouldReplaceGeneratedGameTitle(value) -} - -function isLegacySequenceCopy(value: unknown) { - const content = normalizeCopy(value) - - if (!content) return true - - return ( - content.includes('memoriza la secuencia') || - content.includes('memoriza la ruta de energía') || - content.includes('memoriza la ruta de energia') || - content.includes('busca el punto marcado') || - content === 'ordena las fichas para reconstruir el código.' || - content === 'ordena las fichas para reconstruir el codigo.' - ) -} - -function isLegacySequenceHint(value: unknown) { - const hint = normalizeCopy(value) - - if (!hint) return true - - return ( - hint.includes('memoriza la secuencia') || - hint.includes('recuerda el orden en el que encontraste') - ) -} - -function isExperimentalOrPlanned(game: AdminGameCatalogItem) { - return !isPlayableNow(game) -} - -function normalizeMessage(value: unknown, fallback: string) { - const raw = String(value || '').trim() - if (!raw) return fallback - return LEGACY_MESSAGE_FALLBACKS[raw] || raw -} - -const CUSTOM_GAME_EDITOR_IDS = new Set([ - 'logic_circuit', - 'sequence_code', - 'place_mosaic', - 'tilt_maze', -]) - -function hasCustomGameEditor(game: AdminGameCatalogItem) { - return CUSTOM_GAME_EDITOR_IDS.has(game.id) -} - -function guidedConfigKeysForGame(game: AdminGameCatalogItem, config: Record) { - if (game.id === 'sequence_code' || game.id === 'place_mosaic' || game.id === 'tilt_maze') { - return [] - } - - const keys = new Set() - - if ( - game.category === 'gps' || - game.completionMethod === 'proximity' || - game.completionMethod === 'hold' || - game.completionMethod === 'team' - ) { - for (const key of ['source_radius_m', 'lock_threshold', 'hold_ms']) { - if (key in config) keys.add(key) - } - } - - if (game.category === 'compass' || game.completionMethod === 'bearing') { - for (const key of ['target_bearing_deg', 'tolerance_deg', 'hold_ms']) { - if (key in config) keys.add(key) - } - } - - if (game.category === 'logic' || game.completionMethod === 'puzzle') { - for (const key of ['grid_cols', 'grid_rows', 'difficulty']) { - if (key in config) keys.add(key) - } - } - - if (game.category === 'motion' || game.completionMethod === 'motion') { - for (const key of ['difficulty', 'time_limit_ms', 'stabilize_ms']) { - if (key in config) keys.add(key) - } - } - - if (game.completionMethod === 'manual_code') { - for (const key of ['expected_code', 'difficulty']) { - if (key in config) keys.add(key) - } - } - - if (game.completionMethod === 'sequence') { - for (const key of ['sequence', 'difficulty']) { - if (key in config) keys.add(key) - } - } - - return Array.from(keys).filter((key) => !TECHNICAL_CONFIG_KEYS.has(key)) -} - -function slugOf(value: unknown) { - return ( - String(value || 'item') - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 80) || 'objeto_saga' - ) -} - -function fallbackCode(stage: StageLike) { - const config = configOf(stage) - const raw = String( - stage.fallback_code || - stage.physical_fallback_code || - config.success_code || - config.fallback_code || - '' - ) - if (raw) return raw.toUpperCase() - const n = nodeNumber(stage) || '00' - return `SAGA-${n.padStart(2, '0')}` -} - -function qrKindForGame(game: AdminGameCatalogItem): PhysicalQrKind { - return QR_KIND_BY_GAME_ID[game.id] || normalizeQrKind('collectible') -} - -function qrGameForKind(kind: PhysicalQrKind): AdminGameCatalogItem { - return getAdminGame(QR_GAME_BY_KIND[kind]) -} - -function qrLabel(stage: StageLike) { - return String(stage.physical_item_label || stage.title || 'Objeto SAGA') -} - -function qrItemId(stage: StageLike) { - return String(stage.physical_item_id || slugOf(stage.id || stage.node_id || qrLabel(stage))) -} - -function qrPayload(stage: StageLike) { - return String(stage.qr_payload || `SAGA1:ITEM:${qrItemId(stage)}:${qrLabel(stage)}`) -} - -function qrDesignFromConfig(config: Record): QrCardDesign { - const preset = String(config.qr_card_preset || 'clean') - - const shape = String(config.qr_card_shape || 'rounded') - - const accent = String(config.qr_card_accent || '#2563eb') - - const imageDataUrl = String(config.qr_card_image_data_url || '') - - return { - preset: preset === 'dark' || preset === 'photo' ? preset : 'clean', - shape: shape === 'square' ? 'square' : 'rounded', - accent: /^#[0-9a-f]{6}$/i.test(accent) ? accent : '#2563eb', - imageDataUrl, - } -} - -function formatConfigValue(value: unknown): string { - if (Array.isArray(value)) return value.join(', ') - if (value === undefined || value === null) return '' - return String(value) -} - -function parseConfigValue(key: string, value: string): unknown { - const meta = CONFIG_FIELD_META[key] - if (meta?.type === 'number') { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : 0 - } - if (meta?.type === 'sequence') { - return value - .split(',') - .map((item) => item.trim()) - .filter(Boolean) - } - return value -} - -function copyText(value: string, onDone: (message: string) => void) { - void navigator.clipboard - .writeText(value) - .then(() => onDone('Copiado')) - .catch(() => onDone('No se pudo copiar')) -} - export default function GuidedNodeEditorFlow({ stage, onPatch, @@ -897,6 +251,7 @@ export default function GuidedNodeEditorFlow({ const nextConfig = { ...(base.config || {}), ...config, + is_map_collectible: false, game_id: selectedGame.id, game_title: selectedGame.title, completion_method: selectedGame.completionMethod, @@ -1159,9 +514,9 @@ export default function GuidedNodeEditorFlow({
Paso 2 -

Escolle o xogo

+

Elige el juego

- Mostra o catálogo real. Os planificados poden prepararse, pero os máis seguros son + Muestra el catálogo real. Los planificados pueden prepararse, pero los más seguros son “Jugable”.

@@ -1433,6 +788,7 @@ export default function GuidedNodeEditorFlow({ + {/* ── SECCIÓN 1: ¿Qué objeto da? ── */} + {/* Warnings de ingredientes incompletos */} {['placa_base', 'cables_cobre', 'bateria_litio'].includes( stage.physical_item_id || '' ) ? (
- ⚠️ ¡Atención! Ingrediente incompleto + ⚠️ Ingrediente del Dispositivo EMP - Has configurado un ingrediente para el Dispositivo EMP. El - jugador NO podrá usar este objeto directamente. Para que pueda fabricar el - dispositivo final en su Mesa de Trabajo,{' '} - debes añadir a la misión otros nodos que entreguen el resto - de ingredientes (Placa base, Cables y Batería). El sistema te bloqueará el - guardado si olvidas alguno. + Necesitas 3 nodos que entreguen: Placa base + Cables de cobre + + Batería de litio. El sistema bloqueará el guardado si faltan.
) : null} {['cinta_aislante', 'llave_rota'].includes(stage.physical_item_id || '') ? (
- ⚠️ ¡Atención! Ingrediente incompleto + ⚠️ Ingrediente de la Llave Maestra - Has configurado un ingrediente para la Llave Maestra. El - jugador NO podrá usar este objeto directamente. Para que pueda fabricarla en - su Mesa de Trabajo, debes añadir a la misión otro nodo que - entregue el ingrediente restante. El sistema te bloqueará el guardado si lo - olvidas. + Necesitas 2 nodos que entreguen: Llave rota + Cinta aislante. + El sistema bloqueará el guardado si falta el otro.
) : null} - {![ - 'placa_base', - 'cables_cobre', - 'bateria_litio', - 'cinta_aislante', - 'llave_rota', - ].includes(stage.physical_item_id || '') ? ( + {/* Campos para objeto personalizado */} + {!['placa_base', 'cables_cobre', 'bateria_litio', 'cinta_aislante', 'llave_rota'].includes( + stage.physical_item_id || '' + ) ? ( <> + +
) } const family = familyId(stage) - const radius = readNumber(stage, ['radius', 'capture_radius_m', 'entry_radius_m']) + const radius = readNumber(stage, ['radius', 'capture_radius_m', 'entry_radius_m', 'proximity_radius_m']) const requiredItem = readString(stage, ['required_item_id', 'requiredItemId']) - const rewardItem = readString(stage, ['reward_item_id', 'rewardItemId']) - const manualCode = readString(stage, ['manual_code', 'manualCode']) - const interaction = readString(stage, ['interaction_method', 'interactionMethod']).toLowerCase() - - const needsGps = radius !== null || family.includes('signal') || family.includes('bearing') - const needsItem = Boolean(requiredItem) - const givesItem = Boolean(rewardItem) - const physical = Boolean(manualCode || rewardItem || requiredItem || interaction) - + + const isSignal = family.includes('signal') + const isCompass = family.includes('bearing') + const isPuzzle = family.includes('circuit') || family.includes('sequence') + const isPhysical = family.includes('qr') || family.includes('inventory') + + const needsGps = radius !== null || isSignal || isCompass + return (
-
-
GUIA DEL NODO
-
{stage.title || 'Nodo actual'}
-
- {gameName(family)} -
- -
- - - -
- -
- Que necesitas -
    - {needsGps ? ( -
  • Activa GPS y acercate al punto del mapa.
  • - ) : ( -
  • Este nodo no depende de posicion especial.
  • - )} - {radius !== null ?
  • Debes estar dentro del radio de {radius} m.
  • : null} - {needsItem ? ( -
  • Necesitas tener este objeto en Objetos: {requiredItem}.
  • - ) : ( -
  • No hay objeto obligatorio detectado.
  • - )} -
-
- -
- Como se juega -
    - {howToPlay(stage).map((text) => ( -
  • {text}
  • - ))} -
+
GUÍA PASO A PASO
+
{stage.title || 'Misión Activa'}
-
- Objetos y pruebas -

- Los objetos que guardas en Objetos pueden servir para abrir otros nodos. Si encuentras una - tarjeta, palabra, QR, NFC, sobre o prop, registralo desde Prueba. -

- {needsItem ? ( -

Este nodo comprueba si llevas el objeto requerido antes de avanzar.

- ) : null} - {givesItem ?

Este nodo puede darte una recompensa: {rewardItem}.

: null} - {!physical ? ( -

No hay prueba fisica especial detectada en este nodo.

- ) : null} +
+ {/* Step 1: Ubicación */} + {needsGps && ( +
+
1
+
+
Ve al punto en el mapa
+
+ Acércate físicamente a la ubicación marcada. Necesitas estar a menos de {radius || 50} metros para que tu escáner detecte el nodo. +
+
+
+ )} + + {/* Step 2: Objeto Requerido (Optional) */} + {requiredItem && ( +
+
{needsGps ? '2' : '1'}
+
+
Equipa el objeto necesario
+
+ Para poder interactuar con este nodo, necesitas tener esto en tu mochila: +
+
+ + {requiredItem.replace(/_/g, ' ')} +
+
+
+ )} + + {/* Step 3: Acción del Juego */} +
+
{requiredItem ? (needsGps ? '3' : '2') : (needsGps ? '2' : '1')}
+
+
+ {isSignal && 'Captura la señal estable'} + {isCompass && 'Calibra la brújula y gira'} + {isPuzzle && 'Resuelve el código'} + {isPhysical && 'Escanea el elemento'} + {!isSignal && !isCompass && !isPuzzle && !isPhysical && 'Sigue las instrucciones'} +
+
+ {isSignal && 'Una vez en la zona, mantén tu posición sin salir del círculo hasta que la barra se llene por completo al 100%.'} + {isCompass && 'Gira lentamente sobre ti mismo hasta que tu teléfono apunte en la dirección correcta para decodificar.'} + {isPuzzle && 'Observa tu entorno físico y la información que tienes. Introduce el patrón o código correcto para acceder.'} + {isPhysical && 'Busca un código QR, etiqueta NFC o pista física en la vida real. Usa el botón inferior para escanearlo.'} + {!isSignal && !isCompass && !isPuzzle && !isPhysical && 'Lee cuidadosamente la descripción de la misión para saber qué hacer a continuación.'} +
+
+
- -
Jugador local: {user}
) } -function Mini({ label, value }: { label: string; value: string }) { - return ( -
- {value} - {label} -
- ) -} - +// ─── Estilos Modernos ──────────────────────────────────────────────────────── const panel: CSSProperties = { - display: 'grid', - gap: 10, - padding: 8, + display: 'flex', + flexDirection: 'column', + gap: 16, + padding: '20px', + background: 'linear-gradient(to bottom, rgba(15,23,42,0.8), rgba(15,23,42,0.95))', + borderRadius: '16px', + border: '1px solid rgba(255,255,255,0.05)', + boxShadow: '0 8px 32px rgba(0,0,0,0.4)', } const header: CSSProperties = { display: 'flex', - justifyContent: 'space-between', - gap: 10, + flexDirection: 'column', + gap: 4, } const eyebrow: CSSProperties = { - color: '#bbf7d0', - fontSize: 10, - fontWeight: 950, - letterSpacing: '0.14em', + color: '#38bdf8', + fontSize: 11, + fontWeight: 800, + letterSpacing: '0.15em', + textTransform: 'uppercase', } const title: CSSProperties = { - marginTop: 4, color: '#ffffff', - fontSize: 15, - lineHeight: 1.1, - fontWeight: 950, + fontSize: 18, + lineHeight: 1.2, + fontWeight: 900, } -const badge: CSSProperties = { - alignSelf: 'flex-start', - borderRadius: 999, - border: '1px solid rgba(125,211,252,.22)', - background: 'rgba(14,165,233,.14)', - color: '#dbeafe', - padding: '7px 9px', - fontSize: 9, - fontWeight: 950, +const copy: CSSProperties = { + color: '#94a3b8', + fontSize: 13, + lineHeight: 1.5, + margin: 0, } -const statusGrid: CSSProperties = { - display: 'grid', - gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', - gap: 8, +const stepsContainer: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 16, + position: 'relative', } -const mini: CSSProperties = { - display: 'grid', - gap: 2, - padding: 4, - textAlign: 'center', +const stepRow: CSSProperties = { + display: 'flex', + gap: 12, + position: 'relative', } -const block: CSSProperties = { - display: 'grid', - gap: 6, - padding: 4, +const stepNumber: CSSProperties = { + flexShrink: 0, + width: 24, + height: 24, + borderRadius: '12px', + background: 'rgba(56,189,248,0.15)', + color: '#38bdf8', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 13, + fontWeight: 800, + border: '1px solid rgba(56,189,248,0.3)', + zIndex: 2, } -const list: CSSProperties = { - display: 'grid', - gap: 5, - margin: 0, - paddingLeft: 18, - color: 'rgba(226,232,240,.78)', - fontSize: 12, - lineHeight: 1.35, - fontWeight: 750, +const stepContent: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 4, + paddingTop: 2, } -const copy: CSSProperties = { - margin: 0, - color: 'rgba(226,232,240,.78)', - fontSize: 12, +const stepTitle: CSSProperties = { + color: '#f8fafc', + fontSize: 14, + fontWeight: 700, +} + +const stepDesc: CSSProperties = { + color: '#94a3b8', + fontSize: 13, lineHeight: 1.4, - fontWeight: 750, } -const footer: CSSProperties = { - color: 'rgba(226,232,240,.46)', - fontSize: 10, - fontWeight: 800, +const requiredItemCard: CSSProperties = { + marginTop: 8, + display: 'inline-flex', + alignItems: 'center', + gap: 8, + background: 'rgba(255,255,255,0.05)', + border: '1px solid rgba(255,255,255,0.1)', + padding: '6px 12px', + borderRadius: '8px', + color: '#e2e8f0', + fontSize: 13, + fontWeight: 600, + textTransform: 'capitalize', } diff --git a/frontend/src/styles/mobile-shell.css b/frontend/src/styles/mobile-shell.css index 3dd08fe7..59178050 100644 --- a/frontend/src/styles/mobile-shell.css +++ b/frontend/src/styles/mobile-shell.css @@ -266,3 +266,81 @@ html.saga-route-player .saga-tools-language-row { background: #22c55e; color: #052e16; } + + +/* --- PREMIUM DESIGN UTILITIES (v2.1.0) --- */ + +:root { + --saga-glass-bg: rgba(15, 23, 42, 0.65); + --saga-glass-border: rgba(255, 255, 255, 0.08); + --saga-glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); + --saga-accent-glow: 0 0 20px rgba(34, 197, 94, 0.4); + --saga-transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.saga-glass-panel { + background: var(--saga-glass-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--saga-glass-border); + box-shadow: var(--saga-glass-shadow); + border-radius: 24px; +} + +.saga-premium-button { + transition: var(--saga-transition-smooth); +} +.saga-premium-button:active { + transform: scale(0.96); +} + +.saga-pulse-glow { + animation: sagaPulseGlow 2.5s infinite alternate; +} + +@keyframes sagaPulseGlow { + 0% { box-shadow: 0 0 10px rgba(34, 197, 94, 0.2); } + 100% { box-shadow: 0 0 25px rgba(34, 197, 94, 0.6); } +} + +.saga-slide-up-enter { + animation: sagaSlideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@keyframes sagaSlideUp { + 0% { opacity: 0; transform: translateY(40px) scale(0.98); } + 100% { opacity: 1; transform: translateY(0) scale(1); } +} + +/* --- MAP SMOOTHNESS UTILITIES (v2.1.0) --- + Promote map layers to GPU compositing to prevent paint flicker + on GPS position updates and tile loads. */ + +.leaflet-marker-icon, +.leaflet-marker-shadow { + will-change: transform; +} + +.leaflet-tile { + will-change: transform; +} + +.leaflet-pane, +.leaflet-map-pane, +.leaflet-tile-pane { + will-change: transform; +} + +/* Smooth tile fade-in — avoids the hard white/black blink on load */ +.leaflet-tile-container img { + transition: opacity 0.2s ease-out; +} + +/* Prevent layout shift on the map container when GPS updates arrive */ +.saga-map-root, +[class*="saga-map"] .leaflet-container { + contain: layout paint; + transform: translateZ(0); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} diff --git a/main.py b/main.py index 03454d85..a3d39b3d 100755 --- a/main.py +++ b/main.py @@ -34,6 +34,15 @@ from backend.app.security import client_ip as client_ip_security from backend.app.security import player_session as player_session_security +from backend.app.runtime.core_engine import ( + normalize_stage, + validate_stage, + preserve_physical_stage_fields +) +from backend.app.runtime.minigames import ( + _clean_code, + build_stage_minigame_runtime +) def _split_csv_env(name, default=""): raw = str(os.getenv(name, default) or "").strip() @@ -48,8 +57,10 @@ def _split_csv_env(name, default=""): API_OPENAPI_URL = "/openapi.json" if ENABLE_API_DOCS else None app = FastAPI(docs_url=API_DOCS_URL, redoc_url=API_REDOC_URL, openapi_url=API_OPENAPI_URL) -from backend.app.routers import field_proofs +from backend.app.routers import field_proofs, admin, game app.include_router(field_proofs.router) +app.include_router(admin.router) +app.include_router(game.router) app.add_middleware( CORSMiddleware, @@ -710,360 +721,18 @@ def project_live_profile_status(profile, raw=None, now=None): "debug_enabled": _as_bool(raw.get("debug_enabled"), False), } -def _build_success_conditions(raw): - conditions = [{"kind": "minigame_ok", "value": MINIGAME_OK_CODE}] - - answer = _clean_code(raw.get("answer")) - rune = _clean_code(raw.get("rune")) - - if answer: - conditions.append({"kind": "answer", "value": answer}) - if rune: - conditions.append({"kind": "rune", "value": rune}) - - return conditions -# RUNTIME_CONTRACT_CLEANUP_V1: el player React debe recibir family-native minigames. -# Avoid letting incomplete data silently fall back to outdated minigame defaults. -SAGA_PHYSICAL_STAGE_FIELDS = ( - "physical_node_kind", - "physical_item_kind", - "physical_item_id", - "physical_item_label", - "qr_payload", - "physical_qr", +from backend.app.runtime.core_engine import ( + _build_success_conditions, + preserve_physical_stage_fields, + normalize_stage, + stage_has_manual_fallback, + evaluate_entry, + validate_stage, + _positive_int, + read_stage_item_requirement, ) - -def preserve_physical_stage_fields(raw_stage, node): - if not isinstance(raw_stage, dict) or not isinstance(node, dict): - return node - - for key in SAGA_PHYSICAL_STAGE_FIELDS: - if key in raw_stage: - node[key] = raw_stage[key] - - return node - - -def normalize_stage(raw): - raw = raw or {} - - cfg = raw.get("config") - if not isinstance(cfg, dict): - cfg = {} - - raw_entry = raw.get("entry") - if not isinstance(raw_entry, dict): - raw_entry = {} - - raw_messages = raw.get("messages") - if not isinstance(raw_messages, dict): - raw_messages = {} - - raw_debug = raw.get("debug") - if not isinstance(raw_debug, dict): - raw_debug = {} - - entry_mode = _as_str( - raw_entry.get("mode") or raw.get("entry_mode") or "gps" - ).strip().lower() or "gps" - - require_proximity = _as_bool( - raw_entry.get("require_proximity", raw.get("require_proximity")), - default=(entry_mode != "free") - ) - - raw_minigame = raw.get("minigame") - if not isinstance(raw_minigame, dict): - raw_minigame = {} - - raw_interaction_type = _as_str( - raw_minigame.get("type") or raw.get("type") - ).strip().lower() - - interaction_type_fallback_reason = "" - if not raw_interaction_type: - interaction_type = "signal_hunt" - interaction_type_fallback_reason = "missing_minigame_type" - elif raw_interaction_type not in SUPPORTED_MINIGAME_TYPES: - interaction_type = "signal_hunt" - interaction_type_fallback_reason = f"unsupported_minigame_type:{raw_interaction_type}" - else: - interaction_type = raw_interaction_type - - raw_minigame_config = raw_minigame.get("config") - if not isinstance(raw_minigame_config, dict): - raw_minigame_config = None - - interaction_config = normalize_minigame_config( - interaction_type, - raw_minigame_config if raw_minigame_config is not None else cfg - ) - - item_requirement = read_stage_item_requirement(raw) - - node = { - "id": raw.get("id"), - "version": 2, - "enabled": _as_bool(raw.get("enabled", True), True), - "presentation": { - "title": _as_str(raw.get("title")).strip(), - "content": _as_str(raw.get("content")).strip(), - }, - "location": { - "lat": _as_float(raw.get("lat")), - "lon": _as_float(raw.get("lon")), - "radius_m": _as_radius(raw.get("radius", 0), 0), - }, - "entry": { - "mode": entry_mode, - "require_proximity": require_proximity, - "allow_debug_bypass": _as_bool( - raw_entry.get("allow_debug_bypass", raw.get("allow_debug_bypass")), - True - ), - "allow_manual_fallback_without_gps": _as_bool( - raw_entry.get( - "allow_manual_fallback_without_gps", - raw.get("allow_manual_fallback_without_gps") - ), - True - ), - }, - "interaction": { - "type": interaction_type, - "config": interaction_config, - }, - "success": { - "mode": "any_of", - "conditions": _build_success_conditions(raw), - "case_sensitive": False, - }, - "requirements": { - "items": [item_requirement] if item_requirement else [], - }, - "messages": { - "locked": _as_str( - raw_messages.get("locked") or raw.get("locked_message") - ).strip(), - "gps_unavailable": _as_str( - raw_messages.get("gps_unavailable") or raw.get("gps_unavailable_message") - ).strip(), - "hint": _as_str( - raw_messages.get("hint") or raw.get("hint") - ).strip(), - }, - "debug": { - "force_unlock": _as_bool( - raw_debug.get("force_unlock", raw.get("force_unlock")), - False - ), - "raw_interaction_type": raw_interaction_type, - "interaction_type_fallback_reason": interaction_type_fallback_reason, - }, - } - - return preserve_physical_stage_fields(raw, node) - -def stage_has_manual_fallback(node): - for condition in node["success"]["conditions"]: - if condition.get("kind") in {"answer", "rune"} and _clean_code(condition.get("value")): - return True - return False - -def evaluate_entry(node, distance_m=None, gps_available=True, debug_enabled=False): - entry = node.get("entry") or {} - debug = node.get("debug") or {} - location = node.get("location") or {} - - if not node.get("enabled", True): - return { - "can_enter": False, - "can_submit_manual_code": False, - "reason": "disabled", - } - - if debug_enabled and (entry.get("allow_debug_bypass") or debug.get("force_unlock")): - return { - "can_enter": True, - "can_submit_manual_code": True, - "reason": "debug_bypass", - } - - require_proximity = bool(entry.get("require_proximity", True)) - mode = _as_str(entry.get("mode") or "gps").strip().lower() - - if mode == "free" or not require_proximity: - return { - "can_enter": True, - "can_submit_manual_code": True, - "reason": "free_entry", - } - - if not gps_available: - return { - "can_enter": False, - "can_submit_manual_code": bool(entry.get("allow_manual_fallback_without_gps")) and stage_has_manual_fallback(node), - "reason": "gps_unavailable", - } - - if distance_m is None: - return { - "can_enter": False, - "can_submit_manual_code": stage_has_manual_fallback(node), - "reason": "distance_unknown", - } - - radius = location.get("radius_m") or 0 - if distance_m <= radius: - return { - "can_enter": True, - "can_submit_manual_code": True, - "reason": "within_radius", - } - - return { - "can_enter": False, - "can_submit_manual_code": stage_has_manual_fallback(node), - "reason": "out_of_range", - } - -def validate_stage(raw_stage, idx=None): - node = normalize_stage(raw_stage) - errors = [] - - def add(field, detail): - errors.append({ - "index": idx, - "field": field, - "detail": detail, - }) - - title = node["presentation"]["title"] - if not title: - add("title", "title is required") - - raw_minigame_for_type = raw_stage.get("minigame") if isinstance(raw_stage, dict) else {} - if not isinstance(raw_minigame_for_type, dict): - raw_minigame_for_type = {} - - raw_type_for_validation = _as_str( - raw_minigame_for_type.get("type") or raw_stage.get("type") - ).strip().lower() - - if not raw_type_for_validation: - add("type", "minigame type is required") - elif raw_type_for_validation not in SUPPORTED_MINIGAME_TYPES: - add("type", f"unsupported minigame type: {raw_type_for_validation}") - - raw_minigame = raw_stage.get("minigame") if isinstance(raw_stage, dict) else {} - if not isinstance(raw_minigame, dict): - raw_minigame = {} - - raw_interaction_type = _as_str( - raw_minigame.get("type") or (raw_stage.get("type") if isinstance(raw_stage, dict) else "") - ).strip().lower() - interaction_type = raw_interaction_type or node["interaction"]["type"] - if interaction_type not in SUPPORTED_MINIGAME_TYPES: - add("type", f"unsupported minigame type: {interaction_type}") - - raw_config = raw_minigame.get("config") if isinstance(raw_minigame.get("config"), dict) else ( - raw_stage.get("config") if isinstance(raw_stage, dict) else {} - ) - if raw_config is not None and not isinstance(raw_config, dict): - add("config", "config must be an object") - raw_config = {} - - for field, detail in validate_minigame_config(node["interaction"]["type"], raw_config): - add(field, detail) - - entry_mode = node["entry"]["mode"] - if entry_mode not in {"gps", "free"}: - add("entry.mode", f"unsupported entry mode: {entry_mode}") - - location = node["location"] - if node["entry"]["mode"] == "gps" and node["entry"]["require_proximity"]: - if location["lat"] is None: - add("lat", "lat is required for gps entry") - if location["lon"] is None: - add("lon", "lon is required for gps entry") - if location["radius_m"] is None or location["radius_m"] <= 0: - add("radius", "radius must be > 0 for gps entry") - - conditions = node["success"]["conditions"] - if not isinstance(conditions, list) or not conditions: - add("success.conditions", "at least one success condition is required") - - for i, condition in enumerate(conditions): - kind = _as_str(condition.get("kind")).strip() - value = _clean_code(condition.get("value")) - - if kind not in {"minigame_ok", "answer", "rune"}: - add(f"success.conditions[{i}].kind", f"unsupported success condition kind: {kind}") - if not value: - add(f"success.conditions[{i}].value", "success condition value is required") - - return errors - -def validate_stages(raw_stages): - if not isinstance(raw_stages, list): - return [{"index": None, "field": "stages", "detail": "stages payload must be a list"}] - - errors = [] - for idx, stage in enumerate(raw_stages): - if not isinstance(stage, dict): - errors.append({"index": idx, "field": "node", "detail": "each node must be an object"}) - continue - errors.extend(validate_stage(stage, idx=idx)) - return errors - -def get_runtime_stages(): - raw_stages = load_stages(STAGES_DB) - if not isinstance(raw_stages, list): - return [] - return [normalize_stage(stage) for stage in raw_stages] - -def project_stage_for_player(raw_stage, include_runtime=False): - node = raw_stage if isinstance(raw_stage, dict) and raw_stage.get("version") == 2 else normalize_stage(raw_stage) - - out = { - "id": node["id"], - "title": node["presentation"]["title"], - "lat": node["location"]["lat"], - "lon": node["location"]["lon"], - "radius": node["location"]["radius_m"], - } - - if include_runtime: - out.update({ - "content": node["presentation"]["content"], - "type": node["interaction"]["type"], - "config": node["interaction"]["config"], - "minigame": build_stage_minigame_runtime(node), - "entry": node["entry"], - "success": node["success"], - "requirements": node.get("requirements", {"items": []}), - "messages": node["messages"], - }) - - out = preserve_physical_stage_fields(node, out) - return out - -def stage_accepts_code(raw_stage, code): - node = raw_stage if isinstance(raw_stage, dict) and raw_stage.get("version") == 2 else normalize_stage(raw_stage) - submitted = _clean_code(code) - - if not submitted: - return False - - for condition in node["success"]["conditions"]: - expected = _clean_code(condition.get("value")) - if expected and submitted == expected: - return True - - return False - APP_DIR = Path(__file__).resolve().parent REACT_DIST_DIR = APP_DIR / "frontend" / "dist" REACT_INDEX_FILE = REACT_DIST_DIR / "index.html" @@ -1325,68 +994,63 @@ async def get_config(): "player_profiles": get_player_profiles(cfg) } -@app.get("/api/state/{user}") -async def get_state(user: str): - stages = load_stages(STAGES_DB) - profile = get_player_profile(user) - profile_id = profile.get("id") or _as_str(user).strip() or "PLAYER 1" - lvl = get_player_progress_level(profile_id, get_player_progress_level(user, 0)) - return {"user": profile_id, "level": lvl, "finished": lvl >= len(stages)} - -@app.get("/api/game/{user}") -async def get_game_payload(user: str, request: Request, offline_pack: bool = False): - runtime_stages = get_runtime_stages() - profile = get_player_profile(user) - profile_id = profile.get("id") or user - live_positions = load_live_positions() +def validate_stages(raw_stages): + if not isinstance(raw_stages, list): + return [{"index": None, "field": "stages", "detail": "stages payload must be a list"}] - lvl = get_player_progress_level(profile_id, get_player_progress_level(user, 0)) - finished = lvl >= len(runtime_stages) + errors = [] + for idx, stage in enumerate(raw_stages): + if not isinstance(stage, dict): + errors.append({"index": idx, "field": "node", "detail": "each node must be an object"}) + continue + errors.extend(validate_stage(stage, idx=idx)) + return errors - current_stage = None - if not finished and 0 <= lvl < len(runtime_stages): - current_stage = project_stage_for_player(runtime_stages[lvl], include_runtime=True) +def get_runtime_stages(): + raw_stages = load_stages(STAGES_DB) + if not isinstance(raw_stages, list): + return [] + return [normalize_stage(stage) for stage in raw_stages] - stages = [ - project_stage_for_player(stage, include_runtime=(offline_pack or (i == lvl and not finished))) - for i, stage in enumerate(runtime_stages) - ] +def project_stage_for_player(raw_stage, include_runtime=False): + node = raw_stage if isinstance(raw_stage, dict) and raw_stage.get("version") == 2 else normalize_stage(raw_stage) - payload = { - "user": profile_id, - "display_name": profile.get("display_name", profile_id), - "session_mode": profile.get("mode", "solo"), - "profile": profile, - "live_status": project_live_profile_status(profile, live_positions.get(profile_id)), - "level": lvl, - "finished": finished, - "stages": stages, - "current_stage": current_stage + out = { + "id": node["id"], + "title": node["presentation"]["title"], + "lat": node["location"]["lat"], + "lon": node["location"]["lon"], + "radius": node["location"]["radius_m"], } - response = JSONResponse(payload) - if resolve_known_player_profile(profile_id): - set_player_session_cookie(response, request, profile_id) - return response -@app.get("/api/team/{user}") -async def get_team_payload(user: str): - cfg = load_config() - current_profile = get_player_profile(user, cfg) - current_profile_id = current_profile.get("id") or _as_str(user).strip() or "PLAYER 1" - live_positions = load_live_positions() - now = int(time.time()) + if include_runtime: + out.update({ + "content": node["presentation"]["content"], + "type": node["interaction"]["type"], + "config": node["interaction"]["config"], + "minigame": build_stage_minigame_runtime(node), + "entry": node["entry"], + "success": node["success"], + "requirements": node.get("requirements", {"items": []}), + "messages": node["messages"], + }) - profiles = [] - for profile in get_player_profiles(cfg): - projected = project_live_profile_status(profile, live_positions.get(profile.get("id")), now) - projected["is_self"] = _as_str(profile.get("id")).strip() == _as_str(current_profile_id).strip() - profiles.append(projected) + out = preserve_physical_stage_fields(node, out) + return out - return { - "status": "ok", - "user": current_profile_id, - "profiles": profiles - } +def stage_accepts_code(raw_stage, code): + node = raw_stage if isinstance(raw_stage, dict) and raw_stage.get("version") == 2 else normalize_stage(raw_stage) + submitted = _clean_code(code) + + if not submitted: + return False + + for condition in node["success"]["conditions"]: + expected = _clean_code(condition.get("value")) + if expected and submitted == expected: + return True + + return False PLAYER_EVENT_TYPES = { @@ -1566,241 +1230,6 @@ def apply_synced_player_event(normalized_event, user, profile): return append_event(EVENT_LOG_DB, event) -@app.post("/api/events/sync") -async def sync_player_events(request: Request): - data = await request.json() - user = _as_str(data.get("user")).strip() - require_player_session(request, user) - enforce_player_rate_limit("events_sync", request, user, EVENT_SYNC_RATE_LIMIT_MAX) - - profile = resolve_known_player_profile(user) - if not profile: - raise HTTPException(status_code=403, detail="unknown player") - - events = data.get("events") - if not isinstance(events, list): - raise HTTPException(status_code=400, detail="events must be a list") - - if len(events) > 100: - raise HTTPException(status_code=400, detail="too many events") - - stored = [] - seen_client_events = {} - for raw_event in events: - normalized = normalize_player_event(raw_event, user, profile) - client_event_id = _as_str(normalized.get("client_event_id")).strip() - - if client_event_id: - payload = normalized.get("payload") if isinstance(normalized.get("payload"), dict) else {} - normalized["payload"] = { - **payload, - "client_event_id": client_event_id, - } - - existing = seen_client_events.get(client_event_id) or find_existing_player_client_event(user, client_event_id) - if existing: - duplicate = { - **existing, - "status": existing.get("status") or "synced", - "duplicate": True, - } - stored.append(duplicate) - seen_client_events[client_event_id] = duplicate - continue - - stored_event = apply_synced_player_event(normalized, user, profile) - stored.append(stored_event) - - if client_event_id: - seen_client_events[client_event_id] = stored_event - - append_event( - EVENT_LOG_DB, - { - "type": "offline_sync_received", - "status": "synced", - "source": "server", - "user": user, - "team_id": _as_str(profile.get("id")), - "payload": { - "event_count": len(stored), - }, - }, - ) - - return { - "status": "ok", - "accepted": len(stored), - "events": [ - { - "id": event.get("id"), - "type": event.get("type"), - "status": event.get("status"), - "client_event_id": event.get("client_event_id") or ( - event.get("payload", {}).get("client_event_id") - if isinstance(event.get("payload"), dict) - else None - ), - "node_id": event.get("node_id"), - "error": event.get("error"), - "duplicate": bool(event.get("duplicate")), - } - for event in stored - ], - } - -@app.post("/api/admin/events") -async def admin_events(request: Request): - data = await request.json() - - if not admin_request_authorized(request, data): - raise HTTPException(status_code=403, detail="forbidden") - - limit = data.get("limit", 100) - try: - limit = max(1, min(500, int(limit))) - except (TypeError, ValueError): - limit = 100 - - status = sanitize_event_text(data.get("status"), 80) or None - user = sanitize_event_text(data.get("user"), 120) or None - event_type = sanitize_event_text(data.get("type"), 80) or None - - return { - "status": "ok", - "events": list_events( - EVENT_LOG_DB, - status=status, - user=user, - event_type=event_type, - limit=limit, - ), - } - -@app.post("/api/admin/events/mark") -async def admin_mark_event(request: Request): - data = await request.json() - - if not admin_request_authorized(request, data): - raise HTTPException(status_code=403, detail="forbidden") - - event_id = sanitize_event_text(data.get("event_id"), 120) - next_status = sanitize_event_text(data.get("status"), 40) - - if not event_id: - raise HTTPException(status_code=400, detail="event_id is required") - - updated = mark_event_status( - EVENT_LOG_DB, - event_id, - next_status, - error=sanitize_event_text(data.get("error"), 300) or None, - ) - - if not updated: - raise HTTPException(status_code=404, detail="event not found") - - return { - "status": "ok", - "event": updated, - } - - -@app.post("/api/heartbeat") -async def heartbeat(request: Request): - data = await request.json() - - user = _as_str(data.get("user")).strip() - if not user: - return JSONResponse( - status_code=400, - content={"status": "error", "detail": "user required"} - ) - - cfg = load_config() - profile = resolve_known_player_profile(user, cfg) - if not profile: - return JSONResponse( - status_code=404, - content={"status": "error", "detail": "unknown profile"} - ) - - profile_id = profile.get("id") or user - - now = time.time() - ip = get_heartbeat_client_ip(request) - rate_key = f"{ip}:{profile_id}" - - prune_heartbeat_rate_state(now) - last_seen_for_key = float(HEARTBEAT_LAST_SEEN_BY_KEY.get(rate_key) or 0) - if last_seen_for_key and (now - last_seen_for_key) < HEARTBEAT_MIN_INTERVAL_SECONDS: - retry_after = max(1, int(HEARTBEAT_MIN_INTERVAL_SECONDS - (now - last_seen_for_key))) - return JSONResponse( - status_code=429, - headers={"Retry-After": str(retry_after)}, - content={"status": "error", "detail": f"heartbeat too frequent; retry in {retry_after}s"} - ) - - lat_present = data.get("lat") is not None - lon_present = data.get("lon") is not None - - if lat_present != lon_present: - return JSONResponse( - status_code=400, - content={"status": "error", "detail": "lat and lon must be sent together"} - ) - - lat = _as_float(data.get("lat")) - lon = _as_float(data.get("lon")) - - if lat_present and (lat is None or lon is None): - return JSONResponse( - status_code=400, - content={"status": "error", "detail": "invalid coordinates"} - ) - - if lat is not None and not (-90 <= lat <= 90): - return JSONResponse( - status_code=400, - content={"status": "error", "detail": "lat out of range"} - ) - - if lon is not None and not (-180 <= lon <= 180): - return JSONResponse( - status_code=400, - content={"status": "error", "detail": "lon out of range"} - ) - - current = get_live_position(profile_id) - if not isinstance(current, dict): - current = {} - - if lat is not None and lon is not None: - current["lat"] = lat - current["lon"] = lon - - current["last_seen"] = int(now) - current["gps_status"] = normalize_heartbeat_gps_status( - data.get("gps_status") or current.get("gps_status") or "unknown" - ) - current["source"] = normalize_heartbeat_source( - data.get("source") or current.get("source") or "player" - ) - - # Public heartbeat must not be able to toggle debug state remotely. - current["debug_enabled"] = False - - upsert_live_position_for_user(profile_id, current) - HEARTBEAT_LAST_SEEN_BY_KEY[rate_key] = now - - return { - "status": "ok", - "user": profile_id, - "live_status": project_live_profile_status(profile, current) - } - - - def _safe_runtime_json_file(global_names, fallback): for name in global_names: path = globals().get(name) @@ -2170,63 +1599,6 @@ async def save_config_endpoint(request: Request): -def _positive_int(value, default=1): - try: - parsed = int(value) - except (TypeError, ValueError): - parsed = default - return max(1, parsed) - - -def read_stage_item_requirement(raw_stage): - node = raw_stage if isinstance(raw_stage, dict) else {} - config = {} - - interaction = node.get("interaction") - if isinstance(interaction, dict) and isinstance(interaction.get("config"), dict): - config.update(interaction.get("config") or {}) - - if isinstance(node.get("config"), dict): - config.update(node.get("config") or {}) - - requirements = node.get("requirements") - if isinstance(requirements, dict): - items = requirements.get("items") - if isinstance(items, list) and items and isinstance(items[0], dict): - config.update(items[0]) - - item_id = _as_str( - config.get("required_item_id") - or config.get("item_id") - or config.get("id") - ).strip() - - if not item_id: - return None - - label = _as_str( - config.get("required_item_label") - or config.get("label") - or item_id - ).strip() or item_id - - return { - "item_id": item_id, - "label": label, - "quantity": _positive_int( - config.get("required_item_quantity") - or config.get("quantity") - or 1, - 1, - ), - "consume": _as_bool( - config.get("required_item_consume") - or config.get("consume"), - False, - ), - } - - def _event_payload(event): payload = event.get("payload") if isinstance(event, dict) else {} return payload if isinstance(payload, dict) else {} @@ -2327,229 +1699,6 @@ def append_inventory_item_used_event(user, profile_id, current_node, requirement ) -@app.post("/api/advance") -async def advance(request: Request): - data = await request.json() - user = data.get("user") - code = (data.get("code") or "").strip().upper() - require_player_session(request, user) - enforce_player_rate_limit("advance", request, user, ADVANCE_RATE_LIMIT_MAX) - - profile = get_player_profile(user) - profile_id = profile.get("id") or _as_str(user).strip() or "PLAYER 1" - - stages = get_runtime_stages() - lvl = get_player_progress_level(profile_id, get_player_progress_level(user, 0)) - - if lvl < len(stages): - current_node = stages[lvl] - - if stage_accepts_code(current_node, code): - requirement_status = evaluate_stage_item_requirement(current_node, profile_id) - - if not requirement_status["ok"]: - return { - "status": "fail", - "user": profile_id, - "reason": "missing_required_item", - "requirement": requirement_status, - } - - if requirement_status["required"] and requirement_status["consume"]: - append_inventory_item_used_event(user, profile_id, current_node, requirement_status) - - set_player_progress_level(profile_id, lvl + 1) - return { - "status": "ok", - "user": profile_id, - "requirement": requirement_status, - } - - return {"status": "fail", "user": profile_id} - -@app.post("/api/reset") -async def reset(request: Request): - data = await request.json() - - # /api/reset mutates player progress. Keep it admin-only. - if not admin_request_authorized(request, data): - raise HTTPException(status_code=403, detail="forbidden") - - user = _as_str(data.get("user")).strip() - if not user: - raise HTTPException(status_code=400, detail="user is required") - - reset_player_level(GAME_DB, user) - return {"status": "ok"} - -def _clamp_game_level(value, max_level): - try: - value = int(value) - except Exception: - value = 0 - if value < 0: - return 0 - if value > max_level: - return max_level - return value - -@app.post("/api/admin/profile-action") -async def admin_profile_action(request: Request): - data = await request.json() - - if not admin_request_authorized(request, data): - return JSONResponse( - status_code=403, - content={"status": "error", "detail": "bad password"} - ) - - if admin_password_change_required(): - return JSONResponse( - status_code=403, - content={"status": "error", "detail": "password change required"} - ) - - profile_id = _as_str(data.get("profile_id")).strip() - action = _as_str(data.get("action")).strip().lower() - - allowed_actions = { - "reset_profile", - "level_prev", - "level_next", - "mark_finished", - } - - if action not in allowed_actions: - return JSONResponse( - status_code=400, - content={"status": "error", "detail": "invalid action"} - ) - - cfg = load_config() - profiles = { - _as_str((p or {}).get("id")).strip(): (p or {}) - for p in get_player_profiles(cfg) - } - - if not profile_id or profile_id not in profiles: - return JSONResponse( - status_code=404, - content={"status": "error", "detail": "unknown profile"} - ) - - runtime_stages = get_runtime_stages() - max_level = len(runtime_stages) - - previous_level = _clamp_game_level(get_player_progress_level(profile_id, 0), max_level) - - if action == "reset_profile": - new_level = 0 - elif action == "level_prev": - new_level = max(0, previous_level - 1) - elif action == "level_next": - new_level = min(max_level, previous_level + 1) - else: # mark_finished - new_level = max_level - - set_player_progress_level(profile_id, new_level) - - return { - "status": "ok", - "profile_id": profile_id, - "action": action, - "previous_level": previous_level, - "level": new_level, - "finished": new_level >= max_level, - "total_stages": max_level, - } - - -@app.post("/api/admin/save") -async def save_stages_endpoint(request: Request): - data = await request.json() - if not admin_request_authorized(request, data): - return JSONResponse(status_code=403, content={"status": "error"}) - if admin_password_change_required(): - return JSONResponse(status_code=403, content={"status": "error", "detail": "password change required"}) - - stages = data.get("stages") - errors = validate_stages(stages) - if errors: - return JSONResponse( - status_code=400, - content={"status": "error", "detail": "invalid stages", "errors": errors} - ) - - save_stages(STAGES_DB, stages) - return {"status": "ok"} - -@app.post("/api/admin/login") -async def admin_login(request: Request): - data = await request.json() - now = time.time() - ip = get_client_ip(request) - - remaining = get_admin_lock_remaining_seconds(ip, now) - if remaining > 0: - raise HTTPException( - status_code=429, - detail=f"too many failed attempts; retry in {remaining}s" - ) - - if verify_admin_password(data.get("password")): - clear_admin_login_state(ip) - expires_at = int(time.time()) + ADMIN_SESSION_TTL_SECONDS - response = JSONResponse( - { - "status": "ok", - "must_change": admin_password_change_required(), - "session_expires_at": expires_at, - } - ) - set_admin_session_cookie(response, request, create_admin_session()) - return response - - register_admin_login_failure(ip, now) - raise HTTPException(status_code=401, detail="invalid admin password") - -@app.post("/api/admin/logout") -async def admin_logout(request: Request): - token = request.cookies.get(ADMIN_SESSION_COOKIE) - if token: - ADMIN_SESSIONS.pop(token, None) - admin_auth_security.clear_persistent_admin_session(ADMIN_SESSIONS_DB, token) - - response = JSONResponse({"status": "ok"}) - clear_admin_session_cookie(response, request) - clear_player_session_cookie(response, request) - return response - -@app.post("/api/admin/change-password") -async def admin_change_password(request: Request): - data = await request.json() - if not admin_request_authorized(request, data): - raise HTTPException(status_code=403, detail="forbidden") - - current_password = (data.get("password") or "").strip() - new_password = (data.get("new_password") or "").strip() - confirm_password = (data.get("confirm_password") or "").strip() - - if not verify_admin_password(current_password): - return JSONResponse(status_code=403, content={"status": "error", "detail": "bad password"}) - - if not new_password: - return JSONResponse(status_code=400, content={"status": "error", "detail": "new password required"}) - - if new_password != confirm_password: - return JSONResponse(status_code=400, content={"status": "error", "detail": "passwords do not match"}) - - if is_weak_admin_password(new_password): - return JSONResponse(status_code=400, content={"status": "error", "detail": "choose a stronger password (minimum 10 chars, avoid temporary/default values)"}) - - set_admin_password(new_password, must_change=False, source="web_change") - clear_admin_sessions() - return {"status": "ok"} - @app.api_route("/sw.js", methods=["GET", "HEAD"]) def player_service_worker(): sw_file = REACT_DIST_DIR / "sw.js"