diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..553d89da9dff1d2769684b5c2be62bc6ccfad6f2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# Keep the build context (and the pushed Space repo) small. Multi-GB vendored +# trees, the local model runtime, and Windows-only launchers are excluded. +.git +.venv +venv +runtime/ +tools/ +__pycache__/ +**/__pycache__/ +*.pyc +tmp/ +*.log +*.err.log + +# Heavy / local-only data. The maps, processed graph and voices are kept; +# backups, saved games and the smoke screenshot are not needed in the image. +data/backups/ +data/games/ +data/archives/ +data/raw/archives/ +data/*.log +data/*.png + +# Windows launchers — not used on Linux. +run_game.ps1 +run_game.cmd + +# Build artifact placeholders +C:* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..c44b6092f6c2f29aa97e95e6a26e5a15302c89f6 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# === Default backend: plain text llama.cpp server (no audio) === +# OpenBMB MiniCPM4.1-8B (text-only). Chosen as the default because it stays in +# English reliably, unlike the MiniCPM-o omni model. +PHANTOM_GRID_LLM_PROVIDER=llama_cpp_server +PHANTOM_GRID_LLM_MODEL=MiniCPM4.1-8B-Q4_K_M.gguf +PHANTOM_GRID_LLAMACPP_MODEL_PATH=runtime/models/MiniCPM4.1-8B-gguf/MiniCPM4.1-8B-Q4_K_M.gguf +# Reuse the llama-server built for the omni stack (it also serves plain text GGUFs), +# or any standalone llama.cpp build / installed llama-server. +PHANTOM_GRID_LLAMACPP_SERVER_BIN=runtime/llama.cpp-omni/build/bin/Release/llama-server.exe +PHANTOM_GRID_LLAMACPP_BASE_URL=http://127.0.0.1:8080/v1 +PHANTOM_GRID_LLAMACPP_CONTEXT_LENGTH=8192 +PHANTOM_GRID_LLAMACPP_GPU_LAYERS=auto +PHANTOM_GRID_GPU_DEVICE=auto +# Text backend has no voice; keep TTS off. +PHANTOM_GRID_WITNESS_CHAT_TTS=0 + +# === Optional: MiniCPM-o omni voice mode === +# WARNING: The MiniCPM-o omni model frequently hallucinates and drifts into +# Chinese (especially its TTS/audio branch), even on English-only prompts. Use +# it only if you need synthesized witness voices, and expect occasional Chinese +# output. To enable, set PHANTOM_GRID_LLM_PROVIDER=minicpm_omni and TTS=1. +PHANTOM_GRID_OMNI_GATEWAY_URL=http://127.0.0.1:8006 +PHANTOM_GRID_OMNI_LAUNCHER_PATH=scripts/launch_minicpm_omni.py +PHANTOM_GRID_COMNI_CHECKOUT_PATH=runtime/MiniCPM-o-Demo +PHANTOM_GRID_LLAMACPP_OMNI_ROOT=runtime/llama.cpp-omni +PHANTOM_GRID_MINICPM_MODEL_DIR=runtime/models/MiniCPM-o-4_5-gguf +PHANTOM_GRID_MINICPM_QUANTIZATION=MiniCPM-o-4_5-Q4_K_M.gguf + +# === Gameplay === +PHANTOM_GRID_WITNESS_VOICE_DIR=data\voices +PHANTOM_GRID_MAX_TURNS=12 +PHANTOM_GRID_CHECKS_PER_TURN=2 +PHANTOM_GRID_BLOCKS_PER_TURN=1 +PHANTOM_GRID_INDIVIDUAL_WITNESS_THRESHOLD=12 +PHANTOM_GRID_MEMORY_CORRUPTION_PER_TURN=0.08 diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..adffb5c6f4de0b7d5fc3550c521548f2ce69f499 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,37 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +data/voices/voice_01.wav filter=lfs diff=lfs merge=lfs -text +data/voices/voice_02.wav filter=lfs diff=lfs merge=lfs -text +data/voices/voice_03.wav filter=lfs diff=lfs merge=lfs -text +data/voices/voice_04.wav filter=lfs diff=lfs merge=lfs -text +data/voices/voice_05.wav filter=lfs diff=lfs merge=lfs -text +data/voices/voice_06.wav filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/reference/_asset_contact_sheet.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/reference/frame_header.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/reference/legend_strip_frame.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/reference/paper_note_frame.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/reference/side_panel_frame.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/reference/wanted_card_frame.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/amber_quill.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/blue_hour.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/brass_moth.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/copper_saint.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/glass_finch.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/green_signal.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/ivory_rook.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/madame_mercury.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/rose_diamond.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/saffron_pen.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/scarlet_lark.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/thames_ghost.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/velvet_mask.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/violet_cipher.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/suspects/white_raven.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/tutorial/01_board_overview.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/tutorial/02_briefing.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/tutorial/03_map_layers.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/tutorial/04_tactics_tray.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/tutorial/06_notice.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/tutorial/07_witness_interview.png filter=lfs diff=lfs merge=lfs -text +ui/web/static/assets/tutorial/08_witnesses_map.png filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..e6fbf6c76757294a6931271551be2db58d7d9584 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# Phantom Grid — Docker Hugging Face Space (Linux, CPU). +# +# The image bakes in a prebuilt llama.cpp OpenAI-compatible server +# (llama-cpp-python) and the MiniCPM4.1-8B Q4_K_M GGUF (text backend, <32B), +# so there is NO build-from-source or model download at container startup. +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + HF_HOME=/app/.hfcache \ + MODEL_DIR=/app/models \ + MODEL_PATH=/app/models/MiniCPM4.1-8B-Q4_K_M.gguf + +# Minimal OS deps. libgomp1 is needed by the llama.cpp OpenMP runtime. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libgomp1 curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# --- Python deps ----------------------------------------------------------- +COPY requirements-space.txt ./ +RUN pip install --upgrade pip \ + && pip install -r requirements-space.txt \ + # Prebuilt CPU wheel for the OpenAI-compatible llama.cpp server (no compile). + && pip install "llama-cpp-python[server]" \ + --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu + +# --- Bake the model at BUILD time ----------------------------------------- +# Downloaded into the image so the Space boots without a multi-GB pull. +RUN python -c "from huggingface_hub import hf_hub_download; \ +hf_hub_download(repo_id='openbmb/MiniCPM4.1-8B-GGUF', \ +filename='MiniCPM4.1-8B-Q4_K_M.gguf', local_dir='/app/models')" + +# --- App code -------------------------------------------------------------- +COPY . . + +# Writable dirs for HF's runtime user (Spaces run as uid 1000). +RUN mkdir -p /app/data/games /app/.hfcache \ + && chmod -R 777 /app/data /app/.hfcache /app/models + +# Point the app at the in-container llama.cpp server (text-only, no voice). +ENV PHANTOM_GRID_LLM_PROVIDER=external_llama_cpp_server \ + PHANTOM_GRID_LLAMACPP_BASE_URL=http://127.0.0.1:8080/v1 \ + PHANTOM_GRID_LLM_MODEL=MiniCPM4.1-8B-Q4_K_M.gguf \ + PHANTOM_GRID_WITNESS_CHAT_TTS=0 \ + PHANTOM_GRID_HOST=0.0.0.0 \ + PORT=7860 + +EXPOSE 7860 + +ENTRYPOINT ["bash", "entrypoint.sh"] diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..2c33eebc97010e7eb9b4b001be315fa20dcc4688 --- /dev/null +++ b/app.py @@ -0,0 +1,1825 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import math +import os +import random +import re +import shutil +import subprocess +import sys +import urllib.error +import urllib.request +from dataclasses import asdict +from pathlib import Path +from typing import Any + +import gradio as gr +from fastapi import HTTPException, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles + +from config import load_settings +from game.rules import checks_remaining_this_turn +from game.session import ( + TACTIC_LIMITS, + add_block, + check_junction, + end_turn, + issue_notice, + new_game, + place_tactic, + persist, + question_witness, + remove_tactic, + finalize_game, + update_notes, +) +from game.save_load import load_state +from game.state import GameState, WitnessQuestion +from game.story_engine import compact_story_memory, ensure_case_introduction, story_reveal +from game.context_budget import ContextBudget, normalize_context_length +from game.case_catalog import choose_case +from game.witness_engine import deterministic_witness_answer, witness_by_id +from grid_map.atlas import public_atlas_payload +from grid_map.graph_loader import all_junction_ids, legal_moves_from +from grid_map.map_loader import image_for_layer, load_map_metadata +from grid_map.storage import read_json +from llm.omni_client import OmniClient, OmniResponse, scan_minicpm_models +from llm.audio import wav_to_float32_base64 +from llm.devices import ( + context_length_presets, + detect_devices, + gpu_layer_presets, + quantization_catalog, + resolve_device_env, +) + +DEFAULT_DESCRIPTION = "A nervous-looking person in a grey raincoat carrying a red folder." +DEFAULT_NOTICE = "Request high-confidence reports of a grey raincoat carrying a red folder at the selected junction." +DEFAULT_QUESTION = "What exactly did the person carry?" +DEFAULT_SELECTED_JUNCTION = 100 +MAP_CLICK_RADIUS = 64 + +PROJECT_ROOT = Path(__file__).resolve().parent +WEB_DIR = PROJECT_ROOT / "ui" / "web" +STATIC_DIR = WEB_DIR / "static" + +_SESSIONS: dict[str, GameState] = {} +_LLAMA_PROCESS: subprocess.Popen | None = None +_SETUP_PROCESS: subprocess.Popen | None = None +RUNTIME_ROOT = PROJECT_ROOT / "runtime" + +DIFFICULTY_PRESETS = { + "easy": {"PHANTOM_GRID_MAX_TURNS": "16", "PHANTOM_GRID_CHECKS_PER_TURN": "3", "PHANTOM_GRID_MEMORY_CORRUPTION_PER_TURN": "0.04"}, + "normal": {"PHANTOM_GRID_MAX_TURNS": "12", "PHANTOM_GRID_CHECKS_PER_TURN": "2", "PHANTOM_GRID_MEMORY_CORRUPTION_PER_TURN": "0.08"}, + "hard": {"PHANTOM_GRID_MAX_TURNS": "10", "PHANTOM_GRID_CHECKS_PER_TURN": "1", "PHANTOM_GRID_MEMORY_CORRUPTION_PER_TURN": "0.12"}, +} + + +def build_app() -> gr.Server: + app = gr.Server() + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + @app.get("/", response_class=HTMLResponse) + async def homepage() -> str: + return (WEB_DIR / "index.html").read_text(encoding="utf-8") + + @app.get("/assets/maps/{layer}") + async def map_asset(layer: str) -> FileResponse: + try: + path = Path(image_for_layer(layer)) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"Unknown map layer: {layer}") from exc + if not path.exists(): + raise HTTPException(status_code=404, detail=f"Missing map asset: {layer}") + return FileResponse(path) + + @app.get("/assets/suspect") + async def suspect_asset() -> FileResponse: + return FileResponse(STATIC_DIR / "assets" / "reference" / "suspect_portrait_placeholder.png", media_type="image/png") + + @app.get("/assets/voices/{voice_id}") + async def voice_asset(voice_id: str) -> FileResponse: + path = _voice_path(voice_id) + if path is None: + raise HTTPException(status_code=404, detail="Unknown witness voice.") + return FileResponse(path, media_type="audio/wav") + + @app.get("/api/snapshot") + async def snapshot_route(game_id: str | None = None) -> dict[str, Any]: + return game_snapshot(game_id) + + @app.post("/api/new_case") + async def new_case_route(payload: dict[str, Any]) -> dict[str, Any]: + return new_case(payload.get("initial_description"), require_omni=True) + + @app.post("/api/select_junctions") + async def select_junctions_route(payload: dict[str, Any]) -> dict[str, Any]: + return select_junctions( + payload.get("game_id"), + payload.get("selected_junctions") or [], + payload.get("focused_junction"), + ) + + @app.post("/api/issue_notice") + async def issue_notice_route(payload: dict[str, Any]) -> dict[str, Any]: + return api_issue_notice( + payload.get("game_id"), + payload.get("notice_text") or DEFAULT_NOTICE, + payload.get("selected_junctions") or [], + payload.get("focused_junction"), + ) + + @app.post("/api/add_block") + async def add_block_route(payload: dict[str, Any]) -> dict[str, Any]: + return api_add_block( + payload.get("game_id"), + payload.get("block_type") or "junction_block", + payload.get("focused_junction"), + payload.get("to_junction"), + payload.get("mode"), + payload.get("turns") or 1, + payload.get("selected_junctions") or [], + ) + + @app.post("/api/place_tactic") + async def place_tactic_route(payload: dict[str, Any]) -> dict[str, Any]: + return api_place_tactic( + payload.get("game_id"), + payload.get("tactic_type"), + payload.get("junction_id"), + payload.get("selected_junctions") or [], + payload.get("focused_junction"), + layer=payload.get("layer"), + ) + + @app.post("/api/remove_tactic") + async def remove_tactic_route(payload: dict[str, Any]) -> dict[str, Any]: + return api_remove_tactic( + payload.get("game_id"), + payload.get("tactic_id"), + payload.get("selected_junctions") or [], + payload.get("focused_junction"), + ) + + @app.post("/api/check_junctions") + async def check_junctions_route(payload: dict[str, Any]) -> dict[str, Any]: + return api_check_junctions( + payload.get("game_id"), + payload.get("selected_junctions") or [], + payload.get("focused_junction"), + ) + + @app.post("/api/ask_witness") + async def ask_witness_route(payload: dict[str, Any]) -> dict[str, Any]: + return api_ask_witness( + payload.get("game_id"), + payload.get("witness_id"), + payload.get("question") or DEFAULT_QUESTION, + payload.get("selected_junctions") or [], + payload.get("focused_junction"), + use_model=True, + ) + + @app.post("/api/advance_turn") + async def advance_turn_route(payload: dict[str, Any]) -> dict[str, Any]: + return api_advance_turn( + payload.get("game_id"), + payload.get("selected_junctions") or [], + payload.get("focused_junction"), + use_model=True, + ) + + @app.get("/api/omni/status") + async def omni_status_route() -> dict[str, Any]: + return api_omni_status() + + @app.get("/api/omni/models") + async def omni_models_route() -> dict[str, Any]: + return api_omni_models() + + @app.post("/api/game/{game_id}/notes") + async def notes_route(game_id: str, payload: dict[str, Any]) -> dict[str, Any]: + state = _state_for(game_id) + update_notes(state, str(payload.get("notes") or "")) + return {"ok": True, "notes": state.user_notes} + + @app.post("/api/game/{game_id}/stop") + async def stop_game_route(game_id: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + state = _state_for(game_id) + reveal = finalize_game(state, str((payload or {}).get("reason") or "stopped")) + snapshot = _snapshot(state, [], None, "Case finalized.") + snapshot["story_available"] = False + return {"ok": True, "story": reveal, "snapshot": snapshot} + + @app.get("/api/game/{game_id}/story") + async def story_route(game_id: str) -> dict[str, Any]: + state = _state_for(game_id) + if not state.result and not state.finalized_reason: + raise HTTPException(status_code=403, detail="The private story is revealed only after the case ends.") + return {"ok": True, "story": story_reveal(state)} + + @app.get("/api/witness/{game_id}/{witness_id}") + async def witness_route(game_id: str, witness_id: str) -> dict[str, Any]: + return api_witness_detail(game_id, witness_id) + + @app.post("/api/witness/{game_id}/{witness_id}/message") + async def witness_message_route(game_id: str, witness_id: str, payload: dict[str, Any]) -> dict[str, Any]: + try: + return api_witness_message(game_id, witness_id, str(payload.get("message") or "")) + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + @app.websocket("/ws/witness/{game_id}/{witness_id}") + async def witness_socket(websocket: WebSocket, game_id: str, witness_id: str) -> None: + await proxy_witness_socket(websocket, game_id, witness_id) + + @app.get("/api/settings") + async def settings_route() -> dict[str, Any]: + return api_settings() + + @app.post("/api/settings") + async def update_settings_route(payload: dict[str, Any]) -> dict[str, Any]: + return api_update_settings(payload) + + @app.post("/api/llama/{action}") + async def llama_action_route(action: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + return api_llama_action(action, payload or {}) + + @app.get("/api/setup/status") + async def setup_status_route() -> dict[str, Any]: + return api_setup_status() + + @app.post("/api/setup/start") + async def setup_start_route(payload: dict[str, Any] | None = None) -> dict[str, Any]: + return api_setup_start(payload or {}) + + @app.get("/api/runtime/options") + async def runtime_options_route() -> dict[str, Any]: + return api_runtime_options() + + app.api(new_case, name="new_case") + app.api(select_junctions, name="select_junctions") + app.api(api_issue_notice, name="issue_notice") + app.api(api_add_block, name="add_block") + app.api(api_place_tactic, name="place_tactic") + app.api(api_remove_tactic, name="remove_tactic") + app.api(api_check_junctions, name="check_junctions") + app.api(api_ask_witness, name="ask_witness") + app.api(api_advance_turn, name="advance_turn") + app.api(game_snapshot, name="game_snapshot") + return app + + +def new_case(initial_description: str | None = None, require_omni: bool = False) -> dict[str, Any]: + if require_omni: + health = OmniClient.from_settings().health() + if not health.get("ready"): + raise HTTPException(status_code=503, detail="MiniCPM-o must be healthy before a new case can start.") + case_profile = choose_case() + description = (initial_description or case_profile["description"]).strip() + state = new_game(description, use_model=require_omni, case_profile=case_profile) + _SESSIONS[state.game_id] = state + return _snapshot( + state, + selected_junctions=[], + focused_junction=None, + event="Case opened. The starting point is hidden.", + sound="lookout_raise", + ) + + +def select_junctions( + game_id: str | None = None, + selected_junctions: list[int] | None = None, + focused_junction: int | None = None, +) -> dict[str, Any]: + state = _state_for(game_id, required=False) + clean_selected = _valid_junctions(selected_junctions or []) + clean_focused = _valid_junction(focused_junction) or (clean_selected[-1] if clean_selected else None) + return _snapshot( + state, + selected_junctions=clean_selected, + focused_junction=clean_focused, + event=_selection_event(clean_selected, clean_focused), + sound="map_select", + ) + + +def api_issue_notice( + game_id: str | None, + notice_text: str = DEFAULT_NOTICE, + selected_junctions: list[int] | None = None, + focused_junction: int | None = None, +) -> dict[str, Any]: + state = _state_for(game_id) + selected, focused = _selection_context(selected_junctions, focused_junction) + if focused is None: + focused = state.last_seen_junction or DEFAULT_SELECTED_JUNCTION + selected = [focused] + text = _notice_with_selected_junction(notice_text or DEFAULT_NOTICE, focused) + state, batch = issue_notice(state, text, anchor_junction=focused) + _SESSIONS[state.game_id] = state + message = f"{batch.total_witnesses} witnesses surfaced." + if batch.individual_review_allowed: + message += " Witness cards are available." + else: + message += " The crowd is too dense for individual cards." + return _snapshot(state, selected, focused, message, sound="witness_popup") + + +def api_add_block( + game_id: str | None, + block_type: str, + focused_junction: int | None = None, + to_junction: int | None = None, + mode: str | None = None, + turns: int = 1, + selected_junctions: list[int] | None = None, +) -> dict[str, Any]: + state = _state_for(game_id) + selected, focused = _selection_context(selected_junctions, focused_junction) + if focused is None: + return _snapshot(state, selected, focused, "Select a junction before placing a blockade.", sound="map_select") + + if block_type == "edge_block": + if _valid_junction(to_junction) is None: + return _snapshot(state, selected, focused, "Pick a connected route first.", sound="map_select") + state, message = add_block( + state, + "edge_block", + from_junction=focused, + to_junction=int(to_junction), + mode=mode, + turns=_clean_turns(turns), + ) + elif block_type == "mode_block": + if mode not in {"taxi", "bus", "subway"}: + return _snapshot(state, selected, focused, "Choose taxi, bus, or subway first.", sound="map_select") + state, message = add_block(state, "mode_block", junction_id=focused, mode=mode, turns=_clean_turns(turns)) + else: + state, message = add_block(state, "junction_block", junction_id=focused, turns=_clean_turns(turns)) + + _SESSIONS[state.game_id] = state + return _snapshot(state, selected, focused, message, sound="blockade_set") + + +def api_place_tactic( + game_id: str | None, + tactic_type: str | None, + junction_id: int | None, + selected_junctions: list[int] | None = None, + focused_junction: int | None = None, + layer: str | None = None, +) -> dict[str, Any]: + state = _state_for(game_id) + selected, focused = _selection_context(selected_junctions, focused_junction) + target = _valid_junction(junction_id) or focused + if target is None: + return _snapshot(state, selected, focused, "Drop the tactic on a valid junction.", sound="map_select") + junction = _junction_by_id(target) + if junction is None: + return _snapshot(state, selected, focused, "Drop the tactic on a valid junction.", sound="map_select") + state, message = place_tactic(state, str(tactic_type or ""), target, int(junction["x"]), int(junction["y"]), layer=layer) + _SESSIONS[state.game_id] = state + snapshot = _snapshot(state, [*selected, target], target, message, sound="blockade_set") + if tactic_type == "lookout_board" and "No lookout" not in message: + snapshot["notice_prompt"] = { + "open": True, + "junction_id": target, + "prefill": state.last_notice_text or state.initial_description, + } + return snapshot + + +def api_remove_tactic( + game_id: str | None, + tactic_id: str | None, + selected_junctions: list[int] | None = None, + focused_junction: int | None = None, +) -> dict[str, Any]: + state = _state_for(game_id) + selected, focused = _selection_context(selected_junctions, focused_junction) + if not tactic_id: + return _snapshot(state, selected, focused, "Choose a placed tactic first.", sound="map_select") + state, message = remove_tactic(state, tactic_id) + _SESSIONS[state.game_id] = state + return _snapshot(state, selected, focused, message, sound="map_select") + + +def api_check_junctions( + game_id: str | None, + selected_junctions: list[int] | None = None, + focused_junction: int | None = None, +) -> dict[str, Any]: + state = _state_for(game_id) + selected, focused = _selection_context(selected_junctions, focused_junction) + targets = _ordered_check_targets(selected, focused) + if not targets: + return _snapshot(state, selected, focused, "Select at least one junction to search.", sound="map_select") + + remaining = checks_remaining_this_turn(state.turn_number, state.junction_checks) + if remaining <= 0: + return _snapshot(state, selected, focused, "No searches remain this turn.", sound="map_select") + + messages: list[str] = [] + for junction_id in targets[:remaining]: + state, message = check_junction(state, junction_id) + messages.append(f"J{junction_id}: {message}") + if state.result: + break + + _SESSIONS[state.game_id] = state + return _snapshot(state, selected, focused, " ".join(messages), sound="blockade_set") + + +def api_ask_witness( + game_id: str | None, + witness_id: str | None, + question: str = DEFAULT_QUESTION, + selected_junctions: list[int] | None = None, + focused_junction: int | None = None, + use_model: bool = False, +) -> dict[str, Any]: + state = _state_for(game_id) + selected, focused = _selection_context(selected_junctions, focused_junction) + if not witness_id: + return _snapshot(state, selected, focused, "Choose a witness card first.", sound="map_select") + if use_model: + _require_omni_ready() + state, answer = question_witness(state, witness_id, question or DEFAULT_QUESTION, use_model=use_model) + _SESSIONS[state.game_id] = state + return _snapshot(state, selected, focused, answer, sound="witness_popup") + + +def api_advance_turn( + game_id: str | None, + selected_junctions: list[int] | None = None, + focused_junction: int | None = None, + use_model: bool = False, +) -> dict[str, Any]: + state = _state_for(game_id) + selected, focused = _selection_context(selected_junctions, focused_junction) + if use_model: + _require_omni_ready() + state.effective_context_length = load_settings().llamacpp_context_length + compact_story_memory(state) + previous_batch_count = len(state.witness_batches) + state, message = end_turn(state, use_model=use_model) + _SESSIONS[state.game_id] = state + sound = "witness_popup" if len(state.witness_batches) > previous_batch_count else "turn_advance" + return _snapshot(state, selected, focused, message, sound=sound) + + +def api_witness_detail(game_id: str, witness_id: str) -> dict[str, Any]: + state = _state_for(game_id) + witness = witness_by_id(state, witness_id) + if witness is None: + raise HTTPException(status_code=404, detail="Witness not found or not yet surfaced.") + if witness_id not in state.viewed_witness_ids: + state.viewed_witness_ids.append(witness_id) + persist(state) + return { + "ok": True, + "witness": { + "id": witness.witness_id, + "name": witness.name, + "occupation": witness.occupation, + "junction_id": witness.junction_id, + "personality": witness.personality, + "reliability": witness.reliability, + "memory": witness.memory_strength, + "summary": witness.current_summary, + "voice_id": witness.voice_id, + "voice_url": f"/assets/voices/{witness.voice_id}", + "transcript": [asdict(item) for item in witness.question_history], + "observed_turn": witness.turn_created, + }, + } + + +_CJK_RE = re.compile(r"[㐀-䶿一-鿿豈-﫿]") + + +_CJK_REPLY_RE = re.compile(r"[\u3400-\u9fff\uf900-\ufaff]") +_UNRELATED_REPLY_MARKERS = ( + "what would you like", "can't help with that question", "cannot help with that question", + "criminal matters", "speak in english", "english only", "language instructions", + "as an ai", "i am an ai", "how can i assist", "sure, i can do that", + "give me the details", "beautiful scenery", "scenic spots", "like in movies", + "provide more details", "witness in an english-language", "facts given by the user", + "won't invent", "will not invent", "let's begin", "got it?", +) +_SPECIFICITY_WORDS = { + "red", "blue", "green", "yellow", "black", "white", "brown", "purple", "orange", + "morning", "afternoon", "evening", "midnight", "noon", "am", "pm", +} + + +_GROUNDING_STOPWORDS = { + "about", "after", "again", "answer", "asks", "before", "carefully", "conversation", + "detective", "details", "english", "facts", "final", "from", "gave", "gives", "know", + "noticed", "only", "question", "reply", "sentence", "short", "speak", "stable", "that", + "their", "there", "these", "they", "this", "what", "when", "where", "which", "with", + "witness", "would", "your", "you", "personality", "ordinary", "current", "summary", +} + + +def _usable_witness_reply(text: str, grounding: str, question: str = "") -> bool: + clean = " ".join((text or "").split()).strip() + lowered = clean.lower() + knowledge_lower = grounding.lower() + if not clean or len(clean) > 500 or _CJK_REPLY_RE.search(clean): + return False + if any(marker in lowered for marker in _UNRELATED_REPLY_MARKERS): + return False + answer_words = set(re.findall(r"[a-z]+", lowered)) + knowledge_words = set(re.findall(r"[a-z]+", knowledge_lower)) + if any(word in answer_words and word not in knowledge_words for word in _SPECIFICITY_WORDS): + return False + response_numbers = set(re.findall(r"\b\d+(?::\d+)?\b", lowered)) + knowledge_numbers = set(re.findall(r"\b\d+(?::\d+)?\b", knowledge_lower)) + if not response_numbers <= knowledge_numbers: + return False + if any(phrase in lowered for phrase in ("i don't know", "i do not know", "not sure", "cannot remember", "can't remember")): + return True + meaningful_answer = answer_words - _GROUNDING_STOPWORDS + meaningful_knowledge = knowledge_words - _GROUNDING_STOPWORDS + return bool(meaningful_answer & meaningful_knowledge) + + +def _witness_chat_with_english_retry(settings, system_prompt, user_prompt, voice_path, question, grounding): + # MiniCPM-o-4.5 Q4_K_M still slips into Chinese filler maybe 1 reply in 4 + # even with a plain English prompt. If we detect CJK in the response, retry + # once at a lower temperature with a sharper directive — that produces a + # clean English answer in nearly every case. + client = OmniClient(settings) + response = client.chat( + system_prompt, user_prompt, task="interview", temperature=0.15, tts=False, + ) + if _usable_witness_reply(response.text, grounding, question): + return response + retry_system = ( + system_prompt + " Your previous attempt contained Chinese characters; " + "this attempt MUST be English only — no Chinese characters at all." + ) + for _ in range(2): + response = client.chat(retry_system, user_prompt, task="interview", temperature=0.0, tts=False) + if _usable_witness_reply(response.text, grounding, question): + return response + return OmniResponse(text="") + + +def api_witness_message(game_id: str, witness_id: str, message: str) -> dict[str, Any]: + clean = " ".join(message.split())[:2000] + if not clean: + raise HTTPException(status_code=400, detail="Enter a question for the witness.") + _require_omni_ready() + state = _state_for(game_id) + witness = witness_by_id(state, witness_id) + if witness is None: + raise HTTPException(status_code=404, detail="Witness not found or not yet surfaced.") + voice_path = _voice_path(witness.voice_id) + settings = load_settings() + budget = ContextBudget.for_context(settings.llamacpp_context_length) + # MiniCPM-o-4.5 Q4_K_M reliably degrades to Chinese filler when given a JSON + # blob as the user message — its Chinese assistant prior overwhelms a + # prompt it can't parse. Plain English with the question on the last line + # produces consistent on-topic English replies. + stable_block = ", ".join(witness.stable_facts) if witness.stable_facts else "(none recorded)" + grounding = f"{witness.current_summary} {stable_block}" + history = [ + item for item in witness.question_history[-budget.recent_interview_turns :] + if _usable_witness_reply(item.answer, grounding, item.question) + ] + system_prompt = ( + "You are roleplaying a witness in an English-language detective game. " + "Speak only English. Reply in one or two short sentences. Use only the " + "facts the user gives you. Let the supplied personality control tone, " + "confidence, and brevity. Never invent details. If you don't know, say " + "you don't know." + ) + history_block = ( + "\n".join(f" Detective: {item.question}\n You: {item.answer}" for item in history) + if history else " (no prior questions)" + ) + personality_block = ", ".join(f"{k}: {v}" for k, v in witness.personality.items()) or "ordinary" + user_prompt = ( + f"You are {witness.name}, a {witness.occupation} ({personality_block}).\n" + f"What you saw / know: {witness.current_summary}\n" + f"Stable facts: {stable_block}\n" + f"Conversation so far:\n{history_block}\n" + f"The detective now asks: {clean!r}\n" + f"Reply in character, in English, in one or two short sentences." + ) + greeting = any(word in clean.lower().split() for word in ("hello", "hi", "hey")) + response = OmniResponse(text="") if greeting else _witness_chat_with_english_retry( + settings, system_prompt, user_prompt, voice_path, clean, grounding, + ) + answer = response.text.strip() or deterministic_witness_answer(witness, clean) + if settings.witness_chat_tts: + speech = OmniClient(settings).synthesize( + answer, + ref_audio_path=str(voice_path) if voice_path else None, + ) + response.audio_data = speech.audio_data + response.audio_sample_rate = speech.audio_sample_rate + witness.question_history.append(WitnessQuestion(question=clean, answer=answer, turn_number=state.turn_number)) + if witness_id not in state.viewed_witness_ids: + state.viewed_witness_ids.append(witness_id) + persist(state) + return { + "ok": True, + "answer": answer, + "audio_data": response.audio_data, + "audio_sample_rate": response.audio_sample_rate or 24000, + "snapshot": _snapshot(state, [witness.junction_id], witness.junction_id), + } + + +async def proxy_witness_socket(websocket: WebSocket, game_id: str, witness_id: str) -> None: + state = _state_for(game_id) + witness = witness_by_id(state, witness_id) + if witness is None: + await websocket.close(code=1008, reason="Witness not available") + return + if not OmniClient.from_settings().omni_health().get("ready"): + await websocket.close(code=1013, reason="MiniCPM-o service unavailable") + return + await websocket.accept() + settings = load_settings() + gateway = settings.omni_gateway_url.rstrip("/") + if gateway.startswith("https://"): + gateway = "wss://" + gateway[8:] + elif gateway.startswith("http://"): + gateway = "ws://" + gateway[7:] + session_id = f"{game_id}_{witness_id}".replace("/", "_")[-180:] + target = f"{gateway}/ws/half_duplex/{session_id}" + voice_path = _voice_path(witness.voice_id) + voice_b64, voice_duration = wav_to_float32_base64(voice_path) if voice_path else ("", 0.0) + assistant_chunks: list[str] = [] + try: + import websockets + + async with websockets.connect(target, max_size=32 * 1024 * 1024) as upstream: + async def client_to_upstream() -> None: + async for raw in websocket.iter_text(): + data = json.loads(raw) + if data.get("type") == "prepare": + budget = ContextBudget.for_context(settings.llamacpp_context_length) + data["system_content"] = [ + {"type": "text", "text": f"Clone this voice. You are {witness.name}, a {witness.occupation}. Speak only from this knowledge: {witness.current_summary}"}, + { + "type": "audio", + "data": voice_b64, + "name": f"{witness.voice_id}.wav", + "duration": voice_duration, + }, + {"type": "text", "text": "Stay in character. Reply in English only — do not translate or speak Chinese. Be concise, and never invent hidden facts."}, + ] + data["lang"] = "en" + data["config"] = { + "vad": { + "threshold": 0.5, + "min_speech_duration_ms": 128, + "min_silence_duration_ms": 600, + "speech_pad_ms": 30, + }, + "generation": { + "max_new_tokens": min(96, budget.output_tokens), + "length_penalty": 1.1, + "temperature": 0.7, + }, + "tts": {"enabled": True}, + "session": {"timeout_s": 300}, + } + await upstream.send(json.dumps(data)) + + async def upstream_to_client() -> None: + async for raw in upstream: + data = json.loads(raw) + if data.get("text_delta"): + assistant_chunks.append(str(data["text_delta"])) + if data.get("type") == "turn_done" and assistant_chunks: + answer = "".join(assistant_chunks).strip() + assistant_chunks.clear() + witness.question_history.append(WitnessQuestion( + question="[Spoken question]", answer=answer, turn_number=state.turn_number + )) + if witness_id not in state.viewed_witness_ids: + state.viewed_witness_ids.append(witness_id) + persist(state) + await websocket.send_text(raw) + + tasks = [asyncio.create_task(client_to_upstream()), asyncio.create_task(upstream_to_client())] + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + for task in done: + task.result() + except (WebSocketDisconnect, OSError, ValueError, json.JSONDecodeError) as exc: + try: + await websocket.send_json({"type": "error", "error": str(exc)}) + except Exception: + pass + finally: + try: + await websocket.close() + except Exception: + pass + + +def api_settings() -> dict[str, Any]: + settings = load_settings() + llama_status, omni_status = _service_statuses(settings) + return { + "ok": True, + "settings": _settings_payload(settings), + "llama": llama_status, + "omni": omni_status, + "model_scan": scan_minicpm_models(settings.minicpm_model_dir), + "difficulty_presets": { + "easy": "Longer case, more checks, slower memory decay.", + "normal": "Balanced turn limit, checks, and witness memory decay.", + "hard": "Shorter case, fewer checks, faster witness memory decay.", + }, + } + + +def api_update_settings(payload: dict[str, Any]) -> dict[str, Any]: + updates: dict[str, str] = {} + difficulty = str(payload.get("difficulty") or "").strip().lower() + if difficulty in DIFFICULTY_PRESETS: + updates.update(DIFFICULTY_PRESETS[difficulty]) + updates["PHANTOM_GRID_DIFFICULTY"] = difficulty + + field_map = { + "llm_provider": "PHANTOM_GRID_LLM_PROVIDER", + "llm_model": "PHANTOM_GRID_LLM_MODEL", + "llamacpp_model_path": "PHANTOM_GRID_LLAMACPP_MODEL_PATH", + "llamacpp_server_bin": "PHANTOM_GRID_LLAMACPP_SERVER_BIN", + "llamacpp_base_url": "PHANTOM_GRID_LLAMACPP_BASE_URL", + "max_turns": "PHANTOM_GRID_MAX_TURNS", + "checks_per_turn": "PHANTOM_GRID_CHECKS_PER_TURN", + "memory_corruption_per_turn": "PHANTOM_GRID_MEMORY_CORRUPTION_PER_TURN", + "omni_gateway_url": "PHANTOM_GRID_OMNI_GATEWAY_URL", + "omni_launcher_path": "PHANTOM_GRID_OMNI_LAUNCHER_PATH", + "comni_checkout_path": "PHANTOM_GRID_COMNI_CHECKOUT_PATH", + "llamacpp_omni_root": "PHANTOM_GRID_LLAMACPP_OMNI_ROOT", + "minicpm_model_dir": "PHANTOM_GRID_MINICPM_MODEL_DIR", + "minicpm_quantization": "PHANTOM_GRID_MINICPM_QUANTIZATION", + "llamacpp_gpu_layers": "PHANTOM_GRID_LLAMACPP_GPU_LAYERS", + "minicpm_gpu_device": "PHANTOM_GRID_GPU_DEVICE", + "witness_voice_dir": "PHANTOM_GRID_WITNESS_VOICE_DIR", + } + for field, env_key in field_map.items(): + if field in payload and payload[field] is not None: + value = str(payload[field]).strip() + if value: + updates[env_key] = value + + if "llamacpp_context_length" in payload: + try: + updates["PHANTOM_GRID_LLAMACPP_CONTEXT_LENGTH"] = str(normalize_context_length(payload["llamacpp_context_length"])) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if "llamacpp_gpu_layers" in payload: + gpu_layers = str(payload["llamacpp_gpu_layers"]).strip().lower() + if gpu_layers != "auto": + try: + if int(gpu_layers) < 0: + raise ValueError + except ValueError as exc: + raise HTTPException(status_code=400, detail="GPU layers must be 'auto' or a non-negative integer.") from exc + updates["PHANTOM_GRID_LLAMACPP_GPU_LAYERS"] = gpu_layers + + provider = updates.get("PHANTOM_GRID_LLM_PROVIDER", load_settings().llm_provider) + if provider not in {"minicpm_omni", "llama_cpp_server", "external_llama_cpp_server"}: + raise HTTPException(status_code=400, detail="Choose a supported AI backend.") + if provider == "llama_cpp_server": + model_path = Path(updates.get("PHANTOM_GRID_LLAMACPP_MODEL_PATH") or str(load_settings().llamacpp_model_path or "")).expanduser() + if not model_path.is_file() or model_path.suffix.lower() != ".gguf": + raise HTTPException(status_code=400, detail="Choose an existing .gguf model file for the standalone llama.cpp backend.") + server_bin = Path(updates.get("PHANTOM_GRID_LLAMACPP_SERVER_BIN") or str(load_settings().llamacpp_server_bin or "")).expanduser() + if not server_bin.is_file(): + raise HTTPException(status_code=400, detail="Choose an existing llama-server executable.") + # Persist the resolved paths and sync the model label to the chosen file + # so any user-supplied GGUF (e.g. D:\Models\...\gemma-...Q8_0.gguf) runs. + updates["PHANTOM_GRID_LLAMACPP_MODEL_PATH"] = str(model_path) + updates["PHANTOM_GRID_LLAMACPP_SERVER_BIN"] = str(server_bin) + updates["PHANTOM_GRID_LLM_MODEL"] = model_path.name + elif provider == "external_llama_cpp_server": + base_url = updates.get("PHANTOM_GRID_LLAMACPP_BASE_URL", load_settings().llamacpp_base_url).rstrip("/") + model = updates.get("PHANTOM_GRID_LLM_MODEL", load_settings().llm_model).strip() + if not base_url.startswith(("http://", "https://")): + raise HTTPException(status_code=400, detail="External server URL must start with http:// or https://.") + if not model: + raise HTTPException(status_code=400, detail="Enter the model ID exposed by the external llama.cpp server.") + updates["PHANTOM_GRID_LLAMACPP_BASE_URL"] = base_url + + model_dir = Path(updates.get("PHANTOM_GRID_MINICPM_MODEL_DIR") or str(load_settings().minicpm_model_dir or "")) + selected = updates.get("PHANTOM_GRID_MINICPM_QUANTIZATION") + if selected: + catalog_names = {item["id"] for item in quantization_catalog()} + on_disk_names = {item["filename"] for item in scan_minicpm_models(model_dir).get("models", [])} + # Allow catalog entries even when the file isn't on disk yet — this is the + # first-run case where the user is choosing what the provisioner should + # download. Otherwise require the file to already be present. + if selected not in catalog_names and selected not in on_disk_names: + raise HTTPException(status_code=400, detail="Selected quantization is not a compatible MiniCPM-o LLM GGUF file.") + + if "minicpm_gpu_device" in payload: + device_id = str(payload["minicpm_gpu_device"]).strip() + if device_id: + valid_device_ids = {item["id"] for item in detect_devices()} + # Accept stored ids that simply aren't present anymore (e.g. external + # GPU unplugged) — we just warn via the picker, not the validator. + if device_id in valid_device_ids or device_id == "auto" or device_id.startswith(("cuda:", "rocm:")): + updates["PHANTOM_GRID_GPU_DEVICE"] = device_id + + if "witness_chat_tts" in payload: + value = payload["witness_chat_tts"] + truthy = value if isinstance(value, bool) else str(value).strip().lower() not in {"", "0", "false", "off", "no"} + updates["PHANTOM_GRID_WITNESS_CHAT_TTS"] = "1" if truthy else "0" + + if updates: + _write_env_updates(updates) + os.environ.update(updates) + + return api_settings() + + +def api_llama_action(action: str, payload: dict[str, Any]) -> dict[str, Any]: + if payload: + api_update_settings(payload) + settings = load_settings() + normalized = action.strip().lower() + if settings.llm_provider == "external_llama_cpp_server" and normalized in {"start", "restart", "stop"}: + llama_status, omni_status = _service_statuses(settings) + return { + "ok": llama_status.get("ready", False), + "event": "External llama.cpp is user-managed. Start, restart, and stop it outside Phantom Grid.", + "llama": llama_status, + "omni": omni_status, + "settings": _settings_payload(settings), + } + if normalized == "status": + llama_status, omni_status = _service_statuses(settings) + return {"ok": True, "llama": llama_status, "omni": omni_status, "settings": _settings_payload(settings)} + if normalized == "stop": + _stop_llama_process() + current = load_settings() + llama_status, omni_status = _service_statuses(current) + return {"ok": True, "event": "MiniCPM-o service stopped.", "llama": llama_status, "omni": omni_status, "settings": _settings_payload(current)} + if normalized == "restart": + _stop_llama_process() + started = _start_llama_process(settings) + current = load_settings() + llama_status, omni_status = _service_statuses(current) + return {"ok": started["ok"], "event": started["event"], "llama": llama_status, "omni": omni_status, "settings": _settings_payload(current)} + if normalized == "start": + started = _start_llama_process(settings) + current = load_settings() + llama_status, omni_status = _service_statuses(current) + return {"ok": started["ok"], "event": started["event"], "llama": llama_status, "omni": omni_status, "settings": _settings_payload(current)} + llama_status, omni_status = _service_statuses(settings) + return {"ok": False, "event": f"Unknown llama action: {action}", "llama": llama_status, "omni": omni_status, "settings": _settings_payload(settings)} + + +def api_omni_status() -> dict[str, Any]: + settings = load_settings() + health = OmniClient(settings).omni_health() + return _omni_status_payload(settings, health) + + +def _service_statuses(settings) -> tuple[dict[str, Any], dict[str, Any]]: + client = OmniClient(settings) + return _llama_status(settings, client.health()), _omni_status_payload(settings, client.omni_health()) + + +def _omni_status_payload(settings, health: dict[str, Any]) -> dict[str, Any]: + scan = scan_minicpm_models(settings.minicpm_model_dir) + managed = bool( + settings.llm_provider == "minicpm_omni" + and _LLAMA_PROCESS + and _LLAMA_PROCESS.poll() is None + ) + return { + "ok": True, + "reachable": health.get("reachable", False), + "ready": health.get("ready", False), + "detail": health.get("detail"), + "managed_process": managed, + "pid": _LLAMA_PROCESS.pid if managed else None, + "model_complete": scan.get("complete", False), + "selected_model": settings.minicpm_quantization, + "context_length": settings.llamacpp_context_length, + "gpu_layers": settings.llamacpp_gpu_layers, + } + + +def api_omni_models() -> dict[str, Any]: + settings = load_settings() + return {"ok": True, **scan_minicpm_models(settings.minicpm_model_dir)} + + +def api_setup_status() -> dict[str, Any]: + global _SETUP_PROCESS + paths = _local_runtime_paths() + scan = scan_minicpm_models(paths["models"]) + files_ready = ( + (paths["comni"] / "worker.py").exists() + and (paths["comni"] / "gateway.py").exists() + and _local_comni_python(paths["comni"]).exists() + and _local_llama_server(paths["llama"]) is not None + and scan.get("complete", False) + ) + if _SETUP_PROCESS is not None and _SETUP_PROCESS.poll() is not None: + _SETUP_PROCESS = None + status = _read_setup_status() + process_running = ( + (_SETUP_PROCESS is not None and _SETUP_PROCESS.poll() is None) + or _setup_pid_running() + ) + if files_ready: + _configure_local_runtime(scan) + health = OmniClient(load_settings()).health() + service_ready = bool(health.get("ready")) + return { + "ok": True, + "state": "ready" if service_ready else "installed", + "stage": "ready" if service_ready else "service", + "message": "Local AI is ready." if service_ready else "Local AI is installed and ready to start.", + "progress": 100, + "files_ready": True, + "service_ready": service_ready, + "installing": False, + "updated_at": status.get("updated_at"), + } + if status.get("state") == "running" and not process_running: + status = { + "state": "error", + "stage": "setup", + "message": "The previous setup process stopped unexpectedly. Retry setup; completed downloads will be reused.", + "progress": int(status.get("progress", 0)), + "updated_at": status.get("updated_at"), + } + return { + "ok": status.get("state") != "error", + "state": status.get("state", "missing"), + "stage": status.get("stage", "setup"), + "message": status.get("message", "Preparing the local AI runtime..."), + "progress": int(status.get("progress", 0)), + "files_ready": False, + "service_ready": False, + "installing": process_running, + "updated_at": status.get("updated_at"), + } + + +def api_setup_start(payload: dict[str, Any] | None = None) -> dict[str, Any]: + global _SETUP_PROCESS + # Persist any picker choices before kicking off setup so the provisioner + # and the launcher both see the chosen model/GPU/context. + if payload: + api_update_settings(payload) + current = api_setup_status() + if current["files_ready"]: + started = _start_llama_process(load_settings()) + return {**api_setup_status(), "event": started["event"], "ok": started["ok"]} + if _SETUP_PROCESS is not None and _SETUP_PROCESS.poll() is None: + return current + RUNTIME_ROOT.mkdir(parents=True, exist_ok=True) + (RUNTIME_ROOT / "setup_status.json").write_text( + json.dumps({"state": "running", "stage": "setup", "message": "Starting local AI setup...", "progress": 1}) + "\n", + encoding="utf-8", + ) + provisioner = PROJECT_ROOT / "scripts" / "provision_local_runtime.py" + log = (RUNTIME_ROOT / "provisioner.log").open("a", encoding="utf-8") + settings = load_settings() + catalog_ids = {item["id"] for item in quantization_catalog()} + model_file = settings.minicpm_quantization if settings.minicpm_quantization in catalog_ids else "MiniCPM-o-4_5-Q4_K_M.gguf" + try: + _SETUP_PROCESS = subprocess.Popen( + [ + sys.executable, + str(provisioner), + "--runtime-root", str(RUNTIME_ROOT), + "--model-file", model_file, + ], + cwd=PROJECT_ROOT, + stdout=log, + stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + except OSError as exc: + log.close() + return {**current, "ok": False, "state": "error", "message": f"Could not start setup: {exc}"} + return {**api_setup_status(), "event": "Local AI setup started."} + + +def api_runtime_options() -> dict[str, Any]: + settings = load_settings() + RUNTIME_ROOT.mkdir(parents=True, exist_ok=True) + disk = shutil.disk_usage(RUNTIME_ROOT) + return { + "ok": True, + "devices": detect_devices(), + "quantizations": quantization_catalog(), + "gpu_layer_presets": gpu_layer_presets(), + "context_length_presets": context_length_presets(), + "runtime_root": str(RUNTIME_ROOT), + "free_disk_gb": round(disk.free / 1024**3, 1), + "current": { + "minicpm_quantization": settings.minicpm_quantization or "MiniCPM-o-4_5-Q4_K_M.gguf", + "minicpm_gpu_device": settings.minicpm_gpu_device or "auto", + "llamacpp_gpu_layers": settings.llamacpp_gpu_layers or "auto", + "llamacpp_context_length": settings.llamacpp_context_length, + }, + } + + +def _local_runtime_paths() -> dict[str, Path]: + return { + "comni": RUNTIME_ROOT / "MiniCPM-o-Demo", + "llama": RUNTIME_ROOT / "llama.cpp-omni", + "models": RUNTIME_ROOT / "models" / "MiniCPM-o-4_5-gguf", + } + + +def _read_setup_status() -> dict[str, Any]: + path = RUNTIME_ROOT / "setup_status.json" + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + +def _setup_pid_running() -> bool: + lock_path = RUNTIME_ROOT / "setup.worker.lock" + if lock_path.exists() and os.name == "nt": + import msvcrt + + handle = lock_path.open("r+b") + try: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + except OSError: + handle.close() + return True + handle.close() + path = RUNTIME_ROOT / "setup.pid" + if not path.exists(): + return False + try: + pid = int(path.read_text(encoding="ascii").strip()) + if os.name == "nt": + import ctypes + + handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid) + if not handle: + raise OSError(f"Process {pid} is not running.") + ctypes.windll.kernel32.CloseHandle(handle) + else: + os.kill(pid, 0) + return True + except (OSError, SystemError, ValueError): + path.unlink(missing_ok=True) + return False + + +def _local_llama_server(root: Path) -> Path | None: + candidates = ( + root / "build" / "bin" / "Release" / "llama-omni-server.exe", + root / "build" / "bin" / "llama-omni-server.exe", + root / "build" / "bin" / "llama-omni-server", + root / "build" / "bin" / "Release" / "llama-server.exe", + root / "build" / "bin" / "llama-server.exe", + root / "build" / "bin" / "llama-server", + ) + return next((path for path in candidates if path.exists()), None) + + +def _local_comni_python(root: Path) -> Path: + if os.name == "nt": + return root / ".venv" / "base" / "Scripts" / "python.exe" + return root / ".venv" / "base" / "bin" / "python" + + +def _configure_local_runtime(scan: dict[str, Any]) -> None: + paths = _local_runtime_paths() + models = scan.get("models", []) + if not models: + return + current = load_settings() + on_disk = {item["filename"]: item for item in models} + # Honor the picker's choice if the file is on disk; otherwise fall back to + # Q4_K_M (the default), then to whatever's available. + preferred = ( + on_disk.get(current.minicpm_quantization) + or next((item for item in models if "Q4_K_M" in item["filename"]), models[0]) + ) + updates = { + "PHANTOM_GRID_OMNI_LAUNCHER_PATH": "scripts/launch_minicpm_omni.py", + "PHANTOM_GRID_COMNI_CHECKOUT_PATH": "runtime/MiniCPM-o-Demo", + "PHANTOM_GRID_LLAMACPP_OMNI_ROOT": "runtime/llama.cpp-omni", + "PHANTOM_GRID_MINICPM_MODEL_DIR": "runtime/models/MiniCPM-o-4_5-gguf", + "PHANTOM_GRID_MINICPM_QUANTIZATION": preferred["filename"], + } + if ( + current.comni_checkout_path == paths["comni"] + and current.llamacpp_omni_root == paths["llama"] + and current.minicpm_model_dir == paths["models"] + and current.minicpm_quantization == preferred["filename"] + ): + return + _write_env_updates(updates) + os.environ.update(updates) + + +def game_snapshot(game_id: str | None = None) -> dict[str, Any]: + state = _state_for(game_id, required=False) + return _snapshot(state, [], None, "Ready.") + + +def nearest_junction_for_point(x: int, y: int, max_distance: int = MAP_CLICK_RADIUS) -> int | None: + best_id: int | None = None + best_distance = float(max_distance) + for junction in _junction_records(): + distance = math.dist((x, y), (int(junction["x"]), int(junction["y"]))) + if distance <= best_distance: + best_id = int(junction["id"]) + best_distance = distance + return best_id + + +def junctions_for_drag_path(points: list[dict[str, int]], max_distance: int = MAP_CLICK_RADIUS) -> list[int]: + selected: list[int] = [] + for point in points: + x = _optional_int(point.get("x")) + y = _optional_int(point.get("y")) + if x is None or y is None: + continue + for junction in _junction_records(): + junction_id = int(junction["id"]) + if junction_id in selected: + continue + if math.dist((x, y), (int(junction["x"]), int(junction["y"]))) <= max_distance: + selected.append(junction_id) + return selected + + +def toggle_junction_selection(current: list[int], junction_id: int) -> list[int]: + clean = _valid_junctions(current) + valid = _valid_junction(junction_id) + if valid is None: + return clean + if valid in clean: + return [item for item in clean if item != valid] + return [*clean, valid] + + +def _snapshot( + state: GameState | None, + selected_junctions: list[int] | None = None, + focused_junction: int | None = None, + event: str = "", + sound: str | None = None, +) -> dict[str, Any]: + selected, focused = _selection_context(selected_junctions, focused_junction) + return { + "ok": True, + "event": event, + "sound": sound, + "game": _visible_game_state(state), + "case_introduction": state.case_introduction if state else None, + "map": _map_payload(), + "selection": { + "junctions": selected, + "focused": focused, + "legal_moves": _legal_moves_payload(focused, state), + }, + "lookout": _lookout_payload(state), + "witness_locations": _witness_locations(state), + "witness_cards": _witness_cards(state), + "previous_statements": _previous_statements(state), + "active_blocks": _active_blocks_payload(state), + "placed_tactics": _placed_tactics_payload(state), + "tactic_counts": _tactic_counts_payload(state), + "events": _public_events(state), + "asset_prompts": _asset_prompts(), + "notes": state.user_notes if state else "", + "last_notice_text": state.last_notice_text if state else DEFAULT_NOTICE, + "story_available": bool(state and (state.result or state.finalized_reason)), + } + + +def _visible_game_state(state: GameState | None) -> dict[str, Any] | None: + if state is None: + return None + confirmed_sightings = [ + sighting for sighting in state.case_introduction.get("last_seen", []) + if sighting.get("confidence") == "confirmed" + ] + last_seen = confirmed_sightings[-1] if confirmed_sightings else None + return { + "game_id": state.game_id, + "turn": state.turn_number, + "max_turns": state.max_turns, + "phase": state.phase, + "result": state.result, + "checks_remaining": checks_remaining_this_turn(state.turn_number, state.junction_checks), + "notices": len(state.notices), + "witness_batches": len(state.witness_batches), + "initial_description": state.initial_description, + "suspect_image": state.case_introduction.get("suspect_image", "/assets/suspect"), + "last_seen": last_seen, + "finalized_reason": state.finalized_reason, + "effective_context_length": state.effective_context_length, + } + + +def _map_payload() -> dict[str, Any]: + metadata = load_map_metadata() + return { + "layers": list(metadata.get("images", {}).keys()), + "default_layer": "normal", + "junctions": _junction_records(), + "atlas": public_atlas_payload(), + } + + +def _legal_moves_payload(focused_junction: int | None, state: GameState | None) -> list[dict[str, Any]]: + if focused_junction is None: + return [] + blocks = [asdict(block) for block in state.active_blocks] if state else None + return [ + { + "destination": move.destination, + "mode": move.mode, + "blocked": move.blocked, + "label": f"J{focused_junction} to J{move.destination} by {move.mode}", + } + for move in legal_moves_from(focused_junction, blocks) + ] + + +def _lookout_payload(state: GameState | None) -> dict[str, Any]: + if state is None: + return {"raised": False, "witness_count": 0, "review_allowed": False, "notice": None} + batch = next((item for item in reversed(state.witness_batches) if item.notice_id.startswith("notice_")), None) + if batch is None: + return {"raised": False, "witness_count": 0, "review_allowed": False, "notice": None} + notice = next((item for item in state.notices if item.notice_id == batch.notice_id), None) + return { + "raised": True, + "witness_count": batch.total_witnesses, + "review_allowed": batch.individual_review_allowed, + "notice": notice.text if notice else "", + "parsed_location": notice.parsed_location if notice else "", + } + + +def _witness_locations(state: GameState | None) -> list[dict[str, Any]]: + if state is None or not state.witness_batches: + return [] + distribution: dict[int, dict[str, Any]] = {} + for batch in state.witness_batches: + for witness in batch.witnesses: + location = distribution.setdefault( + witness.junction_id, + { + "junction_id": witness.junction_id, + "count": 0, + "reports": [], + "inspectable": False, + "sample_witness_id": witness.witness_id, + "sample_style": witness.personality.get("style", "witness"), + "sample_summary": witness.current_summary, + "sample_relevance": witness.relevance_score, + "viewed": False, + }, + ) + location["count"] += 1 + location["reports"].append( + { + "id": witness.witness_id, + "viewed": witness.witness_id in state.viewed_witness_ids, + "style": witness.personality.get("style", "witness"), + "summary": witness.current_summary, + "relevance": witness.relevance_score, + "name": witness.name, + "occupation": witness.occupation, + "observed_turn": witness.turn_created, + } + ) + location["inspectable"] = location["inspectable"] or batch.individual_review_allowed + is_viewed = witness.witness_id in state.viewed_witness_ids + location["viewed"] = location["viewed"] or is_viewed + if witness.relevance_score > location["sample_relevance"]: + location["sample_witness_id"] = witness.witness_id + location["sample_style"] = witness.personality.get("style", "witness") + location["sample_summary"] = witness.current_summary + location["sample_relevance"] = witness.relevance_score + return [ + distribution[junction_id] + for junction_id in sorted(distribution) + ] + + +def _witness_cards(state: GameState | None) -> list[dict[str, Any]]: + if state is None: + return [] + cards: list[dict[str, Any]] = [] + for batch in state.witness_batches: + if not batch.individual_review_allowed: + continue + for witness in batch.witnesses: + cards.append( + { + "id": witness.witness_id, + "junction_id": witness.junction_id, + "reliability": witness.reliability, + "memory": witness.memory_strength, + "relevance": witness.relevance_score, + "style": witness.personality.get("style", "witness"), + "name": witness.name, + "occupation": witness.occupation, + "voice_id": witness.voice_id, + "summary": witness.current_summary, + "questions": [asdict(question) for question in witness.question_history[-2:]], + "viewed": witness.witness_id in state.viewed_witness_ids, + "observed_turn": witness.turn_created, + } + ) + return cards[-18:] + + +def _previous_statements(state: GameState | None) -> list[dict[str, Any]]: + if state is None: + return [] + statements: list[dict[str, Any]] = [] + for batch in state.witness_batches: + for witness in batch.witnesses: + if witness.witness_id not in state.viewed_witness_ids or not witness.question_history: + continue + latest = witness.question_history[-1] + statements.append( + { + "id": witness.witness_id, + "turn": latest.turn_number, + "junction_id": witness.junction_id, + "time_label": _time_label(latest.turn_number), + "summary": witness.current_summary, + "question": latest.question, + "answer": latest.answer, + "viewed": True, + "observed_turn": witness.turn_created, + } + ) + return statements[-8:] + + +def _active_blocks_payload(state: GameState | None) -> list[dict[str, Any]]: + if state is None: + return [] + blocks: list[dict[str, Any]] = [] + for block in state.active_blocks: + if block.block_type == "edge_block": + label = f"J{block.from_junction} to J{block.to_junction}" + elif block.block_type == "mode_block": + label = f"{block.mode} near J{block.junction_id or 'all'}" + else: + label = f"J{block.junction_id}" + blocks.append({**asdict(block), "label": label}) + return blocks + + +def _placed_tactics_payload(state: GameState | None) -> list[dict[str, Any]]: + if state is None: + return [] + return [asdict(tactic) for tactic in state.placed_tactics] + + +def _tactic_counts_payload(state: GameState | None) -> dict[str, Any]: + placed_counts = {key: 0 for key in TACTIC_LIMITS} + if state is not None: + for tactic in state.placed_tactics: + if tactic.tactic_type in placed_counts: + placed_counts[tactic.tactic_type] += 1 + remaining = { + key: max(limit - placed_counts.get(key, 0), 0) + for key, limit in TACTIC_LIMITS.items() + } + return { + "limits": TACTIC_LIMITS, + "placed": placed_counts, + "remaining": remaining, + "total_limit": sum(TACTIC_LIMITS.values()), + "total_remaining": sum(remaining.values()), + } + + +def _public_events(state: GameState | None) -> list[dict[str, Any]]: + if state is None: + return [] + return [ + entry + for entry in state.game_log[-12:] + if entry.get("kind") != "culprit_move_private" + ][-6:] + + +def _asset_prompts() -> dict[str, str]: + return { + "case_table_background": "top-down view of a moody London detective desk, paper map, pins, string, chalk dust, warm lamp light, stylized game UI background, no text", + "suspect_placeholder": "anonymous noir suspect silhouette in a grey raincoat holding a red folder, graphic novel style, transparent background, no text", + "witness_card_set": "four small portrait cards of London street witnesses, varied ages and moods, 1930s detective board style, consistent illustration style, no text", + "lookout_board_texture": "green-black chalkboard with faint chalk smudges and taped paper edges, game UI texture, no readable text", + "map_select": "short tactile wooden token tap on a board, warm room tone, 0.3 seconds", + "blockade_set": "metal stamp clack with soft paper thud, detective office, 0.5 seconds", + "lookout_raise": "chalk scrape and corkboard paper rustle, subtle, 0.8 seconds", + "witness_popup": "quick paper card flick with faint bell, playful noir, 0.4 seconds", + "turn_advance": "old clock tick plus distant city ambience swell, 1 second", + } + + +def _settings_payload(settings) -> dict[str, Any]: + return { + "llm_provider": settings.llm_provider, + "llm_model": settings.llm_model, + "llamacpp_model_path": str(settings.llamacpp_model_path or ""), + "llamacpp_model_exists": bool(settings.llamacpp_model_path and settings.llamacpp_model_path.exists()), + "llamacpp_server_bin": str(settings.llamacpp_server_bin or ""), + "llamacpp_server_bin_exists": bool(settings.llamacpp_server_bin and settings.llamacpp_server_bin.exists()), + "llamacpp_base_url": settings.llamacpp_base_url, + "difficulty": os.getenv("PHANTOM_GRID_DIFFICULTY", _difficulty_from_settings(settings)), + "max_turns": settings.max_turns, + "checks_per_turn": settings.checks_per_turn, + "memory_corruption_per_turn": settings.memory_corruption_per_turn, + "omni_gateway_url": settings.omni_gateway_url, + "omni_launcher_path": str(settings.omni_launcher_path or ""), + "omni_launcher_exists": bool(settings.omni_launcher_path and settings.omni_launcher_path.exists()), + "comni_checkout_path": str(settings.comni_checkout_path or ""), + "llamacpp_omni_root": str(settings.llamacpp_omni_root or ""), + "minicpm_model_dir": str(settings.minicpm_model_dir or ""), + "minicpm_quantization": settings.minicpm_quantization, + "llamacpp_context_length": settings.llamacpp_context_length, + "llamacpp_gpu_layers": settings.llamacpp_gpu_layers, + "minicpm_gpu_device": settings.minicpm_gpu_device, + "witness_chat_tts": settings.witness_chat_tts, + "witness_voice_dir": str(settings.witness_voice_dir), + } + + +def _difficulty_from_settings(settings) -> str: + if settings.max_turns >= 16 or settings.checks_per_turn >= 3: + return "easy" + if settings.max_turns <= 10 or settings.checks_per_turn <= 1: + return "hard" + return "normal" + + +def _llama_status(settings, health: dict[str, Any] | None = None) -> dict[str, Any]: + global _LLAMA_PROCESS + if _LLAMA_PROCESS is not None and _LLAMA_PROCESS.poll() is not None: + _LLAMA_PROCESS = None + health = health or OmniClient(settings).health() + managed = bool(settings.llm_provider != "external_llama_cpp_server" and _LLAMA_PROCESS is not None) + return { + "managed_process": managed, + "pid": _LLAMA_PROCESS.pid if managed else None, + "reachable": health.get("reachable", False), + "ready": health.get("ready", False), + "detail": health.get("detail"), + } + + +def _start_llama_process(settings) -> dict[str, Any]: + global _LLAMA_PROCESS + if _LLAMA_PROCESS is not None and _LLAMA_PROCESS.poll() is None: + return {"ok": True, "event": f"The selected AI backend is already managed as PID {_LLAMA_PROCESS.pid}."} + if settings.llm_provider == "external_llama_cpp_server": + return {"ok": False, "event": "External llama.cpp is user-managed and cannot be started by Phantom Grid."} + if settings.llm_provider == "llama_cpp_server": + if not settings.llamacpp_server_bin or not settings.llamacpp_server_bin.is_file(): + return {"ok": False, "event": "Set a valid llama-server executable before starting."} + if not settings.llamacpp_model_path or not settings.llamacpp_model_path.is_file(): + return {"ok": False, "event": "Set a valid GGUF model path before starting."} + gpu_layers = "999" if settings.llamacpp_gpu_layers == "auto" else settings.llamacpp_gpu_layers + args = [ + str(settings.llamacpp_server_bin), "-m", str(settings.llamacpp_model_path), + "--host", "127.0.0.1", "--port", str(_port_from_base_url(settings.llamacpp_base_url)), + "-c", str(settings.llamacpp_context_length), "-ngl", gpu_layers, + ] + env = os.environ.copy() + env.update(resolve_device_env(settings.minicpm_gpu_device or "auto", settings.llamacpp_gpu_layers or "auto")) + try: + _LLAMA_PROCESS = subprocess.Popen( + args, + cwd=str(settings.llamacpp_model_path.parent), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + except OSError as exc: + return {"ok": False, "event": f"Could not start llama.cpp: {exc}"} + return {"ok": True, "event": f"llama.cpp started {settings.llamacpp_model_path.name} as PID {_LLAMA_PROCESS.pid}."} + if not settings.omni_launcher_path or not settings.omni_launcher_path.exists(): + return {"ok": False, "event": "Set a valid Comni launcher path before starting."} + if not settings.comni_checkout_path or not settings.comni_checkout_path.exists(): + return {"ok": False, "event": "Set a valid OpenBMB Comni checkout directory before starting."} + if not settings.llamacpp_omni_root or not settings.llamacpp_omni_root.exists(): + return {"ok": False, "event": "Set a valid llama.cpp-omni root directory before starting."} + scan = scan_minicpm_models(settings.minicpm_model_dir) + valid_names = {item["filename"] for item in scan.get("models", [])} + if settings.minicpm_quantization not in valid_names: + return {"ok": False, "event": "Select a detected MiniCPM-o quantization before starting."} + if not scan.get("complete"): + return {"ok": False, "event": "The MiniCPM-o model directory is missing required audio/TTS companion GGUF modules."} + launcher = settings.omni_launcher_path + suffix = launcher.suffix.lower() + if suffix == ".ps1": + args = ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(launcher)] + elif suffix in {".bat", ".cmd"}: + args = ["cmd", "/c", str(launcher)] + elif suffix == ".py": + args = [sys.executable, str(launcher)] + else: + args = [str(launcher)] + env = os.environ.copy() + env.update({ + "MINICPM_MODEL_DIR": str(settings.minicpm_model_dir or ""), + "MINICPM_LLM_MODEL": settings.minicpm_quantization, + "MINICPM_CTX_SIZE": str(settings.llamacpp_context_length), + "MINICPM_N_GPU_LAYERS": settings.llamacpp_gpu_layers, + "MINICPM_GPU_DEVICE": settings.minicpm_gpu_device or "auto", + "MINICPM_LLAMACPP_ROOT": str(settings.llamacpp_omni_root or ""), + "MINICPM_GATEWAY_URL": settings.omni_gateway_url, + "MINICPM_COMNI_ROOT": str(settings.comni_checkout_path or ""), + "MINICPM_COMNI_PYTHON": str(_local_comni_python(settings.comni_checkout_path)) if settings.comni_checkout_path else "", + }) + env.update(resolve_device_env(settings.minicpm_gpu_device or "auto", settings.llamacpp_gpu_layers or "auto")) + try: + _LLAMA_PROCESS = subprocess.Popen( + args, + cwd=str(launcher.parent), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + except OSError as exc: + return {"ok": False, "event": f"Could not start MiniCPM-o: {exc}"} + return {"ok": True, "event": f"MiniCPM-o stack launcher started as PID {_LLAMA_PROCESS.pid}."} + + +def _stop_llama_process() -> None: + global _LLAMA_PROCESS + if _LLAMA_PROCESS is None: + return + if _LLAMA_PROCESS.poll() is None: + if os.name == "nt": + subprocess.run( + ["taskkill", "/PID", str(_LLAMA_PROCESS.pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + else: + _LLAMA_PROCESS.terminate() + try: + _LLAMA_PROCESS.wait(timeout=5) + except subprocess.TimeoutExpired: + _LLAMA_PROCESS.kill() + _LLAMA_PROCESS = None + + +def _port_from_base_url(base_url: str) -> int: + try: + from urllib.parse import urlparse + + parsed = urlparse(base_url) + return parsed.port or 8080 + except ValueError: + return 8080 + + +def _require_omni_ready() -> None: + health = OmniClient.from_settings().health() + if not health.get("ready"): + raise HTTPException(status_code=503, detail="The selected AI backend is unavailable. Start or retry it in Settings.") + + +def _voice_path(voice_id: str) -> Path | None: + if not voice_id.startswith("voice_") or not voice_id[6:].isdigit(): + return None + root = load_settings().witness_voice_dir.resolve() + candidate = (root / f"{voice_id}.wav").resolve() + if candidate.parent != root or not candidate.exists(): + return None + return candidate + + +def _write_env_updates(updates: dict[str, str]) -> None: + env_path = PROJECT_ROOT / ".env" + existing: dict[str, str] = {} + order: list[str] = [] + if env_path.exists(): + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + if not raw_line.strip() or raw_line.strip().startswith("#") or "=" not in raw_line: + continue + key, value = raw_line.split("=", 1) + key = key.strip() + existing[key] = value.strip().strip('"').strip("'") + order.append(key) + existing.update(updates) + for key in updates: + if key not in order: + order.append(key) + lines = [f"{key}={existing[key]}" for key in order if key in existing] + env_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _junction_by_id(junction_id: int) -> dict[str, Any] | None: + return next((junction for junction in _junction_records() if int(junction["id"]) == junction_id), None) + + +def _time_label(turn_number: int) -> str: + labels = ["morning", "midday", "afternoon", "evening", "night"] + return labels[(turn_number - 1) % len(labels)] + + +def _state_for(game_id: str | None, required: bool = True) -> GameState | None: + if not game_id: + if required: + raise HTTPException(status_code=400, detail="Start a case first.") + return None + state = _SESSIONS.get(game_id) + if state is None: + try: + state = load_state(game_id) + _SESSIONS[game_id] = state + except (FileNotFoundError, KeyError, TypeError, ValueError): + state = None + if state is None and required: + raise HTTPException(status_code=404, detail="Case not found. Start a new case.") + if state is not None and ensure_case_introduction(state): + persist(state) + return state + + +def _selection_context( + selected_junctions: list[int] | None, + focused_junction: int | None, +) -> tuple[list[int], int | None]: + selected = _valid_junctions(selected_junctions or []) + focused = _valid_junction(focused_junction) + if focused is None and selected: + focused = selected[-1] + if focused is not None and focused not in selected: + selected = [*selected, focused] + return selected, focused + + +def _ordered_check_targets(selected_junctions: list[int], focused_junction: int | None) -> list[int]: + targets: list[int] = [] + if focused_junction is not None: + targets.append(focused_junction) + for junction_id in selected_junctions: + if junction_id not in targets: + targets.append(junction_id) + return targets + + +def _valid_junctions(junctions: list[int]) -> list[int]: + valid_ids = set(all_junction_ids()) + clean: list[int] = [] + for raw in junctions: + junction_id = _optional_int(raw) + if junction_id in valid_ids and junction_id not in clean: + clean.append(junction_id) + return clean + + +def _valid_junction(junction_id: int | None) -> int | None: + parsed = _optional_int(junction_id) + if parsed in set(all_junction_ids()): + return parsed + return None + + +def _selection_event(selected_junctions: list[int], focused_junction: int | None) -> str: + if focused_junction is None: + return "No junction selected." + count = len(selected_junctions) + return f"J{focused_junction} focused. {count} selected." + + +def _notice_with_selected_junction(notice_text: str, selected_junction: int | None) -> str: + if selected_junction is None: + return notice_text.replace("selected junction", "the search area") + return notice_text.replace("selected junction", f"Junction {selected_junction}") + + +def _clean_turns(turns: int | str | None) -> int: + parsed = _optional_int(turns) + if parsed is None: + return 1 + return min(max(parsed, 1), 3) + + +def _junction_records() -> list[dict[str, Any]]: + settings = load_settings() + data = read_json(settings.junction_registry_path) + atlas = public_atlas_payload() + places = [*atlas.get("districts", []), *atlas.get("landmarks", [])] + records: list[dict[str, Any]] = [] + for junction in data.get("junctions", []): + enriched = dict(junction) + enriched["nearest_landmarks"] = [ + { + "id": place.get("id"), + "name": place.get("name"), + "category": place.get("category"), + } + for place in places + if int(junction["id"]) in { + *place.get("junction_ids", []), + *place.get("nearby_junction_ids", []), + *([place["junction_id"]] if place.get("junction_id") is not None else []), + } + ] + records.append(enriched) + return records + + +def _optional_int(value: Any) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _case_state_text(state: GameState) -> str: + remaining = max(state.max_turns - state.turn_number + 1, 0) + checks_used = sum(1 for check in state.junction_checks if check.turn_number == state.turn_number) + return "\n".join( + [ + f"Game: {state.game_id}", + f"Turn: {state.turn_number} / {state.max_turns}", + f"Turns remaining: {remaining}", + f"Phase: {state.phase}", + f"Result: {state.result or 'in progress'}", + f"Initial description: {state.initial_description}", + f"Checks used this turn: {checks_used}", + f"Notices issued: {len(state.notices)}", + f"Witness batches: {len(state.witness_batches)}", + ] + ) + + +def _witness_batches_text(state: GameState) -> str: + if not state.witness_batches: + return "No witness batches yet." + lines: list[str] = [] + for batch in state.witness_batches[-4:]: + notice = next((notice for notice in state.notices if notice.notice_id == batch.notice_id), None) + lines.append(f"{batch.batch_id}: {batch.total_witnesses} witnesses") + if notice: + lines.append(f"Notice: {notice.text}") + lines.append(f"Parsed location: {notice.parsed_location}") + lines.append("Individual review: " + ("available" if batch.individual_review_allowed else "unavailable")) + return "\n".join(lines).strip() + + +def _active_blocks_text(state: GameState) -> str: + if not state.active_blocks: + return "No active blocks." + return "\n".join( + f"{block.block_id}: {block.block_type}, mode={block.mode or 'any'}, junction={block.junction_id}, edge={block.from_junction}->{block.to_junction}, turns={block.turns_remaining}" + for block in state.active_blocks + ) + + +def _game_log_text(state: GameState) -> str: + return "\n".join(f"T{entry['turn_number']} {entry['kind']}: {entry['message']}" for entry in state.game_log[-12:]) + + +if __name__ == "__main__": + # Bind address/port are env-overridable so the same entrypoint works locally + # (default loopback) and inside a container / Hugging Face Space, where the + # app must listen on 0.0.0.0 and the platform supplies the port. + host = os.getenv("PHANTOM_GRID_HOST", "127.0.0.1") + port = int(os.getenv("PORT") or os.getenv("PHANTOM_GRID_PORT") or "7860") + build_app().launch(server_name=host, server_port=port, allowed_paths=[str(PROJECT_ROOT)]) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..66f218b75cbe0b83b2c4a764551d0aa151d9e45b --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .settings import PROJECT_ROOT, Settings, load_settings + +__all__ = ["PROJECT_ROOT", "Settings", "load_settings"] diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..3dde4a84045f7f959c235579bf348d338edb39de --- /dev/null +++ b/config/settings.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import os +import shutil +from dataclasses import dataclass +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +ENV_PATH = PROJECT_ROOT / ".env" + + +def _load_env_file() -> None: + if not ENV_PATH.exists(): + return + for raw_line in ENV_PATH.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +def _env_int(name: str, default: int) -> int: + value = os.getenv(name) + return default if not value else int(value) + + +def _env_float(name: str, default: float) -> float: + value = os.getenv(name) + return default if not value else float(value) + + +def _env_path(name: str, default: Path | None = None) -> Path | None: + value = os.getenv(name) + if value: + path = Path(value).expanduser() + return path if path.is_absolute() else PROJECT_ROOT / path + return default + + +@dataclass(frozen=True) +class Settings: + project_root: Path = PROJECT_ROOT + data_dir: Path = PROJECT_ROOT / "data" + raw_maps_dir: Path = PROJECT_ROOT / "data" / "raw" / "maps" + processed_dir: Path = PROJECT_ROOT / "data" / "processed" + games_dir: Path = PROJECT_ROOT / "data" / "games" + junction_registry_path: Path = PROJECT_ROOT / "data" / "processed" / "junction_registry.json" + game_graph_path: Path = PROJECT_ROOT / "data" / "processed" / "game_graph.json" + map_metadata_path: Path = PROJECT_ROOT / "data" / "processed" / "map_metadata.json" + map_atlas_path: Path = PROJECT_ROOT / "data" / "processed" / "map_atlas.json" + + # Default backend is the plain text llama.cpp server running OpenBMB's + # MiniCPM4.1-8B (text-only, no audio). It is far more reliable at staying in + # English than the MiniCPM-o omni model, whose TTS/audio branch frequently + # drifts into Chinese. The omni model remains selectable for voice features. + llm_provider: str = "llama_cpp_server" + llamacpp_server_bin: Path | None = None + llamacpp_model_path: Path | None = None + llamacpp_base_url: str = "http://127.0.0.1:8080/v1" + llm_model: str = "MiniCPM4.1-8B-Q4_K_M.gguf" + omni_gateway_url: str = "http://127.0.0.1:8006" + omni_launcher_path: Path | None = PROJECT_ROOT / "scripts" / "launch_minicpm_omni.py" + comni_checkout_path: Path | None = None + llamacpp_omni_root: Path | None = None + minicpm_model_dir: Path | None = None + minicpm_quantization: str = "" + llamacpp_context_length: int = 8192 + llamacpp_gpu_layers: str = "auto" + minicpm_gpu_device: str = "auto" + # The default text backend has no audio output, so voice is off by default. + # Enabling it only has an effect when the MiniCPM-o omni provider is active. + witness_chat_tts: bool = False + witness_voice_dir: Path = PROJECT_ROOT / "data" / "voices" + + max_turns: int = 12 + checks_per_turn: int = 2 + blocks_per_turn: int = 1 + max_active_blocks: int = 3 + individual_witness_threshold: int = 12 + starting_disguise_changes: int = 3 + memory_corruption_per_turn: float = 0.08 + + +def load_settings() -> Settings: + _load_env_file() + server_bin = _env_path("PHANTOM_GRID_LLAMACPP_SERVER_BIN") + if server_bin is None: + detected = shutil.which("llama-server") or shutil.which("llama-server.exe") + server_bin = Path(detected) if detected else None + + return Settings( + games_dir=_env_path("PHANTOM_GRID_GAMES_DIR", PROJECT_ROOT / "data" / "games") or PROJECT_ROOT / "data" / "games", + llm_provider=os.getenv("PHANTOM_GRID_LLM_PROVIDER", "llama_cpp_server"), + llamacpp_server_bin=server_bin, + llamacpp_model_path=_env_path("PHANTOM_GRID_LLAMACPP_MODEL_PATH"), + llamacpp_base_url=os.getenv("PHANTOM_GRID_LLAMACPP_BASE_URL", "http://127.0.0.1:8080/v1"), + llm_model=os.getenv("PHANTOM_GRID_LLM_MODEL", "MiniCPM4.1-8B-Q4_K_M.gguf"), + omni_gateway_url=os.getenv("PHANTOM_GRID_OMNI_GATEWAY_URL", "http://127.0.0.1:8006"), + omni_launcher_path=_env_path("PHANTOM_GRID_OMNI_LAUNCHER_PATH", PROJECT_ROOT / "scripts" / "launch_minicpm_omni.py"), + comni_checkout_path=_env_path("PHANTOM_GRID_COMNI_CHECKOUT_PATH"), + llamacpp_omni_root=_env_path("PHANTOM_GRID_LLAMACPP_OMNI_ROOT"), + minicpm_model_dir=_env_path("PHANTOM_GRID_MINICPM_MODEL_DIR"), + minicpm_quantization=os.getenv("PHANTOM_GRID_MINICPM_QUANTIZATION", ""), + llamacpp_context_length=_env_int("PHANTOM_GRID_LLAMACPP_CONTEXT_LENGTH", 8192), + llamacpp_gpu_layers=os.getenv("PHANTOM_GRID_LLAMACPP_GPU_LAYERS", "auto"), + minicpm_gpu_device=os.getenv("PHANTOM_GRID_GPU_DEVICE", "auto"), + witness_chat_tts=os.getenv("PHANTOM_GRID_WITNESS_CHAT_TTS", "0").strip().lower() not in {"0", "false", "off", "no"}, + witness_voice_dir=_env_path("PHANTOM_GRID_WITNESS_VOICE_DIR", PROJECT_ROOT / "data" / "voices") or PROJECT_ROOT / "data" / "voices", + max_turns=_env_int("PHANTOM_GRID_MAX_TURNS", 12), + checks_per_turn=_env_int("PHANTOM_GRID_CHECKS_PER_TURN", 2), + blocks_per_turn=_env_int("PHANTOM_GRID_BLOCKS_PER_TURN", 1), + individual_witness_threshold=_env_int("PHANTOM_GRID_INDIVIDUAL_WITNESS_THRESHOLD", 12), + memory_corruption_per_turn=_env_float("PHANTOM_GRID_MEMORY_CORRUPTION_PER_TURN", 0.08), + ) diff --git a/data/processed/game_graph.json b/data/processed/game_graph.json new file mode 100644 index 0000000000000000000000000000000000000000..cdaaee24ca2b60c4004449e5ee165b179f6b9f9c --- /dev/null +++ b/data/processed/game_graph.json @@ -0,0 +1,6684 @@ +{ + "adjacency": { + "1": [ + { + "destination": 5, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 6, + "modes": [ + "bus", + "taxi" + ] + } + ], + "10": [ + { + "destination": 9, + "modes": [ + "bus" + ] + }, + { + "destination": 11, + "modes": [ + "taxi" + ] + }, + { + "destination": 23, + "modes": [ + "bus", + "taxi" + ] + } + ], + "100": [ + { + "destination": 77, + "modes": [ + "bus" + ] + }, + { + "destination": 99, + "modes": [ + "taxi" + ] + }, + { + "destination": 102, + "modes": [ + "bus" + ] + }, + { + "destination": 106, + "modes": [ + "bus", + "taxi" + ] + } + ], + "101": [ + { + "destination": 96, + "modes": [ + "bus" + ] + }, + { + "destination": 97, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 99, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 105, + "modes": [ + "taxi" + ] + }, + { + "destination": 107, + "modes": [ + "bus" + ] + }, + { + "destination": 110, + "modes": [ + "taxi" + ] + } + ], + "102": [ + { + "destination": 87, + "modes": [ + "bus" + ] + }, + { + "destination": 100, + "modes": [ + "bus" + ] + }, + { + "destination": 106, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 108, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 112, + "modes": [ + "bus" + ] + }, + { + "destination": 115, + "modes": [ + "taxi" + ] + } + ], + "103": [ + { + "destination": 86, + "modes": [ + "bus" + ] + }, + { + "destination": 98, + "modes": [ + "taxi" + ] + }, + { + "destination": 104, + "modes": [ + "taxi" + ] + }, + { + "destination": 109, + "modes": [ + "taxi" + ] + }, + { + "destination": 114, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 122, + "modes": [ + "taxi" + ] + } + ], + "104": [ + { + "destination": 96, + "modes": [ + "bus" + ] + }, + { + "destination": 98, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 103, + "modes": [ + "taxi" + ] + }, + { + "destination": 107, + "modes": [ + "bus" + ] + } + ], + "105": [ + { + "destination": 96, + "modes": [ + "taxi" + ] + }, + { + "destination": 101, + "modes": [ + "taxi" + ] + }, + { + "destination": 107, + "modes": [ + "taxi" + ] + }, + { + "destination": 110, + "modes": [ + "taxi" + ] + } + ], + "106": [ + { + "destination": 100, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 102, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 111, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 115, + "modes": [ + "taxi" + ] + } + ], + "107": [ + { + "destination": 96, + "modes": [ + "taxi" + ] + }, + { + "destination": 101, + "modes": [ + "bus" + ] + }, + { + "destination": 104, + "modes": [ + "bus" + ] + }, + { + "destination": 105, + "modes": [ + "taxi" + ] + }, + { + "destination": 109, + "modes": [ + "taxi" + ] + }, + { + "destination": 110, + "modes": [ + "bus" + ] + } + ], + "108": [ + { + "destination": 102, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 112, + "modes": [ + "bus", + "taxi" + ] + } + ], + "109": [ + { + "destination": 103, + "modes": [ + "taxi" + ] + }, + { + "destination": 107, + "modes": [ + "taxi" + ] + } + ], + "11": [ + { + "destination": 10, + "modes": [ + "taxi" + ] + }, + { + "destination": 12, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 23, + "modes": [ + "bus", + "taxi" + ] + } + ], + "110": [ + { + "destination": 101, + "modes": [ + "taxi" + ] + }, + { + "destination": 105, + "modes": [ + "taxi" + ] + }, + { + "destination": 107, + "modes": [ + "bus" + ] + }, + { + "destination": 111, + "modes": [ + "taxi" + ] + }, + { + "destination": 117, + "modes": [ + "bus" + ] + } + ], + "111": [ + { + "destination": 87, + "modes": [ + "subway" + ] + }, + { + "destination": 99, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 106, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 110, + "modes": [ + "taxi" + ] + }, + { + "destination": 115, + "modes": [ + "bus" + ] + }, + { + "destination": 117, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 120, + "modes": [ + "subway" + ] + }, + { + "destination": 122, + "modes": [ + "subway" + ] + } + ], + "112": [ + { + "destination": 87, + "modes": [ + "bus" + ] + }, + { + "destination": 102, + "modes": [ + "bus" + ] + }, + { + "destination": 108, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 113, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 116, + "modes": [ + "bus" + ] + } + ], + "113": [ + { + "destination": 89, + "modes": [ + "bus" + ] + }, + { + "destination": 94, + "modes": [ + "bus" + ] + }, + { + "destination": 112, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 116, + "modes": [ + "bus", + "taxi" + ] + } + ], + "114": [ + { + "destination": 103, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 122, + "modes": [ + "taxi" + ] + } + ], + "115": [ + { + "destination": 102, + "modes": [ + "taxi" + ] + }, + { + "destination": 106, + "modes": [ + "taxi" + ] + }, + { + "destination": 111, + "modes": [ + "bus" + ] + }, + { + "destination": 123, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 124, + "modes": [ + "taxi" + ] + } + ], + "116": [ + { + "destination": 112, + "modes": [ + "bus" + ] + }, + { + "destination": 113, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 119, + "modes": [ + "bus", + "taxi" + ] + } + ], + "117": [ + { + "destination": 110, + "modes": [ + "bus" + ] + }, + { + "destination": 111, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 118, + "modes": [ + "taxi" + ] + }, + { + "destination": 126, + "modes": [ + "taxi" + ] + }, + { + "destination": 132, + "modes": [ + "bus" + ] + } + ], + "118": [ + { + "destination": 117, + "modes": [ + "taxi" + ] + }, + { + "destination": 126, + "modes": [ + "taxi" + ] + } + ], + "119": [ + { + "destination": 94, + "modes": [ + "bus" + ] + }, + { + "destination": 116, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 120, + "modes": [ + "bus", + "taxi" + ] + } + ], + "12": [ + { + "destination": 5, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 11, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 17, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 34, + "modes": [ + "bus" + ] + } + ], + "120": [ + { + "destination": 94, + "modes": [ + "bus", + "subway" + ] + }, + { + "destination": 111, + "modes": [ + "subway" + ] + }, + { + "destination": 119, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 121, + "modes": [ + "subway" + ] + }, + { + "destination": 125, + "modes": [ + "bus" + ] + }, + { + "destination": 130, + "modes": [ + "bus" + ] + }, + { + "destination": 131, + "modes": [ + "taxi" + ] + }, + { + "destination": 138, + "modes": [ + "bus" + ] + }, + { + "destination": 147, + "modes": [ + "subway" + ] + } + ], + "121": [ + { + "destination": 90, + "modes": [ + "bus" + ] + }, + { + "destination": 95, + "modes": [ + "bus" + ] + }, + { + "destination": 120, + "modes": [ + "subway" + ] + }, + { + "destination": 125, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 134, + "modes": [ + "bus" + ] + } + ], + "122": [ + { + "destination": 103, + "modes": [ + "taxi" + ] + }, + { + "destination": 111, + "modes": [ + "subway" + ] + }, + { + "destination": 114, + "modes": [ + "taxi" + ] + } + ], + "123": [ + { + "destination": 115, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 124, + "modes": [ + "bus", + "taxi" + ] + } + ], + "124": [ + { + "destination": 115, + "modes": [ + "taxi" + ] + }, + { + "destination": 123, + "modes": [ + "bus", + "taxi" + ] + } + ], + "125": [ + { + "destination": 120, + "modes": [ + "bus" + ] + }, + { + "destination": 121, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 139, + "modes": [ + "taxi" + ] + }, + { + "destination": 144, + "modes": [ + "taxi" + ] + } + ], + "126": [ + { + "destination": 117, + "modes": [ + "taxi" + ] + }, + { + "destination": 118, + "modes": [ + "taxi" + ] + }, + { + "destination": 132, + "modes": [ + "taxi" + ] + }, + { + "destination": 133, + "modes": [ + "taxi" + ] + } + ], + "127": [ + { + "destination": 133, + "modes": [ + "taxi" + ] + }, + { + "destination": 136, + "modes": [ + "bus", + "taxi" + ] + } + ], + "128": [ + { + "destination": 129, + "modes": [ + "taxi" + ] + }, + { + "destination": 141, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 150, + "modes": [ + "bus" + ] + } + ], + "129": [ + { + "destination": 128, + "modes": [ + "taxi" + ] + }, + { + "destination": 130, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 137, + "modes": [ + "taxi" + ] + } + ], + "13": [ + { + "destination": 5, + "modes": [ + "taxi" + ] + }, + { + "destination": 6, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 24, + "modes": [ + "bus", + "taxi" + ] + } + ], + "130": [ + { + "destination": 120, + "modes": [ + "bus" + ] + }, + { + "destination": 129, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 137, + "modes": [ + "bus", + "taxi" + ] + } + ], + "131": [ + { + "destination": 120, + "modes": [ + "taxi" + ] + }, + { + "destination": 139, + "modes": [ + "taxi" + ] + } + ], + "132": [ + { + "destination": 117, + "modes": [ + "bus" + ] + }, + { + "destination": 126, + "modes": [ + "taxi" + ] + }, + { + "destination": 140, + "modes": [ + "taxi" + ] + }, + { + "destination": 142, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 145, + "modes": [ + "taxi" + ] + } + ], + "133": [ + { + "destination": 126, + "modes": [ + "taxi" + ] + }, + { + "destination": 127, + "modes": [ + "taxi" + ] + }, + { + "destination": 136, + "modes": [ + "taxi" + ] + } + ], + "134": [ + { + "destination": 121, + "modes": [ + "bus" + ] + }, + { + "destination": 144, + "modes": [ + "taxi" + ] + }, + { + "destination": 147, + "modes": [ + "bus" + ] + } + ], + "135": [ + { + "destination": 142, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 149, + "modes": [ + "taxi" + ] + } + ], + "136": [ + { + "destination": 127, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 133, + "modes": [ + "taxi" + ] + }, + { + "destination": 140, + "modes": [ + "bus" + ] + }, + { + "destination": 146, + "modes": [ + "bus", + "taxi" + ] + } + ], + "137": [ + { + "destination": 129, + "modes": [ + "taxi" + ] + }, + { + "destination": 130, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 143, + "modes": [ + "taxi" + ] + } + ], + "138": [ + { + "destination": 120, + "modes": [ + "bus" + ] + }, + { + "destination": 139, + "modes": [ + "taxi" + ] + }, + { + "destination": 144, + "modes": [ + "taxi" + ] + }, + { + "destination": 147, + "modes": [ + "bus", + "taxi" + ] + } + ], + "139": [ + { + "destination": 125, + "modes": [ + "taxi" + ] + }, + { + "destination": 131, + "modes": [ + "taxi" + ] + }, + { + "destination": 138, + "modes": [ + "taxi" + ] + }, + { + "destination": 144, + "modes": [ + "taxi" + ] + } + ], + "14": [ + { + "destination": 7, + "modes": [ + "bus" + ] + }, + { + "destination": 26, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 27, + "modes": [ + "taxi" + ] + }, + { + "destination": 28, + "modes": [ + "bus" + ] + }, + { + "destination": 36, + "modes": [ + "bus" + ] + }, + { + "destination": 43, + "modes": [ + "subway" + ] + } + ], + "140": [ + { + "destination": 132, + "modes": [ + "taxi" + ] + }, + { + "destination": 136, + "modes": [ + "bus" + ] + }, + { + "destination": 145, + "modes": [ + "taxi" + ] + }, + { + "destination": 146, + "modes": [ + "bus" + ] + } + ], + "141": [ + { + "destination": 128, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 150, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 151, + "modes": [ + "bus" + ] + } + ], + "142": [ + { + "destination": 132, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 135, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 145, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 149, + "modes": [ + "bus", + "taxi" + ] + } + ], + "143": [ + { + "destination": 137, + "modes": [ + "taxi" + ] + }, + { + "destination": 150, + "modes": [ + "bus", + "taxi" + ] + } + ], + "144": [ + { + "destination": 125, + "modes": [ + "taxi" + ] + }, + { + "destination": 134, + "modes": [ + "taxi" + ] + }, + { + "destination": 138, + "modes": [ + "taxi" + ] + }, + { + "destination": 139, + "modes": [ + "taxi" + ] + }, + { + "destination": 147, + "modes": [ + "bus", + "taxi" + ] + } + ], + "145": [ + { + "destination": 132, + "modes": [ + "taxi" + ] + }, + { + "destination": 140, + "modes": [ + "taxi" + ] + }, + { + "destination": 142, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 149, + "modes": [ + "bus" + ] + } + ], + "146": [ + { + "destination": 136, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 140, + "modes": [ + "bus" + ] + }, + { + "destination": 148, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 151, + "modes": [ + "taxi" + ] + } + ], + "147": [ + { + "destination": 120, + "modes": [ + "subway" + ] + }, + { + "destination": 134, + "modes": [ + "bus" + ] + }, + { + "destination": 138, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 144, + "modes": [ + "bus", + "taxi" + ] + } + ], + "148": [ + { + "destination": 146, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 151, + "modes": [ + "bus", + "taxi" + ] + } + ], + "149": [ + { + "destination": 135, + "modes": [ + "taxi" + ] + }, + { + "destination": 142, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 145, + "modes": [ + "bus" + ] + } + ], + "15": [ + { + "destination": 2, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 20, + "modes": [ + "taxi" + ] + }, + { + "destination": 28, + "modes": [ + "bus" + ] + }, + { + "destination": 29, + "modes": [ + "taxi" + ] + } + ], + "150": [ + { + "destination": 128, + "modes": [ + "bus" + ] + }, + { + "destination": 141, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 143, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 151, + "modes": [ + "bus" + ] + } + ], + "151": [ + { + "destination": 141, + "modes": [ + "bus" + ] + }, + { + "destination": 146, + "modes": [ + "taxi" + ] + }, + { + "destination": 148, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 150, + "modes": [ + "bus" + ] + } + ], + "16": [ + { + "destination": 4, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 21, + "modes": [ + "taxi" + ] + }, + { + "destination": 30, + "modes": [ + "bus", + "taxi" + ] + } + ], + "17": [ + { + "destination": 5, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 12, + "modes": [ + "bus", + "taxi" + ] + } + ], + "18": [ + { + "destination": 7, + "modes": [ + "taxi" + ] + }, + { + "destination": 19, + "modes": [ + "taxi" + ] + }, + { + "destination": 25, + "modes": [ + "taxi" + ] + } + ], + "19": [ + { + "destination": 8, + "modes": [ + "taxi" + ] + }, + { + "destination": 18, + "modes": [ + "taxi" + ] + } + ], + "2": [ + { + "destination": 3, + "modes": [ + "taxi" + ] + }, + { + "destination": 4, + "modes": [ + "bus" + ] + }, + { + "destination": 15, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 28, + "modes": [ + "bus" + ] + } + ], + "20": [ + { + "destination": 15, + "modes": [ + "taxi" + ] + }, + { + "destination": 29, + "modes": [ + "taxi" + ] + }, + { + "destination": 30, + "modes": [ + "taxi" + ] + } + ], + "21": [ + { + "destination": 16, + "modes": [ + "taxi" + ] + }, + { + "destination": 30, + "modes": [ + "bus" + ] + }, + { + "destination": 32, + "modes": [ + "taxi" + ] + }, + { + "destination": 33, + "modes": [ + "bus" + ] + } + ], + "22": [ + { + "destination": 33, + "modes": [ + "taxi" + ] + }, + { + "destination": 47, + "modes": [ + "taxi" + ] + } + ], + "23": [ + { + "destination": 10, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 11, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 34, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 39, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 49, + "modes": [ + "bus", + "taxi" + ] + } + ], + "24": [ + { + "destination": 6, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 7, + "modes": [ + "bus" + ] + }, + { + "destination": 13, + "modes": [ + "bus", + "taxi" + ] + } + ], + "25": [ + { + "destination": 18, + "modes": [ + "taxi" + ] + }, + { + "destination": 35, + "modes": [ + "taxi" + ] + } + ], + "26": [ + { + "destination": 14, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 27, + "modes": [ + "taxi" + ] + }, + { + "destination": 43, + "modes": [ + "bus", + "taxi" + ] + } + ], + "27": [ + { + "destination": 14, + "modes": [ + "taxi" + ] + }, + { + "destination": 26, + "modes": [ + "taxi" + ] + } + ], + "28": [ + { + "destination": 2, + "modes": [ + "bus" + ] + }, + { + "destination": 14, + "modes": [ + "bus" + ] + }, + { + "destination": 15, + "modes": [ + "bus" + ] + }, + { + "destination": 36, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 44, + "modes": [ + "taxi" + ] + } + ], + "29": [ + { + "destination": 15, + "modes": [ + "taxi" + ] + }, + { + "destination": 20, + "modes": [ + "taxi" + ] + }, + { + "destination": 37, + "modes": [ + "taxi" + ] + } + ], + "3": [ + { + "destination": 2, + "modes": [ + "taxi" + ] + }, + { + "destination": 4, + "modes": [ + "taxi" + ] + } + ], + "30": [ + { + "destination": 16, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 20, + "modes": [ + "taxi" + ] + }, + { + "destination": 21, + "modes": [ + "bus" + ] + }, + { + "destination": 45, + "modes": [ + "bus" + ] + }, + { + "destination": 53, + "modes": [ + "bus" + ] + } + ], + "31": [ + { + "destination": 33, + "modes": [ + "taxi" + ] + }, + { + "destination": 48, + "modes": [ + "taxi" + ] + } + ], + "32": [ + { + "destination": 21, + "modes": [ + "taxi" + ] + }, + { + "destination": 46, + "modes": [ + "taxi" + ] + } + ], + "33": [ + { + "destination": 21, + "modes": [ + "bus" + ] + }, + { + "destination": 22, + "modes": [ + "taxi" + ] + }, + { + "destination": 31, + "modes": [ + "taxi" + ] + }, + { + "destination": 47, + "modes": [ + "taxi" + ] + }, + { + "destination": 48, + "modes": [ + "taxi" + ] + }, + { + "destination": 59, + "modes": [ + "bus" + ] + }, + { + "destination": 62, + "modes": [ + "bus" + ] + }, + { + "destination": 84, + "modes": [ + "subway" + ] + } + ], + "34": [ + { + "destination": 12, + "modes": [ + "bus" + ] + }, + { + "destination": 23, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 40, + "modes": [ + "taxi" + ] + }, + { + "destination": 49, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 54, + "modes": [ + "taxi" + ] + } + ], + "35": [ + { + "destination": 25, + "modes": [ + "taxi" + ] + }, + { + "destination": 42, + "modes": [ + "taxi" + ] + } + ], + "36": [ + { + "destination": 14, + "modes": [ + "bus" + ] + }, + { + "destination": 28, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 44, + "modes": [ + "taxi" + ] + }, + { + "destination": 51, + "modes": [ + "taxi" + ] + }, + { + "destination": 52, + "modes": [ + "taxi" + ] + }, + { + "destination": 67, + "modes": [ + "bus" + ] + } + ], + "37": [ + { + "destination": 29, + "modes": [ + "taxi" + ] + }, + { + "destination": 45, + "modes": [ + "taxi" + ] + } + ], + "38": [ + { + "destination": 46, + "modes": [ + "taxi" + ] + }, + { + "destination": 47, + "modes": [ + "taxi" + ] + } + ], + "39": [ + { + "destination": 9, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 23, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 54, + "modes": [ + "taxi" + ] + }, + { + "destination": 64, + "modes": [ + "bus" + ] + } + ], + "4": [ + { + "destination": 2, + "modes": [ + "bus" + ] + }, + { + "destination": 3, + "modes": [ + "taxi" + ] + }, + { + "destination": 16, + "modes": [ + "bus", + "taxi" + ] + } + ], + "40": [ + { + "destination": 34, + "modes": [ + "taxi" + ] + }, + { + "destination": 41, + "modes": [ + "bus" + ] + }, + { + "destination": 49, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 64, + "modes": [ + "subway" + ] + }, + { + "destination": 71, + "modes": [ + "subway" + ] + } + ], + "41": [ + { + "destination": 40, + "modes": [ + "bus" + ] + }, + { + "destination": 42, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 55, + "modes": [ + "taxi" + ] + } + ], + "42": [ + { + "destination": 35, + "modes": [ + "taxi" + ] + }, + { + "destination": 41, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 55, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 71, + "modes": [ + "bus" + ] + } + ], + "43": [ + { + "destination": 14, + "modes": [ + "subway" + ] + }, + { + "destination": 26, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 50, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 67, + "modes": [ + "bus" + ] + }, + { + "destination": 87, + "modes": [ + "subway" + ] + } + ], + "44": [ + { + "destination": 28, + "modes": [ + "taxi" + ] + }, + { + "destination": 36, + "modes": [ + "taxi" + ] + }, + { + "destination": 52, + "modes": [ + "taxi" + ] + }, + { + "destination": 53, + "modes": [ + "bus" + ] + } + ], + "45": [ + { + "destination": 30, + "modes": [ + "bus" + ] + }, + { + "destination": 37, + "modes": [ + "taxi" + ] + }, + { + "destination": 53, + "modes": [ + "bus", + "taxi" + ] + } + ], + "46": [ + { + "destination": 32, + "modes": [ + "taxi" + ] + }, + { + "destination": 38, + "modes": [ + "taxi" + ] + } + ], + "47": [ + { + "destination": 22, + "modes": [ + "taxi" + ] + }, + { + "destination": 33, + "modes": [ + "taxi" + ] + }, + { + "destination": 38, + "modes": [ + "taxi" + ] + }, + { + "destination": 59, + "modes": [ + "taxi" + ] + } + ], + "48": [ + { + "destination": 31, + "modes": [ + "taxi" + ] + }, + { + "destination": 33, + "modes": [ + "taxi" + ] + } + ], + "49": [ + { + "destination": 23, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 34, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 40, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 54, + "modes": [ + "bus", + "subway", + "taxi" + ] + }, + { + "destination": 65, + "modes": [ + "taxi" + ] + } + ], + "5": [ + { + "destination": 1, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 12, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 13, + "modes": [ + "taxi" + ] + }, + { + "destination": 17, + "modes": [ + "bus", + "taxi" + ] + } + ], + "50": [ + { + "destination": 43, + "modes": [ + "bus", + "taxi" + ] + } + ], + "51": [ + { + "destination": 36, + "modes": [ + "taxi" + ] + }, + { + "destination": 67, + "modes": [ + "taxi" + ] + } + ], + "52": [ + { + "destination": 36, + "modes": [ + "taxi" + ] + }, + { + "destination": 44, + "modes": [ + "taxi" + ] + } + ], + "53": [ + { + "destination": 30, + "modes": [ + "bus" + ] + }, + { + "destination": 44, + "modes": [ + "bus" + ] + }, + { + "destination": 45, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 56, + "modes": [ + "bus", + "taxi" + ] + } + ], + "54": [ + { + "destination": 34, + "modes": [ + "taxi" + ] + }, + { + "destination": 39, + "modes": [ + "taxi" + ] + }, + { + "destination": 49, + "modes": [ + "bus", + "subway", + "taxi" + ] + }, + { + "destination": 70, + "modes": [ + "bus" + ] + }, + { + "destination": 74, + "modes": [ + "bus" + ] + } + ], + "55": [ + { + "destination": 41, + "modes": [ + "taxi" + ] + }, + { + "destination": 42, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 71, + "modes": [ + "bus", + "taxi" + ] + } + ], + "56": [ + { + "destination": 53, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 57, + "modes": [ + "taxi" + ] + }, + { + "destination": 69, + "modes": [ + "bus", + "taxi" + ] + } + ], + "57": [ + { + "destination": 56, + "modes": [ + "taxi" + ] + }, + { + "destination": 58, + "modes": [ + "taxi" + ] + } + ], + "58": [ + { + "destination": 57, + "modes": [ + "taxi" + ] + }, + { + "destination": 60, + "modes": [ + "taxi" + ] + } + ], + "59": [ + { + "destination": 33, + "modes": [ + "bus" + ] + }, + { + "destination": 47, + "modes": [ + "taxi" + ] + }, + { + "destination": 61, + "modes": [ + "taxi" + ] + }, + { + "destination": 62, + "modes": [ + "bus", + "taxi" + ] + } + ], + "6": [ + { + "destination": 1, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 7, + "modes": [ + "bus" + ] + }, + { + "destination": 13, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 24, + "modes": [ + "bus", + "taxi" + ] + } + ], + "60": [ + { + "destination": 58, + "modes": [ + "taxi" + ] + }, + { + "destination": 61, + "modes": [ + "taxi" + ] + } + ], + "61": [ + { + "destination": 59, + "modes": [ + "taxi" + ] + }, + { + "destination": 60, + "modes": [ + "taxi" + ] + }, + { + "destination": 62, + "modes": [ + "bus" + ] + }, + { + "destination": 79, + "modes": [ + "bus" + ] + } + ], + "62": [ + { + "destination": 33, + "modes": [ + "bus" + ] + }, + { + "destination": 59, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 61, + "modes": [ + "bus" + ] + }, + { + "destination": 63, + "modes": [ + "taxi" + ] + }, + { + "destination": 73, + "modes": [ + "bus", + "taxi" + ] + } + ], + "63": [ + { + "destination": 62, + "modes": [ + "taxi" + ] + }, + { + "destination": 73, + "modes": [ + "taxi" + ] + } + ], + "64": [ + { + "destination": 39, + "modes": [ + "bus" + ] + }, + { + "destination": 40, + "modes": [ + "subway" + ] + }, + { + "destination": 70, + "modes": [ + "taxi" + ] + }, + { + "destination": 86, + "modes": [ + "bus", + "taxi" + ] + } + ], + "65": [ + { + "destination": 49, + "modes": [ + "taxi" + ] + }, + { + "destination": 75, + "modes": [ + "taxi" + ] + } + ], + "66": [ + { + "destination": 76, + "modes": [ + "taxi" + ] + }, + { + "destination": 77, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 78, + "modes": [ + "bus", + "taxi" + ] + } + ], + "67": [ + { + "destination": 36, + "modes": [ + "bus" + ] + }, + { + "destination": 43, + "modes": [ + "bus" + ] + }, + { + "destination": 51, + "modes": [ + "taxi" + ] + }, + { + "destination": 68, + "modes": [ + "taxi" + ] + }, + { + "destination": 83, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 87, + "modes": [ + "bus" + ] + } + ], + "68": [ + { + "destination": 67, + "modes": [ + "taxi" + ] + }, + { + "destination": 69, + "modes": [ + "taxi" + ] + }, + { + "destination": 72, + "modes": [ + "taxi" + ] + } + ], + "69": [ + { + "destination": 56, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 68, + "modes": [ + "taxi" + ] + }, + { + "destination": 72, + "modes": [ + "bus", + "taxi" + ] + } + ], + "7": [ + { + "destination": 6, + "modes": [ + "bus" + ] + }, + { + "destination": 8, + "modes": [ + "taxi" + ] + }, + { + "destination": 14, + "modes": [ + "bus" + ] + }, + { + "destination": 18, + "modes": [ + "taxi" + ] + }, + { + "destination": 24, + "modes": [ + "bus" + ] + } + ], + "70": [ + { + "destination": 54, + "modes": [ + "bus" + ] + }, + { + "destination": 64, + "modes": [ + "taxi" + ] + }, + { + "destination": 74, + "modes": [ + "taxi" + ] + }, + { + "destination": 82, + "modes": [ + "taxi" + ] + }, + { + "destination": 86, + "modes": [ + "bus" + ] + } + ], + "71": [ + { + "destination": 40, + "modes": [ + "subway" + ] + }, + { + "destination": 42, + "modes": [ + "bus" + ] + }, + { + "destination": 55, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 75, + "modes": [ + "taxi" + ] + }, + { + "destination": 76, + "modes": [ + "bus", + "subway" + ] + }, + { + "destination": 87, + "modes": [ + "subway" + ] + }, + { + "destination": 93, + "modes": [ + "bus", + "subway" + ] + } + ], + "72": [ + { + "destination": 68, + "modes": [ + "taxi" + ] + }, + { + "destination": 69, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 83, + "modes": [ + "bus" + ] + }, + { + "destination": 89, + "modes": [ + "bus" + ] + } + ], + "73": [ + { + "destination": 62, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 63, + "modes": [ + "taxi" + ] + }, + { + "destination": 80, + "modes": [ + "taxi" + ] + } + ], + "74": [ + { + "destination": 54, + "modes": [ + "bus" + ] + }, + { + "destination": 70, + "modes": [ + "taxi" + ] + }, + { + "destination": 75, + "modes": [ + "taxi" + ] + }, + { + "destination": 96, + "modes": [ + "bus" + ] + } + ], + "75": [ + { + "destination": 65, + "modes": [ + "taxi" + ] + }, + { + "destination": 71, + "modes": [ + "taxi" + ] + }, + { + "destination": 74, + "modes": [ + "taxi" + ] + } + ], + "76": [ + { + "destination": 66, + "modes": [ + "taxi" + ] + }, + { + "destination": 71, + "modes": [ + "bus", + "subway" + ] + }, + { + "destination": 77, + "modes": [ + "bus", + "taxi" + ] + } + ], + "77": [ + { + "destination": 66, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 76, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 78, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 100, + "modes": [ + "bus" + ] + } + ], + "78": [ + { + "destination": 66, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 77, + "modes": [ + "bus", + "taxi" + ] + } + ], + "79": [ + { + "destination": 61, + "modes": [ + "bus" + ] + }, + { + "destination": 84, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 88, + "modes": [ + "bus", + "taxi" + ] + } + ], + "8": [ + { + "destination": 7, + "modes": [ + "taxi" + ] + }, + { + "destination": 19, + "modes": [ + "taxi" + ] + } + ], + "80": [ + { + "destination": 73, + "modes": [ + "taxi" + ] + }, + { + "destination": 81, + "modes": [ + "taxi" + ] + }, + { + "destination": 85, + "modes": [ + "taxi" + ] + }, + { + "destination": 92, + "modes": [ + "taxi" + ] + } + ], + "81": [ + { + "destination": 80, + "modes": [ + "taxi" + ] + }, + { + "destination": 85, + "modes": [ + "taxi" + ] + } + ], + "82": [ + { + "destination": 70, + "modes": [ + "taxi" + ] + }, + { + "destination": 86, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 98, + "modes": [ + "taxi" + ] + } + ], + "83": [ + { + "destination": 67, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 72, + "modes": [ + "bus" + ] + }, + { + "destination": 87, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 89, + "modes": [ + "taxi" + ] + } + ], + "84": [ + { + "destination": 33, + "modes": [ + "subway" + ] + }, + { + "destination": 79, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 90, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 94, + "modes": [ + "subway" + ] + } + ], + "85": [ + { + "destination": 80, + "modes": [ + "taxi" + ] + }, + { + "destination": 81, + "modes": [ + "taxi" + ] + } + ], + "86": [ + { + "destination": 64, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 70, + "modes": [ + "bus" + ] + }, + { + "destination": 82, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 98, + "modes": [ + "taxi" + ] + }, + { + "destination": 103, + "modes": [ + "bus" + ] + } + ], + "87": [ + { + "destination": 43, + "modes": [ + "subway" + ] + }, + { + "destination": 67, + "modes": [ + "bus" + ] + }, + { + "destination": 71, + "modes": [ + "subway" + ] + }, + { + "destination": 83, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 89, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 94, + "modes": [ + "subway" + ] + }, + { + "destination": 102, + "modes": [ + "bus" + ] + }, + { + "destination": 111, + "modes": [ + "subway" + ] + }, + { + "destination": 112, + "modes": [ + "bus" + ] + } + ], + "88": [ + { + "destination": 79, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 90, + "modes": [ + "bus" + ] + }, + { + "destination": 94, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 95, + "modes": [ + "bus", + "taxi" + ] + } + ], + "89": [ + { + "destination": 72, + "modes": [ + "bus" + ] + }, + { + "destination": 83, + "modes": [ + "taxi" + ] + }, + { + "destination": 87, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 113, + "modes": [ + "bus" + ] + } + ], + "9": [ + { + "destination": 10, + "modes": [ + "bus" + ] + }, + { + "destination": 39, + "modes": [ + "bus", + "taxi" + ] + } + ], + "90": [ + { + "destination": 84, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 88, + "modes": [ + "bus" + ] + }, + { + "destination": 91, + "modes": [ + "taxi" + ] + }, + { + "destination": 95, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 121, + "modes": [ + "bus" + ] + } + ], + "91": [ + { + "destination": 90, + "modes": [ + "taxi" + ] + }, + { + "destination": 92, + "modes": [ + "taxi" + ] + } + ], + "92": [ + { + "destination": 80, + "modes": [ + "taxi" + ] + }, + { + "destination": 91, + "modes": [ + "taxi" + ] + } + ], + "93": [ + { + "destination": 71, + "modes": [ + "bus", + "subway" + ] + }, + { + "destination": 97, + "modes": [ + "taxi" + ] + }, + { + "destination": 99, + "modes": [ + "bus", + "taxi" + ] + } + ], + "94": [ + { + "destination": 84, + "modes": [ + "subway" + ] + }, + { + "destination": 87, + "modes": [ + "subway" + ] + }, + { + "destination": 88, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 95, + "modes": [ + "taxi" + ] + }, + { + "destination": 113, + "modes": [ + "bus" + ] + }, + { + "destination": 119, + "modes": [ + "bus" + ] + }, + { + "destination": 120, + "modes": [ + "bus", + "subway" + ] + } + ], + "95": [ + { + "destination": 88, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 90, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 94, + "modes": [ + "taxi" + ] + }, + { + "destination": 121, + "modes": [ + "bus" + ] + } + ], + "96": [ + { + "destination": 74, + "modes": [ + "bus" + ] + }, + { + "destination": 97, + "modes": [ + "bus" + ] + }, + { + "destination": 101, + "modes": [ + "bus" + ] + }, + { + "destination": 104, + "modes": [ + "bus" + ] + }, + { + "destination": 105, + "modes": [ + "taxi" + ] + }, + { + "destination": 107, + "modes": [ + "taxi" + ] + } + ], + "97": [ + { + "destination": 93, + "modes": [ + "taxi" + ] + }, + { + "destination": 96, + "modes": [ + "bus" + ] + }, + { + "destination": 101, + "modes": [ + "bus", + "taxi" + ] + } + ], + "98": [ + { + "destination": 82, + "modes": [ + "taxi" + ] + }, + { + "destination": 86, + "modes": [ + "taxi" + ] + }, + { + "destination": 103, + "modes": [ + "taxi" + ] + }, + { + "destination": 104, + "modes": [ + "bus", + "taxi" + ] + } + ], + "99": [ + { + "destination": 93, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 100, + "modes": [ + "taxi" + ] + }, + { + "destination": 101, + "modes": [ + "bus", + "taxi" + ] + }, + { + "destination": 111, + "modes": [ + "bus", + "taxi" + ] + } + ] + }, + "edges": [ + { + "modes": [ + "bus", + "taxi" + ], + "source": 1, + "target": 5 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 1, + "target": 6 + }, + { + "modes": [ + "taxi" + ], + "source": 2, + "target": 3 + }, + { + "modes": [ + "bus" + ], + "source": 2, + "target": 4 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 2, + "target": 15 + }, + { + "modes": [ + "bus" + ], + "source": 2, + "target": 28 + }, + { + "modes": [ + "taxi" + ], + "source": 3, + "target": 4 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 4, + "target": 16 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 5, + "target": 12 + }, + { + "modes": [ + "taxi" + ], + "source": 5, + "target": 13 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 5, + "target": 17 + }, + { + "modes": [ + "bus" + ], + "source": 6, + "target": 7 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 6, + "target": 13 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 6, + "target": 24 + }, + { + "modes": [ + "taxi" + ], + "source": 7, + "target": 8 + }, + { + "modes": [ + "bus" + ], + "source": 7, + "target": 14 + }, + { + "modes": [ + "taxi" + ], + "source": 7, + "target": 18 + }, + { + "modes": [ + "bus" + ], + "source": 7, + "target": 24 + }, + { + "modes": [ + "taxi" + ], + "source": 8, + "target": 19 + }, + { + "modes": [ + "bus" + ], + "source": 9, + "target": 10 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 9, + "target": 39 + }, + { + "modes": [ + "taxi" + ], + "source": 10, + "target": 11 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 10, + "target": 23 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 11, + "target": 12 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 11, + "target": 23 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 12, + "target": 17 + }, + { + "modes": [ + "bus" + ], + "source": 12, + "target": 34 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 13, + "target": 24 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 14, + "target": 26 + }, + { + "modes": [ + "taxi" + ], + "source": 14, + "target": 27 + }, + { + "modes": [ + "bus" + ], + "source": 14, + "target": 28 + }, + { + "modes": [ + "bus" + ], + "source": 14, + "target": 36 + }, + { + "modes": [ + "subway" + ], + "source": 14, + "target": 43 + }, + { + "modes": [ + "taxi" + ], + "source": 15, + "target": 20 + }, + { + "modes": [ + "bus" + ], + "source": 15, + "target": 28 + }, + { + "modes": [ + "taxi" + ], + "source": 15, + "target": 29 + }, + { + "modes": [ + "taxi" + ], + "source": 16, + "target": 21 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 16, + "target": 30 + }, + { + "modes": [ + "taxi" + ], + "source": 18, + "target": 19 + }, + { + "modes": [ + "taxi" + ], + "source": 18, + "target": 25 + }, + { + "modes": [ + "taxi" + ], + "source": 20, + "target": 29 + }, + { + "modes": [ + "taxi" + ], + "source": 20, + "target": 30 + }, + { + "modes": [ + "bus" + ], + "source": 21, + "target": 30 + }, + { + "modes": [ + "taxi" + ], + "source": 21, + "target": 32 + }, + { + "modes": [ + "bus" + ], + "source": 21, + "target": 33 + }, + { + "modes": [ + "taxi" + ], + "source": 22, + "target": 33 + }, + { + "modes": [ + "taxi" + ], + "source": 22, + "target": 47 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 23, + "target": 34 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 23, + "target": 39 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 23, + "target": 49 + }, + { + "modes": [ + "taxi" + ], + "source": 25, + "target": 35 + }, + { + "modes": [ + "taxi" + ], + "source": 26, + "target": 27 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 26, + "target": 43 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 28, + "target": 36 + }, + { + "modes": [ + "taxi" + ], + "source": 28, + "target": 44 + }, + { + "modes": [ + "taxi" + ], + "source": 29, + "target": 37 + }, + { + "modes": [ + "bus" + ], + "source": 30, + "target": 45 + }, + { + "modes": [ + "bus" + ], + "source": 30, + "target": 53 + }, + { + "modes": [ + "taxi" + ], + "source": 31, + "target": 33 + }, + { + "modes": [ + "taxi" + ], + "source": 31, + "target": 48 + }, + { + "modes": [ + "taxi" + ], + "source": 32, + "target": 46 + }, + { + "modes": [ + "taxi" + ], + "source": 33, + "target": 47 + }, + { + "modes": [ + "taxi" + ], + "source": 33, + "target": 48 + }, + { + "modes": [ + "bus" + ], + "source": 33, + "target": 59 + }, + { + "modes": [ + "bus" + ], + "source": 33, + "target": 62 + }, + { + "modes": [ + "subway" + ], + "source": 33, + "target": 84 + }, + { + "modes": [ + "taxi" + ], + "source": 34, + "target": 40 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 34, + "target": 49 + }, + { + "modes": [ + "taxi" + ], + "source": 34, + "target": 54 + }, + { + "modes": [ + "taxi" + ], + "source": 35, + "target": 42 + }, + { + "modes": [ + "taxi" + ], + "source": 36, + "target": 44 + }, + { + "modes": [ + "taxi" + ], + "source": 36, + "target": 51 + }, + { + "modes": [ + "taxi" + ], + "source": 36, + "target": 52 + }, + { + "modes": [ + "bus" + ], + "source": 36, + "target": 67 + }, + { + "modes": [ + "taxi" + ], + "source": 37, + "target": 45 + }, + { + "modes": [ + "taxi" + ], + "source": 38, + "target": 46 + }, + { + "modes": [ + "taxi" + ], + "source": 38, + "target": 47 + }, + { + "modes": [ + "taxi" + ], + "source": 39, + "target": 54 + }, + { + "modes": [ + "bus" + ], + "source": 39, + "target": 64 + }, + { + "modes": [ + "bus" + ], + "source": 40, + "target": 41 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 40, + "target": 49 + }, + { + "modes": [ + "subway" + ], + "source": 40, + "target": 64 + }, + { + "modes": [ + "subway" + ], + "source": 40, + "target": 71 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 41, + "target": 42 + }, + { + "modes": [ + "taxi" + ], + "source": 41, + "target": 55 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 42, + "target": 55 + }, + { + "modes": [ + "bus" + ], + "source": 42, + "target": 71 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 43, + "target": 50 + }, + { + "modes": [ + "bus" + ], + "source": 43, + "target": 67 + }, + { + "modes": [ + "subway" + ], + "source": 43, + "target": 87 + }, + { + "modes": [ + "taxi" + ], + "source": 44, + "target": 52 + }, + { + "modes": [ + "bus" + ], + "source": 44, + "target": 53 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 45, + "target": 53 + }, + { + "modes": [ + "taxi" + ], + "source": 47, + "target": 59 + }, + { + "modes": [ + "bus", + "subway", + "taxi" + ], + "source": 49, + "target": 54 + }, + { + "modes": [ + "taxi" + ], + "source": 49, + "target": 65 + }, + { + "modes": [ + "taxi" + ], + "source": 51, + "target": 67 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 53, + "target": 56 + }, + { + "modes": [ + "bus" + ], + "source": 54, + "target": 70 + }, + { + "modes": [ + "bus" + ], + "source": 54, + "target": 74 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 55, + "target": 71 + }, + { + "modes": [ + "taxi" + ], + "source": 56, + "target": 57 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 56, + "target": 69 + }, + { + "modes": [ + "taxi" + ], + "source": 57, + "target": 58 + }, + { + "modes": [ + "taxi" + ], + "source": 58, + "target": 60 + }, + { + "modes": [ + "taxi" + ], + "source": 59, + "target": 61 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 59, + "target": 62 + }, + { + "modes": [ + "taxi" + ], + "source": 60, + "target": 61 + }, + { + "modes": [ + "bus" + ], + "source": 61, + "target": 62 + }, + { + "modes": [ + "bus" + ], + "source": 61, + "target": 79 + }, + { + "modes": [ + "taxi" + ], + "source": 62, + "target": 63 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 62, + "target": 73 + }, + { + "modes": [ + "taxi" + ], + "source": 63, + "target": 73 + }, + { + "modes": [ + "taxi" + ], + "source": 64, + "target": 70 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 64, + "target": 86 + }, + { + "modes": [ + "taxi" + ], + "source": 65, + "target": 75 + }, + { + "modes": [ + "taxi" + ], + "source": 66, + "target": 76 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 66, + "target": 77 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 66, + "target": 78 + }, + { + "modes": [ + "taxi" + ], + "source": 67, + "target": 68 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 67, + "target": 83 + }, + { + "modes": [ + "bus" + ], + "source": 67, + "target": 87 + }, + { + "modes": [ + "taxi" + ], + "source": 68, + "target": 69 + }, + { + "modes": [ + "taxi" + ], + "source": 68, + "target": 72 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 69, + "target": 72 + }, + { + "modes": [ + "taxi" + ], + "source": 70, + "target": 74 + }, + { + "modes": [ + "taxi" + ], + "source": 70, + "target": 82 + }, + { + "modes": [ + "bus" + ], + "source": 70, + "target": 86 + }, + { + "modes": [ + "taxi" + ], + "source": 71, + "target": 75 + }, + { + "modes": [ + "bus", + "subway" + ], + "source": 71, + "target": 76 + }, + { + "modes": [ + "subway" + ], + "source": 71, + "target": 87 + }, + { + "modes": [ + "bus", + "subway" + ], + "source": 71, + "target": 93 + }, + { + "modes": [ + "bus" + ], + "source": 72, + "target": 83 + }, + { + "modes": [ + "bus" + ], + "source": 72, + "target": 89 + }, + { + "modes": [ + "taxi" + ], + "source": 73, + "target": 80 + }, + { + "modes": [ + "taxi" + ], + "source": 74, + "target": 75 + }, + { + "modes": [ + "bus" + ], + "source": 74, + "target": 96 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 76, + "target": 77 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 77, + "target": 78 + }, + { + "modes": [ + "bus" + ], + "source": 77, + "target": 100 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 79, + "target": 84 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 79, + "target": 88 + }, + { + "modes": [ + "taxi" + ], + "source": 80, + "target": 81 + }, + { + "modes": [ + "taxi" + ], + "source": 80, + "target": 85 + }, + { + "modes": [ + "taxi" + ], + "source": 80, + "target": 92 + }, + { + "modes": [ + "taxi" + ], + "source": 81, + "target": 85 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 82, + "target": 86 + }, + { + "modes": [ + "taxi" + ], + "source": 82, + "target": 98 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 83, + "target": 87 + }, + { + "modes": [ + "taxi" + ], + "source": 83, + "target": 89 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 84, + "target": 90 + }, + { + "modes": [ + "subway" + ], + "source": 84, + "target": 94 + }, + { + "modes": [ + "taxi" + ], + "source": 86, + "target": 98 + }, + { + "modes": [ + "bus" + ], + "source": 86, + "target": 103 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 87, + "target": 89 + }, + { + "modes": [ + "subway" + ], + "source": 87, + "target": 94 + }, + { + "modes": [ + "bus" + ], + "source": 87, + "target": 102 + }, + { + "modes": [ + "subway" + ], + "source": 87, + "target": 111 + }, + { + "modes": [ + "bus" + ], + "source": 87, + "target": 112 + }, + { + "modes": [ + "bus" + ], + "source": 88, + "target": 90 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 88, + "target": 94 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 88, + "target": 95 + }, + { + "modes": [ + "bus" + ], + "source": 89, + "target": 113 + }, + { + "modes": [ + "taxi" + ], + "source": 90, + "target": 91 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 90, + "target": 95 + }, + { + "modes": [ + "bus" + ], + "source": 90, + "target": 121 + }, + { + "modes": [ + "taxi" + ], + "source": 91, + "target": 92 + }, + { + "modes": [ + "taxi" + ], + "source": 93, + "target": 97 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 93, + "target": 99 + }, + { + "modes": [ + "taxi" + ], + "source": 94, + "target": 95 + }, + { + "modes": [ + "bus" + ], + "source": 94, + "target": 113 + }, + { + "modes": [ + "bus" + ], + "source": 94, + "target": 119 + }, + { + "modes": [ + "bus", + "subway" + ], + "source": 94, + "target": 120 + }, + { + "modes": [ + "bus" + ], + "source": 95, + "target": 121 + }, + { + "modes": [ + "bus" + ], + "source": 96, + "target": 97 + }, + { + "modes": [ + "bus" + ], + "source": 96, + "target": 101 + }, + { + "modes": [ + "bus" + ], + "source": 96, + "target": 104 + }, + { + "modes": [ + "taxi" + ], + "source": 96, + "target": 105 + }, + { + "modes": [ + "taxi" + ], + "source": 96, + "target": 107 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 97, + "target": 101 + }, + { + "modes": [ + "taxi" + ], + "source": 98, + "target": 103 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 98, + "target": 104 + }, + { + "modes": [ + "taxi" + ], + "source": 99, + "target": 100 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 99, + "target": 101 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 99, + "target": 111 + }, + { + "modes": [ + "bus" + ], + "source": 100, + "target": 102 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 100, + "target": 106 + }, + { + "modes": [ + "taxi" + ], + "source": 101, + "target": 105 + }, + { + "modes": [ + "bus" + ], + "source": 101, + "target": 107 + }, + { + "modes": [ + "taxi" + ], + "source": 101, + "target": 110 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 102, + "target": 106 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 102, + "target": 108 + }, + { + "modes": [ + "bus" + ], + "source": 102, + "target": 112 + }, + { + "modes": [ + "taxi" + ], + "source": 102, + "target": 115 + }, + { + "modes": [ + "taxi" + ], + "source": 103, + "target": 104 + }, + { + "modes": [ + "taxi" + ], + "source": 103, + "target": 109 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 103, + "target": 114 + }, + { + "modes": [ + "taxi" + ], + "source": 103, + "target": 122 + }, + { + "modes": [ + "bus" + ], + "source": 104, + "target": 107 + }, + { + "modes": [ + "taxi" + ], + "source": 105, + "target": 107 + }, + { + "modes": [ + "taxi" + ], + "source": 105, + "target": 110 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 106, + "target": 111 + }, + { + "modes": [ + "taxi" + ], + "source": 106, + "target": 115 + }, + { + "modes": [ + "taxi" + ], + "source": 107, + "target": 109 + }, + { + "modes": [ + "bus" + ], + "source": 107, + "target": 110 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 108, + "target": 112 + }, + { + "modes": [ + "taxi" + ], + "source": 110, + "target": 111 + }, + { + "modes": [ + "bus" + ], + "source": 110, + "target": 117 + }, + { + "modes": [ + "bus" + ], + "source": 111, + "target": 115 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 111, + "target": 117 + }, + { + "modes": [ + "subway" + ], + "source": 111, + "target": 120 + }, + { + "modes": [ + "subway" + ], + "source": 111, + "target": 122 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 112, + "target": 113 + }, + { + "modes": [ + "bus" + ], + "source": 112, + "target": 116 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 113, + "target": 116 + }, + { + "modes": [ + "taxi" + ], + "source": 114, + "target": 122 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 115, + "target": 123 + }, + { + "modes": [ + "taxi" + ], + "source": 115, + "target": 124 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 116, + "target": 119 + }, + { + "modes": [ + "taxi" + ], + "source": 117, + "target": 118 + }, + { + "modes": [ + "taxi" + ], + "source": 117, + "target": 126 + }, + { + "modes": [ + "bus" + ], + "source": 117, + "target": 132 + }, + { + "modes": [ + "taxi" + ], + "source": 118, + "target": 126 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 119, + "target": 120 + }, + { + "modes": [ + "subway" + ], + "source": 120, + "target": 121 + }, + { + "modes": [ + "bus" + ], + "source": 120, + "target": 125 + }, + { + "modes": [ + "bus" + ], + "source": 120, + "target": 130 + }, + { + "modes": [ + "taxi" + ], + "source": 120, + "target": 131 + }, + { + "modes": [ + "bus" + ], + "source": 120, + "target": 138 + }, + { + "modes": [ + "subway" + ], + "source": 120, + "target": 147 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 121, + "target": 125 + }, + { + "modes": [ + "bus" + ], + "source": 121, + "target": 134 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 123, + "target": 124 + }, + { + "modes": [ + "taxi" + ], + "source": 125, + "target": 139 + }, + { + "modes": [ + "taxi" + ], + "source": 125, + "target": 144 + }, + { + "modes": [ + "taxi" + ], + "source": 126, + "target": 132 + }, + { + "modes": [ + "taxi" + ], + "source": 126, + "target": 133 + }, + { + "modes": [ + "taxi" + ], + "source": 127, + "target": 133 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 127, + "target": 136 + }, + { + "modes": [ + "taxi" + ], + "source": 128, + "target": 129 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 128, + "target": 141 + }, + { + "modes": [ + "bus" + ], + "source": 128, + "target": 150 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 129, + "target": 130 + }, + { + "modes": [ + "taxi" + ], + "source": 129, + "target": 137 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 130, + "target": 137 + }, + { + "modes": [ + "taxi" + ], + "source": 131, + "target": 139 + }, + { + "modes": [ + "taxi" + ], + "source": 132, + "target": 140 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 132, + "target": 142 + }, + { + "modes": [ + "taxi" + ], + "source": 132, + "target": 145 + }, + { + "modes": [ + "taxi" + ], + "source": 133, + "target": 136 + }, + { + "modes": [ + "taxi" + ], + "source": 134, + "target": 144 + }, + { + "modes": [ + "bus" + ], + "source": 134, + "target": 147 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 135, + "target": 142 + }, + { + "modes": [ + "taxi" + ], + "source": 135, + "target": 149 + }, + { + "modes": [ + "bus" + ], + "source": 136, + "target": 140 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 136, + "target": 146 + }, + { + "modes": [ + "taxi" + ], + "source": 137, + "target": 143 + }, + { + "modes": [ + "taxi" + ], + "source": 138, + "target": 139 + }, + { + "modes": [ + "taxi" + ], + "source": 138, + "target": 144 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 138, + "target": 147 + }, + { + "modes": [ + "taxi" + ], + "source": 139, + "target": 144 + }, + { + "modes": [ + "taxi" + ], + "source": 140, + "target": 145 + }, + { + "modes": [ + "bus" + ], + "source": 140, + "target": 146 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 141, + "target": 150 + }, + { + "modes": [ + "bus" + ], + "source": 141, + "target": 151 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 142, + "target": 145 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 142, + "target": 149 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 143, + "target": 150 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 144, + "target": 147 + }, + { + "modes": [ + "bus" + ], + "source": 145, + "target": 149 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 146, + "target": 148 + }, + { + "modes": [ + "taxi" + ], + "source": 146, + "target": 151 + }, + { + "modes": [ + "bus", + "taxi" + ], + "source": 148, + "target": 151 + }, + { + "modes": [ + "bus" + ], + "source": 150, + "target": 151 + } + ], + "nodes": [ + { + "id": 1, + "radius": 12, + "x": 424, + "y": 79 + }, + { + "id": 2, + "radius": 11, + "x": 949, + "y": 69 + }, + { + "id": 3, + "radius": 12, + "x": 1028, + "y": 78 + }, + { + "id": 4, + "radius": 13, + "x": 1059, + "y": 103 + }, + { + "id": 5, + "radius": 12, + "x": 353, + "y": 119 + }, + { + "id": 6, + "radius": 11, + "x": 434, + "y": 128 + }, + { + "id": 7, + "radius": 12, + "x": 562, + "y": 123 + }, + { + "id": 8, + "radius": 12, + "x": 628, + "y": 114 + }, + { + "id": 9, + "radius": 12, + "x": 99, + "y": 153 + }, + { + "id": 10, + "radius": 11, + "x": 143, + "y": 146 + }, + { + "id": 11, + "radius": 11, + "x": 233, + "y": 151 + }, + { + "id": 12, + "radius": 11, + "x": 289, + "y": 142 + }, + { + "id": 13, + "radius": 11, + "x": 392, + "y": 153 + }, + { + "id": 14, + "radius": 12, + "x": 719, + "y": 152 + }, + { + "id": 15, + "radius": 11, + "x": 925, + "y": 133 + }, + { + "id": 16, + "radius": 12, + "x": 1054, + "y": 148 + }, + { + "id": 17, + "radius": 11, + "x": 317, + "y": 172 + }, + { + "id": 18, + "radius": 11, + "x": 573, + "y": 173 + }, + { + "id": 19, + "radius": 11, + "x": 624, + "y": 158 + }, + { + "id": 20, + "radius": 11, + "x": 976, + "y": 163 + }, + { + "id": 21, + "radius": 11, + "x": 1114, + "y": 162 + }, + { + "id": 22, + "radius": 12, + "x": 1243, + "y": 179 + }, + { + "id": 23, + "radius": 11, + "x": 211, + "y": 202 + }, + { + "id": 24, + "radius": 11, + "x": 424, + "y": 184 + }, + { + "id": 25, + "radius": 11, + "x": 543, + "y": 198 + }, + { + "id": 26, + "radius": 11, + "x": 696, + "y": 187 + }, + { + "id": 27, + "radius": 11, + "x": 738, + "y": 202 + }, + { + "id": 28, + "radius": 11, + "x": 817, + "y": 191 + }, + { + "id": 29, + "radius": 11, + "x": 927, + "y": 188 + }, + { + "id": 30, + "radius": 12, + "x": 1044, + "y": 202 + }, + { + "id": 31, + "radius": 11, + "x": 1396, + "y": 193 + }, + { + "id": 32, + "radius": 12, + "x": 1125, + "y": 214 + }, + { + "id": 33, + "radius": 11, + "x": 1319, + "y": 225 + }, + { + "id": 34, + "radius": 9, + "x": 242, + "y": 237 + }, + { + "id": 35, + "radius": 11, + "x": 498, + "y": 238 + }, + { + "id": 36, + "radius": 12, + "x": 796, + "y": 246 + }, + { + "id": 37, + "radius": 11, + "x": 946, + "y": 228 + }, + { + "id": 38, + "radius": 11, + "x": 1188, + "y": 249 + }, + { + "id": 39, + "radius": 9, + "x": 139, + "y": 263 + }, + { + "id": 40, + "radius": 11, + "x": 282, + "y": 259 + }, + { + "id": 41, + "radius": 11, + "x": 368, + "y": 266 + }, + { + "id": 42, + "radius": 11, + "x": 419, + "y": 259 + }, + { + "id": 43, + "radius": 11, + "x": 682, + "y": 263 + }, + { + "id": 44, + "radius": 10, + "x": 851, + "y": 257 + }, + { + "id": 45, + "radius": 10, + "x": 984, + "y": 259 + }, + { + "id": 46, + "radius": 11, + "x": 1133, + "y": 264 + }, + { + "id": 47, + "radius": 11, + "x": 1243, + "y": 265 + }, + { + "id": 48, + "radius": 12, + "x": 1367, + "y": 265 + }, + { + "id": 49, + "radius": 11, + "x": 247, + "y": 279 + }, + { + "id": 50, + "radius": 11, + "x": 621, + "y": 288 + }, + { + "id": 51, + "radius": 11, + "x": 748, + "y": 292 + }, + { + "id": 52, + "radius": 11, + "x": 819, + "y": 282 + }, + { + "id": 53, + "radius": 11, + "x": 973, + "y": 278 + }, + { + "id": 54, + "radius": 11, + "x": 203, + "y": 303 + }, + { + "id": 55, + "radius": 12, + "x": 397, + "y": 311 + }, + { + "id": 56, + "radius": 11, + "x": 974, + "y": 321 + }, + { + "id": 57, + "radius": 11, + "x": 1056, + "y": 318 + }, + { + "id": 58, + "radius": 11, + "x": 1102, + "y": 323 + }, + { + "id": 59, + "radius": 9, + "x": 1244, + "y": 318 + }, + { + "id": 60, + "radius": 10, + "x": 1156, + "y": 340 + }, + { + "id": 61, + "radius": 11, + "x": 1188, + "y": 338 + }, + { + "id": 62, + "radius": 11, + "x": 1264, + "y": 347 + }, + { + "id": 63, + "radius": 10, + "x": 1322, + "y": 333 + }, + { + "id": 64, + "radius": 12, + "x": 58, + "y": 365 + }, + { + "id": 65, + "radius": 11, + "x": 281, + "y": 354 + }, + { + "id": 66, + "radius": 11, + "x": 565, + "y": 353 + }, + { + "id": 67, + "radius": 11, + "x": 768, + "y": 352 + }, + { + "id": 68, + "radius": 10, + "x": 871, + "y": 350 + }, + { + "id": 69, + "radius": 11, + "x": 931, + "y": 348 + }, + { + "id": 70, + "radius": 11, + "x": 157, + "y": 388 + }, + { + "id": 71, + "radius": 11, + "x": 384, + "y": 376 + }, + { + "id": 72, + "radius": 11, + "x": 846, + "y": 388 + }, + { + "id": 73, + "radius": 11, + "x": 1299, + "y": 373 + }, + { + "id": 74, + "radius": 11, + "x": 222, + "y": 408 + }, + { + "id": 75, + "radius": 9, + "x": 296, + "y": 399 + }, + { + "id": 76, + "radius": 11, + "x": 493, + "y": 402 + }, + { + "id": 77, + "radius": 10, + "x": 549, + "y": 416 + }, + { + "id": 78, + "radius": 9, + "x": 588, + "y": 398 + }, + { + "id": 79, + "radius": 11, + "x": 1170, + "y": 408 + }, + { + "id": 80, + "radius": 10, + "x": 1303, + "y": 416 + }, + { + "id": 81, + "radius": 12, + "x": 1378, + "y": 400 + }, + { + "id": 82, + "radius": 9, + "x": 143, + "y": 427 + }, + { + "id": 83, + "radius": 11, + "x": 777, + "y": 428 + }, + { + "id": 84, + "radius": 11, + "x": 1211, + "y": 439 + }, + { + "id": 85, + "radius": 11, + "x": 1397, + "y": 437 + }, + { + "id": 86, + "radius": 12, + "x": 98, + "y": 449 + }, + { + "id": 87, + "radius": 13, + "x": 757, + "y": 463 + }, + { + "id": 88, + "radius": 11, + "x": 1132, + "y": 458 + }, + { + "id": 89, + "radius": 9, + "x": 851, + "y": 472 + }, + { + "id": 90, + "radius": 11, + "x": 1221, + "y": 488 + }, + { + "id": 91, + "radius": 9, + "x": 1277, + "y": 489 + }, + { + "id": 92, + "radius": 11, + "x": 1319, + "y": 485 + }, + { + "id": 93, + "radius": 9, + "x": 386, + "y": 511 + }, + { + "id": 94, + "radius": 11, + "x": 1048, + "y": 505 + }, + { + "id": 95, + "radius": 12, + "x": 1145, + "y": 514 + }, + { + "id": 96, + "radius": 12, + "x": 257, + "y": 532 + }, + { + "id": 97, + "radius": 9, + "x": 352, + "y": 530 + }, + { + "id": 98, + "radius": 12, + "x": 134, + "y": 551 + }, + { + "id": 99, + "radius": 13, + "x": 430, + "y": 556 + }, + { + "id": 100, + "radius": 10, + "x": 475, + "y": 557 + }, + { + "id": 101, + "radius": 11, + "x": 377, + "y": 584 + }, + { + "id": 102, + "radius": 11, + "x": 652, + "y": 575 + }, + { + "id": 103, + "radius": 11, + "x": 146, + "y": 599 + }, + { + "id": 104, + "radius": 11, + "x": 223, + "y": 605 + }, + { + "id": 105, + "radius": 9, + "x": 325, + "y": 605 + }, + { + "id": 106, + "radius": 9, + "x": 579, + "y": 600 + }, + { + "id": 107, + "radius": 9, + "x": 265, + "y": 620 + }, + { + "id": 108, + "radius": 11, + "x": 682, + "y": 622 + }, + { + "id": 109, + "radius": 9, + "x": 209, + "y": 647 + }, + { + "id": 110, + "radius": 9, + "x": 349, + "y": 650 + }, + { + "id": 111, + "radius": 12, + "x": 487, + "y": 656 + }, + { + "id": 112, + "radius": 9, + "x": 770, + "y": 637 + }, + { + "id": 113, + "radius": 11, + "x": 836, + "y": 637 + }, + { + "id": 114, + "radius": 9, + "x": 95, + "y": 685 + }, + { + "id": 115, + "radius": 9, + "x": 551, + "y": 688 + }, + { + "id": 116, + "radius": 9, + "x": 871, + "y": 692 + }, + { + "id": 117, + "radius": 11, + "x": 409, + "y": 719 + }, + { + "id": 118, + "radius": 9, + "x": 472, + "y": 722 + }, + { + "id": 119, + "radius": 11, + "x": 925, + "y": 731 + }, + { + "id": 120, + "radius": 11, + "x": 980, + "y": 724 + }, + { + "id": 121, + "radius": 13, + "x": 1169, + "y": 727 + }, + { + "id": 122, + "radius": 9, + "x": 91, + "y": 745 + }, + { + "id": 123, + "radius": 9, + "x": 574, + "y": 743 + }, + { + "id": 124, + "radius": 9, + "x": 622, + "y": 740 + }, + { + "id": 125, + "radius": 9, + "x": 1075, + "y": 740 + }, + { + "id": 126, + "radius": 11, + "x": 445, + "y": 763 + }, + { + "id": 127, + "radius": 10, + "x": 542, + "y": 763 + }, + { + "id": 128, + "radius": 9, + "x": 716, + "y": 769 + }, + { + "id": 129, + "radius": 9, + "x": 773, + "y": 766 + }, + { + "id": 130, + "radius": 9, + "x": 835, + "y": 767 + }, + { + "id": 131, + "radius": 11, + "x": 986, + "y": 768 + }, + { + "id": 132, + "radius": 11, + "x": 415, + "y": 799 + }, + { + "id": 133, + "radius": 9, + "x": 488, + "y": 788 + }, + { + "id": 134, + "radius": 11, + "x": 1159, + "y": 787 + }, + { + "id": 135, + "radius": 11, + "x": 267, + "y": 827 + }, + { + "id": 136, + "radius": 9, + "x": 530, + "y": 812 + }, + { + "id": 137, + "radius": 11, + "x": 839, + "y": 811 + }, + { + "id": 138, + "radius": 9, + "x": 958, + "y": 806 + }, + { + "id": 139, + "radius": 11, + "x": 1016, + "y": 806 + }, + { + "id": 140, + "radius": 11, + "x": 467, + "y": 844 + }, + { + "id": 141, + "radius": 9, + "x": 737, + "y": 845 + }, + { + "id": 142, + "radius": 11, + "x": 391, + "y": 872 + }, + { + "id": 143, + "radius": 9, + "x": 839, + "y": 854 + }, + { + "id": 144, + "radius": 9, + "x": 1103, + "y": 857 + }, + { + "id": 145, + "radius": 11, + "x": 448, + "y": 890 + }, + { + "id": 146, + "radius": 12, + "x": 572, + "y": 899 + }, + { + "id": 147, + "radius": 10, + "x": 1003, + "y": 881 + }, + { + "id": 148, + "radius": 9, + "x": 620, + "y": 919 + }, + { + "id": 149, + "radius": 11, + "x": 301, + "y": 932 + }, + { + "id": 150, + "radius": 9, + "x": 811, + "y": 934 + }, + { + "id": 151, + "radius": 12, + "x": 686, + "y": 958 + } + ], + "schema_version": 1 +} diff --git a/data/processed/junction_registry.json b/data/processed/junction_registry.json new file mode 100644 index 0000000000000000000000000000000000000000..6a41eb85ada6c77cb40507632eabcb8476ee027b --- /dev/null +++ b/data/processed/junction_registry.json @@ -0,0 +1,2930 @@ +{ + "junctions": [ + { + "base_neighbors": [ + 5, + 6 + ], + "district": null, + "id": 1, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 424, + "y": 79 + }, + { + "base_neighbors": [ + 3, + 15, + 28 + ], + "district": null, + "id": 2, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 949, + "y": 69 + }, + { + "base_neighbors": [ + 2, + 4 + ], + "district": null, + "id": 3, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 1028, + "y": 78 + }, + { + "base_neighbors": [ + 3, + 16 + ], + "district": null, + "id": 4, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 13, + "x": 1059, + "y": 103 + }, + { + "base_neighbors": [ + 1, + 12, + 13, + 17 + ], + "district": null, + "id": 5, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 353, + "y": 119 + }, + { + "base_neighbors": [ + 1, + 7, + 13, + 24 + ], + "district": null, + "id": 6, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 434, + "y": 128 + }, + { + "base_neighbors": [ + 6, + 8, + 18, + 24 + ], + "district": null, + "id": 7, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 562, + "y": 123 + }, + { + "base_neighbors": [ + 7, + 19 + ], + "district": null, + "id": 8, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 628, + "y": 114 + }, + { + "base_neighbors": [ + 10, + 39 + ], + "district": null, + "id": 9, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 99, + "y": 153 + }, + { + "base_neighbors": [ + 9, + 11, + 23 + ], + "district": null, + "id": 10, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 143, + "y": 146 + }, + { + "base_neighbors": [ + 10, + 12, + 23 + ], + "district": null, + "id": 11, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 233, + "y": 151 + }, + { + "base_neighbors": [ + 5, + 11, + 17, + 34 + ], + "district": null, + "id": 12, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 289, + "y": 142 + }, + { + "base_neighbors": [ + 5, + 6, + 24 + ], + "district": null, + "id": 13, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 392, + "y": 153 + }, + { + "base_neighbors": [ + 26, + 27, + 28, + 36 + ], + "district": null, + "id": 14, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 719, + "y": 152 + }, + { + "base_neighbors": [ + 2, + 20, + 28, + 29 + ], + "district": null, + "id": 15, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 925, + "y": 133 + }, + { + "base_neighbors": [ + 4, + 21, + 30 + ], + "district": null, + "id": 16, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 1054, + "y": 148 + }, + { + "base_neighbors": [ + 5, + 12 + ], + "district": null, + "id": 17, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 317, + "y": 172 + }, + { + "base_neighbors": [ + 7, + 19, + 25 + ], + "district": null, + "id": 18, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 573, + "y": 173 + }, + { + "base_neighbors": [ + 8, + 18 + ], + "district": null, + "id": 19, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 624, + "y": 158 + }, + { + "base_neighbors": [ + 15, + 29, + 30 + ], + "district": null, + "id": 20, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 976, + "y": 163 + }, + { + "base_neighbors": [ + 16, + 30, + 32 + ], + "district": null, + "id": 21, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1114, + "y": 162 + }, + { + "base_neighbors": [ + 33, + 47 + ], + "district": null, + "id": 22, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 1243, + "y": 179 + }, + { + "base_neighbors": [ + 10, + 11, + 34, + 39, + 49 + ], + "district": null, + "id": 23, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 211, + "y": 202 + }, + { + "base_neighbors": [ + 6, + 7, + 13 + ], + "district": null, + "id": 24, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 424, + "y": 184 + }, + { + "base_neighbors": [ + 18, + 35 + ], + "district": null, + "id": 25, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 543, + "y": 198 + }, + { + "base_neighbors": [ + 14, + 27, + 43 + ], + "district": null, + "id": 26, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 696, + "y": 187 + }, + { + "base_neighbors": [ + 14, + 26 + ], + "district": null, + "id": 27, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 738, + "y": 202 + }, + { + "base_neighbors": [ + 2, + 14, + 15, + 36, + 44 + ], + "district": null, + "id": 28, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 817, + "y": 191 + }, + { + "base_neighbors": [ + 15, + 20, + 37 + ], + "district": null, + "id": 29, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 927, + "y": 188 + }, + { + "base_neighbors": [ + 16, + 20, + 21, + 45, + 53 + ], + "district": null, + "id": 30, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 1044, + "y": 202 + }, + { + "base_neighbors": [ + 33, + 48 + ], + "district": null, + "id": 31, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1396, + "y": 193 + }, + { + "base_neighbors": [ + 21, + 46 + ], + "district": null, + "id": 32, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 1125, + "y": 214 + }, + { + "base_neighbors": [ + 22, + 31, + 47, + 48, + 59, + 62 + ], + "district": null, + "id": 33, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1319, + "y": 225 + }, + { + "base_neighbors": [ + 12, + 23, + 40, + 49, + 54 + ], + "district": null, + "id": 34, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 242, + "y": 237 + }, + { + "base_neighbors": [ + 25, + 42 + ], + "district": null, + "id": 35, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 498, + "y": 238 + }, + { + "base_neighbors": [ + 14, + 28, + 44, + 51, + 52 + ], + "district": null, + "id": 36, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 796, + "y": 246 + }, + { + "base_neighbors": [ + 29, + 45 + ], + "district": null, + "id": 37, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 946, + "y": 228 + }, + { + "base_neighbors": [ + 46, + 47 + ], + "district": null, + "id": 38, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1188, + "y": 249 + }, + { + "base_neighbors": [ + 9, + 23, + 54 + ], + "district": null, + "id": 39, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 139, + "y": 263 + }, + { + "base_neighbors": [ + 34, + 41, + 49, + 55 + ], + "district": null, + "id": 40, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 282, + "y": 259 + }, + { + "base_neighbors": [ + 40, + 42, + 55 + ], + "district": null, + "id": 41, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 368, + "y": 266 + }, + { + "base_neighbors": [ + 35, + 41, + 55, + 71 + ], + "district": null, + "id": 42, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 419, + "y": 259 + }, + { + "base_neighbors": [ + 26, + 50, + 87 + ], + "district": null, + "id": 43, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 682, + "y": 263 + }, + { + "base_neighbors": [ + 28, + 36, + 52, + 53 + ], + "district": null, + "id": 44, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 10, + "x": 851, + "y": 257 + }, + { + "base_neighbors": [ + 30, + 37, + 53 + ], + "district": null, + "id": 45, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 10, + "x": 984, + "y": 259 + }, + { + "base_neighbors": [ + 32, + 38 + ], + "district": null, + "id": 46, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1133, + "y": 264 + }, + { + "base_neighbors": [ + 22, + 33, + 38, + 59 + ], + "district": null, + "id": 47, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1243, + "y": 265 + }, + { + "base_neighbors": [ + 31, + 33 + ], + "district": null, + "id": 48, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 1367, + "y": 265 + }, + { + "base_neighbors": [ + 23, + 34, + 40, + 54, + 65 + ], + "district": null, + "id": 49, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 247, + "y": 279 + }, + { + "base_neighbors": [ + 43, + 66 + ], + "district": null, + "id": 50, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 621, + "y": 288 + }, + { + "base_neighbors": [ + 36, + 67 + ], + "district": null, + "id": 51, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 748, + "y": 292 + }, + { + "base_neighbors": [ + 36, + 44 + ], + "district": null, + "id": 52, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 819, + "y": 282 + }, + { + "base_neighbors": [ + 30, + 44, + 45, + 56 + ], + "district": null, + "id": 53, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 973, + "y": 278 + }, + { + "base_neighbors": [ + 34, + 39, + 49 + ], + "district": null, + "id": 54, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 203, + "y": 303 + }, + { + "base_neighbors": [ + 40, + 41, + 42, + 71 + ], + "district": null, + "id": 55, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 397, + "y": 311 + }, + { + "base_neighbors": [ + 53, + 57, + 69 + ], + "district": null, + "id": 56, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 974, + "y": 321 + }, + { + "base_neighbors": [ + 56, + 58 + ], + "district": null, + "id": 57, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1056, + "y": 318 + }, + { + "base_neighbors": [ + 57, + 60 + ], + "district": null, + "id": 58, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1102, + "y": 323 + }, + { + "base_neighbors": [ + 33, + 47, + 61, + 62 + ], + "district": null, + "id": 59, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 1244, + "y": 318 + }, + { + "base_neighbors": [ + 58, + 61 + ], + "district": null, + "id": 60, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 10, + "x": 1156, + "y": 340 + }, + { + "base_neighbors": [ + 59, + 60, + 62, + 79 + ], + "district": null, + "id": 61, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1188, + "y": 338 + }, + { + "base_neighbors": [ + 33, + 59, + 61, + 63, + 73, + 84, + 88 + ], + "district": null, + "id": 62, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1264, + "y": 347 + }, + { + "base_neighbors": [ + 62, + 73 + ], + "district": null, + "id": 63, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 10, + "x": 1322, + "y": 333 + }, + { + "base_neighbors": [ + 70, + 86 + ], + "district": null, + "id": 64, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 58, + "y": 365 + }, + { + "base_neighbors": [ + 49, + 75 + ], + "district": null, + "id": 65, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 281, + "y": 354 + }, + { + "base_neighbors": [ + 50, + 76, + 77, + 78 + ], + "district": null, + "id": 66, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 565, + "y": 353 + }, + { + "base_neighbors": [ + 51, + 68, + 83, + 87 + ], + "district": null, + "id": 67, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 768, + "y": 352 + }, + { + "base_neighbors": [ + 67, + 69, + 72 + ], + "district": null, + "id": 68, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 10, + "x": 871, + "y": 350 + }, + { + "base_neighbors": [ + 56, + 68, + 72 + ], + "district": null, + "id": 69, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 931, + "y": 348 + }, + { + "base_neighbors": [ + 64, + 74, + 82, + 86 + ], + "district": null, + "id": 70, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 157, + "y": 388 + }, + { + "base_neighbors": [ + 42, + 55, + 75, + 76, + 77 + ], + "district": null, + "id": 71, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 384, + "y": 376 + }, + { + "base_neighbors": [ + 68, + 69, + 83, + 89 + ], + "district": null, + "id": 72, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 846, + "y": 388 + }, + { + "base_neighbors": [ + 62, + 63, + 80, + 84 + ], + "district": null, + "id": 73, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1299, + "y": 373 + }, + { + "base_neighbors": [ + 70, + 75 + ], + "district": null, + "id": 74, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 222, + "y": 408 + }, + { + "base_neighbors": [ + 65, + 71, + 74 + ], + "district": null, + "id": 75, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 296, + "y": 399 + }, + { + "base_neighbors": [ + 66, + 71, + 77 + ], + "district": null, + "id": 76, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 493, + "y": 402 + }, + { + "base_neighbors": [ + 66, + 71, + 76, + 78, + 87, + 108 + ], + "district": null, + "id": 77, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 10, + "x": 549, + "y": 416 + }, + { + "base_neighbors": [ + 66, + 77 + ], + "district": null, + "id": 78, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 588, + "y": 398 + }, + { + "base_neighbors": [ + 61, + 84, + 88 + ], + "district": null, + "id": 79, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1170, + "y": 408 + }, + { + "base_neighbors": [ + 73, + 81, + 85, + 92 + ], + "district": null, + "id": 80, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 10, + "x": 1303, + "y": 416 + }, + { + "base_neighbors": [ + 80, + 85 + ], + "district": null, + "id": 81, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 1378, + "y": 400 + }, + { + "base_neighbors": [ + 70, + 86, + 98 + ], + "district": null, + "id": 82, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 143, + "y": 427 + }, + { + "base_neighbors": [ + 67, + 72, + 87, + 89 + ], + "district": null, + "id": 83, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 777, + "y": 428 + }, + { + "base_neighbors": [ + 62, + 73, + 79, + 88, + 90 + ], + "district": null, + "id": 84, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1211, + "y": 439 + }, + { + "base_neighbors": [ + 80, + 81 + ], + "district": null, + "id": 85, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1397, + "y": 437 + }, + { + "base_neighbors": [ + 64, + 70, + 82, + 98 + ], + "district": null, + "id": 86, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 98, + "y": 449 + }, + { + "base_neighbors": [ + 43, + 67, + 77, + 83, + 89, + 94, + 108, + 112, + 113 + ], + "district": null, + "id": 87, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 13, + "x": 757, + "y": 463 + }, + { + "base_neighbors": [ + 62, + 79, + 84, + 90, + 94, + 95 + ], + "district": null, + "id": 88, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1132, + "y": 458 + }, + { + "base_neighbors": [ + 72, + 83, + 87, + 113 + ], + "district": null, + "id": 89, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 851, + "y": 472 + }, + { + "base_neighbors": [ + 84, + 88, + 91, + 95, + 121 + ], + "district": null, + "id": 90, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1221, + "y": 488 + }, + { + "base_neighbors": [ + 90, + 92 + ], + "district": null, + "id": 91, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 1277, + "y": 489 + }, + { + "base_neighbors": [ + 80, + 91 + ], + "district": null, + "id": 92, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1319, + "y": 485 + }, + { + "base_neighbors": [ + 97, + 99 + ], + "district": null, + "id": 93, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 386, + "y": 511 + }, + { + "base_neighbors": [ + 87, + 88, + 95, + 119, + 131 + ], + "district": null, + "id": 94, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1048, + "y": 505 + }, + { + "base_neighbors": [ + 88, + 90, + 94, + 121 + ], + "district": null, + "id": 95, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 1145, + "y": 514 + }, + { + "base_neighbors": [ + 97, + 101, + 105, + 107 + ], + "district": null, + "id": 96, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 257, + "y": 532 + }, + { + "base_neighbors": [ + 93, + 96, + 101 + ], + "district": null, + "id": 97, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 352, + "y": 530 + }, + { + "base_neighbors": [ + 82, + 86, + 103, + 104 + ], + "district": null, + "id": 98, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 134, + "y": 551 + }, + { + "base_neighbors": [ + 93, + 100, + 101 + ], + "district": null, + "id": 99, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 13, + "x": 430, + "y": 556 + }, + { + "base_neighbors": [ + 99, + 102, + 106 + ], + "district": null, + "id": 100, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 10, + "x": 475, + "y": 557 + }, + { + "base_neighbors": [ + 96, + 97, + 99, + 105, + 110 + ], + "district": null, + "id": 101, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 377, + "y": 584 + }, + { + "base_neighbors": [ + 100, + 106, + 108, + 112, + 115 + ], + "district": null, + "id": 102, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 652, + "y": 575 + }, + { + "base_neighbors": [ + 98, + 104, + 109, + 114, + 122 + ], + "district": null, + "id": 103, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 146, + "y": 599 + }, + { + "base_neighbors": [ + 98, + 103 + ], + "district": null, + "id": 104, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 223, + "y": 605 + }, + { + "base_neighbors": [ + 96, + 101, + 107, + 110 + ], + "district": null, + "id": 105, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 325, + "y": 605 + }, + { + "base_neighbors": [ + 100, + 102, + 115 + ], + "district": null, + "id": 106, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 579, + "y": 600 + }, + { + "base_neighbors": [ + 96, + 105, + 109, + 110 + ], + "district": null, + "id": 107, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 265, + "y": 620 + }, + { + "base_neighbors": [ + 77, + 87, + 102, + 112 + ], + "district": null, + "id": 108, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 682, + "y": 622 + }, + { + "base_neighbors": [ + 103, + 107 + ], + "district": null, + "id": 109, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 209, + "y": 647 + }, + { + "base_neighbors": [ + 101, + 105, + 107, + 117 + ], + "district": null, + "id": 110, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 349, + "y": 650 + }, + { + "base_neighbors": [], + "district": null, + "id": 111, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 487, + "y": 656 + }, + { + "base_neighbors": [ + 87, + 102, + 108, + 113, + 116 + ], + "district": null, + "id": 112, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 770, + "y": 637 + }, + { + "base_neighbors": [ + 87, + 89, + 112, + 116 + ], + "district": null, + "id": 113, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 836, + "y": 637 + }, + { + "base_neighbors": [ + 103, + 122 + ], + "district": null, + "id": 114, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 95, + "y": 685 + }, + { + "base_neighbors": [ + 102, + 106, + 123, + 124 + ], + "district": null, + "id": 115, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 551, + "y": 688 + }, + { + "base_neighbors": [ + 112, + 113, + 119 + ], + "district": null, + "id": 116, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 871, + "y": 692 + }, + { + "base_neighbors": [ + 110, + 118, + 126, + 132 + ], + "district": null, + "id": 117, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 409, + "y": 719 + }, + { + "base_neighbors": [ + 117, + 126 + ], + "district": null, + "id": 118, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 472, + "y": 722 + }, + { + "base_neighbors": [ + 94, + 116, + 120, + 131 + ], + "district": null, + "id": 119, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 925, + "y": 731 + }, + { + "base_neighbors": [ + 119, + 130, + 131, + 138, + 147 + ], + "district": null, + "id": 120, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 980, + "y": 724 + }, + { + "base_neighbors": [ + 90, + 95, + 125 + ], + "district": null, + "id": 121, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 13, + "x": 1169, + "y": 727 + }, + { + "base_neighbors": [ + 103, + 114 + ], + "district": null, + "id": 122, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 91, + "y": 745 + }, + { + "base_neighbors": [ + 115, + 124 + ], + "district": null, + "id": 123, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 574, + "y": 743 + }, + { + "base_neighbors": [ + 115, + 123 + ], + "district": null, + "id": 124, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 622, + "y": 740 + }, + { + "base_neighbors": [ + 121, + 131, + 139, + 144 + ], + "district": null, + "id": 125, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 1075, + "y": 740 + }, + { + "base_neighbors": [ + 117, + 118, + 132, + 133 + ], + "district": null, + "id": 126, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 445, + "y": 763 + }, + { + "base_neighbors": [ + 133, + 136 + ], + "district": null, + "id": 127, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 10, + "x": 542, + "y": 763 + }, + { + "base_neighbors": [ + 129, + 141, + 150 + ], + "district": null, + "id": 128, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 716, + "y": 769 + }, + { + "base_neighbors": [ + 128, + 130, + 137 + ], + "district": null, + "id": 129, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 773, + "y": 766 + }, + { + "base_neighbors": [ + 120, + 129, + 137 + ], + "district": null, + "id": 130, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 835, + "y": 767 + }, + { + "base_neighbors": [ + 94, + 119, + 120, + 125, + 138, + 139 + ], + "district": null, + "id": 131, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 986, + "y": 768 + }, + { + "base_neighbors": [ + 117, + 126, + 140, + 142, + 145 + ], + "district": null, + "id": 132, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 415, + "y": 799 + }, + { + "base_neighbors": [ + 126, + 127, + 136 + ], + "district": null, + "id": 133, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 488, + "y": 788 + }, + { + "base_neighbors": [ + 144, + 147 + ], + "district": null, + "id": 134, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1159, + "y": 787 + }, + { + "base_neighbors": [ + 142, + 149 + ], + "district": null, + "id": 135, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 267, + "y": 827 + }, + { + "base_neighbors": [ + 127, + 133, + 140, + 146 + ], + "district": null, + "id": 136, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 530, + "y": 812 + }, + { + "base_neighbors": [ + 129, + 130, + 143 + ], + "district": null, + "id": 137, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 839, + "y": 811 + }, + { + "base_neighbors": [ + 120, + 131, + 139, + 144, + 147 + ], + "district": null, + "id": 138, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 958, + "y": 806 + }, + { + "base_neighbors": [ + 125, + 131, + 138, + 144, + 147 + ], + "district": null, + "id": 139, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 1016, + "y": 806 + }, + { + "base_neighbors": [ + 132, + 136, + 145, + 146 + ], + "district": null, + "id": 140, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 467, + "y": 844 + }, + { + "base_neighbors": [ + 128, + 150, + 151 + ], + "district": null, + "id": 141, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 737, + "y": 845 + }, + { + "base_neighbors": [ + 132, + 135, + 145, + 149 + ], + "district": null, + "id": 142, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 391, + "y": 872 + }, + { + "base_neighbors": [ + 137, + 150 + ], + "district": null, + "id": 143, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 839, + "y": 854 + }, + { + "base_neighbors": [ + 125, + 134, + 138, + 139, + 147 + ], + "district": null, + "id": 144, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 1103, + "y": 857 + }, + { + "base_neighbors": [ + 132, + 140, + 142, + 149 + ], + "district": null, + "id": 145, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 448, + "y": 890 + }, + { + "base_neighbors": [ + 136, + 140, + 148, + 151 + ], + "district": null, + "id": 146, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 572, + "y": 899 + }, + { + "base_neighbors": [ + 120, + 134, + 138, + 139, + 144 + ], + "district": null, + "id": 147, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 10, + "x": 1003, + "y": 881 + }, + { + "base_neighbors": [ + 146, + 151 + ], + "district": null, + "id": 148, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 620, + "y": 919 + }, + { + "base_neighbors": [ + 135, + 142, + 145 + ], + "district": null, + "id": 149, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 11, + "x": 301, + "y": 932 + }, + { + "base_neighbors": [ + 128, + 141, + 143, + 151 + ], + "district": null, + "id": 150, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 9, + "x": 811, + "y": 934 + }, + { + "base_neighbors": [ + 141, + 146, + 148, + 150 + ], + "district": null, + "id": 151, + "layers_present": [ + "bus", + "normal", + "subway", + "taxi" + ], + "nearest_landmarks": [], + "radius": 12, + "x": 686, + "y": 958 + } + ], + "schema_version": 1 +} diff --git a/data/processed/map_atlas.json b/data/processed/map_atlas.json new file mode 100644 index 0000000000000000000000000000000000000000..8d3449f3dfcc4efe11d60c6bfbb14a33e05980a6 --- /dev/null +++ b/data/processed/map_atlas.json @@ -0,0 +1,1438 @@ +{ + "schema_version": 2, + "map_id": "london_main", + "source_image": "data/raw/maps/images/normal.png", + "districts": [ + { + "id": "west_garden", + "name": "West Garden", + "category": "district", + "junction_ids": [ + 9, + 10, + 11, + 12, + 17, + 23, + 30, + 39, + 40, + 49, + 64, + 65, + 70, + 74 + ], + "anchor": { + "x": 210, + "y": 80 + } + }, + { + "id": "university_hill", + "name": "University Hill", + "category": "district", + "junction_ids": [ + 1, + 5, + 6, + 7, + 8, + 13, + 18, + 24, + 25, + 26, + 34, + 35, + 41, + 42, + 55 + ], + "anchor": { + "x": 610, + "y": 65 + } + }, + { + "id": "observatory_heights", + "name": "Observatory Heights", + "category": "district", + "junction_ids": [ + 2, + 3, + 4, + 14, + 15, + 16, + 19, + 20, + 21, + 27, + 28, + 29, + 32, + 36, + 37, + 44, + 45, + 46, + 52, + 57 + ], + "anchor": { + "x": 850, + "y": 135 + } + }, + { + "id": "hill_district", + "name": "Hill District", + "category": "district", + "junction_ids": [ + 22, + 31, + 33, + 38, + 43, + 47, + 48, + 53, + 54, + 59, + 60, + 61, + 62, + 63, + 73, + 80, + 81, + 85, + 92 + ], + "anchor": { + "x": 1185, + "y": 80 + } + }, + { + "id": "old_quarter", + "name": "Old Quarter", + "category": "district", + "junction_ids": [ + 18, + 25, + 26, + 27, + 28, + 34, + 35, + 36, + 40, + 41, + 42, + 43, + 49, + 50, + 51, + 52, + 55, + 56, + 66, + 67, + 68, + 69, + 71, + 72, + 75, + 76, + 77, + 78, + 82, + 83 + ], + "anchor": { + "x": 675, + "y": 210 + } + }, + { + "id": "lantern_row_district", + "name": "Lantern Row", + "category": "district", + "junction_ids": [ + 32, + 37, + 38, + 44, + 45, + 46, + 53, + 54, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 68, + 69, + 72, + 73, + 79, + 80, + 81, + 85, + 88, + 92, + 94, + 95 + ], + "anchor": { + "x": 1055, + "y": 245 + } + }, + { + "id": "civic_center", + "name": "Civic Center", + "category": "district", + "junction_ids": [ + 66, + 67, + 68, + 69, + 71, + 72, + 75, + 76, + 77, + 78, + 82, + 83, + 87, + 88, + 89, + 93, + 94, + 95 + ], + "anchor": { + "x": 660, + "y": 395 + } + }, + { + "id": "riverside", + "name": "Riverside", + "category": "district", + "junction_ids": [ + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 109, + 114, + 115, + 116, + 122 + ], + "anchor": { + "x": 320, + "y": 590 + } + }, + { + "id": "southbank", + "name": "Southbank", + "category": "district", + "junction_ids": [ + 108, + 110, + 111, + 112, + 113, + 117, + 118, + 119, + 120, + 123, + 124, + 125, + 126, + 127, + 128, + 129, + 130, + 131, + 132, + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151 + ], + "anchor": { + "x": 710, + "y": 720 + } + }, + { + "id": "ironworks", + "name": "Ironworks", + "category": "district", + "junction_ids": [ + 109, + 114, + 115, + 116, + 121, + 122, + 127, + 132, + 135, + 140, + 142, + 145, + 149 + ], + "anchor": { + "x": 335, + "y": 795 + } + }, + { + "id": "harbor_docks", + "name": "Harbor Docks", + "category": "district", + "junction_ids": [ + 120, + 121, + 125, + 126, + 131, + 133, + 134, + 138, + 139, + 143, + 144, + 147 + ], + "anchor": { + "x": 1135, + "y": 810 + } + } + ], + "landmarks": [ + { + "id": "fosglove_park", + "name": "Fosglove Park", + "category": "park", + "junction_id": 9, + "nearby_junction_ids": [ + 10 + ], + "district_id": "west_garden", + "aliases": [], + "story_tags": [ + "park", + "garden", + "quiet" + ], + "anchor": { + "x": 116, + "y": 128 + } + }, + { + "id": "rosewood_lane", + "name": "Rosewood Lane", + "category": "street", + "junction_id": 12, + "nearby_junction_ids": [ + 5, + 17 + ], + "district_id": "west_garden", + "aliases": [], + "story_tags": [ + "residential", + "lane" + ], + "anchor": { + "x": 315, + "y": 124 + } + }, + { + "id": "garden_pavilion", + "name": "Garden Pavilion", + "category": "pavilion", + "junction_id": 39, + "nearby_junction_ids": [ + 9 + ], + "district_id": "west_garden", + "aliases": [], + "story_tags": [ + "garden", + "public" + ], + "anchor": { + "x": 116, + "y": 235 + } + }, + { + "id": "market_square", + "name": "Market Square", + "category": "market", + "junction_id": 40, + "nearby_junction_ids": [ + 34, + 49 + ], + "district_id": "west_garden", + "aliases": [], + "story_tags": [ + "market", + "busy", + "public" + ], + "anchor": { + "x": 280, + "y": 237 + } + }, + { + "id": "lantern_market", + "name": "Lantern Market", + "category": "market", + "junction_id": 41, + "nearby_junction_ids": [ + 42, + 55 + ], + "district_id": "university_hill", + "aliases": [], + "story_tags": [ + "market", + "shops", + "busy" + ], + "anchor": { + "x": 360, + "y": 257 + } + }, + { + "id": "merchants_row", + "name": "Merchants Row", + "category": "street", + "junction_id": 65, + "nearby_junction_ids": [ + 49, + 40 + ], + "district_id": "west_garden", + "aliases": [], + "story_tags": [ + "shops", + "street" + ], + "anchor": { + "x": 293, + "y": 325 + } + }, + { + "id": "ravenwood", + "name": "Ravenwood", + "category": "woodland", + "junction_id": 64, + "nearby_junction_ids": [ + 70 + ], + "district_id": "west_garden", + "aliases": [], + "story_tags": [ + "woodland", + "quiet" + ], + "anchor": { + "x": 70, + "y": 354 + } + }, + { + "id": "sundial_square", + "name": "Sundial Square", + "category": "square", + "junction_id": 74, + "nearby_junction_ids": [ + 70, + 65 + ], + "district_id": "west_garden", + "aliases": [], + "story_tags": [ + "square", + "public" + ], + "anchor": { + "x": 218, + "y": 384 + } + }, + { + "id": "spice_hall", + "name": "Spice Hall", + "category": "hall", + "junction_id": 71, + "nearby_junction_ids": [ + 55, + 75 + ], + "district_id": "old_quarter", + "aliases": [], + "story_tags": [ + "market", + "indoor" + ], + "anchor": { + "x": 369, + "y": 365 + } + }, + { + "id": "cathedral_square", + "name": "Cathedral Square", + "category": "square", + "junction_id": 76, + "nearby_junction_ids": [ + 77, + 66 + ], + "district_id": "old_quarter", + "aliases": [], + "story_tags": [ + "religious", + "square", + "busy" + ], + "anchor": { + "x": 511, + "y": 385 + } + }, + { + "id": "bishops_walk", + "name": "Bishop's Walk", + "category": "street", + "junction_id": 35, + "nearby_junction_ids": [ + 25, + 18 + ], + "district_id": "old_quarter", + "aliases": [], + "story_tags": [ + "street", + "historic" + ], + "anchor": { + "x": 516, + "y": 220 + } + }, + { + "id": "scholars_gate", + "name": "Scholar's Gate", + "category": "gate", + "junction_id": 1, + "nearby_junction_ids": [ + 6, + 5 + ], + "district_id": "university_hill", + "aliases": [], + "story_tags": [ + "university", + "gate" + ], + "anchor": { + "x": 438, + "y": 51 + } + }, + { + "id": "iverley_quad", + "name": "Iverley Quad", + "category": "university", + "junction_id": 7, + "nearby_junction_ids": [ + 8, + 18 + ], + "district_id": "university_hill", + "aliases": [], + "story_tags": [ + "university", + "public" + ], + "anchor": { + "x": 553, + "y": 96 + } + }, + { + "id": "stonebridge", + "name": "Stonebridge", + "category": "bridge", + "junction_id": 14, + "nearby_junction_ids": [ + 27, + 26 + ], + "district_id": "observatory_heights", + "aliases": [], + "story_tags": [ + "bridge", + "street" + ], + "anchor": { + "x": 742, + "y": 137 + } + }, + { + "id": "starview_observatory", + "name": "Starview Observatory", + "category": "observatory", + "junction_id": 15, + "nearby_junction_ids": [ + 2, + 28 + ], + "district_id": "observatory_heights", + "aliases": [], + "story_tags": [ + "observatory", + "tourist" + ], + "anchor": { + "x": 862, + "y": 101 + } + }, + { + "id": "hillhide_park", + "name": "Hillhide Park", + "category": "park", + "junction_id": 20, + "nearby_junction_ids": [ + 3, + 16 + ], + "district_id": "hill_district", + "aliases": [], + "story_tags": [ + "park", + "quiet" + ], + "anchor": { + "x": 994, + "y": 126 + } + }, + { + "id": "lookout_point", + "name": "Lookout Point", + "category": "lookout", + "junction_id": 3, + "nearby_junction_ids": [ + 4, + 16 + ], + "district_id": "hill_district", + "aliases": [], + "story_tags": [ + "lookout", + "public" + ], + "anchor": { + "x": 1058, + "y": 54 + } + }, + { + "id": "granite_terrace", + "name": "Granite Terrace", + "category": "street", + "junction_id": 21, + "nearby_junction_ids": [ + 16, + 32 + ], + "district_id": "hill_district", + "aliases": [], + "story_tags": [ + "residential", + "street" + ], + "anchor": { + "x": 1130, + "y": 137 + } + }, + { + "id": "north_bastion", + "name": "North Bastion", + "category": "fortification", + "junction_id": 22, + "nearby_junction_ids": [ + 33, + 47 + ], + "district_id": "hill_district", + "aliases": [], + "story_tags": [ + "fortification", + "public" + ], + "anchor": { + "x": 1252, + "y": 151 + } + }, + { + "id": "fort_eastview", + "name": "Fort Eastview", + "category": "fortification", + "junction_id": 31, + "nearby_junction_ids": [ + 33, + 48 + ], + "district_id": "hill_district", + "aliases": [], + "story_tags": [ + "fortification", + "harbor" + ], + "anchor": { + "x": 1396, + "y": 174 + } + }, + { + "id": "eastgate", + "name": "Eastgate", + "category": "gate", + "junction_id": 33, + "nearby_junction_ids": [ + 48, + 31 + ], + "district_id": "hill_district", + "aliases": [], + "story_tags": [ + "gate", + "busy" + ], + "anchor": { + "x": 1348, + "y": 229 + } + }, + { + "id": "lantern_row", + "name": "Lantern Row", + "category": "street", + "junction_id": 46, + "nearby_junction_ids": [ + 32, + 38 + ], + "district_id": "lantern_row_district", + "aliases": [], + "story_tags": [ + "street", + "shops" + ], + "anchor": { + "x": 1135, + "y": 246 + } + }, + { + "id": "gilded_alley", + "name": "Gilded Alley", + "category": "alley", + "junction_id": 57, + "nearby_junction_ids": [ + 58, + 45 + ], + "district_id": "lantern_row_district", + "aliases": [], + "story_tags": [ + "alley", + "shops" + ], + "anchor": { + "x": 1054, + "y": 296 + } + }, + { + "id": "the_forum", + "name": "The Forum", + "category": "forum", + "junction_id": 61, + "nearby_junction_ids": [ + 60, + 59 + ], + "district_id": "lantern_row_district", + "aliases": [], + "story_tags": [ + "public", + "busy" + ], + "anchor": { + "x": 1190, + "y": 307 + } + }, + { + "id": "harbor_road", + "name": "Harbor Road", + "category": "street", + "junction_id": 48, + "nearby_junction_ids": [ + 63, + 73 + ], + "district_id": "hill_district", + "aliases": [], + "story_tags": [ + "harbor", + "street" + ], + "anchor": { + "x": 1360, + "y": 306 + } + }, + { + "id": "eastgate_plaza", + "name": "Eastgate Plaza", + "category": "square", + "junction_id": 73, + "nearby_junction_ids": [ + 80, + 62 + ], + "district_id": "lantern_row_district", + "aliases": [], + "story_tags": [ + "square", + "busy" + ], + "anchor": { + "x": 1304, + "y": 387 + } + }, + { + "id": "eastgate_market", + "name": "Eastgate Market", + "category": "market", + "junction_id": 92, + "nearby_junction_ids": [ + 85, + 81 + ], + "district_id": "lantern_row_district", + "aliases": [], + "story_tags": [ + "market", + "busy" + ], + "anchor": { + "x": 1354, + "y": 459 + } + }, + { + "id": "harbor_vista", + "name": "Harbor Vista", + "category": "lookout", + "junction_id": 92, + "nearby_junction_ids": [ + 91, + 85 + ], + "district_id": "lantern_row_district", + "aliases": [], + "story_tags": [ + "harbor", + "lookout" + ], + "anchor": { + "x": 1343, + "y": 549 + } + }, + { + "id": "crown_court", + "name": "Crown Court", + "category": "court", + "junction_id": 44, + "nearby_junction_ids": [ + 37, + 29 + ], + "district_id": "old_quarter", + "aliases": [], + "story_tags": [ + "official", + "public" + ], + "anchor": { + "x": 887, + "y": 232 + } + }, + { + "id": "kings_steps", + "name": "King's Steps", + "category": "steps", + "junction_id": 36, + "nearby_junction_ids": [ + 52, + 28 + ], + "district_id": "old_quarter", + "aliases": [], + "story_tags": [ + "street", + "public" + ], + "anchor": { + "x": 788, + "y": 244 + } + }, + { + "id": "clock_tower", + "name": "Clock Tower", + "category": "tower", + "junction_id": 67, + "nearby_junction_ids": [ + 52, + 51 + ], + "district_id": "old_quarter", + "aliases": [], + "story_tags": [ + "clock", + "public" + ], + "anchor": { + "x": 792, + "y": 337 + } + }, + { + "id": "heritage_lane", + "name": "Heritage Lane", + "category": "street", + "junction_id": 68, + "nearby_junction_ids": [ + 69, + 72 + ], + "district_id": "old_quarter", + "aliases": [], + "story_tags": [ + "street", + "historic" + ], + "anchor": { + "x": 875, + "y": 334 + } + }, + { + "id": "city_hall", + "name": "City Hall", + "category": "civic", + "junction_id": 83, + "nearby_junction_ids": [ + 67, + 87 + ], + "district_id": "civic_center", + "aliases": [], + "story_tags": [ + "official", + "busy", + "public" + ], + "anchor": { + "x": 774, + "y": 403 + } + }, + { + "id": "sentinel_square", + "name": "Sentinel Square", + "category": "square", + "junction_id": 89, + "nearby_junction_ids": [ + 83, + 72 + ], + "district_id": "civic_center", + "aliases": [], + "story_tags": [ + "square", + "public" + ], + "anchor": { + "x": 836, + "y": 460 + } + }, + { + "id": "opera_house", + "name": "Opera House", + "category": "theatre", + "junction_id": 89, + "nearby_junction_ids": [ + 94, + 72 + ], + "district_id": "civic_center", + "aliases": [], + "story_tags": [ + "theatre", + "evening", + "public" + ], + "anchor": { + "x": 941, + "y": 503 + } + }, + { + "id": "museum_row", + "name": "Museum Row", + "category": "street", + "junction_id": 94, + "nearby_junction_ids": [ + 88, + 95 + ], + "district_id": "civic_center", + "aliases": [], + "story_tags": [ + "museum", + "street" + ], + "anchor": { + "x": 1055, + "y": 476 + } + }, + { + "id": "grand_museum", + "name": "Grand Museum", + "category": "museum", + "junction_id": 88, + "nearby_junction_ids": [ + 79, + 94 + ], + "district_id": "lantern_row_district", + "aliases": [], + "story_tags": [ + "museum", + "tourist", + "public" + ], + "anchor": { + "x": 1093, + "y": 418 + } + }, + { + "id": "riverswatch", + "name": "Riverswatch", + "category": "waterfront", + "junction_id": 96, + "nearby_junction_ids": [ + 74, + 104 + ], + "district_id": "riverside", + "aliases": [], + "story_tags": [ + "river", + "public" + ], + "anchor": { + "x": 239, + "y": 505 + } + }, + { + "id": "riverside_walk", + "name": "Riverside Walk", + "category": "promenade", + "junction_id": 103, + "nearby_junction_ids": [ + 114, + 98 + ], + "district_id": "riverside", + "aliases": [], + "story_tags": [ + "river", + "promenade" + ], + "anchor": { + "x": 96, + "y": 621 + } + }, + { + "id": "willow_bend", + "name": "Willow Bend", + "category": "street", + "junction_id": 109, + "nearby_junction_ids": [ + 107, + 104 + ], + "district_id": "riverside", + "aliases": [], + "story_tags": [ + "river", + "street" + ], + "anchor": { + "x": 237, + "y": 665 + } + }, + { + "id": "ironworks_gate", + "name": "Ironworks Gate", + "category": "gate", + "junction_id": 122, + "nearby_junction_ids": [ + 114, + 109 + ], + "district_id": "ironworks", + "aliases": [], + "story_tags": [ + "industrial", + "gate" + ], + "anchor": { + "x": 102, + "y": 743 + } + }, + { + "id": "smelters_yard", + "name": "Smelter's Yard", + "category": "industrial", + "junction_id": 135, + "nearby_junction_ids": [ + 149, + 142 + ], + "district_id": "ironworks", + "aliases": [], + "story_tags": [ + "industrial", + "yard" + ], + "anchor": { + "x": 267, + "y": 842 + } + }, + { + "id": "forge_square", + "name": "Forge Square", + "category": "square", + "junction_id": 140, + "nearby_junction_ids": [ + 145, + 132 + ], + "district_id": "ironworks", + "aliases": [], + "story_tags": [ + "industrial", + "square" + ], + "anchor": { + "x": 457, + "y": 842 + } + }, + { + "id": "vanguard_point", + "name": "Vanguard Point", + "category": "lookout", + "junction_id": 145, + "nearby_junction_ids": [ + 146, + 140 + ], + "district_id": "southbank", + "aliases": [], + "story_tags": [ + "lookout", + "public" + ], + "anchor": { + "x": 503, + "y": 938 + } + }, + { + "id": "victory_point", + "name": "Victory Point", + "category": "lookout", + "junction_id": 151, + "nearby_junction_ids": [ + 148, + 150 + ], + "district_id": "southbank", + "aliases": [], + "story_tags": [ + "lookout", + "waterfront" + ], + "anchor": { + "x": 700, + "y": 1003 + } + }, + { + "id": "southbank_plaza", + "name": "Southbank Plaza", + "category": "square", + "junction_id": 124, + "nearby_junction_ids": [ + 123, + 128 + ], + "district_id": "southbank", + "aliases": [], + "story_tags": [ + "square", + "busy" + ], + "anchor": { + "x": 631, + "y": 756 + } + }, + { + "id": "millstream", + "name": "Millstream", + "category": "waterfront", + "junction_id": 128, + "nearby_junction_ids": [ + 129, + 141 + ], + "district_id": "southbank", + "aliases": [], + "story_tags": [ + "river", + "street" + ], + "anchor": { + "x": 739, + "y": 776 + } + }, + { + "id": "dockmasters_way", + "name": "Dockmaster's Way", + "category": "street", + "junction_id": 130, + "nearby_junction_ids": [ + 137, + 129 + ], + "district_id": "southbank", + "aliases": [], + "story_tags": [ + "docks", + "street" + ], + "anchor": { + "x": 830, + "y": 766 + } + }, + { + "id": "dockside", + "name": "Dockside", + "category": "waterfront", + "junction_id": 125, + "nearby_junction_ids": [ + 131, + 120 + ], + "district_id": "harbor_docks", + "aliases": [], + "story_tags": [ + "docks", + "busy" + ], + "anchor": { + "x": 1029, + "y": 741 + } + }, + { + "id": "harbor_gate", + "name": "Harbor Gate", + "category": "gate", + "junction_id": 121, + "nearby_junction_ids": [ + 134, + 125 + ], + "district_id": "harbor_docks", + "aliases": [], + "story_tags": [ + "harbor", + "gate" + ], + "anchor": { + "x": 1255, + "y": 758 + } + }, + { + "id": "iron_pier", + "name": "Iron Pier", + "category": "pier", + "junction_id": 147, + "nearby_junction_ids": [ + 144, + 139 + ], + "district_id": "harbor_docks", + "aliases": [], + "story_tags": [ + "pier", + "harbor" + ], + "anchor": { + "x": 1062, + "y": 947 + } + }, + { + "id": "breakwater_point", + "name": "Breakwater Point", + "category": "lighthouse", + "junction_id": 134, + "nearby_junction_ids": [ + 144, + 121 + ], + "district_id": "harbor_docks", + "aliases": [], + "story_tags": [ + "lighthouse", + "harbor" + ], + "anchor": { + "x": 1390, + "y": 1014 + } + }, + { + "id": "crown_stadium", + "name": "Crown Stadium", + "category": "stadium", + "junction_id": 148, + "nearby_junction_ids": [ + 146, + 151 + ], + "district_id": "southbank", + "aliases": [], + "story_tags": [ + "stadium", + "crowd" + ], + "anchor": { + "x": 638, + "y": 900 + } + }, + { + "id": "police_headquarters", + "name": "Police Headquarters", + "category": "police", + "junction_id": 112, + "nearby_junction_ids": [ + 108, + 113 + ], + "district_id": "southbank", + "aliases": [], + "story_tags": [ + "police", + "official" + ], + "anchor": { + "x": 761, + "y": 589 + } + } + ], + "notes": [ + "Canonical names are manually transcribed from the printed map.", + "Runtime-generated subvenues are stored in each game state, not in this file." + ] +} diff --git a/data/processed/map_metadata.json b/data/processed/map_metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..dbc389c56afe13e6043c0bd009d6d85e4780c846 --- /dev/null +++ b/data/processed/map_metadata.json @@ -0,0 +1,38 @@ +{ + "generated_at": "2026-06-13T13:02:35.720307+00:00", + "images": { + "bus": "data\\raw\\maps\\bus_cv_out\\graph_on_map.png", + "normal": "data\\raw\\maps\\normal_cv_out\\junctions_labelled.png", + "subway": "data\\raw\\maps\\subway_cv_out\\graph_on_map.png", + "taxi": "data\\raw\\maps\\taxi_cv_out\\graph_on_map.png" + }, + "layers": { + "bus": { + "edge_count": 150, + "folder": "data\\raw\\maps\\bus_cv_out", + "node_count": 151 + }, + "normal": { + "edge_count": 255, + "folder": "data\\raw\\maps\\normal_cv_out", + "node_count": 151 + }, + "subway": { + "edge_count": 17, + "folder": "data\\raw\\maps\\subway_cv_out", + "node_count": 151 + }, + "taxi": { + "edge_count": 194, + "folder": "data\\raw\\maps\\taxi_cv_out", + "node_count": 151 + } + }, + "schema_version": 1, + "source_images": { + "bus": "data\\raw\\maps\\images\\bus.png", + "normal": "data\\raw\\maps\\images\\normal.png", + "subway": "data\\raw\\maps\\images\\subway.png", + "taxi": "data\\raw\\maps\\images\\taxi.png" + } +} diff --git a/data/raw/maps/bus_cv_out/graph.graphml b/data/raw/maps/bus_cv_out/graph.graphml new file mode 100644 index 0000000000000000000000000000000000000000..412b4c3e2479ffb758254659246d974dcf061f4f --- /dev/null +++ b/data/raw/maps/bus_cv_out/graph.graphml @@ -0,0 +1,910 @@ + + + + + + 424 + 79 + 12 + + + 949 + 69 + 11 + + + 1028 + 78 + 12 + + + 1059 + 103 + 13 + + + 353 + 119 + 12 + + + 434 + 128 + 11 + + + 562 + 123 + 12 + + + 628 + 114 + 12 + + + 99 + 153 + 12 + + + 143 + 146 + 11 + + + 233 + 151 + 11 + + + 289 + 142 + 11 + + + 392 + 153 + 11 + + + 719 + 152 + 12 + + + 925 + 133 + 11 + + + 1054 + 148 + 12 + + + 317 + 172 + 11 + + + 573 + 173 + 11 + + + 624 + 158 + 11 + + + 976 + 163 + 11 + + + 1114 + 162 + 11 + + + 1243 + 179 + 12 + + + 211 + 202 + 11 + + + 424 + 184 + 11 + + + 543 + 198 + 11 + + + 696 + 187 + 11 + + + 738 + 202 + 11 + + + 817 + 191 + 11 + + + 927 + 188 + 11 + + + 1044 + 202 + 12 + + + 1396 + 193 + 11 + + + 1125 + 214 + 12 + + + 1319 + 225 + 11 + + + 242 + 237 + 9 + + + 498 + 238 + 11 + + + 796 + 246 + 12 + + + 946 + 228 + 11 + + + 1188 + 249 + 11 + + + 139 + 263 + 9 + + + 282 + 259 + 11 + + + 368 + 266 + 11 + + + 419 + 259 + 11 + + + 682 + 263 + 11 + + + 851 + 257 + 10 + + + 984 + 259 + 10 + + + 1133 + 264 + 11 + + + 1243 + 265 + 11 + + + 1367 + 265 + 12 + + + 247 + 279 + 11 + + + 621 + 288 + 11 + + + 748 + 292 + 11 + + + 819 + 282 + 11 + + + 973 + 278 + 11 + + + 203 + 303 + 11 + + + 397 + 311 + 12 + + + 974 + 321 + 11 + + + 1056 + 318 + 11 + + + 1102 + 323 + 11 + + + 1244 + 318 + 9 + + + 1156 + 340 + 10 + + + 1188 + 338 + 11 + + + 1264 + 347 + 11 + + + 1322 + 333 + 10 + + + 58 + 365 + 12 + + + 281 + 354 + 11 + + + 565 + 353 + 11 + + + 768 + 352 + 11 + + + 871 + 350 + 10 + + + 931 + 348 + 11 + + + 157 + 388 + 11 + + + 384 + 376 + 11 + + + 846 + 388 + 11 + + + 1299 + 373 + 11 + + + 222 + 408 + 11 + + + 296 + 399 + 9 + + + 493 + 402 + 11 + + + 549 + 416 + 10 + + + 588 + 398 + 9 + + + 1170 + 408 + 11 + + + 1303 + 416 + 10 + + + 1378 + 400 + 12 + + + 143 + 427 + 9 + + + 777 + 428 + 11 + + + 1211 + 439 + 11 + + + 1397 + 437 + 11 + + + 98 + 449 + 12 + + + 757 + 463 + 13 + + + 1132 + 458 + 11 + + + 851 + 472 + 9 + + + 1221 + 488 + 11 + + + 1277 + 489 + 9 + + + 1319 + 485 + 11 + + + 386 + 511 + 9 + + + 1048 + 505 + 11 + + + 1145 + 514 + 12 + + + 257 + 532 + 12 + + + 352 + 530 + 9 + + + 134 + 551 + 12 + + + 430 + 556 + 13 + + + 475 + 557 + 10 + + + 377 + 584 + 11 + + + 652 + 575 + 11 + + + 146 + 599 + 11 + + + 223 + 605 + 11 + + + 325 + 605 + 9 + + + 579 + 600 + 9 + + + 265 + 620 + 9 + + + 682 + 622 + 11 + + + 209 + 647 + 9 + + + 349 + 650 + 9 + + + 487 + 656 + 12 + + + 770 + 637 + 9 + + + 836 + 637 + 11 + + + 95 + 685 + 9 + + + 551 + 688 + 9 + + + 871 + 692 + 9 + + + 409 + 719 + 11 + + + 472 + 722 + 9 + + + 925 + 731 + 11 + + + 980 + 724 + 11 + + + 1169 + 727 + 13 + + + 91 + 745 + 9 + + + 574 + 743 + 9 + + + 622 + 740 + 9 + + + 1075 + 740 + 9 + + + 445 + 763 + 11 + + + 542 + 763 + 10 + + + 716 + 769 + 9 + + + 773 + 766 + 9 + + + 835 + 767 + 9 + + + 986 + 768 + 11 + + + 415 + 799 + 11 + + + 488 + 788 + 9 + + + 1159 + 787 + 11 + + + 267 + 827 + 11 + + + 530 + 812 + 9 + + + 839 + 811 + 11 + + + 958 + 806 + 9 + + + 1016 + 806 + 11 + + + 467 + 844 + 11 + + + 737 + 845 + 9 + + + 391 + 872 + 11 + + + 839 + 854 + 9 + + + 1103 + 857 + 9 + + + 448 + 890 + 11 + + + 572 + 899 + 12 + + + 1003 + 881 + 10 + + + 620 + 919 + 9 + + + 301 + 932 + 11 + + + 811 + 934 + 9 + + + 686 + 958 + 12 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/data/raw/maps/bus_cv_out/graph.json b/data/raw/maps/bus_cv_out/graph.json new file mode 100644 index 0000000000000000000000000000000000000000..988b1e6085cf31ada87eca3af8d6fa5b6b094b1b --- /dev/null +++ b/data/raw/maps/bus_cv_out/graph.json @@ -0,0 +1,2077 @@ +{ + "nodes": [ + { + "id": 1, + "x": 424, + "y": 79, + "r": 12 + }, + { + "id": 2, + "x": 949, + "y": 69, + "r": 11 + }, + { + "id": 3, + "x": 1028, + "y": 78, + "r": 12 + }, + { + "id": 4, + "x": 1059, + "y": 103, + "r": 13 + }, + { + "id": 5, + "x": 353, + "y": 119, + "r": 12 + }, + { + "id": 6, + "x": 434, + "y": 128, + "r": 11 + }, + { + "id": 7, + "x": 562, + "y": 123, + "r": 12 + }, + { + "id": 8, + "x": 628, + "y": 114, + "r": 12 + }, + { + "id": 9, + "x": 99, + "y": 153, + "r": 12 + }, + { + "id": 10, + "x": 143, + "y": 146, + "r": 11 + }, + { + "id": 11, + "x": 233, + "y": 151, + "r": 11 + }, + { + "id": 12, + "x": 289, + "y": 142, + "r": 11 + }, + { + "id": 13, + "x": 392, + "y": 153, + "r": 11 + }, + { + "id": 14, + "x": 719, + "y": 152, + "r": 12 + }, + { + "id": 15, + "x": 925, + "y": 133, + "r": 11 + }, + { + "id": 16, + "x": 1054, + "y": 148, + "r": 12 + }, + { + "id": 17, + "x": 317, + "y": 172, + "r": 11 + }, + { + "id": 18, + "x": 573, + "y": 173, + "r": 11 + }, + { + "id": 19, + "x": 624, + "y": 158, + "r": 11 + }, + { + "id": 20, + "x": 976, + "y": 163, + "r": 11 + }, + { + "id": 21, + "x": 1114, + "y": 162, + "r": 11 + }, + { + "id": 22, + "x": 1243, + "y": 179, + "r": 12 + }, + { + "id": 23, + "x": 211, + "y": 202, + "r": 11 + }, + { + "id": 24, + "x": 424, + "y": 184, + "r": 11 + }, + { + "id": 25, + "x": 543, + "y": 198, + "r": 11 + }, + { + "id": 26, + "x": 696, + "y": 187, + "r": 11 + }, + { + "id": 27, + "x": 738, + "y": 202, + "r": 11 + }, + { + "id": 28, + "x": 817, + "y": 191, + "r": 11 + }, + { + "id": 29, + "x": 927, + "y": 188, + "r": 11 + }, + { + "id": 30, + "x": 1044, + "y": 202, + "r": 12 + }, + { + "id": 31, + "x": 1396, + "y": 193, + "r": 11 + }, + { + "id": 32, + "x": 1125, + "y": 214, + "r": 12 + }, + { + "id": 33, + "x": 1319, + "y": 225, + "r": 11 + }, + { + "id": 34, + "x": 242, + "y": 237, + "r": 9 + }, + { + "id": 35, + "x": 498, + "y": 238, + "r": 11 + }, + { + "id": 36, + "x": 796, + "y": 246, + "r": 12 + }, + { + "id": 37, + "x": 946, + "y": 228, + "r": 11 + }, + { + "id": 38, + "x": 1188, + "y": 249, + "r": 11 + }, + { + "id": 39, + "x": 139, + "y": 263, + "r": 9 + }, + { + "id": 40, + "x": 282, + "y": 259, + "r": 11 + }, + { + "id": 41, + "x": 368, + "y": 266, + "r": 11 + }, + { + "id": 42, + "x": 419, + "y": 259, + "r": 11 + }, + { + "id": 43, + "x": 682, + "y": 263, + "r": 11 + }, + { + "id": 44, + "x": 851, + "y": 257, + "r": 10 + }, + { + "id": 45, + "x": 984, + "y": 259, + "r": 10 + }, + { + "id": 46, + "x": 1133, + "y": 264, + "r": 11 + }, + { + "id": 47, + "x": 1243, + "y": 265, + "r": 11 + }, + { + "id": 48, + "x": 1367, + "y": 265, + "r": 12 + }, + { + "id": 49, + "x": 247, + "y": 279, + "r": 11 + }, + { + "id": 50, + "x": 621, + "y": 288, + "r": 11 + }, + { + "id": 51, + "x": 748, + "y": 292, + "r": 11 + }, + { + "id": 52, + "x": 819, + "y": 282, + "r": 11 + }, + { + "id": 53, + "x": 973, + "y": 278, + "r": 11 + }, + { + "id": 54, + "x": 203, + "y": 303, + "r": 11 + }, + { + "id": 55, + "x": 397, + "y": 311, + "r": 12 + }, + { + "id": 56, + "x": 974, + "y": 321, + "r": 11 + }, + { + "id": 57, + "x": 1056, + "y": 318, + "r": 11 + }, + { + "id": 58, + "x": 1102, + "y": 323, + "r": 11 + }, + { + "id": 59, + "x": 1244, + "y": 318, + "r": 9 + }, + { + "id": 60, + "x": 1156, + "y": 340, + "r": 10 + }, + { + "id": 61, + "x": 1188, + "y": 338, + "r": 11 + }, + { + "id": 62, + "x": 1264, + "y": 347, + "r": 11 + }, + { + "id": 63, + "x": 1322, + "y": 333, + "r": 10 + }, + { + "id": 64, + "x": 58, + "y": 365, + "r": 12 + }, + { + "id": 65, + "x": 281, + "y": 354, + "r": 11 + }, + { + "id": 66, + "x": 565, + "y": 353, + "r": 11 + }, + { + "id": 67, + "x": 768, + "y": 352, + "r": 11 + }, + { + "id": 68, + "x": 871, + "y": 350, + "r": 10 + }, + { + "id": 69, + "x": 931, + "y": 348, + "r": 11 + }, + { + "id": 70, + "x": 157, + "y": 388, + "r": 11 + }, + { + "id": 71, + "x": 384, + "y": 376, + "r": 11 + }, + { + "id": 72, + "x": 846, + "y": 388, + "r": 11 + }, + { + "id": 73, + "x": 1299, + "y": 373, + "r": 11 + }, + { + "id": 74, + "x": 222, + "y": 408, + "r": 11 + }, + { + "id": 75, + "x": 296, + "y": 399, + "r": 9 + }, + { + "id": 76, + "x": 493, + "y": 402, + "r": 11 + }, + { + "id": 77, + "x": 549, + "y": 416, + "r": 10 + }, + { + "id": 78, + "x": 588, + "y": 398, + "r": 9 + }, + { + "id": 79, + "x": 1170, + "y": 408, + "r": 11 + }, + { + "id": 80, + "x": 1303, + "y": 416, + "r": 10 + }, + { + "id": 81, + "x": 1378, + "y": 400, + "r": 12 + }, + { + "id": 82, + "x": 143, + "y": 427, + "r": 9 + }, + { + "id": 83, + "x": 777, + "y": 428, + "r": 11 + }, + { + "id": 84, + "x": 1211, + "y": 439, + "r": 11 + }, + { + "id": 85, + "x": 1397, + "y": 437, + "r": 11 + }, + { + "id": 86, + "x": 98, + "y": 449, + "r": 12 + }, + { + "id": 87, + "x": 757, + "y": 463, + "r": 13 + }, + { + "id": 88, + "x": 1132, + "y": 458, + "r": 11 + }, + { + "id": 89, + "x": 851, + "y": 472, + "r": 9 + }, + { + "id": 90, + "x": 1221, + "y": 488, + "r": 11 + }, + { + "id": 91, + "x": 1277, + "y": 489, + "r": 9 + }, + { + "id": 92, + "x": 1319, + "y": 485, + "r": 11 + }, + { + "id": 93, + "x": 386, + "y": 511, + "r": 9 + }, + { + "id": 94, + "x": 1048, + "y": 505, + "r": 11 + }, + { + "id": 95, + "x": 1145, + "y": 514, + "r": 12 + }, + { + "id": 96, + "x": 257, + "y": 532, + "r": 12 + }, + { + "id": 97, + "x": 352, + "y": 530, + "r": 9 + }, + { + "id": 98, + "x": 134, + "y": 551, + "r": 12 + }, + { + "id": 99, + "x": 430, + "y": 556, + "r": 13 + }, + { + "id": 100, + "x": 475, + "y": 557, + "r": 10 + }, + { + "id": 101, + "x": 377, + "y": 584, + "r": 11 + }, + { + "id": 102, + "x": 652, + "y": 575, + "r": 11 + }, + { + "id": 103, + "x": 146, + "y": 599, + "r": 11 + }, + { + "id": 104, + "x": 223, + "y": 605, + "r": 11 + }, + { + "id": 105, + "x": 325, + "y": 605, + "r": 9 + }, + { + "id": 106, + "x": 579, + "y": 600, + "r": 9 + }, + { + "id": 107, + "x": 265, + "y": 620, + "r": 9 + }, + { + "id": 108, + "x": 682, + "y": 622, + "r": 11 + }, + { + "id": 109, + "x": 209, + "y": 647, + "r": 9 + }, + { + "id": 110, + "x": 349, + "y": 650, + "r": 9 + }, + { + "id": 111, + "x": 487, + "y": 656, + "r": 12 + }, + { + "id": 112, + "x": 770, + "y": 637, + "r": 9 + }, + { + "id": 113, + "x": 836, + "y": 637, + "r": 11 + }, + { + "id": 114, + "x": 95, + "y": 685, + "r": 9 + }, + { + "id": 115, + "x": 551, + "y": 688, + "r": 9 + }, + { + "id": 116, + "x": 871, + "y": 692, + "r": 9 + }, + { + "id": 117, + "x": 409, + "y": 719, + "r": 11 + }, + { + "id": 118, + "x": 472, + "y": 722, + "r": 9 + }, + { + "id": 119, + "x": 925, + "y": 731, + "r": 11 + }, + { + "id": 120, + "x": 980, + "y": 724, + "r": 11 + }, + { + "id": 121, + "x": 1169, + "y": 727, + "r": 13 + }, + { + "id": 122, + "x": 91, + "y": 745, + "r": 9 + }, + { + "id": 123, + "x": 574, + "y": 743, + "r": 9 + }, + { + "id": 124, + "x": 622, + "y": 740, + "r": 9 + }, + { + "id": 125, + "x": 1075, + "y": 740, + "r": 9 + }, + { + "id": 126, + "x": 445, + "y": 763, + "r": 11 + }, + { + "id": 127, + "x": 542, + "y": 763, + "r": 10 + }, + { + "id": 128, + "x": 716, + "y": 769, + "r": 9 + }, + { + "id": 129, + "x": 773, + "y": 766, + "r": 9 + }, + { + "id": 130, + "x": 835, + "y": 767, + "r": 9 + }, + { + "id": 131, + "x": 986, + "y": 768, + "r": 11 + }, + { + "id": 132, + "x": 415, + "y": 799, + "r": 11 + }, + { + "id": 133, + "x": 488, + "y": 788, + "r": 9 + }, + { + "id": 134, + "x": 1159, + "y": 787, + "r": 11 + }, + { + "id": 135, + "x": 267, + "y": 827, + "r": 11 + }, + { + "id": 136, + "x": 530, + "y": 812, + "r": 9 + }, + { + "id": 137, + "x": 839, + "y": 811, + "r": 11 + }, + { + "id": 138, + "x": 958, + "y": 806, + "r": 9 + }, + { + "id": 139, + "x": 1016, + "y": 806, + "r": 11 + }, + { + "id": 140, + "x": 467, + "y": 844, + "r": 11 + }, + { + "id": 141, + "x": 737, + "y": 845, + "r": 9 + }, + { + "id": 142, + "x": 391, + "y": 872, + "r": 11 + }, + { + "id": 143, + "x": 839, + "y": 854, + "r": 9 + }, + { + "id": 144, + "x": 1103, + "y": 857, + "r": 9 + }, + { + "id": 145, + "x": 448, + "y": 890, + "r": 11 + }, + { + "id": 146, + "x": 572, + "y": 899, + "r": 12 + }, + { + "id": 147, + "x": 1003, + "y": 881, + "r": 10 + }, + { + "id": 148, + "x": 620, + "y": 919, + "r": 9 + }, + { + "id": 149, + "x": 301, + "y": 932, + "r": 11 + }, + { + "id": 150, + "x": 811, + "y": 934, + "r": 9 + }, + { + "id": 151, + "x": 686, + "y": 958, + "r": 12 + } + ], + "edges": [ + { + "source": 1, + "target": 5 + }, + { + "source": 1, + "target": 6 + }, + { + "source": 2, + "target": 4 + }, + { + "source": 2, + "target": 15 + }, + { + "source": 2, + "target": 28 + }, + { + "source": 4, + "target": 16 + }, + { + "source": 5, + "target": 12 + }, + { + "source": 5, + "target": 17 + }, + { + "source": 6, + "target": 7 + }, + { + "source": 6, + "target": 13 + }, + { + "source": 6, + "target": 24 + }, + { + "source": 7, + "target": 14 + }, + { + "source": 7, + "target": 24 + }, + { + "source": 9, + "target": 10 + }, + { + "source": 9, + "target": 39 + }, + { + "source": 10, + "target": 23 + }, + { + "source": 11, + "target": 12 + }, + { + "source": 11, + "target": 23 + }, + { + "source": 12, + "target": 17 + }, + { + "source": 12, + "target": 34 + }, + { + "source": 13, + "target": 24 + }, + { + "source": 14, + "target": 26 + }, + { + "source": 14, + "target": 28 + }, + { + "source": 14, + "target": 36 + }, + { + "source": 15, + "target": 28 + }, + { + "source": 16, + "target": 30 + }, + { + "source": 21, + "target": 30 + }, + { + "source": 21, + "target": 33 + }, + { + "source": 23, + "target": 34 + }, + { + "source": 23, + "target": 39 + }, + { + "source": 23, + "target": 49 + }, + { + "source": 26, + "target": 43 + }, + { + "source": 28, + "target": 36 + }, + { + "source": 30, + "target": 45 + }, + { + "source": 30, + "target": 53 + }, + { + "source": 33, + "target": 59 + }, + { + "source": 33, + "target": 62 + }, + { + "source": 34, + "target": 49 + }, + { + "source": 36, + "target": 67 + }, + { + "source": 39, + "target": 64 + }, + { + "source": 40, + "target": 41 + }, + { + "source": 40, + "target": 49 + }, + { + "source": 41, + "target": 42 + }, + { + "source": 42, + "target": 55 + }, + { + "source": 42, + "target": 71 + }, + { + "source": 43, + "target": 50 + }, + { + "source": 43, + "target": 67 + }, + { + "source": 44, + "target": 53 + }, + { + "source": 45, + "target": 53 + }, + { + "source": 49, + "target": 54 + }, + { + "source": 53, + "target": 56 + }, + { + "source": 54, + "target": 70 + }, + { + "source": 54, + "target": 74 + }, + { + "source": 55, + "target": 71 + }, + { + "source": 56, + "target": 69 + }, + { + "source": 59, + "target": 62 + }, + { + "source": 61, + "target": 62 + }, + { + "source": 61, + "target": 79 + }, + { + "source": 62, + "target": 73 + }, + { + "source": 64, + "target": 86 + }, + { + "source": 66, + "target": 77 + }, + { + "source": 66, + "target": 78 + }, + { + "source": 67, + "target": 83 + }, + { + "source": 67, + "target": 87 + }, + { + "source": 69, + "target": 72 + }, + { + "source": 70, + "target": 86 + }, + { + "source": 71, + "target": 76 + }, + { + "source": 71, + "target": 93 + }, + { + "source": 72, + "target": 83 + }, + { + "source": 72, + "target": 89 + }, + { + "source": 74, + "target": 96 + }, + { + "source": 76, + "target": 77 + }, + { + "source": 77, + "target": 78 + }, + { + "source": 77, + "target": 100 + }, + { + "source": 79, + "target": 84 + }, + { + "source": 79, + "target": 88 + }, + { + "source": 82, + "target": 86 + }, + { + "source": 83, + "target": 87 + }, + { + "source": 84, + "target": 90 + }, + { + "source": 86, + "target": 103 + }, + { + "source": 87, + "target": 89 + }, + { + "source": 87, + "target": 102 + }, + { + "source": 87, + "target": 112 + }, + { + "source": 88, + "target": 90 + }, + { + "source": 88, + "target": 94 + }, + { + "source": 88, + "target": 95 + }, + { + "source": 89, + "target": 113 + }, + { + "source": 90, + "target": 95 + }, + { + "source": 90, + "target": 121 + }, + { + "source": 93, + "target": 99 + }, + { + "source": 94, + "target": 113 + }, + { + "source": 94, + "target": 119 + }, + { + "source": 94, + "target": 120 + }, + { + "source": 95, + "target": 121 + }, + { + "source": 96, + "target": 97 + }, + { + "source": 96, + "target": 101 + }, + { + "source": 96, + "target": 104 + }, + { + "source": 97, + "target": 101 + }, + { + "source": 98, + "target": 104 + }, + { + "source": 99, + "target": 101 + }, + { + "source": 99, + "target": 111 + }, + { + "source": 100, + "target": 102 + }, + { + "source": 100, + "target": 106 + }, + { + "source": 101, + "target": 107 + }, + { + "source": 102, + "target": 106 + }, + { + "source": 102, + "target": 108 + }, + { + "source": 102, + "target": 112 + }, + { + "source": 103, + "target": 114 + }, + { + "source": 104, + "target": 107 + }, + { + "source": 106, + "target": 111 + }, + { + "source": 107, + "target": 110 + }, + { + "source": 108, + "target": 112 + }, + { + "source": 110, + "target": 117 + }, + { + "source": 111, + "target": 115 + }, + { + "source": 111, + "target": 117 + }, + { + "source": 112, + "target": 113 + }, + { + "source": 112, + "target": 116 + }, + { + "source": 113, + "target": 116 + }, + { + "source": 115, + "target": 123 + }, + { + "source": 116, + "target": 119 + }, + { + "source": 117, + "target": 132 + }, + { + "source": 119, + "target": 120 + }, + { + "source": 120, + "target": 125 + }, + { + "source": 120, + "target": 130 + }, + { + "source": 120, + "target": 138 + }, + { + "source": 121, + "target": 125 + }, + { + "source": 121, + "target": 134 + }, + { + "source": 123, + "target": 124 + }, + { + "source": 127, + "target": 136 + }, + { + "source": 128, + "target": 141 + }, + { + "source": 128, + "target": 150 + }, + { + "source": 129, + "target": 130 + }, + { + "source": 130, + "target": 137 + }, + { + "source": 132, + "target": 142 + }, + { + "source": 134, + "target": 147 + }, + { + "source": 135, + "target": 142 + }, + { + "source": 136, + "target": 140 + }, + { + "source": 136, + "target": 146 + }, + { + "source": 138, + "target": 147 + }, + { + "source": 140, + "target": 146 + }, + { + "source": 141, + "target": 150 + }, + { + "source": 141, + "target": 151 + }, + { + "source": 142, + "target": 145 + }, + { + "source": 142, + "target": 149 + }, + { + "source": 143, + "target": 150 + }, + { + "source": 144, + "target": 147 + }, + { + "source": 145, + "target": 149 + }, + { + "source": 146, + "target": 148 + }, + { + "source": 148, + "target": 151 + }, + { + "source": 150, + "target": 151 + } + ], + "adjacency": { + "1": [ + 5, + 6 + ], + "2": [ + 4, + 15, + 28 + ], + "3": [], + "4": [ + 2, + 16 + ], + "5": [ + 1, + 12, + 17 + ], + "6": [ + 1, + 7, + 13, + 24 + ], + "7": [ + 6, + 14, + 24 + ], + "8": [], + "9": [ + 10, + 39 + ], + "10": [ + 9, + 23 + ], + "11": [ + 12, + 23 + ], + "12": [ + 5, + 11, + 17, + 34 + ], + "13": [ + 6, + 24 + ], + "14": [ + 7, + 26, + 28, + 36 + ], + "15": [ + 2, + 28 + ], + "16": [ + 4, + 30 + ], + "17": [ + 5, + 12 + ], + "18": [], + "19": [], + "20": [], + "21": [ + 30, + 33 + ], + "22": [], + "23": [ + 10, + 11, + 34, + 39, + 49 + ], + "24": [ + 6, + 7, + 13 + ], + "25": [], + "26": [ + 14, + 43 + ], + "27": [], + "28": [ + 2, + 14, + 15, + 36 + ], + "29": [], + "30": [ + 16, + 21, + 45, + 53 + ], + "31": [], + "32": [], + "33": [ + 21, + 59, + 62 + ], + "34": [ + 12, + 23, + 49 + ], + "35": [], + "36": [ + 14, + 28, + 67 + ], + "37": [], + "38": [], + "39": [ + 9, + 23, + 64 + ], + "40": [ + 41, + 49 + ], + "41": [ + 40, + 42 + ], + "42": [ + 41, + 55, + 71 + ], + "43": [ + 26, + 50, + 67 + ], + "44": [ + 53 + ], + "45": [ + 30, + 53 + ], + "46": [], + "47": [], + "48": [], + "49": [ + 23, + 34, + 40, + 54 + ], + "50": [ + 43 + ], + "51": [], + "52": [], + "53": [ + 30, + 44, + 45, + 56 + ], + "54": [ + 49, + 70, + 74 + ], + "55": [ + 42, + 71 + ], + "56": [ + 53, + 69 + ], + "57": [], + "58": [], + "59": [ + 33, + 62 + ], + "60": [], + "61": [ + 62, + 79 + ], + "62": [ + 33, + 59, + 61, + 73 + ], + "63": [], + "64": [ + 39, + 86 + ], + "65": [], + "66": [ + 77, + 78 + ], + "67": [ + 36, + 43, + 83, + 87 + ], + "68": [], + "69": [ + 56, + 72 + ], + "70": [ + 54, + 86 + ], + "71": [ + 42, + 55, + 76, + 93 + ], + "72": [ + 69, + 83, + 89 + ], + "73": [ + 62 + ], + "74": [ + 54, + 96 + ], + "75": [], + "76": [ + 71, + 77 + ], + "77": [ + 66, + 76, + 78, + 100 + ], + "78": [ + 66, + 77 + ], + "79": [ + 61, + 84, + 88 + ], + "80": [], + "81": [], + "82": [ + 86 + ], + "83": [ + 67, + 72, + 87 + ], + "84": [ + 79, + 90 + ], + "85": [], + "86": [ + 64, + 70, + 82, + 103 + ], + "87": [ + 67, + 83, + 89, + 102, + 112 + ], + "88": [ + 79, + 90, + 94, + 95 + ], + "89": [ + 72, + 87, + 113 + ], + "90": [ + 84, + 88, + 95, + 121 + ], + "91": [], + "92": [], + "93": [ + 71, + 99 + ], + "94": [ + 88, + 113, + 119, + 120 + ], + "95": [ + 88, + 90, + 121 + ], + "96": [ + 74, + 97, + 101, + 104 + ], + "97": [ + 96, + 101 + ], + "98": [ + 104 + ], + "99": [ + 93, + 101, + 111 + ], + "100": [ + 77, + 102, + 106 + ], + "101": [ + 96, + 97, + 99, + 107 + ], + "102": [ + 87, + 100, + 106, + 108, + 112 + ], + "103": [ + 86, + 114 + ], + "104": [ + 96, + 98, + 107 + ], + "105": [], + "106": [ + 100, + 102, + 111 + ], + "107": [ + 101, + 104, + 110 + ], + "108": [ + 102, + 112 + ], + "109": [], + "110": [ + 107, + 117 + ], + "111": [ + 99, + 106, + 115, + 117 + ], + "112": [ + 87, + 102, + 108, + 113, + 116 + ], + "113": [ + 89, + 94, + 112, + 116 + ], + "114": [ + 103 + ], + "115": [ + 111, + 123 + ], + "116": [ + 112, + 113, + 119 + ], + "117": [ + 110, + 111, + 132 + ], + "118": [], + "119": [ + 94, + 116, + 120 + ], + "120": [ + 94, + 119, + 125, + 130, + 138 + ], + "121": [ + 90, + 95, + 125, + 134 + ], + "122": [], + "123": [ + 115, + 124 + ], + "124": [ + 123 + ], + "125": [ + 120, + 121 + ], + "126": [], + "127": [ + 136 + ], + "128": [ + 141, + 150 + ], + "129": [ + 130 + ], + "130": [ + 120, + 129, + 137 + ], + "131": [], + "132": [ + 117, + 142 + ], + "133": [], + "134": [ + 121, + 147 + ], + "135": [ + 142 + ], + "136": [ + 127, + 140, + 146 + ], + "137": [ + 130 + ], + "138": [ + 120, + 147 + ], + "139": [], + "140": [ + 136, + 146 + ], + "141": [ + 128, + 150, + 151 + ], + "142": [ + 132, + 135, + 145, + 149 + ], + "143": [ + 150 + ], + "144": [ + 147 + ], + "145": [ + 142, + 149 + ], + "146": [ + 136, + 140, + 148 + ], + "147": [ + 134, + 138, + 144 + ], + "148": [ + 146, + 151 + ], + "149": [ + 142, + 145 + ], + "150": [ + 128, + 141, + 143, + 151 + ], + "151": [ + 141, + 148, + 150 + ] + } +} diff --git a/data/raw/maps/bus_cv_out/junctions.csv b/data/raw/maps/bus_cv_out/junctions.csv new file mode 100644 index 0000000000000000000000000000000000000000..0c2c3653c708d4ff21c58187e1d6c4a68f433748 --- /dev/null +++ b/data/raw/maps/bus_cv_out/junctions.csv @@ -0,0 +1,152 @@ +id,x,y,radius,neighbors +1,424,79,12,5 6 +2,949,69,11,4 15 28 +3,1028,78,12, +4,1059,103,13,2 16 +5,353,119,12,1 12 17 +6,434,128,11,1 7 13 24 +7,562,123,12,6 14 24 +8,628,114,12, +9,99,153,12,10 39 +10,143,146,11,9 23 +11,233,151,11,12 23 +12,289,142,11,5 11 17 34 +13,392,153,11,6 24 +14,719,152,12,7 26 28 36 +15,925,133,11,2 28 +16,1054,148,12,4 30 +17,317,172,11,5 12 +18,573,173,11, +19,624,158,11, +20,976,163,11, +21,1114,162,11,30 33 +22,1243,179,12, +23,211,202,11,10 11 34 39 49 +24,424,184,11,6 7 13 +25,543,198,11, +26,696,187,11,14 43 +27,738,202,11, +28,817,191,11,2 14 15 36 +29,927,188,11, +30,1044,202,12,16 21 45 53 +31,1396,193,11, +32,1125,214,12, +33,1319,225,11,21 59 62 +34,242,237,9,12 23 49 +35,498,238,11, +36,796,246,12,14 28 67 +37,946,228,11, +38,1188,249,11, +39,139,263,9,9 23 64 +40,282,259,11,41 49 +41,368,266,11,40 42 +42,419,259,11,41 55 71 +43,682,263,11,26 50 67 +44,851,257,10,53 +45,984,259,10,30 53 +46,1133,264,11, +47,1243,265,11, +48,1367,265,12, +49,247,279,11,23 34 40 54 +50,621,288,11,43 +51,748,292,11, +52,819,282,11, +53,973,278,11,30 44 45 56 +54,203,303,11,49 70 74 +55,397,311,12,42 71 +56,974,321,11,53 69 +57,1056,318,11, +58,1102,323,11, +59,1244,318,9,33 62 +60,1156,340,10, +61,1188,338,11,62 79 +62,1264,347,11,33 59 61 73 +63,1322,333,10, +64,58,365,12,39 86 +65,281,354,11, +66,565,353,11,77 78 +67,768,352,11,36 43 83 87 +68,871,350,10, +69,931,348,11,56 72 +70,157,388,11,54 86 +71,384,376,11,42 55 76 93 +72,846,388,11,69 83 89 +73,1299,373,11,62 +74,222,408,11,54 96 +75,296,399,9, +76,493,402,11,71 77 +77,549,416,10,66 76 78 100 +78,588,398,9,66 77 +79,1170,408,11,61 84 88 +80,1303,416,10, +81,1378,400,12, +82,143,427,9,86 +83,777,428,11,67 72 87 +84,1211,439,11,79 90 +85,1397,437,11, +86,98,449,12,64 70 82 103 +87,757,463,13,67 83 89 102 112 +88,1132,458,11,79 90 94 95 +89,851,472,9,72 87 113 +90,1221,488,11,84 88 95 121 +91,1277,489,9, +92,1319,485,11, +93,386,511,9,71 99 +94,1048,505,11,88 113 119 120 +95,1145,514,12,88 90 121 +96,257,532,12,74 97 101 104 +97,352,530,9,96 101 +98,134,551,12,104 +99,430,556,13,93 101 111 +100,475,557,10,77 102 106 +101,377,584,11,96 97 99 107 +102,652,575,11,87 100 106 108 112 +103,146,599,11,86 114 +104,223,605,11,96 98 107 +105,325,605,9, +106,579,600,9,100 102 111 +107,265,620,9,101 104 110 +108,682,622,11,102 112 +109,209,647,9, +110,349,650,9,107 117 +111,487,656,12,99 106 115 117 +112,770,637,9,87 102 108 113 116 +113,836,637,11,89 94 112 116 +114,95,685,9,103 +115,551,688,9,111 123 +116,871,692,9,112 113 119 +117,409,719,11,110 111 132 +118,472,722,9, +119,925,731,11,94 116 120 +120,980,724,11,94 119 125 130 138 +121,1169,727,13,90 95 125 134 +122,91,745,9, +123,574,743,9,115 124 +124,622,740,9,123 +125,1075,740,9,120 121 +126,445,763,11, +127,542,763,10,136 +128,716,769,9,141 150 +129,773,766,9,130 +130,835,767,9,120 129 137 +131,986,768,11, +132,415,799,11,117 142 +133,488,788,9, +134,1159,787,11,121 147 +135,267,827,11,142 +136,530,812,9,127 140 146 +137,839,811,11,130 +138,958,806,9,120 147 +139,1016,806,11, +140,467,844,11,136 146 +141,737,845,9,128 150 151 +142,391,872,11,132 135 145 149 +143,839,854,9,150 +144,1103,857,9,147 +145,448,890,11,142 149 +146,572,899,12,136 140 148 +147,1003,881,10,134 138 144 +148,620,919,9,146 151 +149,301,932,11,142 145 +150,811,934,9,128 141 143 151 +151,686,958,12,141 148 150 diff --git a/data/raw/maps/normal_cv_out/graph.graphml b/data/raw/maps/normal_cv_out/graph.graphml new file mode 100644 index 0000000000000000000000000000000000000000..d2fc9a5887e5632f0382a44a49d8c1a331fcc0f2 --- /dev/null +++ b/data/raw/maps/normal_cv_out/graph.graphml @@ -0,0 +1,1015 @@ + + + + + + 424 + 79 + 12 + + + 949 + 69 + 11 + + + 1028 + 78 + 12 + + + 1059 + 103 + 13 + + + 353 + 119 + 12 + + + 434 + 128 + 11 + + + 562 + 123 + 12 + + + 628 + 114 + 12 + + + 99 + 153 + 12 + + + 143 + 146 + 11 + + + 233 + 151 + 11 + + + 289 + 142 + 11 + + + 392 + 153 + 11 + + + 719 + 152 + 12 + + + 925 + 133 + 11 + + + 1054 + 148 + 12 + + + 317 + 172 + 11 + + + 573 + 173 + 11 + + + 624 + 158 + 11 + + + 976 + 163 + 11 + + + 1114 + 162 + 11 + + + 1243 + 179 + 12 + + + 211 + 202 + 11 + + + 424 + 184 + 11 + + + 543 + 198 + 11 + + + 696 + 187 + 11 + + + 738 + 202 + 11 + + + 817 + 191 + 11 + + + 927 + 188 + 11 + + + 1044 + 202 + 12 + + + 1396 + 193 + 11 + + + 1125 + 214 + 12 + + + 1319 + 225 + 11 + + + 242 + 237 + 9 + + + 498 + 238 + 11 + + + 796 + 246 + 12 + + + 946 + 228 + 11 + + + 1188 + 249 + 11 + + + 139 + 263 + 9 + + + 282 + 259 + 11 + + + 368 + 266 + 11 + + + 419 + 259 + 11 + + + 682 + 263 + 11 + + + 851 + 257 + 10 + + + 984 + 259 + 10 + + + 1133 + 264 + 11 + + + 1243 + 265 + 11 + + + 1367 + 265 + 12 + + + 247 + 279 + 11 + + + 621 + 288 + 11 + + + 748 + 292 + 11 + + + 819 + 282 + 11 + + + 973 + 278 + 11 + + + 203 + 303 + 11 + + + 397 + 311 + 12 + + + 974 + 321 + 11 + + + 1056 + 318 + 11 + + + 1102 + 323 + 11 + + + 1244 + 318 + 9 + + + 1156 + 340 + 10 + + + 1188 + 338 + 11 + + + 1264 + 347 + 11 + + + 1322 + 333 + 10 + + + 58 + 365 + 12 + + + 281 + 354 + 11 + + + 565 + 353 + 11 + + + 768 + 352 + 11 + + + 871 + 350 + 10 + + + 931 + 348 + 11 + + + 157 + 388 + 11 + + + 384 + 376 + 11 + + + 846 + 388 + 11 + + + 1299 + 373 + 11 + + + 222 + 408 + 11 + + + 296 + 399 + 9 + + + 493 + 402 + 11 + + + 549 + 416 + 10 + + + 588 + 398 + 9 + + + 1170 + 408 + 11 + + + 1303 + 416 + 10 + + + 1378 + 400 + 12 + + + 143 + 427 + 9 + + + 777 + 428 + 11 + + + 1211 + 439 + 11 + + + 1397 + 437 + 11 + + + 98 + 449 + 12 + + + 757 + 463 + 13 + + + 1132 + 458 + 11 + + + 851 + 472 + 9 + + + 1221 + 488 + 11 + + + 1277 + 489 + 9 + + + 1319 + 485 + 11 + + + 386 + 511 + 9 + + + 1048 + 505 + 11 + + + 1145 + 514 + 12 + + + 257 + 532 + 12 + + + 352 + 530 + 9 + + + 134 + 551 + 12 + + + 430 + 556 + 13 + + + 475 + 557 + 10 + + + 377 + 584 + 11 + + + 652 + 575 + 11 + + + 146 + 599 + 11 + + + 223 + 605 + 11 + + + 325 + 605 + 9 + + + 579 + 600 + 9 + + + 265 + 620 + 9 + + + 682 + 622 + 11 + + + 209 + 647 + 9 + + + 349 + 650 + 9 + + + 487 + 656 + 12 + + + 770 + 637 + 9 + + + 836 + 637 + 11 + + + 95 + 685 + 9 + + + 551 + 688 + 9 + + + 871 + 692 + 9 + + + 409 + 719 + 11 + + + 472 + 722 + 9 + + + 925 + 731 + 11 + + + 980 + 724 + 11 + + + 1169 + 727 + 13 + + + 91 + 745 + 9 + + + 574 + 743 + 9 + + + 622 + 740 + 9 + + + 1075 + 740 + 9 + + + 445 + 763 + 11 + + + 542 + 763 + 10 + + + 716 + 769 + 9 + + + 773 + 766 + 9 + + + 835 + 767 + 9 + + + 986 + 768 + 11 + + + 415 + 799 + 11 + + + 488 + 788 + 9 + + + 1159 + 787 + 11 + + + 267 + 827 + 11 + + + 530 + 812 + 9 + + + 839 + 811 + 11 + + + 958 + 806 + 9 + + + 1016 + 806 + 11 + + + 467 + 844 + 11 + + + 737 + 845 + 9 + + + 391 + 872 + 11 + + + 839 + 854 + 9 + + + 1103 + 857 + 9 + + + 448 + 890 + 11 + + + 572 + 899 + 12 + + + 1003 + 881 + 10 + + + 620 + 919 + 9 + + + 301 + 932 + 11 + + + 811 + 934 + 9 + + + 686 + 958 + 12 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/data/raw/maps/normal_cv_out/graph.json b/data/raw/maps/normal_cv_out/graph.json new file mode 100644 index 0000000000000000000000000000000000000000..ecd1e4ffa807617c571335b9e2175ef53b1e6771 --- /dev/null +++ b/data/raw/maps/normal_cv_out/graph.json @@ -0,0 +1,2745 @@ +{ + "nodes": [ + { + "id": 1, + "x": 424, + "y": 79, + "r": 12 + }, + { + "id": 2, + "x": 949, + "y": 69, + "r": 11 + }, + { + "id": 3, + "x": 1028, + "y": 78, + "r": 12 + }, + { + "id": 4, + "x": 1059, + "y": 103, + "r": 13 + }, + { + "id": 5, + "x": 353, + "y": 119, + "r": 12 + }, + { + "id": 6, + "x": 434, + "y": 128, + "r": 11 + }, + { + "id": 7, + "x": 562, + "y": 123, + "r": 12 + }, + { + "id": 8, + "x": 628, + "y": 114, + "r": 12 + }, + { + "id": 9, + "x": 99, + "y": 153, + "r": 12 + }, + { + "id": 10, + "x": 143, + "y": 146, + "r": 11 + }, + { + "id": 11, + "x": 233, + "y": 151, + "r": 11 + }, + { + "id": 12, + "x": 289, + "y": 142, + "r": 11 + }, + { + "id": 13, + "x": 392, + "y": 153, + "r": 11 + }, + { + "id": 14, + "x": 719, + "y": 152, + "r": 12 + }, + { + "id": 15, + "x": 925, + "y": 133, + "r": 11 + }, + { + "id": 16, + "x": 1054, + "y": 148, + "r": 12 + }, + { + "id": 17, + "x": 317, + "y": 172, + "r": 11 + }, + { + "id": 18, + "x": 573, + "y": 173, + "r": 11 + }, + { + "id": 19, + "x": 624, + "y": 158, + "r": 11 + }, + { + "id": 20, + "x": 976, + "y": 163, + "r": 11 + }, + { + "id": 21, + "x": 1114, + "y": 162, + "r": 11 + }, + { + "id": 22, + "x": 1243, + "y": 179, + "r": 12 + }, + { + "id": 23, + "x": 211, + "y": 202, + "r": 11 + }, + { + "id": 24, + "x": 424, + "y": 184, + "r": 11 + }, + { + "id": 25, + "x": 543, + "y": 198, + "r": 11 + }, + { + "id": 26, + "x": 696, + "y": 187, + "r": 11 + }, + { + "id": 27, + "x": 738, + "y": 202, + "r": 11 + }, + { + "id": 28, + "x": 817, + "y": 191, + "r": 11 + }, + { + "id": 29, + "x": 927, + "y": 188, + "r": 11 + }, + { + "id": 30, + "x": 1044, + "y": 202, + "r": 12 + }, + { + "id": 31, + "x": 1396, + "y": 193, + "r": 11 + }, + { + "id": 32, + "x": 1125, + "y": 214, + "r": 12 + }, + { + "id": 33, + "x": 1319, + "y": 225, + "r": 11 + }, + { + "id": 34, + "x": 242, + "y": 237, + "r": 9 + }, + { + "id": 35, + "x": 498, + "y": 238, + "r": 11 + }, + { + "id": 36, + "x": 796, + "y": 246, + "r": 12 + }, + { + "id": 37, + "x": 946, + "y": 228, + "r": 11 + }, + { + "id": 38, + "x": 1188, + "y": 249, + "r": 11 + }, + { + "id": 39, + "x": 139, + "y": 263, + "r": 9 + }, + { + "id": 40, + "x": 282, + "y": 259, + "r": 11 + }, + { + "id": 41, + "x": 368, + "y": 266, + "r": 11 + }, + { + "id": 42, + "x": 419, + "y": 259, + "r": 11 + }, + { + "id": 43, + "x": 682, + "y": 263, + "r": 11 + }, + { + "id": 44, + "x": 851, + "y": 257, + "r": 10 + }, + { + "id": 45, + "x": 984, + "y": 259, + "r": 10 + }, + { + "id": 46, + "x": 1133, + "y": 264, + "r": 11 + }, + { + "id": 47, + "x": 1243, + "y": 265, + "r": 11 + }, + { + "id": 48, + "x": 1367, + "y": 265, + "r": 12 + }, + { + "id": 49, + "x": 247, + "y": 279, + "r": 11 + }, + { + "id": 50, + "x": 621, + "y": 288, + "r": 11 + }, + { + "id": 51, + "x": 748, + "y": 292, + "r": 11 + }, + { + "id": 52, + "x": 819, + "y": 282, + "r": 11 + }, + { + "id": 53, + "x": 973, + "y": 278, + "r": 11 + }, + { + "id": 54, + "x": 203, + "y": 303, + "r": 11 + }, + { + "id": 55, + "x": 397, + "y": 311, + "r": 12 + }, + { + "id": 56, + "x": 974, + "y": 321, + "r": 11 + }, + { + "id": 57, + "x": 1056, + "y": 318, + "r": 11 + }, + { + "id": 58, + "x": 1102, + "y": 323, + "r": 11 + }, + { + "id": 59, + "x": 1244, + "y": 318, + "r": 9 + }, + { + "id": 60, + "x": 1156, + "y": 340, + "r": 10 + }, + { + "id": 61, + "x": 1188, + "y": 338, + "r": 11 + }, + { + "id": 62, + "x": 1264, + "y": 347, + "r": 11 + }, + { + "id": 63, + "x": 1322, + "y": 333, + "r": 10 + }, + { + "id": 64, + "x": 58, + "y": 365, + "r": 12 + }, + { + "id": 65, + "x": 281, + "y": 354, + "r": 11 + }, + { + "id": 66, + "x": 565, + "y": 353, + "r": 11 + }, + { + "id": 67, + "x": 768, + "y": 352, + "r": 11 + }, + { + "id": 68, + "x": 871, + "y": 350, + "r": 10 + }, + { + "id": 69, + "x": 931, + "y": 348, + "r": 11 + }, + { + "id": 70, + "x": 157, + "y": 388, + "r": 11 + }, + { + "id": 71, + "x": 384, + "y": 376, + "r": 11 + }, + { + "id": 72, + "x": 846, + "y": 388, + "r": 11 + }, + { + "id": 73, + "x": 1299, + "y": 373, + "r": 11 + }, + { + "id": 74, + "x": 222, + "y": 408, + "r": 11 + }, + { + "id": 75, + "x": 296, + "y": 399, + "r": 9 + }, + { + "id": 76, + "x": 493, + "y": 402, + "r": 11 + }, + { + "id": 77, + "x": 549, + "y": 416, + "r": 10 + }, + { + "id": 78, + "x": 588, + "y": 398, + "r": 9 + }, + { + "id": 79, + "x": 1170, + "y": 408, + "r": 11 + }, + { + "id": 80, + "x": 1303, + "y": 416, + "r": 10 + }, + { + "id": 81, + "x": 1378, + "y": 400, + "r": 12 + }, + { + "id": 82, + "x": 143, + "y": 427, + "r": 9 + }, + { + "id": 83, + "x": 777, + "y": 428, + "r": 11 + }, + { + "id": 84, + "x": 1211, + "y": 439, + "r": 11 + }, + { + "id": 85, + "x": 1397, + "y": 437, + "r": 11 + }, + { + "id": 86, + "x": 98, + "y": 449, + "r": 12 + }, + { + "id": 87, + "x": 757, + "y": 463, + "r": 13 + }, + { + "id": 88, + "x": 1132, + "y": 458, + "r": 11 + }, + { + "id": 89, + "x": 851, + "y": 472, + "r": 9 + }, + { + "id": 90, + "x": 1221, + "y": 488, + "r": 11 + }, + { + "id": 91, + "x": 1277, + "y": 489, + "r": 9 + }, + { + "id": 92, + "x": 1319, + "y": 485, + "r": 11 + }, + { + "id": 93, + "x": 386, + "y": 511, + "r": 9 + }, + { + "id": 94, + "x": 1048, + "y": 505, + "r": 11 + }, + { + "id": 95, + "x": 1145, + "y": 514, + "r": 12 + }, + { + "id": 96, + "x": 257, + "y": 532, + "r": 12 + }, + { + "id": 97, + "x": 352, + "y": 530, + "r": 9 + }, + { + "id": 98, + "x": 134, + "y": 551, + "r": 12 + }, + { + "id": 99, + "x": 430, + "y": 556, + "r": 13 + }, + { + "id": 100, + "x": 475, + "y": 557, + "r": 10 + }, + { + "id": 101, + "x": 377, + "y": 584, + "r": 11 + }, + { + "id": 102, + "x": 652, + "y": 575, + "r": 11 + }, + { + "id": 103, + "x": 146, + "y": 599, + "r": 11 + }, + { + "id": 104, + "x": 223, + "y": 605, + "r": 11 + }, + { + "id": 105, + "x": 325, + "y": 605, + "r": 9 + }, + { + "id": 106, + "x": 579, + "y": 600, + "r": 9 + }, + { + "id": 107, + "x": 265, + "y": 620, + "r": 9 + }, + { + "id": 108, + "x": 682, + "y": 622, + "r": 11 + }, + { + "id": 109, + "x": 209, + "y": 647, + "r": 9 + }, + { + "id": 110, + "x": 349, + "y": 650, + "r": 9 + }, + { + "id": 111, + "x": 487, + "y": 656, + "r": 12 + }, + { + "id": 112, + "x": 770, + "y": 637, + "r": 9 + }, + { + "id": 113, + "x": 836, + "y": 637, + "r": 11 + }, + { + "id": 114, + "x": 95, + "y": 685, + "r": 9 + }, + { + "id": 115, + "x": 551, + "y": 688, + "r": 9 + }, + { + "id": 116, + "x": 871, + "y": 692, + "r": 9 + }, + { + "id": 117, + "x": 409, + "y": 719, + "r": 11 + }, + { + "id": 118, + "x": 472, + "y": 722, + "r": 9 + }, + { + "id": 119, + "x": 925, + "y": 731, + "r": 11 + }, + { + "id": 120, + "x": 980, + "y": 724, + "r": 11 + }, + { + "id": 121, + "x": 1169, + "y": 727, + "r": 13 + }, + { + "id": 122, + "x": 91, + "y": 745, + "r": 9 + }, + { + "id": 123, + "x": 574, + "y": 743, + "r": 9 + }, + { + "id": 124, + "x": 622, + "y": 740, + "r": 9 + }, + { + "id": 125, + "x": 1075, + "y": 740, + "r": 9 + }, + { + "id": 126, + "x": 445, + "y": 763, + "r": 11 + }, + { + "id": 127, + "x": 542, + "y": 763, + "r": 10 + }, + { + "id": 128, + "x": 716, + "y": 769, + "r": 9 + }, + { + "id": 129, + "x": 773, + "y": 766, + "r": 9 + }, + { + "id": 130, + "x": 835, + "y": 767, + "r": 9 + }, + { + "id": 131, + "x": 986, + "y": 768, + "r": 11 + }, + { + "id": 132, + "x": 415, + "y": 799, + "r": 11 + }, + { + "id": 133, + "x": 488, + "y": 788, + "r": 9 + }, + { + "id": 134, + "x": 1159, + "y": 787, + "r": 11 + }, + { + "id": 135, + "x": 267, + "y": 827, + "r": 11 + }, + { + "id": 136, + "x": 530, + "y": 812, + "r": 9 + }, + { + "id": 137, + "x": 839, + "y": 811, + "r": 11 + }, + { + "id": 138, + "x": 958, + "y": 806, + "r": 9 + }, + { + "id": 139, + "x": 1016, + "y": 806, + "r": 11 + }, + { + "id": 140, + "x": 467, + "y": 844, + "r": 11 + }, + { + "id": 141, + "x": 737, + "y": 845, + "r": 9 + }, + { + "id": 142, + "x": 391, + "y": 872, + "r": 11 + }, + { + "id": 143, + "x": 839, + "y": 854, + "r": 9 + }, + { + "id": 144, + "x": 1103, + "y": 857, + "r": 9 + }, + { + "id": 145, + "x": 448, + "y": 890, + "r": 11 + }, + { + "id": 146, + "x": 572, + "y": 899, + "r": 12 + }, + { + "id": 147, + "x": 1003, + "y": 881, + "r": 10 + }, + { + "id": 148, + "x": 620, + "y": 919, + "r": 9 + }, + { + "id": 149, + "x": 301, + "y": 932, + "r": 11 + }, + { + "id": 150, + "x": 811, + "y": 934, + "r": 9 + }, + { + "id": 151, + "x": 686, + "y": 958, + "r": 12 + } + ], + "edges": [ + { + "source": 1, + "target": 5 + }, + { + "source": 1, + "target": 6 + }, + { + "source": 2, + "target": 3 + }, + { + "source": 2, + "target": 15 + }, + { + "source": 2, + "target": 28 + }, + { + "source": 3, + "target": 4 + }, + { + "source": 4, + "target": 16 + }, + { + "source": 5, + "target": 12 + }, + { + "source": 5, + "target": 13 + }, + { + "source": 5, + "target": 17 + }, + { + "source": 6, + "target": 7 + }, + { + "source": 6, + "target": 13 + }, + { + "source": 6, + "target": 24 + }, + { + "source": 7, + "target": 8 + }, + { + "source": 7, + "target": 18 + }, + { + "source": 7, + "target": 24 + }, + { + "source": 8, + "target": 19 + }, + { + "source": 9, + "target": 10 + }, + { + "source": 9, + "target": 39 + }, + { + "source": 10, + "target": 11 + }, + { + "source": 10, + "target": 23 + }, + { + "source": 11, + "target": 12 + }, + { + "source": 11, + "target": 23 + }, + { + "source": 12, + "target": 17 + }, + { + "source": 12, + "target": 34 + }, + { + "source": 13, + "target": 24 + }, + { + "source": 14, + "target": 26 + }, + { + "source": 14, + "target": 27 + }, + { + "source": 14, + "target": 28 + }, + { + "source": 14, + "target": 36 + }, + { + "source": 15, + "target": 20 + }, + { + "source": 15, + "target": 28 + }, + { + "source": 15, + "target": 29 + }, + { + "source": 16, + "target": 21 + }, + { + "source": 16, + "target": 30 + }, + { + "source": 18, + "target": 19 + }, + { + "source": 18, + "target": 25 + }, + { + "source": 20, + "target": 29 + }, + { + "source": 20, + "target": 30 + }, + { + "source": 21, + "target": 30 + }, + { + "source": 21, + "target": 32 + }, + { + "source": 22, + "target": 33 + }, + { + "source": 22, + "target": 47 + }, + { + "source": 23, + "target": 34 + }, + { + "source": 23, + "target": 39 + }, + { + "source": 23, + "target": 49 + }, + { + "source": 25, + "target": 35 + }, + { + "source": 26, + "target": 27 + }, + { + "source": 26, + "target": 43 + }, + { + "source": 28, + "target": 36 + }, + { + "source": 28, + "target": 44 + }, + { + "source": 29, + "target": 37 + }, + { + "source": 30, + "target": 45 + }, + { + "source": 30, + "target": 53 + }, + { + "source": 31, + "target": 33 + }, + { + "source": 31, + "target": 48 + }, + { + "source": 32, + "target": 46 + }, + { + "source": 33, + "target": 47 + }, + { + "source": 33, + "target": 48 + }, + { + "source": 33, + "target": 59 + }, + { + "source": 33, + "target": 62 + }, + { + "source": 34, + "target": 40 + }, + { + "source": 34, + "target": 49 + }, + { + "source": 34, + "target": 54 + }, + { + "source": 35, + "target": 42 + }, + { + "source": 36, + "target": 44 + }, + { + "source": 36, + "target": 51 + }, + { + "source": 36, + "target": 52 + }, + { + "source": 37, + "target": 45 + }, + { + "source": 38, + "target": 46 + }, + { + "source": 38, + "target": 47 + }, + { + "source": 39, + "target": 54 + }, + { + "source": 40, + "target": 41 + }, + { + "source": 40, + "target": 49 + }, + { + "source": 40, + "target": 55 + }, + { + "source": 41, + "target": 42 + }, + { + "source": 41, + "target": 55 + }, + { + "source": 42, + "target": 55 + }, + { + "source": 42, + "target": 71 + }, + { + "source": 43, + "target": 50 + }, + { + "source": 43, + "target": 87 + }, + { + "source": 44, + "target": 52 + }, + { + "source": 44, + "target": 53 + }, + { + "source": 45, + "target": 53 + }, + { + "source": 47, + "target": 59 + }, + { + "source": 49, + "target": 54 + }, + { + "source": 49, + "target": 65 + }, + { + "source": 50, + "target": 66 + }, + { + "source": 51, + "target": 67 + }, + { + "source": 53, + "target": 56 + }, + { + "source": 55, + "target": 71 + }, + { + "source": 56, + "target": 57 + }, + { + "source": 56, + "target": 69 + }, + { + "source": 57, + "target": 58 + }, + { + "source": 58, + "target": 60 + }, + { + "source": 59, + "target": 61 + }, + { + "source": 59, + "target": 62 + }, + { + "source": 60, + "target": 61 + }, + { + "source": 61, + "target": 62 + }, + { + "source": 61, + "target": 79 + }, + { + "source": 62, + "target": 63 + }, + { + "source": 62, + "target": 73 + }, + { + "source": 62, + "target": 84 + }, + { + "source": 62, + "target": 88 + }, + { + "source": 63, + "target": 73 + }, + { + "source": 64, + "target": 70 + }, + { + "source": 64, + "target": 86 + }, + { + "source": 65, + "target": 75 + }, + { + "source": 66, + "target": 76 + }, + { + "source": 66, + "target": 77 + }, + { + "source": 66, + "target": 78 + }, + { + "source": 67, + "target": 68 + }, + { + "source": 67, + "target": 83 + }, + { + "source": 67, + "target": 87 + }, + { + "source": 68, + "target": 69 + }, + { + "source": 68, + "target": 72 + }, + { + "source": 69, + "target": 72 + }, + { + "source": 70, + "target": 74 + }, + { + "source": 70, + "target": 82 + }, + { + "source": 70, + "target": 86 + }, + { + "source": 71, + "target": 75 + }, + { + "source": 71, + "target": 76 + }, + { + "source": 71, + "target": 77 + }, + { + "source": 72, + "target": 83 + }, + { + "source": 72, + "target": 89 + }, + { + "source": 73, + "target": 80 + }, + { + "source": 73, + "target": 84 + }, + { + "source": 74, + "target": 75 + }, + { + "source": 76, + "target": 77 + }, + { + "source": 77, + "target": 78 + }, + { + "source": 77, + "target": 87 + }, + { + "source": 77, + "target": 108 + }, + { + "source": 79, + "target": 84 + }, + { + "source": 79, + "target": 88 + }, + { + "source": 80, + "target": 81 + }, + { + "source": 80, + "target": 85 + }, + { + "source": 80, + "target": 92 + }, + { + "source": 81, + "target": 85 + }, + { + "source": 82, + "target": 86 + }, + { + "source": 82, + "target": 98 + }, + { + "source": 83, + "target": 87 + }, + { + "source": 83, + "target": 89 + }, + { + "source": 84, + "target": 88 + }, + { + "source": 84, + "target": 90 + }, + { + "source": 86, + "target": 98 + }, + { + "source": 87, + "target": 89 + }, + { + "source": 87, + "target": 94 + }, + { + "source": 87, + "target": 108 + }, + { + "source": 87, + "target": 112 + }, + { + "source": 87, + "target": 113 + }, + { + "source": 88, + "target": 90 + }, + { + "source": 88, + "target": 94 + }, + { + "source": 88, + "target": 95 + }, + { + "source": 89, + "target": 113 + }, + { + "source": 90, + "target": 91 + }, + { + "source": 90, + "target": 95 + }, + { + "source": 90, + "target": 121 + }, + { + "source": 91, + "target": 92 + }, + { + "source": 93, + "target": 97 + }, + { + "source": 93, + "target": 99 + }, + { + "source": 94, + "target": 95 + }, + { + "source": 94, + "target": 119 + }, + { + "source": 94, + "target": 131 + }, + { + "source": 95, + "target": 121 + }, + { + "source": 96, + "target": 97 + }, + { + "source": 96, + "target": 101 + }, + { + "source": 96, + "target": 105 + }, + { + "source": 96, + "target": 107 + }, + { + "source": 97, + "target": 101 + }, + { + "source": 98, + "target": 103 + }, + { + "source": 98, + "target": 104 + }, + { + "source": 99, + "target": 100 + }, + { + "source": 99, + "target": 101 + }, + { + "source": 100, + "target": 102 + }, + { + "source": 100, + "target": 106 + }, + { + "source": 101, + "target": 105 + }, + { + "source": 101, + "target": 110 + }, + { + "source": 102, + "target": 106 + }, + { + "source": 102, + "target": 108 + }, + { + "source": 102, + "target": 112 + }, + { + "source": 102, + "target": 115 + }, + { + "source": 103, + "target": 104 + }, + { + "source": 103, + "target": 109 + }, + { + "source": 103, + "target": 114 + }, + { + "source": 103, + "target": 122 + }, + { + "source": 105, + "target": 107 + }, + { + "source": 105, + "target": 110 + }, + { + "source": 106, + "target": 115 + }, + { + "source": 107, + "target": 109 + }, + { + "source": 107, + "target": 110 + }, + { + "source": 108, + "target": 112 + }, + { + "source": 110, + "target": 117 + }, + { + "source": 112, + "target": 113 + }, + { + "source": 112, + "target": 116 + }, + { + "source": 113, + "target": 116 + }, + { + "source": 114, + "target": 122 + }, + { + "source": 115, + "target": 123 + }, + { + "source": 115, + "target": 124 + }, + { + "source": 116, + "target": 119 + }, + { + "source": 117, + "target": 118 + }, + { + "source": 117, + "target": 126 + }, + { + "source": 117, + "target": 132 + }, + { + "source": 118, + "target": 126 + }, + { + "source": 119, + "target": 120 + }, + { + "source": 119, + "target": 131 + }, + { + "source": 120, + "target": 130 + }, + { + "source": 120, + "target": 131 + }, + { + "source": 120, + "target": 138 + }, + { + "source": 120, + "target": 147 + }, + { + "source": 121, + "target": 125 + }, + { + "source": 123, + "target": 124 + }, + { + "source": 125, + "target": 131 + }, + { + "source": 125, + "target": 139 + }, + { + "source": 125, + "target": 144 + }, + { + "source": 126, + "target": 132 + }, + { + "source": 126, + "target": 133 + }, + { + "source": 127, + "target": 133 + }, + { + "source": 127, + "target": 136 + }, + { + "source": 128, + "target": 129 + }, + { + "source": 128, + "target": 141 + }, + { + "source": 128, + "target": 150 + }, + { + "source": 129, + "target": 130 + }, + { + "source": 129, + "target": 137 + }, + { + "source": 130, + "target": 137 + }, + { + "source": 131, + "target": 138 + }, + { + "source": 131, + "target": 139 + }, + { + "source": 132, + "target": 140 + }, + { + "source": 132, + "target": 142 + }, + { + "source": 132, + "target": 145 + }, + { + "source": 133, + "target": 136 + }, + { + "source": 134, + "target": 144 + }, + { + "source": 134, + "target": 147 + }, + { + "source": 135, + "target": 142 + }, + { + "source": 135, + "target": 149 + }, + { + "source": 136, + "target": 140 + }, + { + "source": 136, + "target": 146 + }, + { + "source": 137, + "target": 143 + }, + { + "source": 138, + "target": 139 + }, + { + "source": 138, + "target": 144 + }, + { + "source": 138, + "target": 147 + }, + { + "source": 139, + "target": 144 + }, + { + "source": 139, + "target": 147 + }, + { + "source": 140, + "target": 145 + }, + { + "source": 140, + "target": 146 + }, + { + "source": 141, + "target": 150 + }, + { + "source": 141, + "target": 151 + }, + { + "source": 142, + "target": 145 + }, + { + "source": 142, + "target": 149 + }, + { + "source": 143, + "target": 150 + }, + { + "source": 144, + "target": 147 + }, + { + "source": 145, + "target": 149 + }, + { + "source": 146, + "target": 148 + }, + { + "source": 146, + "target": 151 + }, + { + "source": 148, + "target": 151 + }, + { + "source": 150, + "target": 151 + } + ], + "adjacency": { + "1": [ + 5, + 6 + ], + "2": [ + 3, + 15, + 28 + ], + "3": [ + 2, + 4 + ], + "4": [ + 3, + 16 + ], + "5": [ + 1, + 12, + 13, + 17 + ], + "6": [ + 1, + 7, + 13, + 24 + ], + "7": [ + 6, + 8, + 18, + 24 + ], + "8": [ + 7, + 19 + ], + "9": [ + 10, + 39 + ], + "10": [ + 9, + 11, + 23 + ], + "11": [ + 10, + 12, + 23 + ], + "12": [ + 5, + 11, + 17, + 34 + ], + "13": [ + 5, + 6, + 24 + ], + "14": [ + 26, + 27, + 28, + 36 + ], + "15": [ + 2, + 20, + 28, + 29 + ], + "16": [ + 4, + 21, + 30 + ], + "17": [ + 5, + 12 + ], + "18": [ + 7, + 19, + 25 + ], + "19": [ + 8, + 18 + ], + "20": [ + 15, + 29, + 30 + ], + "21": [ + 16, + 30, + 32 + ], + "22": [ + 33, + 47 + ], + "23": [ + 10, + 11, + 34, + 39, + 49 + ], + "24": [ + 6, + 7, + 13 + ], + "25": [ + 18, + 35 + ], + "26": [ + 14, + 27, + 43 + ], + "27": [ + 14, + 26 + ], + "28": [ + 2, + 14, + 15, + 36, + 44 + ], + "29": [ + 15, + 20, + 37 + ], + "30": [ + 16, + 20, + 21, + 45, + 53 + ], + "31": [ + 33, + 48 + ], + "32": [ + 21, + 46 + ], + "33": [ + 22, + 31, + 47, + 48, + 59, + 62 + ], + "34": [ + 12, + 23, + 40, + 49, + 54 + ], + "35": [ + 25, + 42 + ], + "36": [ + 14, + 28, + 44, + 51, + 52 + ], + "37": [ + 29, + 45 + ], + "38": [ + 46, + 47 + ], + "39": [ + 9, + 23, + 54 + ], + "40": [ + 34, + 41, + 49, + 55 + ], + "41": [ + 40, + 42, + 55 + ], + "42": [ + 35, + 41, + 55, + 71 + ], + "43": [ + 26, + 50, + 87 + ], + "44": [ + 28, + 36, + 52, + 53 + ], + "45": [ + 30, + 37, + 53 + ], + "46": [ + 32, + 38 + ], + "47": [ + 22, + 33, + 38, + 59 + ], + "48": [ + 31, + 33 + ], + "49": [ + 23, + 34, + 40, + 54, + 65 + ], + "50": [ + 43, + 66 + ], + "51": [ + 36, + 67 + ], + "52": [ + 36, + 44 + ], + "53": [ + 30, + 44, + 45, + 56 + ], + "54": [ + 34, + 39, + 49 + ], + "55": [ + 40, + 41, + 42, + 71 + ], + "56": [ + 53, + 57, + 69 + ], + "57": [ + 56, + 58 + ], + "58": [ + 57, + 60 + ], + "59": [ + 33, + 47, + 61, + 62 + ], + "60": [ + 58, + 61 + ], + "61": [ + 59, + 60, + 62, + 79 + ], + "62": [ + 33, + 59, + 61, + 63, + 73, + 84, + 88 + ], + "63": [ + 62, + 73 + ], + "64": [ + 70, + 86 + ], + "65": [ + 49, + 75 + ], + "66": [ + 50, + 76, + 77, + 78 + ], + "67": [ + 51, + 68, + 83, + 87 + ], + "68": [ + 67, + 69, + 72 + ], + "69": [ + 56, + 68, + 72 + ], + "70": [ + 64, + 74, + 82, + 86 + ], + "71": [ + 42, + 55, + 75, + 76, + 77 + ], + "72": [ + 68, + 69, + 83, + 89 + ], + "73": [ + 62, + 63, + 80, + 84 + ], + "74": [ + 70, + 75 + ], + "75": [ + 65, + 71, + 74 + ], + "76": [ + 66, + 71, + 77 + ], + "77": [ + 66, + 71, + 76, + 78, + 87, + 108 + ], + "78": [ + 66, + 77 + ], + "79": [ + 61, + 84, + 88 + ], + "80": [ + 73, + 81, + 85, + 92 + ], + "81": [ + 80, + 85 + ], + "82": [ + 70, + 86, + 98 + ], + "83": [ + 67, + 72, + 87, + 89 + ], + "84": [ + 62, + 73, + 79, + 88, + 90 + ], + "85": [ + 80, + 81 + ], + "86": [ + 64, + 70, + 82, + 98 + ], + "87": [ + 43, + 67, + 77, + 83, + 89, + 94, + 108, + 112, + 113 + ], + "88": [ + 62, + 79, + 84, + 90, + 94, + 95 + ], + "89": [ + 72, + 83, + 87, + 113 + ], + "90": [ + 84, + 88, + 91, + 95, + 121 + ], + "91": [ + 90, + 92 + ], + "92": [ + 80, + 91 + ], + "93": [ + 97, + 99 + ], + "94": [ + 87, + 88, + 95, + 119, + 131 + ], + "95": [ + 88, + 90, + 94, + 121 + ], + "96": [ + 97, + 101, + 105, + 107 + ], + "97": [ + 93, + 96, + 101 + ], + "98": [ + 82, + 86, + 103, + 104 + ], + "99": [ + 93, + 100, + 101 + ], + "100": [ + 99, + 102, + 106 + ], + "101": [ + 96, + 97, + 99, + 105, + 110 + ], + "102": [ + 100, + 106, + 108, + 112, + 115 + ], + "103": [ + 98, + 104, + 109, + 114, + 122 + ], + "104": [ + 98, + 103 + ], + "105": [ + 96, + 101, + 107, + 110 + ], + "106": [ + 100, + 102, + 115 + ], + "107": [ + 96, + 105, + 109, + 110 + ], + "108": [ + 77, + 87, + 102, + 112 + ], + "109": [ + 103, + 107 + ], + "110": [ + 101, + 105, + 107, + 117 + ], + "111": [], + "112": [ + 87, + 102, + 108, + 113, + 116 + ], + "113": [ + 87, + 89, + 112, + 116 + ], + "114": [ + 103, + 122 + ], + "115": [ + 102, + 106, + 123, + 124 + ], + "116": [ + 112, + 113, + 119 + ], + "117": [ + 110, + 118, + 126, + 132 + ], + "118": [ + 117, + 126 + ], + "119": [ + 94, + 116, + 120, + 131 + ], + "120": [ + 119, + 130, + 131, + 138, + 147 + ], + "121": [ + 90, + 95, + 125 + ], + "122": [ + 103, + 114 + ], + "123": [ + 115, + 124 + ], + "124": [ + 115, + 123 + ], + "125": [ + 121, + 131, + 139, + 144 + ], + "126": [ + 117, + 118, + 132, + 133 + ], + "127": [ + 133, + 136 + ], + "128": [ + 129, + 141, + 150 + ], + "129": [ + 128, + 130, + 137 + ], + "130": [ + 120, + 129, + 137 + ], + "131": [ + 94, + 119, + 120, + 125, + 138, + 139 + ], + "132": [ + 117, + 126, + 140, + 142, + 145 + ], + "133": [ + 126, + 127, + 136 + ], + "134": [ + 144, + 147 + ], + "135": [ + 142, + 149 + ], + "136": [ + 127, + 133, + 140, + 146 + ], + "137": [ + 129, + 130, + 143 + ], + "138": [ + 120, + 131, + 139, + 144, + 147 + ], + "139": [ + 125, + 131, + 138, + 144, + 147 + ], + "140": [ + 132, + 136, + 145, + 146 + ], + "141": [ + 128, + 150, + 151 + ], + "142": [ + 132, + 135, + 145, + 149 + ], + "143": [ + 137, + 150 + ], + "144": [ + 125, + 134, + 138, + 139, + 147 + ], + "145": [ + 132, + 140, + 142, + 149 + ], + "146": [ + 136, + 140, + 148, + 151 + ], + "147": [ + 120, + 134, + 138, + 139, + 144 + ], + "148": [ + 146, + 151 + ], + "149": [ + 135, + 142, + 145 + ], + "150": [ + 128, + 141, + 143, + 151 + ], + "151": [ + 141, + 146, + 148, + 150 + ] + } +} diff --git a/data/raw/maps/normal_cv_out/junctions.csv b/data/raw/maps/normal_cv_out/junctions.csv new file mode 100644 index 0000000000000000000000000000000000000000..c894fd978ec2cf9f6dcde3c97f68d84726dd952a --- /dev/null +++ b/data/raw/maps/normal_cv_out/junctions.csv @@ -0,0 +1,152 @@ +id,x,y,radius,neighbors +1,424,79,12,5 6 +2,949,69,11,3 15 28 +3,1028,78,12,2 4 +4,1059,103,13,3 16 +5,353,119,12,1 12 13 17 +6,434,128,11,1 7 13 24 +7,562,123,12,6 8 18 24 +8,628,114,12,7 19 +9,99,153,12,10 39 +10,143,146,11,9 11 23 +11,233,151,11,10 12 23 +12,289,142,11,5 11 17 34 +13,392,153,11,5 6 24 +14,719,152,12,26 27 28 36 +15,925,133,11,2 20 28 29 +16,1054,148,12,4 21 30 +17,317,172,11,5 12 +18,573,173,11,7 19 25 +19,624,158,11,8 18 +20,976,163,11,15 29 30 +21,1114,162,11,16 30 32 +22,1243,179,12,33 47 +23,211,202,11,10 11 34 39 49 +24,424,184,11,6 7 13 +25,543,198,11,18 35 +26,696,187,11,14 27 43 +27,738,202,11,14 26 +28,817,191,11,2 14 15 36 44 +29,927,188,11,15 20 37 +30,1044,202,12,16 20 21 45 53 +31,1396,193,11,33 48 +32,1125,214,12,21 46 +33,1319,225,11,22 31 47 48 59 62 +34,242,237,9,12 23 40 49 54 +35,498,238,11,25 42 +36,796,246,12,14 28 44 51 52 +37,946,228,11,29 45 +38,1188,249,11,46 47 +39,139,263,9,9 23 54 +40,282,259,11,34 41 49 55 +41,368,266,11,40 42 55 +42,419,259,11,35 41 55 71 +43,682,263,11,26 50 87 +44,851,257,10,28 36 52 53 +45,984,259,10,30 37 53 +46,1133,264,11,32 38 +47,1243,265,11,22 33 38 59 +48,1367,265,12,31 33 +49,247,279,11,23 34 40 54 65 +50,621,288,11,43 66 +51,748,292,11,36 67 +52,819,282,11,36 44 +53,973,278,11,30 44 45 56 +54,203,303,11,34 39 49 +55,397,311,12,40 41 42 71 +56,974,321,11,53 57 69 +57,1056,318,11,56 58 +58,1102,323,11,57 60 +59,1244,318,9,33 47 61 62 +60,1156,340,10,58 61 +61,1188,338,11,59 60 62 79 +62,1264,347,11,33 59 61 63 73 84 88 +63,1322,333,10,62 73 +64,58,365,12,70 86 +65,281,354,11,49 75 +66,565,353,11,50 76 77 78 +67,768,352,11,51 68 83 87 +68,871,350,10,67 69 72 +69,931,348,11,56 68 72 +70,157,388,11,64 74 82 86 +71,384,376,11,42 55 75 76 77 +72,846,388,11,68 69 83 89 +73,1299,373,11,62 63 80 84 +74,222,408,11,70 75 +75,296,399,9,65 71 74 +76,493,402,11,66 71 77 +77,549,416,10,66 71 76 78 87 108 +78,588,398,9,66 77 +79,1170,408,11,61 84 88 +80,1303,416,10,73 81 85 92 +81,1378,400,12,80 85 +82,143,427,9,70 86 98 +83,777,428,11,67 72 87 89 +84,1211,439,11,62 73 79 88 90 +85,1397,437,11,80 81 +86,98,449,12,64 70 82 98 +87,757,463,13,43 67 77 83 89 94 108 112 113 +88,1132,458,11,62 79 84 90 94 95 +89,851,472,9,72 83 87 113 +90,1221,488,11,84 88 91 95 121 +91,1277,489,9,90 92 +92,1319,485,11,80 91 +93,386,511,9,97 99 +94,1048,505,11,87 88 95 119 131 +95,1145,514,12,88 90 94 121 +96,257,532,12,97 101 105 107 +97,352,530,9,93 96 101 +98,134,551,12,82 86 103 104 +99,430,556,13,93 100 101 +100,475,557,10,99 102 106 +101,377,584,11,96 97 99 105 110 +102,652,575,11,100 106 108 112 115 +103,146,599,11,98 104 109 114 122 +104,223,605,11,98 103 +105,325,605,9,96 101 107 110 +106,579,600,9,100 102 115 +107,265,620,9,96 105 109 110 +108,682,622,11,77 87 102 112 +109,209,647,9,103 107 +110,349,650,9,101 105 107 117 +111,487,656,12, +112,770,637,9,87 102 108 113 116 +113,836,637,11,87 89 112 116 +114,95,685,9,103 122 +115,551,688,9,102 106 123 124 +116,871,692,9,112 113 119 +117,409,719,11,110 118 126 132 +118,472,722,9,117 126 +119,925,731,11,94 116 120 131 +120,980,724,11,119 130 131 138 147 +121,1169,727,13,90 95 125 +122,91,745,9,103 114 +123,574,743,9,115 124 +124,622,740,9,115 123 +125,1075,740,9,121 131 139 144 +126,445,763,11,117 118 132 133 +127,542,763,10,133 136 +128,716,769,9,129 141 150 +129,773,766,9,128 130 137 +130,835,767,9,120 129 137 +131,986,768,11,94 119 120 125 138 139 +132,415,799,11,117 126 140 142 145 +133,488,788,9,126 127 136 +134,1159,787,11,144 147 +135,267,827,11,142 149 +136,530,812,9,127 133 140 146 +137,839,811,11,129 130 143 +138,958,806,9,120 131 139 144 147 +139,1016,806,11,125 131 138 144 147 +140,467,844,11,132 136 145 146 +141,737,845,9,128 150 151 +142,391,872,11,132 135 145 149 +143,839,854,9,137 150 +144,1103,857,9,125 134 138 139 147 +145,448,890,11,132 140 142 149 +146,572,899,12,136 140 148 151 +147,1003,881,10,120 134 138 139 144 +148,620,919,9,146 151 +149,301,932,11,135 142 145 +150,811,934,9,128 141 143 151 +151,686,958,12,141 146 148 150 diff --git a/data/raw/maps/subway_cv_out/graph.graphml b/data/raw/maps/subway_cv_out/graph.graphml new file mode 100644 index 0000000000000000000000000000000000000000..5299b61efd4b0fa7e44d43aca85ca9a49bc87eb4 --- /dev/null +++ b/data/raw/maps/subway_cv_out/graph.graphml @@ -0,0 +1,777 @@ + + + + + + 424 + 79 + 12 + + + 949 + 69 + 11 + + + 1028 + 78 + 12 + + + 1059 + 103 + 13 + + + 353 + 119 + 12 + + + 434 + 128 + 11 + + + 562 + 123 + 12 + + + 628 + 114 + 12 + + + 99 + 153 + 12 + + + 143 + 146 + 11 + + + 233 + 151 + 11 + + + 289 + 142 + 11 + + + 392 + 153 + 11 + + + 719 + 152 + 12 + + + 925 + 133 + 11 + + + 1054 + 148 + 12 + + + 317 + 172 + 11 + + + 573 + 173 + 11 + + + 624 + 158 + 11 + + + 976 + 163 + 11 + + + 1114 + 162 + 11 + + + 1243 + 179 + 12 + + + 211 + 202 + 11 + + + 424 + 184 + 11 + + + 543 + 198 + 11 + + + 696 + 187 + 11 + + + 738 + 202 + 11 + + + 817 + 191 + 11 + + + 927 + 188 + 11 + + + 1044 + 202 + 12 + + + 1396 + 193 + 11 + + + 1125 + 214 + 12 + + + 1319 + 225 + 11 + + + 242 + 237 + 9 + + + 498 + 238 + 11 + + + 796 + 246 + 12 + + + 946 + 228 + 11 + + + 1188 + 249 + 11 + + + 139 + 263 + 9 + + + 282 + 259 + 11 + + + 368 + 266 + 11 + + + 419 + 259 + 11 + + + 682 + 263 + 11 + + + 851 + 257 + 10 + + + 984 + 259 + 10 + + + 1133 + 264 + 11 + + + 1243 + 265 + 11 + + + 1367 + 265 + 12 + + + 247 + 279 + 11 + + + 621 + 288 + 11 + + + 748 + 292 + 11 + + + 819 + 282 + 11 + + + 973 + 278 + 11 + + + 203 + 303 + 11 + + + 397 + 311 + 12 + + + 974 + 321 + 11 + + + 1056 + 318 + 11 + + + 1102 + 323 + 11 + + + 1244 + 318 + 9 + + + 1156 + 340 + 10 + + + 1188 + 338 + 11 + + + 1264 + 347 + 11 + + + 1322 + 333 + 10 + + + 58 + 365 + 12 + + + 281 + 354 + 11 + + + 565 + 353 + 11 + + + 768 + 352 + 11 + + + 871 + 350 + 10 + + + 931 + 348 + 11 + + + 157 + 388 + 11 + + + 384 + 376 + 11 + + + 846 + 388 + 11 + + + 1299 + 373 + 11 + + + 222 + 408 + 11 + + + 296 + 399 + 9 + + + 493 + 402 + 11 + + + 549 + 416 + 10 + + + 588 + 398 + 9 + + + 1170 + 408 + 11 + + + 1303 + 416 + 10 + + + 1378 + 400 + 12 + + + 143 + 427 + 9 + + + 777 + 428 + 11 + + + 1211 + 439 + 11 + + + 1397 + 437 + 11 + + + 98 + 449 + 12 + + + 757 + 463 + 13 + + + 1132 + 458 + 11 + + + 851 + 472 + 9 + + + 1221 + 488 + 11 + + + 1277 + 489 + 9 + + + 1319 + 485 + 11 + + + 386 + 511 + 9 + + + 1048 + 505 + 11 + + + 1145 + 514 + 12 + + + 257 + 532 + 12 + + + 352 + 530 + 9 + + + 134 + 551 + 12 + + + 430 + 556 + 13 + + + 475 + 557 + 10 + + + 377 + 584 + 11 + + + 652 + 575 + 11 + + + 146 + 599 + 11 + + + 223 + 605 + 11 + + + 325 + 605 + 9 + + + 579 + 600 + 9 + + + 265 + 620 + 9 + + + 682 + 622 + 11 + + + 209 + 647 + 9 + + + 349 + 650 + 9 + + + 487 + 656 + 12 + + + 770 + 637 + 9 + + + 836 + 637 + 11 + + + 95 + 685 + 9 + + + 551 + 688 + 9 + + + 871 + 692 + 9 + + + 409 + 719 + 11 + + + 472 + 722 + 9 + + + 925 + 731 + 11 + + + 980 + 724 + 11 + + + 1169 + 727 + 13 + + + 91 + 745 + 9 + + + 574 + 743 + 9 + + + 622 + 740 + 9 + + + 1075 + 740 + 9 + + + 445 + 763 + 11 + + + 542 + 763 + 10 + + + 716 + 769 + 9 + + + 773 + 766 + 9 + + + 835 + 767 + 9 + + + 986 + 768 + 11 + + + 415 + 799 + 11 + + + 488 + 788 + 9 + + + 1159 + 787 + 11 + + + 267 + 827 + 11 + + + 530 + 812 + 9 + + + 839 + 811 + 11 + + + 958 + 806 + 9 + + + 1016 + 806 + 11 + + + 467 + 844 + 11 + + + 737 + 845 + 9 + + + 391 + 872 + 11 + + + 839 + 854 + 9 + + + 1103 + 857 + 9 + + + 448 + 890 + 11 + + + 572 + 899 + 12 + + + 1003 + 881 + 10 + + + 620 + 919 + 9 + + + 301 + 932 + 11 + + + 811 + 934 + 9 + + + 686 + 958 + 12 + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/data/raw/maps/subway_cv_out/graph.json b/data/raw/maps/subway_cv_out/graph.json new file mode 100644 index 0000000000000000000000000000000000000000..7ee22bacdec1036ed02b6a650a81d5ef14e3c00e --- /dev/null +++ b/data/raw/maps/subway_cv_out/graph.json @@ -0,0 +1,1185 @@ +{ + "nodes": [ + { + "id": 1, + "x": 424, + "y": 79, + "r": 12 + }, + { + "id": 2, + "x": 949, + "y": 69, + "r": 11 + }, + { + "id": 3, + "x": 1028, + "y": 78, + "r": 12 + }, + { + "id": 4, + "x": 1059, + "y": 103, + "r": 13 + }, + { + "id": 5, + "x": 353, + "y": 119, + "r": 12 + }, + { + "id": 6, + "x": 434, + "y": 128, + "r": 11 + }, + { + "id": 7, + "x": 562, + "y": 123, + "r": 12 + }, + { + "id": 8, + "x": 628, + "y": 114, + "r": 12 + }, + { + "id": 9, + "x": 99, + "y": 153, + "r": 12 + }, + { + "id": 10, + "x": 143, + "y": 146, + "r": 11 + }, + { + "id": 11, + "x": 233, + "y": 151, + "r": 11 + }, + { + "id": 12, + "x": 289, + "y": 142, + "r": 11 + }, + { + "id": 13, + "x": 392, + "y": 153, + "r": 11 + }, + { + "id": 14, + "x": 719, + "y": 152, + "r": 12 + }, + { + "id": 15, + "x": 925, + "y": 133, + "r": 11 + }, + { + "id": 16, + "x": 1054, + "y": 148, + "r": 12 + }, + { + "id": 17, + "x": 317, + "y": 172, + "r": 11 + }, + { + "id": 18, + "x": 573, + "y": 173, + "r": 11 + }, + { + "id": 19, + "x": 624, + "y": 158, + "r": 11 + }, + { + "id": 20, + "x": 976, + "y": 163, + "r": 11 + }, + { + "id": 21, + "x": 1114, + "y": 162, + "r": 11 + }, + { + "id": 22, + "x": 1243, + "y": 179, + "r": 12 + }, + { + "id": 23, + "x": 211, + "y": 202, + "r": 11 + }, + { + "id": 24, + "x": 424, + "y": 184, + "r": 11 + }, + { + "id": 25, + "x": 543, + "y": 198, + "r": 11 + }, + { + "id": 26, + "x": 696, + "y": 187, + "r": 11 + }, + { + "id": 27, + "x": 738, + "y": 202, + "r": 11 + }, + { + "id": 28, + "x": 817, + "y": 191, + "r": 11 + }, + { + "id": 29, + "x": 927, + "y": 188, + "r": 11 + }, + { + "id": 30, + "x": 1044, + "y": 202, + "r": 12 + }, + { + "id": 31, + "x": 1396, + "y": 193, + "r": 11 + }, + { + "id": 32, + "x": 1125, + "y": 214, + "r": 12 + }, + { + "id": 33, + "x": 1319, + "y": 225, + "r": 11 + }, + { + "id": 34, + "x": 242, + "y": 237, + "r": 9 + }, + { + "id": 35, + "x": 498, + "y": 238, + "r": 11 + }, + { + "id": 36, + "x": 796, + "y": 246, + "r": 12 + }, + { + "id": 37, + "x": 946, + "y": 228, + "r": 11 + }, + { + "id": 38, + "x": 1188, + "y": 249, + "r": 11 + }, + { + "id": 39, + "x": 139, + "y": 263, + "r": 9 + }, + { + "id": 40, + "x": 282, + "y": 259, + "r": 11 + }, + { + "id": 41, + "x": 368, + "y": 266, + "r": 11 + }, + { + "id": 42, + "x": 419, + "y": 259, + "r": 11 + }, + { + "id": 43, + "x": 682, + "y": 263, + "r": 11 + }, + { + "id": 44, + "x": 851, + "y": 257, + "r": 10 + }, + { + "id": 45, + "x": 984, + "y": 259, + "r": 10 + }, + { + "id": 46, + "x": 1133, + "y": 264, + "r": 11 + }, + { + "id": 47, + "x": 1243, + "y": 265, + "r": 11 + }, + { + "id": 48, + "x": 1367, + "y": 265, + "r": 12 + }, + { + "id": 49, + "x": 247, + "y": 279, + "r": 11 + }, + { + "id": 50, + "x": 621, + "y": 288, + "r": 11 + }, + { + "id": 51, + "x": 748, + "y": 292, + "r": 11 + }, + { + "id": 52, + "x": 819, + "y": 282, + "r": 11 + }, + { + "id": 53, + "x": 973, + "y": 278, + "r": 11 + }, + { + "id": 54, + "x": 203, + "y": 303, + "r": 11 + }, + { + "id": 55, + "x": 397, + "y": 311, + "r": 12 + }, + { + "id": 56, + "x": 974, + "y": 321, + "r": 11 + }, + { + "id": 57, + "x": 1056, + "y": 318, + "r": 11 + }, + { + "id": 58, + "x": 1102, + "y": 323, + "r": 11 + }, + { + "id": 59, + "x": 1244, + "y": 318, + "r": 9 + }, + { + "id": 60, + "x": 1156, + "y": 340, + "r": 10 + }, + { + "id": 61, + "x": 1188, + "y": 338, + "r": 11 + }, + { + "id": 62, + "x": 1264, + "y": 347, + "r": 11 + }, + { + "id": 63, + "x": 1322, + "y": 333, + "r": 10 + }, + { + "id": 64, + "x": 58, + "y": 365, + "r": 12 + }, + { + "id": 65, + "x": 281, + "y": 354, + "r": 11 + }, + { + "id": 66, + "x": 565, + "y": 353, + "r": 11 + }, + { + "id": 67, + "x": 768, + "y": 352, + "r": 11 + }, + { + "id": 68, + "x": 871, + "y": 350, + "r": 10 + }, + { + "id": 69, + "x": 931, + "y": 348, + "r": 11 + }, + { + "id": 70, + "x": 157, + "y": 388, + "r": 11 + }, + { + "id": 71, + "x": 384, + "y": 376, + "r": 11 + }, + { + "id": 72, + "x": 846, + "y": 388, + "r": 11 + }, + { + "id": 73, + "x": 1299, + "y": 373, + "r": 11 + }, + { + "id": 74, + "x": 222, + "y": 408, + "r": 11 + }, + { + "id": 75, + "x": 296, + "y": 399, + "r": 9 + }, + { + "id": 76, + "x": 493, + "y": 402, + "r": 11 + }, + { + "id": 77, + "x": 549, + "y": 416, + "r": 10 + }, + { + "id": 78, + "x": 588, + "y": 398, + "r": 9 + }, + { + "id": 79, + "x": 1170, + "y": 408, + "r": 11 + }, + { + "id": 80, + "x": 1303, + "y": 416, + "r": 10 + }, + { + "id": 81, + "x": 1378, + "y": 400, + "r": 12 + }, + { + "id": 82, + "x": 143, + "y": 427, + "r": 9 + }, + { + "id": 83, + "x": 777, + "y": 428, + "r": 11 + }, + { + "id": 84, + "x": 1211, + "y": 439, + "r": 11 + }, + { + "id": 85, + "x": 1397, + "y": 437, + "r": 11 + }, + { + "id": 86, + "x": 98, + "y": 449, + "r": 12 + }, + { + "id": 87, + "x": 757, + "y": 463, + "r": 13 + }, + { + "id": 88, + "x": 1132, + "y": 458, + "r": 11 + }, + { + "id": 89, + "x": 851, + "y": 472, + "r": 9 + }, + { + "id": 90, + "x": 1221, + "y": 488, + "r": 11 + }, + { + "id": 91, + "x": 1277, + "y": 489, + "r": 9 + }, + { + "id": 92, + "x": 1319, + "y": 485, + "r": 11 + }, + { + "id": 93, + "x": 386, + "y": 511, + "r": 9 + }, + { + "id": 94, + "x": 1048, + "y": 505, + "r": 11 + }, + { + "id": 95, + "x": 1145, + "y": 514, + "r": 12 + }, + { + "id": 96, + "x": 257, + "y": 532, + "r": 12 + }, + { + "id": 97, + "x": 352, + "y": 530, + "r": 9 + }, + { + "id": 98, + "x": 134, + "y": 551, + "r": 12 + }, + { + "id": 99, + "x": 430, + "y": 556, + "r": 13 + }, + { + "id": 100, + "x": 475, + "y": 557, + "r": 10 + }, + { + "id": 101, + "x": 377, + "y": 584, + "r": 11 + }, + { + "id": 102, + "x": 652, + "y": 575, + "r": 11 + }, + { + "id": 103, + "x": 146, + "y": 599, + "r": 11 + }, + { + "id": 104, + "x": 223, + "y": 605, + "r": 11 + }, + { + "id": 105, + "x": 325, + "y": 605, + "r": 9 + }, + { + "id": 106, + "x": 579, + "y": 600, + "r": 9 + }, + { + "id": 107, + "x": 265, + "y": 620, + "r": 9 + }, + { + "id": 108, + "x": 682, + "y": 622, + "r": 11 + }, + { + "id": 109, + "x": 209, + "y": 647, + "r": 9 + }, + { + "id": 110, + "x": 349, + "y": 650, + "r": 9 + }, + { + "id": 111, + "x": 487, + "y": 656, + "r": 12 + }, + { + "id": 112, + "x": 770, + "y": 637, + "r": 9 + }, + { + "id": 113, + "x": 836, + "y": 637, + "r": 11 + }, + { + "id": 114, + "x": 95, + "y": 685, + "r": 9 + }, + { + "id": 115, + "x": 551, + "y": 688, + "r": 9 + }, + { + "id": 116, + "x": 871, + "y": 692, + "r": 9 + }, + { + "id": 117, + "x": 409, + "y": 719, + "r": 11 + }, + { + "id": 118, + "x": 472, + "y": 722, + "r": 9 + }, + { + "id": 119, + "x": 925, + "y": 731, + "r": 11 + }, + { + "id": 120, + "x": 980, + "y": 724, + "r": 11 + }, + { + "id": 121, + "x": 1169, + "y": 727, + "r": 13 + }, + { + "id": 122, + "x": 91, + "y": 745, + "r": 9 + }, + { + "id": 123, + "x": 574, + "y": 743, + "r": 9 + }, + { + "id": 124, + "x": 622, + "y": 740, + "r": 9 + }, + { + "id": 125, + "x": 1075, + "y": 740, + "r": 9 + }, + { + "id": 126, + "x": 445, + "y": 763, + "r": 11 + }, + { + "id": 127, + "x": 542, + "y": 763, + "r": 10 + }, + { + "id": 128, + "x": 716, + "y": 769, + "r": 9 + }, + { + "id": 129, + "x": 773, + "y": 766, + "r": 9 + }, + { + "id": 130, + "x": 835, + "y": 767, + "r": 9 + }, + { + "id": 131, + "x": 986, + "y": 768, + "r": 11 + }, + { + "id": 132, + "x": 415, + "y": 799, + "r": 11 + }, + { + "id": 133, + "x": 488, + "y": 788, + "r": 9 + }, + { + "id": 134, + "x": 1159, + "y": 787, + "r": 11 + }, + { + "id": 135, + "x": 267, + "y": 827, + "r": 11 + }, + { + "id": 136, + "x": 530, + "y": 812, + "r": 9 + }, + { + "id": 137, + "x": 839, + "y": 811, + "r": 11 + }, + { + "id": 138, + "x": 958, + "y": 806, + "r": 9 + }, + { + "id": 139, + "x": 1016, + "y": 806, + "r": 11 + }, + { + "id": 140, + "x": 467, + "y": 844, + "r": 11 + }, + { + "id": 141, + "x": 737, + "y": 845, + "r": 9 + }, + { + "id": 142, + "x": 391, + "y": 872, + "r": 11 + }, + { + "id": 143, + "x": 839, + "y": 854, + "r": 9 + }, + { + "id": 144, + "x": 1103, + "y": 857, + "r": 9 + }, + { + "id": 145, + "x": 448, + "y": 890, + "r": 11 + }, + { + "id": 146, + "x": 572, + "y": 899, + "r": 12 + }, + { + "id": 147, + "x": 1003, + "y": 881, + "r": 10 + }, + { + "id": 148, + "x": 620, + "y": 919, + "r": 9 + }, + { + "id": 149, + "x": 301, + "y": 932, + "r": 11 + }, + { + "id": 150, + "x": 811, + "y": 934, + "r": 9 + }, + { + "id": 151, + "x": 686, + "y": 958, + "r": 12 + } + ], + "edges": [ + { + "source": 14, + "target": 43 + }, + { + "source": 33, + "target": 84 + }, + { + "source": 40, + "target": 64 + }, + { + "source": 40, + "target": 71 + }, + { + "source": 43, + "target": 87 + }, + { + "source": 49, + "target": 54 + }, + { + "source": 71, + "target": 76 + }, + { + "source": 71, + "target": 87 + }, + { + "source": 71, + "target": 93 + }, + { + "source": 84, + "target": 94 + }, + { + "source": 87, + "target": 94 + }, + { + "source": 87, + "target": 111 + }, + { + "source": 94, + "target": 120 + }, + { + "source": 111, + "target": 120 + }, + { + "source": 111, + "target": 122 + }, + { + "source": 120, + "target": 121 + }, + { + "source": 120, + "target": 147 + } + ], + "adjacency": { + "1": [], + "2": [], + "3": [], + "4": [], + "5": [], + "6": [], + "7": [], + "8": [], + "9": [], + "10": [], + "11": [], + "12": [], + "13": [], + "14": [ + 43 + ], + "15": [], + "16": [], + "17": [], + "18": [], + "19": [], + "20": [], + "21": [], + "22": [], + "23": [], + "24": [], + "25": [], + "26": [], + "27": [], + "28": [], + "29": [], + "30": [], + "31": [], + "32": [], + "33": [ + 84 + ], + "34": [], + "35": [], + "36": [], + "37": [], + "38": [], + "39": [], + "40": [ + 64, + 71 + ], + "41": [], + "42": [], + "43": [ + 14, + 87 + ], + "44": [], + "45": [], + "46": [], + "47": [], + "48": [], + "49": [ + 54 + ], + "50": [], + "51": [], + "52": [], + "53": [], + "54": [ + 49 + ], + "55": [], + "56": [], + "57": [], + "58": [], + "59": [], + "60": [], + "61": [], + "62": [], + "63": [], + "64": [ + 40 + ], + "65": [], + "66": [], + "67": [], + "68": [], + "69": [], + "70": [], + "71": [ + 40, + 76, + 87, + 93 + ], + "72": [], + "73": [], + "74": [], + "75": [], + "76": [ + 71 + ], + "77": [], + "78": [], + "79": [], + "80": [], + "81": [], + "82": [], + "83": [], + "84": [ + 33, + 94 + ], + "85": [], + "86": [], + "87": [ + 43, + 71, + 94, + 111 + ], + "88": [], + "89": [], + "90": [], + "91": [], + "92": [], + "93": [ + 71 + ], + "94": [ + 84, + 87, + 120 + ], + "95": [], + "96": [], + "97": [], + "98": [], + "99": [], + "100": [], + "101": [], + "102": [], + "103": [], + "104": [], + "105": [], + "106": [], + "107": [], + "108": [], + "109": [], + "110": [], + "111": [ + 87, + 120, + 122 + ], + "112": [], + "113": [], + "114": [], + "115": [], + "116": [], + "117": [], + "118": [], + "119": [], + "120": [ + 94, + 111, + 121, + 147 + ], + "121": [ + 120 + ], + "122": [ + 111 + ], + "123": [], + "124": [], + "125": [], + "126": [], + "127": [], + "128": [], + "129": [], + "130": [], + "131": [], + "132": [], + "133": [], + "134": [], + "135": [], + "136": [], + "137": [], + "138": [], + "139": [], + "140": [], + "141": [], + "142": [], + "143": [], + "144": [], + "145": [], + "146": [], + "147": [ + 120 + ], + "148": [], + "149": [], + "150": [], + "151": [] + } +} diff --git a/data/raw/maps/subway_cv_out/junctions.csv b/data/raw/maps/subway_cv_out/junctions.csv new file mode 100644 index 0000000000000000000000000000000000000000..d08dfa629d22f9c701270a7a0714d94bc27400db --- /dev/null +++ b/data/raw/maps/subway_cv_out/junctions.csv @@ -0,0 +1,152 @@ +id,x,y,radius,neighbors +1,424,79,12, +2,949,69,11, +3,1028,78,12, +4,1059,103,13, +5,353,119,12, +6,434,128,11, +7,562,123,12, +8,628,114,12, +9,99,153,12, +10,143,146,11, +11,233,151,11, +12,289,142,11, +13,392,153,11, +14,719,152,12,43 +15,925,133,11, +16,1054,148,12, +17,317,172,11, +18,573,173,11, +19,624,158,11, +20,976,163,11, +21,1114,162,11, +22,1243,179,12, +23,211,202,11, +24,424,184,11, +25,543,198,11, +26,696,187,11, +27,738,202,11, +28,817,191,11, +29,927,188,11, +30,1044,202,12, +31,1396,193,11, +32,1125,214,12, +33,1319,225,11,84 +34,242,237,9, +35,498,238,11, +36,796,246,12, +37,946,228,11, +38,1188,249,11, +39,139,263,9, +40,282,259,11,64 71 +41,368,266,11, +42,419,259,11, +43,682,263,11,14 87 +44,851,257,10, +45,984,259,10, +46,1133,264,11, +47,1243,265,11, +48,1367,265,12, +49,247,279,11,54 +50,621,288,11, +51,748,292,11, +52,819,282,11, +53,973,278,11, +54,203,303,11,49 +55,397,311,12, +56,974,321,11, +57,1056,318,11, +58,1102,323,11, +59,1244,318,9, +60,1156,340,10, +61,1188,338,11, +62,1264,347,11, +63,1322,333,10, +64,58,365,12,40 +65,281,354,11, +66,565,353,11, +67,768,352,11, +68,871,350,10, +69,931,348,11, +70,157,388,11, +71,384,376,11,40 76 87 93 +72,846,388,11, +73,1299,373,11, +74,222,408,11, +75,296,399,9, +76,493,402,11,71 +77,549,416,10, +78,588,398,9, +79,1170,408,11, +80,1303,416,10, +81,1378,400,12, +82,143,427,9, +83,777,428,11, +84,1211,439,11,33 94 +85,1397,437,11, +86,98,449,12, +87,757,463,13,43 71 94 111 +88,1132,458,11, +89,851,472,9, +90,1221,488,11, +91,1277,489,9, +92,1319,485,11, +93,386,511,9,71 +94,1048,505,11,84 87 120 +95,1145,514,12, +96,257,532,12, +97,352,530,9, +98,134,551,12, +99,430,556,13, +100,475,557,10, +101,377,584,11, +102,652,575,11, +103,146,599,11, +104,223,605,11, +105,325,605,9, +106,579,600,9, +107,265,620,9, +108,682,622,11, +109,209,647,9, +110,349,650,9, +111,487,656,12,87 120 122 +112,770,637,9, +113,836,637,11, +114,95,685,9, +115,551,688,9, +116,871,692,9, +117,409,719,11, +118,472,722,9, +119,925,731,11, +120,980,724,11,94 111 121 147 +121,1169,727,13,120 +122,91,745,9,111 +123,574,743,9, +124,622,740,9, +125,1075,740,9, +126,445,763,11, +127,542,763,10, +128,716,769,9, +129,773,766,9, +130,835,767,9, +131,986,768,11, +132,415,799,11, +133,488,788,9, +134,1159,787,11, +135,267,827,11, +136,530,812,9, +137,839,811,11, +138,958,806,9, +139,1016,806,11, +140,467,844,11, +141,737,845,9, +142,391,872,11, +143,839,854,9, +144,1103,857,9, +145,448,890,11, +146,572,899,12, +147,1003,881,10,120 +148,620,919,9, +149,301,932,11, +150,811,934,9, +151,686,958,12, diff --git a/data/raw/maps/taxi_cv_out/graph.graphml b/data/raw/maps/taxi_cv_out/graph.graphml new file mode 100644 index 0000000000000000000000000000000000000000..4ad975275ec6f9ab7c39c19397fcb37797276c21 --- /dev/null +++ b/data/raw/maps/taxi_cv_out/graph.graphml @@ -0,0 +1,954 @@ + + + + + + 424 + 79 + 12 + + + 949 + 69 + 11 + + + 1028 + 78 + 12 + + + 1059 + 103 + 13 + + + 353 + 119 + 12 + + + 434 + 128 + 11 + + + 562 + 123 + 12 + + + 628 + 114 + 12 + + + 99 + 153 + 12 + + + 143 + 146 + 11 + + + 233 + 151 + 11 + + + 289 + 142 + 11 + + + 392 + 153 + 11 + + + 719 + 152 + 12 + + + 925 + 133 + 11 + + + 1054 + 148 + 12 + + + 317 + 172 + 11 + + + 573 + 173 + 11 + + + 624 + 158 + 11 + + + 976 + 163 + 11 + + + 1114 + 162 + 11 + + + 1243 + 179 + 12 + + + 211 + 202 + 11 + + + 424 + 184 + 11 + + + 543 + 198 + 11 + + + 696 + 187 + 11 + + + 738 + 202 + 11 + + + 817 + 191 + 11 + + + 927 + 188 + 11 + + + 1044 + 202 + 12 + + + 1396 + 193 + 11 + + + 1125 + 214 + 12 + + + 1319 + 225 + 11 + + + 242 + 237 + 9 + + + 498 + 238 + 11 + + + 796 + 246 + 12 + + + 946 + 228 + 11 + + + 1188 + 249 + 11 + + + 139 + 263 + 9 + + + 282 + 259 + 11 + + + 368 + 266 + 11 + + + 419 + 259 + 11 + + + 682 + 263 + 11 + + + 851 + 257 + 10 + + + 984 + 259 + 10 + + + 1133 + 264 + 11 + + + 1243 + 265 + 11 + + + 1367 + 265 + 12 + + + 247 + 279 + 11 + + + 621 + 288 + 11 + + + 748 + 292 + 11 + + + 819 + 282 + 11 + + + 973 + 278 + 11 + + + 203 + 303 + 11 + + + 397 + 311 + 12 + + + 974 + 321 + 11 + + + 1056 + 318 + 11 + + + 1102 + 323 + 11 + + + 1244 + 318 + 9 + + + 1156 + 340 + 10 + + + 1188 + 338 + 11 + + + 1264 + 347 + 11 + + + 1322 + 333 + 10 + + + 58 + 365 + 12 + + + 281 + 354 + 11 + + + 565 + 353 + 11 + + + 768 + 352 + 11 + + + 871 + 350 + 10 + + + 931 + 348 + 11 + + + 157 + 388 + 11 + + + 384 + 376 + 11 + + + 846 + 388 + 11 + + + 1299 + 373 + 11 + + + 222 + 408 + 11 + + + 296 + 399 + 9 + + + 493 + 402 + 11 + + + 549 + 416 + 10 + + + 588 + 398 + 9 + + + 1170 + 408 + 11 + + + 1303 + 416 + 10 + + + 1378 + 400 + 12 + + + 143 + 427 + 9 + + + 777 + 428 + 11 + + + 1211 + 439 + 11 + + + 1397 + 437 + 11 + + + 98 + 449 + 12 + + + 757 + 463 + 13 + + + 1132 + 458 + 11 + + + 851 + 472 + 9 + + + 1221 + 488 + 11 + + + 1277 + 489 + 9 + + + 1319 + 485 + 11 + + + 386 + 511 + 9 + + + 1048 + 505 + 11 + + + 1145 + 514 + 12 + + + 257 + 532 + 12 + + + 352 + 530 + 9 + + + 134 + 551 + 12 + + + 430 + 556 + 13 + + + 475 + 557 + 10 + + + 377 + 584 + 11 + + + 652 + 575 + 11 + + + 146 + 599 + 11 + + + 223 + 605 + 11 + + + 325 + 605 + 9 + + + 579 + 600 + 9 + + + 265 + 620 + 9 + + + 682 + 622 + 11 + + + 209 + 647 + 9 + + + 349 + 650 + 9 + + + 487 + 656 + 12 + + + 770 + 637 + 9 + + + 836 + 637 + 11 + + + 95 + 685 + 9 + + + 551 + 688 + 9 + + + 871 + 692 + 9 + + + 409 + 719 + 11 + + + 472 + 722 + 9 + + + 925 + 731 + 11 + + + 980 + 724 + 11 + + + 1169 + 727 + 13 + + + 91 + 745 + 9 + + + 574 + 743 + 9 + + + 622 + 740 + 9 + + + 1075 + 740 + 9 + + + 445 + 763 + 11 + + + 542 + 763 + 10 + + + 716 + 769 + 9 + + + 773 + 766 + 9 + + + 835 + 767 + 9 + + + 986 + 768 + 11 + + + 415 + 799 + 11 + + + 488 + 788 + 9 + + + 1159 + 787 + 11 + + + 267 + 827 + 11 + + + 530 + 812 + 9 + + + 839 + 811 + 11 + + + 958 + 806 + 9 + + + 1016 + 806 + 11 + + + 467 + 844 + 11 + + + 737 + 845 + 9 + + + 391 + 872 + 11 + + + 839 + 854 + 9 + + + 1103 + 857 + 9 + + + 448 + 890 + 11 + + + 572 + 899 + 12 + + + 1003 + 881 + 10 + + + 620 + 919 + 9 + + + 301 + 932 + 11 + + + 811 + 934 + 9 + + + 686 + 958 + 12 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/data/raw/maps/taxi_cv_out/graph.json b/data/raw/maps/taxi_cv_out/graph.json new file mode 100644 index 0000000000000000000000000000000000000000..6df4133689f411bddaa2568fad88b244da85672b --- /dev/null +++ b/data/raw/maps/taxi_cv_out/graph.json @@ -0,0 +1,2380 @@ +{ + "nodes": [ + { + "id": 1, + "x": 424, + "y": 79, + "r": 12 + }, + { + "id": 2, + "x": 949, + "y": 69, + "r": 11 + }, + { + "id": 3, + "x": 1028, + "y": 78, + "r": 12 + }, + { + "id": 4, + "x": 1059, + "y": 103, + "r": 13 + }, + { + "id": 5, + "x": 353, + "y": 119, + "r": 12 + }, + { + "id": 6, + "x": 434, + "y": 128, + "r": 11 + }, + { + "id": 7, + "x": 562, + "y": 123, + "r": 12 + }, + { + "id": 8, + "x": 628, + "y": 114, + "r": 12 + }, + { + "id": 9, + "x": 99, + "y": 153, + "r": 12 + }, + { + "id": 10, + "x": 143, + "y": 146, + "r": 11 + }, + { + "id": 11, + "x": 233, + "y": 151, + "r": 11 + }, + { + "id": 12, + "x": 289, + "y": 142, + "r": 11 + }, + { + "id": 13, + "x": 392, + "y": 153, + "r": 11 + }, + { + "id": 14, + "x": 719, + "y": 152, + "r": 12 + }, + { + "id": 15, + "x": 925, + "y": 133, + "r": 11 + }, + { + "id": 16, + "x": 1054, + "y": 148, + "r": 12 + }, + { + "id": 17, + "x": 317, + "y": 172, + "r": 11 + }, + { + "id": 18, + "x": 573, + "y": 173, + "r": 11 + }, + { + "id": 19, + "x": 624, + "y": 158, + "r": 11 + }, + { + "id": 20, + "x": 976, + "y": 163, + "r": 11 + }, + { + "id": 21, + "x": 1114, + "y": 162, + "r": 11 + }, + { + "id": 22, + "x": 1243, + "y": 179, + "r": 12 + }, + { + "id": 23, + "x": 211, + "y": 202, + "r": 11 + }, + { + "id": 24, + "x": 424, + "y": 184, + "r": 11 + }, + { + "id": 25, + "x": 543, + "y": 198, + "r": 11 + }, + { + "id": 26, + "x": 696, + "y": 187, + "r": 11 + }, + { + "id": 27, + "x": 738, + "y": 202, + "r": 11 + }, + { + "id": 28, + "x": 817, + "y": 191, + "r": 11 + }, + { + "id": 29, + "x": 927, + "y": 188, + "r": 11 + }, + { + "id": 30, + "x": 1044, + "y": 202, + "r": 12 + }, + { + "id": 31, + "x": 1396, + "y": 193, + "r": 11 + }, + { + "id": 32, + "x": 1125, + "y": 214, + "r": 12 + }, + { + "id": 33, + "x": 1319, + "y": 225, + "r": 11 + }, + { + "id": 34, + "x": 242, + "y": 237, + "r": 9 + }, + { + "id": 35, + "x": 498, + "y": 238, + "r": 11 + }, + { + "id": 36, + "x": 796, + "y": 246, + "r": 12 + }, + { + "id": 37, + "x": 946, + "y": 228, + "r": 11 + }, + { + "id": 38, + "x": 1188, + "y": 249, + "r": 11 + }, + { + "id": 39, + "x": 139, + "y": 263, + "r": 9 + }, + { + "id": 40, + "x": 282, + "y": 259, + "r": 11 + }, + { + "id": 41, + "x": 368, + "y": 266, + "r": 11 + }, + { + "id": 42, + "x": 419, + "y": 259, + "r": 11 + }, + { + "id": 43, + "x": 682, + "y": 263, + "r": 11 + }, + { + "id": 44, + "x": 851, + "y": 257, + "r": 10 + }, + { + "id": 45, + "x": 984, + "y": 259, + "r": 10 + }, + { + "id": 46, + "x": 1133, + "y": 264, + "r": 11 + }, + { + "id": 47, + "x": 1243, + "y": 265, + "r": 11 + }, + { + "id": 48, + "x": 1367, + "y": 265, + "r": 12 + }, + { + "id": 49, + "x": 247, + "y": 279, + "r": 11 + }, + { + "id": 50, + "x": 621, + "y": 288, + "r": 11 + }, + { + "id": 51, + "x": 748, + "y": 292, + "r": 11 + }, + { + "id": 52, + "x": 819, + "y": 282, + "r": 11 + }, + { + "id": 53, + "x": 973, + "y": 278, + "r": 11 + }, + { + "id": 54, + "x": 203, + "y": 303, + "r": 11 + }, + { + "id": 55, + "x": 397, + "y": 311, + "r": 12 + }, + { + "id": 56, + "x": 974, + "y": 321, + "r": 11 + }, + { + "id": 57, + "x": 1056, + "y": 318, + "r": 11 + }, + { + "id": 58, + "x": 1102, + "y": 323, + "r": 11 + }, + { + "id": 59, + "x": 1244, + "y": 318, + "r": 9 + }, + { + "id": 60, + "x": 1156, + "y": 340, + "r": 10 + }, + { + "id": 61, + "x": 1188, + "y": 338, + "r": 11 + }, + { + "id": 62, + "x": 1264, + "y": 347, + "r": 11 + }, + { + "id": 63, + "x": 1322, + "y": 333, + "r": 10 + }, + { + "id": 64, + "x": 58, + "y": 365, + "r": 12 + }, + { + "id": 65, + "x": 281, + "y": 354, + "r": 11 + }, + { + "id": 66, + "x": 565, + "y": 353, + "r": 11 + }, + { + "id": 67, + "x": 768, + "y": 352, + "r": 11 + }, + { + "id": 68, + "x": 871, + "y": 350, + "r": 10 + }, + { + "id": 69, + "x": 931, + "y": 348, + "r": 11 + }, + { + "id": 70, + "x": 157, + "y": 388, + "r": 11 + }, + { + "id": 71, + "x": 384, + "y": 376, + "r": 11 + }, + { + "id": 72, + "x": 846, + "y": 388, + "r": 11 + }, + { + "id": 73, + "x": 1299, + "y": 373, + "r": 11 + }, + { + "id": 74, + "x": 222, + "y": 408, + "r": 11 + }, + { + "id": 75, + "x": 296, + "y": 399, + "r": 9 + }, + { + "id": 76, + "x": 493, + "y": 402, + "r": 11 + }, + { + "id": 77, + "x": 549, + "y": 416, + "r": 10 + }, + { + "id": 78, + "x": 588, + "y": 398, + "r": 9 + }, + { + "id": 79, + "x": 1170, + "y": 408, + "r": 11 + }, + { + "id": 80, + "x": 1303, + "y": 416, + "r": 10 + }, + { + "id": 81, + "x": 1378, + "y": 400, + "r": 12 + }, + { + "id": 82, + "x": 143, + "y": 427, + "r": 9 + }, + { + "id": 83, + "x": 777, + "y": 428, + "r": 11 + }, + { + "id": 84, + "x": 1211, + "y": 439, + "r": 11 + }, + { + "id": 85, + "x": 1397, + "y": 437, + "r": 11 + }, + { + "id": 86, + "x": 98, + "y": 449, + "r": 12 + }, + { + "id": 87, + "x": 757, + "y": 463, + "r": 13 + }, + { + "id": 88, + "x": 1132, + "y": 458, + "r": 11 + }, + { + "id": 89, + "x": 851, + "y": 472, + "r": 9 + }, + { + "id": 90, + "x": 1221, + "y": 488, + "r": 11 + }, + { + "id": 91, + "x": 1277, + "y": 489, + "r": 9 + }, + { + "id": 92, + "x": 1319, + "y": 485, + "r": 11 + }, + { + "id": 93, + "x": 386, + "y": 511, + "r": 9 + }, + { + "id": 94, + "x": 1048, + "y": 505, + "r": 11 + }, + { + "id": 95, + "x": 1145, + "y": 514, + "r": 12 + }, + { + "id": 96, + "x": 257, + "y": 532, + "r": 12 + }, + { + "id": 97, + "x": 352, + "y": 530, + "r": 9 + }, + { + "id": 98, + "x": 134, + "y": 551, + "r": 12 + }, + { + "id": 99, + "x": 430, + "y": 556, + "r": 13 + }, + { + "id": 100, + "x": 475, + "y": 557, + "r": 10 + }, + { + "id": 101, + "x": 377, + "y": 584, + "r": 11 + }, + { + "id": 102, + "x": 652, + "y": 575, + "r": 11 + }, + { + "id": 103, + "x": 146, + "y": 599, + "r": 11 + }, + { + "id": 104, + "x": 223, + "y": 605, + "r": 11 + }, + { + "id": 105, + "x": 325, + "y": 605, + "r": 9 + }, + { + "id": 106, + "x": 579, + "y": 600, + "r": 9 + }, + { + "id": 107, + "x": 265, + "y": 620, + "r": 9 + }, + { + "id": 108, + "x": 682, + "y": 622, + "r": 11 + }, + { + "id": 109, + "x": 209, + "y": 647, + "r": 9 + }, + { + "id": 110, + "x": 349, + "y": 650, + "r": 9 + }, + { + "id": 111, + "x": 487, + "y": 656, + "r": 12 + }, + { + "id": 112, + "x": 770, + "y": 637, + "r": 9 + }, + { + "id": 113, + "x": 836, + "y": 637, + "r": 11 + }, + { + "id": 114, + "x": 95, + "y": 685, + "r": 9 + }, + { + "id": 115, + "x": 551, + "y": 688, + "r": 9 + }, + { + "id": 116, + "x": 871, + "y": 692, + "r": 9 + }, + { + "id": 117, + "x": 409, + "y": 719, + "r": 11 + }, + { + "id": 118, + "x": 472, + "y": 722, + "r": 9 + }, + { + "id": 119, + "x": 925, + "y": 731, + "r": 11 + }, + { + "id": 120, + "x": 980, + "y": 724, + "r": 11 + }, + { + "id": 121, + "x": 1169, + "y": 727, + "r": 13 + }, + { + "id": 122, + "x": 91, + "y": 745, + "r": 9 + }, + { + "id": 123, + "x": 574, + "y": 743, + "r": 9 + }, + { + "id": 124, + "x": 622, + "y": 740, + "r": 9 + }, + { + "id": 125, + "x": 1075, + "y": 740, + "r": 9 + }, + { + "id": 126, + "x": 445, + "y": 763, + "r": 11 + }, + { + "id": 127, + "x": 542, + "y": 763, + "r": 10 + }, + { + "id": 128, + "x": 716, + "y": 769, + "r": 9 + }, + { + "id": 129, + "x": 773, + "y": 766, + "r": 9 + }, + { + "id": 130, + "x": 835, + "y": 767, + "r": 9 + }, + { + "id": 131, + "x": 986, + "y": 768, + "r": 11 + }, + { + "id": 132, + "x": 415, + "y": 799, + "r": 11 + }, + { + "id": 133, + "x": 488, + "y": 788, + "r": 9 + }, + { + "id": 134, + "x": 1159, + "y": 787, + "r": 11 + }, + { + "id": 135, + "x": 267, + "y": 827, + "r": 11 + }, + { + "id": 136, + "x": 530, + "y": 812, + "r": 9 + }, + { + "id": 137, + "x": 839, + "y": 811, + "r": 11 + }, + { + "id": 138, + "x": 958, + "y": 806, + "r": 9 + }, + { + "id": 139, + "x": 1016, + "y": 806, + "r": 11 + }, + { + "id": 140, + "x": 467, + "y": 844, + "r": 11 + }, + { + "id": 141, + "x": 737, + "y": 845, + "r": 9 + }, + { + "id": 142, + "x": 391, + "y": 872, + "r": 11 + }, + { + "id": 143, + "x": 839, + "y": 854, + "r": 9 + }, + { + "id": 144, + "x": 1103, + "y": 857, + "r": 9 + }, + { + "id": 145, + "x": 448, + "y": 890, + "r": 11 + }, + { + "id": 146, + "x": 572, + "y": 899, + "r": 12 + }, + { + "id": 147, + "x": 1003, + "y": 881, + "r": 10 + }, + { + "id": 148, + "x": 620, + "y": 919, + "r": 9 + }, + { + "id": 149, + "x": 301, + "y": 932, + "r": 11 + }, + { + "id": 150, + "x": 811, + "y": 934, + "r": 9 + }, + { + "id": 151, + "x": 686, + "y": 958, + "r": 12 + } + ], + "edges": [ + { + "source": 1, + "target": 5 + }, + { + "source": 1, + "target": 6 + }, + { + "source": 2, + "target": 3 + }, + { + "source": 2, + "target": 15 + }, + { + "source": 3, + "target": 4 + }, + { + "source": 4, + "target": 16 + }, + { + "source": 5, + "target": 12 + }, + { + "source": 5, + "target": 13 + }, + { + "source": 5, + "target": 17 + }, + { + "source": 6, + "target": 13 + }, + { + "source": 6, + "target": 24 + }, + { + "source": 7, + "target": 8 + }, + { + "source": 7, + "target": 18 + }, + { + "source": 8, + "target": 19 + }, + { + "source": 9, + "target": 39 + }, + { + "source": 10, + "target": 11 + }, + { + "source": 10, + "target": 23 + }, + { + "source": 11, + "target": 12 + }, + { + "source": 11, + "target": 23 + }, + { + "source": 12, + "target": 17 + }, + { + "source": 13, + "target": 24 + }, + { + "source": 14, + "target": 26 + }, + { + "source": 14, + "target": 27 + }, + { + "source": 15, + "target": 20 + }, + { + "source": 15, + "target": 29 + }, + { + "source": 16, + "target": 21 + }, + { + "source": 16, + "target": 30 + }, + { + "source": 18, + "target": 19 + }, + { + "source": 18, + "target": 25 + }, + { + "source": 20, + "target": 29 + }, + { + "source": 20, + "target": 30 + }, + { + "source": 21, + "target": 32 + }, + { + "source": 22, + "target": 33 + }, + { + "source": 22, + "target": 47 + }, + { + "source": 23, + "target": 34 + }, + { + "source": 23, + "target": 39 + }, + { + "source": 23, + "target": 49 + }, + { + "source": 25, + "target": 35 + }, + { + "source": 26, + "target": 27 + }, + { + "source": 26, + "target": 43 + }, + { + "source": 28, + "target": 36 + }, + { + "source": 28, + "target": 44 + }, + { + "source": 29, + "target": 37 + }, + { + "source": 31, + "target": 33 + }, + { + "source": 31, + "target": 48 + }, + { + "source": 32, + "target": 46 + }, + { + "source": 33, + "target": 47 + }, + { + "source": 33, + "target": 48 + }, + { + "source": 34, + "target": 40 + }, + { + "source": 34, + "target": 49 + }, + { + "source": 34, + "target": 54 + }, + { + "source": 35, + "target": 42 + }, + { + "source": 36, + "target": 44 + }, + { + "source": 36, + "target": 51 + }, + { + "source": 36, + "target": 52 + }, + { + "source": 37, + "target": 45 + }, + { + "source": 38, + "target": 46 + }, + { + "source": 38, + "target": 47 + }, + { + "source": 39, + "target": 54 + }, + { + "source": 40, + "target": 49 + }, + { + "source": 41, + "target": 42 + }, + { + "source": 41, + "target": 55 + }, + { + "source": 42, + "target": 55 + }, + { + "source": 43, + "target": 50 + }, + { + "source": 44, + "target": 52 + }, + { + "source": 45, + "target": 53 + }, + { + "source": 47, + "target": 59 + }, + { + "source": 49, + "target": 54 + }, + { + "source": 49, + "target": 65 + }, + { + "source": 51, + "target": 67 + }, + { + "source": 53, + "target": 56 + }, + { + "source": 55, + "target": 71 + }, + { + "source": 56, + "target": 57 + }, + { + "source": 56, + "target": 69 + }, + { + "source": 57, + "target": 58 + }, + { + "source": 58, + "target": 60 + }, + { + "source": 59, + "target": 61 + }, + { + "source": 59, + "target": 62 + }, + { + "source": 60, + "target": 61 + }, + { + "source": 62, + "target": 63 + }, + { + "source": 62, + "target": 73 + }, + { + "source": 63, + "target": 73 + }, + { + "source": 64, + "target": 70 + }, + { + "source": 64, + "target": 86 + }, + { + "source": 65, + "target": 75 + }, + { + "source": 66, + "target": 76 + }, + { + "source": 66, + "target": 77 + }, + { + "source": 66, + "target": 78 + }, + { + "source": 67, + "target": 68 + }, + { + "source": 67, + "target": 83 + }, + { + "source": 68, + "target": 69 + }, + { + "source": 68, + "target": 72 + }, + { + "source": 69, + "target": 72 + }, + { + "source": 70, + "target": 74 + }, + { + "source": 70, + "target": 82 + }, + { + "source": 71, + "target": 75 + }, + { + "source": 73, + "target": 80 + }, + { + "source": 74, + "target": 75 + }, + { + "source": 76, + "target": 77 + }, + { + "source": 77, + "target": 78 + }, + { + "source": 79, + "target": 84 + }, + { + "source": 79, + "target": 88 + }, + { + "source": 80, + "target": 81 + }, + { + "source": 80, + "target": 85 + }, + { + "source": 80, + "target": 92 + }, + { + "source": 81, + "target": 85 + }, + { + "source": 82, + "target": 86 + }, + { + "source": 82, + "target": 98 + }, + { + "source": 83, + "target": 87 + }, + { + "source": 83, + "target": 89 + }, + { + "source": 84, + "target": 90 + }, + { + "source": 86, + "target": 98 + }, + { + "source": 87, + "target": 89 + }, + { + "source": 88, + "target": 94 + }, + { + "source": 88, + "target": 95 + }, + { + "source": 90, + "target": 91 + }, + { + "source": 90, + "target": 95 + }, + { + "source": 91, + "target": 92 + }, + { + "source": 93, + "target": 97 + }, + { + "source": 93, + "target": 99 + }, + { + "source": 94, + "target": 95 + }, + { + "source": 96, + "target": 105 + }, + { + "source": 96, + "target": 107 + }, + { + "source": 97, + "target": 101 + }, + { + "source": 98, + "target": 103 + }, + { + "source": 98, + "target": 104 + }, + { + "source": 99, + "target": 100 + }, + { + "source": 99, + "target": 101 + }, + { + "source": 99, + "target": 111 + }, + { + "source": 100, + "target": 106 + }, + { + "source": 101, + "target": 105 + }, + { + "source": 101, + "target": 110 + }, + { + "source": 102, + "target": 106 + }, + { + "source": 102, + "target": 108 + }, + { + "source": 102, + "target": 115 + }, + { + "source": 103, + "target": 104 + }, + { + "source": 103, + "target": 109 + }, + { + "source": 103, + "target": 114 + }, + { + "source": 103, + "target": 122 + }, + { + "source": 105, + "target": 107 + }, + { + "source": 105, + "target": 110 + }, + { + "source": 106, + "target": 111 + }, + { + "source": 106, + "target": 115 + }, + { + "source": 107, + "target": 109 + }, + { + "source": 108, + "target": 112 + }, + { + "source": 110, + "target": 111 + }, + { + "source": 111, + "target": 117 + }, + { + "source": 112, + "target": 113 + }, + { + "source": 113, + "target": 116 + }, + { + "source": 114, + "target": 122 + }, + { + "source": 115, + "target": 123 + }, + { + "source": 115, + "target": 124 + }, + { + "source": 116, + "target": 119 + }, + { + "source": 117, + "target": 118 + }, + { + "source": 117, + "target": 126 + }, + { + "source": 118, + "target": 126 + }, + { + "source": 119, + "target": 120 + }, + { + "source": 120, + "target": 131 + }, + { + "source": 121, + "target": 125 + }, + { + "source": 123, + "target": 124 + }, + { + "source": 125, + "target": 139 + }, + { + "source": 125, + "target": 144 + }, + { + "source": 126, + "target": 132 + }, + { + "source": 126, + "target": 133 + }, + { + "source": 127, + "target": 133 + }, + { + "source": 127, + "target": 136 + }, + { + "source": 128, + "target": 129 + }, + { + "source": 128, + "target": 141 + }, + { + "source": 129, + "target": 130 + }, + { + "source": 129, + "target": 137 + }, + { + "source": 130, + "target": 137 + }, + { + "source": 131, + "target": 139 + }, + { + "source": 132, + "target": 140 + }, + { + "source": 132, + "target": 142 + }, + { + "source": 132, + "target": 145 + }, + { + "source": 133, + "target": 136 + }, + { + "source": 134, + "target": 144 + }, + { + "source": 135, + "target": 142 + }, + { + "source": 135, + "target": 149 + }, + { + "source": 136, + "target": 146 + }, + { + "source": 137, + "target": 143 + }, + { + "source": 138, + "target": 139 + }, + { + "source": 138, + "target": 144 + }, + { + "source": 138, + "target": 147 + }, + { + "source": 139, + "target": 144 + }, + { + "source": 140, + "target": 145 + }, + { + "source": 141, + "target": 150 + }, + { + "source": 142, + "target": 145 + }, + { + "source": 142, + "target": 149 + }, + { + "source": 143, + "target": 150 + }, + { + "source": 144, + "target": 147 + }, + { + "source": 146, + "target": 148 + }, + { + "source": 146, + "target": 151 + }, + { + "source": 148, + "target": 151 + } + ], + "adjacency": { + "1": [ + 5, + 6 + ], + "2": [ + 3, + 15 + ], + "3": [ + 2, + 4 + ], + "4": [ + 3, + 16 + ], + "5": [ + 1, + 12, + 13, + 17 + ], + "6": [ + 1, + 13, + 24 + ], + "7": [ + 8, + 18 + ], + "8": [ + 7, + 19 + ], + "9": [ + 39 + ], + "10": [ + 11, + 23 + ], + "11": [ + 10, + 12, + 23 + ], + "12": [ + 5, + 11, + 17 + ], + "13": [ + 5, + 6, + 24 + ], + "14": [ + 26, + 27 + ], + "15": [ + 2, + 20, + 29 + ], + "16": [ + 4, + 21, + 30 + ], + "17": [ + 5, + 12 + ], + "18": [ + 7, + 19, + 25 + ], + "19": [ + 8, + 18 + ], + "20": [ + 15, + 29, + 30 + ], + "21": [ + 16, + 32 + ], + "22": [ + 33, + 47 + ], + "23": [ + 10, + 11, + 34, + 39, + 49 + ], + "24": [ + 6, + 13 + ], + "25": [ + 18, + 35 + ], + "26": [ + 14, + 27, + 43 + ], + "27": [ + 14, + 26 + ], + "28": [ + 36, + 44 + ], + "29": [ + 15, + 20, + 37 + ], + "30": [ + 16, + 20 + ], + "31": [ + 33, + 48 + ], + "32": [ + 21, + 46 + ], + "33": [ + 22, + 31, + 47, + 48 + ], + "34": [ + 23, + 40, + 49, + 54 + ], + "35": [ + 25, + 42 + ], + "36": [ + 28, + 44, + 51, + 52 + ], + "37": [ + 29, + 45 + ], + "38": [ + 46, + 47 + ], + "39": [ + 9, + 23, + 54 + ], + "40": [ + 34, + 49 + ], + "41": [ + 42, + 55 + ], + "42": [ + 35, + 41, + 55 + ], + "43": [ + 26, + 50 + ], + "44": [ + 28, + 36, + 52 + ], + "45": [ + 37, + 53 + ], + "46": [ + 32, + 38 + ], + "47": [ + 22, + 33, + 38, + 59 + ], + "48": [ + 31, + 33 + ], + "49": [ + 23, + 34, + 40, + 54, + 65 + ], + "50": [ + 43 + ], + "51": [ + 36, + 67 + ], + "52": [ + 36, + 44 + ], + "53": [ + 45, + 56 + ], + "54": [ + 34, + 39, + 49 + ], + "55": [ + 41, + 42, + 71 + ], + "56": [ + 53, + 57, + 69 + ], + "57": [ + 56, + 58 + ], + "58": [ + 57, + 60 + ], + "59": [ + 47, + 61, + 62 + ], + "60": [ + 58, + 61 + ], + "61": [ + 59, + 60 + ], + "62": [ + 59, + 63, + 73 + ], + "63": [ + 62, + 73 + ], + "64": [ + 70, + 86 + ], + "65": [ + 49, + 75 + ], + "66": [ + 76, + 77, + 78 + ], + "67": [ + 51, + 68, + 83 + ], + "68": [ + 67, + 69, + 72 + ], + "69": [ + 56, + 68, + 72 + ], + "70": [ + 64, + 74, + 82 + ], + "71": [ + 55, + 75 + ], + "72": [ + 68, + 69 + ], + "73": [ + 62, + 63, + 80 + ], + "74": [ + 70, + 75 + ], + "75": [ + 65, + 71, + 74 + ], + "76": [ + 66, + 77 + ], + "77": [ + 66, + 76, + 78 + ], + "78": [ + 66, + 77 + ], + "79": [ + 84, + 88 + ], + "80": [ + 73, + 81, + 85, + 92 + ], + "81": [ + 80, + 85 + ], + "82": [ + 70, + 86, + 98 + ], + "83": [ + 67, + 87, + 89 + ], + "84": [ + 79, + 90 + ], + "85": [ + 80, + 81 + ], + "86": [ + 64, + 82, + 98 + ], + "87": [ + 83, + 89 + ], + "88": [ + 79, + 94, + 95 + ], + "89": [ + 83, + 87 + ], + "90": [ + 84, + 91, + 95 + ], + "91": [ + 90, + 92 + ], + "92": [ + 80, + 91 + ], + "93": [ + 97, + 99 + ], + "94": [ + 88, + 95 + ], + "95": [ + 88, + 90, + 94 + ], + "96": [ + 105, + 107 + ], + "97": [ + 93, + 101 + ], + "98": [ + 82, + 86, + 103, + 104 + ], + "99": [ + 93, + 100, + 101, + 111 + ], + "100": [ + 99, + 106 + ], + "101": [ + 97, + 99, + 105, + 110 + ], + "102": [ + 106, + 108, + 115 + ], + "103": [ + 98, + 104, + 109, + 114, + 122 + ], + "104": [ + 98, + 103 + ], + "105": [ + 96, + 101, + 107, + 110 + ], + "106": [ + 100, + 102, + 111, + 115 + ], + "107": [ + 96, + 105, + 109 + ], + "108": [ + 102, + 112 + ], + "109": [ + 103, + 107 + ], + "110": [ + 101, + 105, + 111 + ], + "111": [ + 99, + 106, + 110, + 117 + ], + "112": [ + 108, + 113 + ], + "113": [ + 112, + 116 + ], + "114": [ + 103, + 122 + ], + "115": [ + 102, + 106, + 123, + 124 + ], + "116": [ + 113, + 119 + ], + "117": [ + 111, + 118, + 126 + ], + "118": [ + 117, + 126 + ], + "119": [ + 116, + 120 + ], + "120": [ + 119, + 131 + ], + "121": [ + 125 + ], + "122": [ + 103, + 114 + ], + "123": [ + 115, + 124 + ], + "124": [ + 115, + 123 + ], + "125": [ + 121, + 139, + 144 + ], + "126": [ + 117, + 118, + 132, + 133 + ], + "127": [ + 133, + 136 + ], + "128": [ + 129, + 141 + ], + "129": [ + 128, + 130, + 137 + ], + "130": [ + 129, + 137 + ], + "131": [ + 120, + 139 + ], + "132": [ + 126, + 140, + 142, + 145 + ], + "133": [ + 126, + 127, + 136 + ], + "134": [ + 144 + ], + "135": [ + 142, + 149 + ], + "136": [ + 127, + 133, + 146 + ], + "137": [ + 129, + 130, + 143 + ], + "138": [ + 139, + 144, + 147 + ], + "139": [ + 125, + 131, + 138, + 144 + ], + "140": [ + 132, + 145 + ], + "141": [ + 128, + 150 + ], + "142": [ + 132, + 135, + 145, + 149 + ], + "143": [ + 137, + 150 + ], + "144": [ + 125, + 134, + 138, + 139, + 147 + ], + "145": [ + 132, + 140, + 142 + ], + "146": [ + 136, + 148, + 151 + ], + "147": [ + 138, + 144 + ], + "148": [ + 146, + 151 + ], + "149": [ + 135, + 142 + ], + "150": [ + 141, + 143 + ], + "151": [ + 146, + 148 + ] + } +} diff --git a/data/raw/maps/taxi_cv_out/junctions.csv b/data/raw/maps/taxi_cv_out/junctions.csv new file mode 100644 index 0000000000000000000000000000000000000000..7de5982d99219da001bcbf68fd562f1086c9d38a --- /dev/null +++ b/data/raw/maps/taxi_cv_out/junctions.csv @@ -0,0 +1,152 @@ +id,x,y,radius,neighbors +1,424,79,12,5 6 +2,949,69,11,3 15 +3,1028,78,12,2 4 +4,1059,103,13,3 16 +5,353,119,12,1 12 13 17 +6,434,128,11,1 13 24 +7,562,123,12,8 18 +8,628,114,12,7 19 +9,99,153,12,39 +10,143,146,11,11 23 +11,233,151,11,10 12 23 +12,289,142,11,5 11 17 +13,392,153,11,5 6 24 +14,719,152,12,26 27 +15,925,133,11,2 20 29 +16,1054,148,12,4 21 30 +17,317,172,11,5 12 +18,573,173,11,7 19 25 +19,624,158,11,8 18 +20,976,163,11,15 29 30 +21,1114,162,11,16 32 +22,1243,179,12,33 47 +23,211,202,11,10 11 34 39 49 +24,424,184,11,6 13 +25,543,198,11,18 35 +26,696,187,11,14 27 43 +27,738,202,11,14 26 +28,817,191,11,36 44 +29,927,188,11,15 20 37 +30,1044,202,12,16 20 +31,1396,193,11,33 48 +32,1125,214,12,21 46 +33,1319,225,11,22 31 47 48 +34,242,237,9,23 40 49 54 +35,498,238,11,25 42 +36,796,246,12,28 44 51 52 +37,946,228,11,29 45 +38,1188,249,11,46 47 +39,139,263,9,9 23 54 +40,282,259,11,34 49 +41,368,266,11,42 55 +42,419,259,11,35 41 55 +43,682,263,11,26 50 +44,851,257,10,28 36 52 +45,984,259,10,37 53 +46,1133,264,11,32 38 +47,1243,265,11,22 33 38 59 +48,1367,265,12,31 33 +49,247,279,11,23 34 40 54 65 +50,621,288,11,43 +51,748,292,11,36 67 +52,819,282,11,36 44 +53,973,278,11,45 56 +54,203,303,11,34 39 49 +55,397,311,12,41 42 71 +56,974,321,11,53 57 69 +57,1056,318,11,56 58 +58,1102,323,11,57 60 +59,1244,318,9,47 61 62 +60,1156,340,10,58 61 +61,1188,338,11,59 60 +62,1264,347,11,59 63 73 +63,1322,333,10,62 73 +64,58,365,12,70 86 +65,281,354,11,49 75 +66,565,353,11,76 77 78 +67,768,352,11,51 68 83 +68,871,350,10,67 69 72 +69,931,348,11,56 68 72 +70,157,388,11,64 74 82 +71,384,376,11,55 75 +72,846,388,11,68 69 +73,1299,373,11,62 63 80 +74,222,408,11,70 75 +75,296,399,9,65 71 74 +76,493,402,11,66 77 +77,549,416,10,66 76 78 +78,588,398,9,66 77 +79,1170,408,11,84 88 +80,1303,416,10,73 81 85 92 +81,1378,400,12,80 85 +82,143,427,9,70 86 98 +83,777,428,11,67 87 89 +84,1211,439,11,79 90 +85,1397,437,11,80 81 +86,98,449,12,64 82 98 +87,757,463,13,83 89 +88,1132,458,11,79 94 95 +89,851,472,9,83 87 +90,1221,488,11,84 91 95 +91,1277,489,9,90 92 +92,1319,485,11,80 91 +93,386,511,9,97 99 +94,1048,505,11,88 95 +95,1145,514,12,88 90 94 +96,257,532,12,105 107 +97,352,530,9,93 101 +98,134,551,12,82 86 103 104 +99,430,556,13,93 100 101 111 +100,475,557,10,99 106 +101,377,584,11,97 99 105 110 +102,652,575,11,106 108 115 +103,146,599,11,98 104 109 114 122 +104,223,605,11,98 103 +105,325,605,9,96 101 107 110 +106,579,600,9,100 102 111 115 +107,265,620,9,96 105 109 +108,682,622,11,102 112 +109,209,647,9,103 107 +110,349,650,9,101 105 111 +111,487,656,12,99 106 110 117 +112,770,637,9,108 113 +113,836,637,11,112 116 +114,95,685,9,103 122 +115,551,688,9,102 106 123 124 +116,871,692,9,113 119 +117,409,719,11,111 118 126 +118,472,722,9,117 126 +119,925,731,11,116 120 +120,980,724,11,119 131 +121,1169,727,13,125 +122,91,745,9,103 114 +123,574,743,9,115 124 +124,622,740,9,115 123 +125,1075,740,9,121 139 144 +126,445,763,11,117 118 132 133 +127,542,763,10,133 136 +128,716,769,9,129 141 +129,773,766,9,128 130 137 +130,835,767,9,129 137 +131,986,768,11,120 139 +132,415,799,11,126 140 142 145 +133,488,788,9,126 127 136 +134,1159,787,11,144 +135,267,827,11,142 149 +136,530,812,9,127 133 146 +137,839,811,11,129 130 143 +138,958,806,9,139 144 147 +139,1016,806,11,125 131 138 144 +140,467,844,11,132 145 +141,737,845,9,128 150 +142,391,872,11,132 135 145 149 +143,839,854,9,137 150 +144,1103,857,9,125 134 138 139 147 +145,448,890,11,132 140 142 +146,572,899,12,136 148 151 +147,1003,881,10,138 144 +148,620,919,9,146 151 +149,301,932,11,135 142 +150,811,934,9,141 143 +151,686,958,12,146 148 diff --git a/data/raw/maps/taxi_cv_out/taxi_cv_out/graph.graphml b/data/raw/maps/taxi_cv_out/taxi_cv_out/graph.graphml new file mode 100644 index 0000000000000000000000000000000000000000..09e89e3d846617a253b654acdc1621db59308113 --- /dev/null +++ b/data/raw/maps/taxi_cv_out/taxi_cv_out/graph.graphml @@ -0,0 +1,970 @@ + + + + + + 915 + 64 + 11 + + + 987 + 74 + 12 + + + 424 + 80 + 12 + + + 1018 + 95 + 13 + + + 626 + 112 + 12 + + + 356 + 119 + 12 + + + 559 + 123 + 12 + + + 889 + 125 + 11 + + + 431 + 127 + 11 + + + 286 + 140 + 11 + + + 1016 + 140 + 12 + + + 137 + 145 + 11 + + + 231 + 151 + 11 + + + 393 + 152 + 11 + + + 100 + 153 + 12 + + + 715 + 154 + 12 + + + 941 + 155 + 11 + + + 1073 + 161 + 11 + + + 627 + 163 + 11 + + + 315 + 170 + 11 + + + 575 + 173 + 11 + + + 1195 + 173 + 12 + + + 893 + 178 + 11 + + + 422 + 184 + 11 + + + 698 + 187 + 11 + + + 1004 + 189 + 12 + + + 1343 + 189 + 11 + + + 821 + 191 + 11 + + + 541 + 199 + 11 + + + 211 + 200 + 11 + + + 1088 + 205 + 12 + + + 739 + 206 + 11 + + + 1262 + 215 + 11 + + + 907 + 218 + 11 + + + 239 + 236 + 9 + + + 1139 + 236 + 11 + + + 495 + 239 + 11 + + + 946 + 247 + 10 + + + 797 + 248 + 12 + + + 1193 + 249 + 11 + + + 656 + 253 + 11 + + + 284 + 254 + 11 + + + 1305 + 255 + 12 + + + 137 + 256 + 9 + + + 850 + 257 + 10 + + + 1089 + 257 + 11 + + + 415 + 261 + 11 + + + 370 + 267 + 11 + + + 931 + 267 + 11 + + + 247 + 277 + 11 + + + 818 + 281 + 11 + + + 621 + 291 + 11 + + + 745 + 293 + 11 + + + 202 + 305 + 11 + + + 1193 + 308 + 9 + + + 937 + 310 + 11 + + + 401 + 311 + 12 + + + 1054 + 311 + 11 + + + 1013 + 313 + 11 + + + 1108 + 322 + 10 + + + 1267 + 322 + 10 + + + 1137 + 329 + 11 + + + 1208 + 331 + 11 + + + 892 + 337 + 11 + + + 275 + 343 + 11 + + + 839 + 343 + 10 + + + 1240 + 350 + 11 + + + 568 + 353 + 11 + + + 764 + 355 + 11 + + + 676 + 369 + 11 + + + 59 + 375 + 12 + + + 388 + 377 + 11 + + + 1315 + 385 + 12 + + + 157 + 386 + 11 + + + 1115 + 389 + 11 + + + 842 + 392 + 11 + + + 586 + 395 + 9 + + + 296 + 397 + 9 + + + 1246 + 403 + 10 + + + 220 + 406 + 11 + + + 494 + 406 + 11 + + + 549 + 416 + 10 + + + 1157 + 419 + 11 + + + 1336 + 419 + 11 + + + 145 + 428 + 9 + + + 771 + 434 + 11 + + + 1085 + 441 + 11 + + + 100 + 448 + 12 + + + 755 + 461 + 13 + + + 1256 + 467 + 11 + + + 1166 + 470 + 11 + + + 1219 + 472 + 9 + + + 849 + 473 + 9 + + + 111 + 477 + 12 + + + 1005 + 490 + 11 + + + 1096 + 494 + 12 + + + 386 + 511 + 9 + + + 355 + 535 + 9 + + + 254 + 538 + 12 + + + 133 + 555 + 11 + + + 472 + 556 + 10 + + + 431 + 560 + 13 + + + 649 + 575 + 11 + + + 559 + 577 + 9 + + + 376 + 590 + 11 + + + 143 + 603 + 11 + + + 326 + 607 + 9 + + + 571 + 616 + 11 + + + 679 + 621 + 9 + + + 259 + 623 + 9 + + + 769 + 634 + 11 + + + 206 + 646 + 9 + + + 352 + 650 + 9 + + + 568 + 650 + 9 + + + 811 + 676 + 9 + + + 548 + 688 + 9 + + + 99 + 693 + 9 + + + 874 + 693 + 11 + + + 602 + 703 + 9 + + + 410 + 718 + 11 + + + 469 + 727 + 9 + + + 1168 + 727 + 13 + + + 979 + 729 + 11 + + + 921 + 733 + 11 + + + 1071 + 739 + 9 + + + 93 + 741 + 9 + + + 542 + 763 + 10 + + + 797 + 764 + 9 + + + 445 + 766 + 11 + + + 707 + 767 + 9 + + + 829 + 767 + 9 + + + 991 + 779 + 11 + + + 1151 + 787 + 10 + + + 489 + 790 + 9 + + + 959 + 806 + 9 + + + 838 + 811 + 11 + + + 1021 + 812 + 9 + + + 530 + 815 + 9 + + + 417 + 819 + 11 + + + 284 + 829 + 11 + + + 727 + 836 + 9 + + + 470 + 845 + 11 + + + 814 + 848 + 9 + + + 151 + 851 + 17 + + + 1102 + 856 + 11 + + + 373 + 868 + 11 + + + 1005 + 884 + 10 + + + 453 + 891 + 11 + + + 577 + 903 + 12 + + + 782 + 903 + 9 + + + 622 + 921 + 9 + + + 299 + 937 + 11 + + + 676 + 943 + 12 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/data/raw/maps/taxi_cv_out/taxi_cv_out/graph.json b/data/raw/maps/taxi_cv_out/taxi_cv_out/graph.json new file mode 100644 index 0000000000000000000000000000000000000000..3fa0d5595e16d3c951689dc3f7dc0e9b10077756 --- /dev/null +++ b/data/raw/maps/taxi_cv_out/taxi_cv_out/graph.json @@ -0,0 +1,2432 @@ +{ + "nodes": [ + { + "id": 1, + "x": 915, + "y": 64, + "r": 11 + }, + { + "id": 2, + "x": 987, + "y": 74, + "r": 12 + }, + { + "id": 3, + "x": 424, + "y": 80, + "r": 12 + }, + { + "id": 4, + "x": 1018, + "y": 95, + "r": 13 + }, + { + "id": 5, + "x": 626, + "y": 112, + "r": 12 + }, + { + "id": 6, + "x": 356, + "y": 119, + "r": 12 + }, + { + "id": 7, + "x": 559, + "y": 123, + "r": 12 + }, + { + "id": 8, + "x": 889, + "y": 125, + "r": 11 + }, + { + "id": 9, + "x": 431, + "y": 127, + "r": 11 + }, + { + "id": 10, + "x": 286, + "y": 140, + "r": 11 + }, + { + "id": 11, + "x": 1016, + "y": 140, + "r": 12 + }, + { + "id": 12, + "x": 137, + "y": 145, + "r": 11 + }, + { + "id": 13, + "x": 231, + "y": 151, + "r": 11 + }, + { + "id": 14, + "x": 393, + "y": 152, + "r": 11 + }, + { + "id": 15, + "x": 100, + "y": 153, + "r": 12 + }, + { + "id": 16, + "x": 715, + "y": 154, + "r": 12 + }, + { + "id": 17, + "x": 941, + "y": 155, + "r": 11 + }, + { + "id": 18, + "x": 1073, + "y": 161, + "r": 11 + }, + { + "id": 19, + "x": 627, + "y": 163, + "r": 11 + }, + { + "id": 20, + "x": 315, + "y": 170, + "r": 11 + }, + { + "id": 21, + "x": 575, + "y": 173, + "r": 11 + }, + { + "id": 22, + "x": 1195, + "y": 173, + "r": 12 + }, + { + "id": 23, + "x": 893, + "y": 178, + "r": 11 + }, + { + "id": 24, + "x": 422, + "y": 184, + "r": 11 + }, + { + "id": 25, + "x": 698, + "y": 187, + "r": 11 + }, + { + "id": 26, + "x": 1004, + "y": 189, + "r": 12 + }, + { + "id": 27, + "x": 1343, + "y": 189, + "r": 11 + }, + { + "id": 28, + "x": 821, + "y": 191, + "r": 11 + }, + { + "id": 29, + "x": 541, + "y": 199, + "r": 11 + }, + { + "id": 30, + "x": 211, + "y": 200, + "r": 11 + }, + { + "id": 31, + "x": 1088, + "y": 205, + "r": 12 + }, + { + "id": 32, + "x": 739, + "y": 206, + "r": 11 + }, + { + "id": 33, + "x": 1262, + "y": 215, + "r": 11 + }, + { + "id": 34, + "x": 907, + "y": 218, + "r": 11 + }, + { + "id": 35, + "x": 239, + "y": 236, + "r": 9 + }, + { + "id": 36, + "x": 1139, + "y": 236, + "r": 11 + }, + { + "id": 37, + "x": 495, + "y": 239, + "r": 11 + }, + { + "id": 38, + "x": 946, + "y": 247, + "r": 10 + }, + { + "id": 39, + "x": 797, + "y": 248, + "r": 12 + }, + { + "id": 40, + "x": 1193, + "y": 249, + "r": 11 + }, + { + "id": 41, + "x": 656, + "y": 253, + "r": 11 + }, + { + "id": 42, + "x": 284, + "y": 254, + "r": 11 + }, + { + "id": 43, + "x": 1305, + "y": 255, + "r": 12 + }, + { + "id": 44, + "x": 137, + "y": 256, + "r": 9 + }, + { + "id": 45, + "x": 850, + "y": 257, + "r": 10 + }, + { + "id": 46, + "x": 1089, + "y": 257, + "r": 11 + }, + { + "id": 47, + "x": 415, + "y": 261, + "r": 11 + }, + { + "id": 48, + "x": 370, + "y": 267, + "r": 11 + }, + { + "id": 49, + "x": 931, + "y": 267, + "r": 11 + }, + { + "id": 50, + "x": 247, + "y": 277, + "r": 11 + }, + { + "id": 51, + "x": 818, + "y": 281, + "r": 11 + }, + { + "id": 52, + "x": 621, + "y": 291, + "r": 11 + }, + { + "id": 53, + "x": 745, + "y": 293, + "r": 11 + }, + { + "id": 54, + "x": 202, + "y": 305, + "r": 11 + }, + { + "id": 55, + "x": 1193, + "y": 308, + "r": 9 + }, + { + "id": 56, + "x": 937, + "y": 310, + "r": 11 + }, + { + "id": 57, + "x": 401, + "y": 311, + "r": 12 + }, + { + "id": 58, + "x": 1054, + "y": 311, + "r": 11 + }, + { + "id": 59, + "x": 1013, + "y": 313, + "r": 11 + }, + { + "id": 60, + "x": 1108, + "y": 322, + "r": 10 + }, + { + "id": 61, + "x": 1267, + "y": 322, + "r": 10 + }, + { + "id": 62, + "x": 1137, + "y": 329, + "r": 11 + }, + { + "id": 63, + "x": 1208, + "y": 331, + "r": 11 + }, + { + "id": 64, + "x": 892, + "y": 337, + "r": 11 + }, + { + "id": 65, + "x": 275, + "y": 343, + "r": 11 + }, + { + "id": 66, + "x": 839, + "y": 343, + "r": 10 + }, + { + "id": 67, + "x": 1240, + "y": 350, + "r": 11 + }, + { + "id": 68, + "x": 568, + "y": 353, + "r": 11 + }, + { + "id": 69, + "x": 764, + "y": 355, + "r": 11 + }, + { + "id": 70, + "x": 676, + "y": 369, + "r": 11 + }, + { + "id": 71, + "x": 59, + "y": 375, + "r": 12 + }, + { + "id": 72, + "x": 388, + "y": 377, + "r": 11 + }, + { + "id": 73, + "x": 1315, + "y": 385, + "r": 12 + }, + { + "id": 74, + "x": 157, + "y": 386, + "r": 11 + }, + { + "id": 75, + "x": 1115, + "y": 389, + "r": 11 + }, + { + "id": 76, + "x": 842, + "y": 392, + "r": 11 + }, + { + "id": 77, + "x": 586, + "y": 395, + "r": 9 + }, + { + "id": 78, + "x": 296, + "y": 397, + "r": 9 + }, + { + "id": 79, + "x": 1246, + "y": 403, + "r": 10 + }, + { + "id": 80, + "x": 220, + "y": 406, + "r": 11 + }, + { + "id": 81, + "x": 494, + "y": 406, + "r": 11 + }, + { + "id": 82, + "x": 549, + "y": 416, + "r": 10 + }, + { + "id": 83, + "x": 1157, + "y": 419, + "r": 11 + }, + { + "id": 84, + "x": 1336, + "y": 419, + "r": 11 + }, + { + "id": 85, + "x": 145, + "y": 428, + "r": 9 + }, + { + "id": 86, + "x": 771, + "y": 434, + "r": 11 + }, + { + "id": 87, + "x": 1085, + "y": 441, + "r": 11 + }, + { + "id": 88, + "x": 100, + "y": 448, + "r": 12 + }, + { + "id": 89, + "x": 755, + "y": 461, + "r": 13 + }, + { + "id": 90, + "x": 1256, + "y": 467, + "r": 11 + }, + { + "id": 91, + "x": 1166, + "y": 470, + "r": 11 + }, + { + "id": 92, + "x": 1219, + "y": 472, + "r": 9 + }, + { + "id": 93, + "x": 849, + "y": 473, + "r": 9 + }, + { + "id": 94, + "x": 111, + "y": 477, + "r": 12 + }, + { + "id": 95, + "x": 1005, + "y": 490, + "r": 11 + }, + { + "id": 96, + "x": 1096, + "y": 494, + "r": 12 + }, + { + "id": 97, + "x": 386, + "y": 511, + "r": 9 + }, + { + "id": 98, + "x": 355, + "y": 535, + "r": 9 + }, + { + "id": 99, + "x": 254, + "y": 538, + "r": 12 + }, + { + "id": 100, + "x": 133, + "y": 555, + "r": 11 + }, + { + "id": 101, + "x": 472, + "y": 556, + "r": 10 + }, + { + "id": 102, + "x": 431, + "y": 560, + "r": 13 + }, + { + "id": 103, + "x": 649, + "y": 575, + "r": 11 + }, + { + "id": 104, + "x": 559, + "y": 577, + "r": 9 + }, + { + "id": 105, + "x": 376, + "y": 590, + "r": 11 + }, + { + "id": 106, + "x": 143, + "y": 603, + "r": 11 + }, + { + "id": 107, + "x": 326, + "y": 607, + "r": 9 + }, + { + "id": 108, + "x": 571, + "y": 616, + "r": 11 + }, + { + "id": 109, + "x": 679, + "y": 621, + "r": 9 + }, + { + "id": 110, + "x": 259, + "y": 623, + "r": 9 + }, + { + "id": 111, + "x": 769, + "y": 634, + "r": 11 + }, + { + "id": 112, + "x": 206, + "y": 646, + "r": 9 + }, + { + "id": 113, + "x": 352, + "y": 650, + "r": 9 + }, + { + "id": 114, + "x": 568, + "y": 650, + "r": 9 + }, + { + "id": 115, + "x": 811, + "y": 676, + "r": 9 + }, + { + "id": 116, + "x": 548, + "y": 688, + "r": 9 + }, + { + "id": 117, + "x": 99, + "y": 693, + "r": 9 + }, + { + "id": 118, + "x": 874, + "y": 693, + "r": 11 + }, + { + "id": 119, + "x": 602, + "y": 703, + "r": 9 + }, + { + "id": 120, + "x": 410, + "y": 718, + "r": 11 + }, + { + "id": 121, + "x": 469, + "y": 727, + "r": 9 + }, + { + "id": 122, + "x": 1168, + "y": 727, + "r": 13 + }, + { + "id": 123, + "x": 979, + "y": 729, + "r": 11 + }, + { + "id": 124, + "x": 921, + "y": 733, + "r": 11 + }, + { + "id": 125, + "x": 1071, + "y": 739, + "r": 9 + }, + { + "id": 126, + "x": 93, + "y": 741, + "r": 9 + }, + { + "id": 127, + "x": 542, + "y": 763, + "r": 10 + }, + { + "id": 128, + "x": 797, + "y": 764, + "r": 9 + }, + { + "id": 129, + "x": 445, + "y": 766, + "r": 11 + }, + { + "id": 130, + "x": 707, + "y": 767, + "r": 9 + }, + { + "id": 131, + "x": 829, + "y": 767, + "r": 9 + }, + { + "id": 132, + "x": 991, + "y": 779, + "r": 11 + }, + { + "id": 133, + "x": 1151, + "y": 787, + "r": 10 + }, + { + "id": 134, + "x": 489, + "y": 790, + "r": 9 + }, + { + "id": 135, + "x": 959, + "y": 806, + "r": 9 + }, + { + "id": 136, + "x": 838, + "y": 811, + "r": 11 + }, + { + "id": 137, + "x": 1021, + "y": 812, + "r": 9 + }, + { + "id": 138, + "x": 530, + "y": 815, + "r": 9 + }, + { + "id": 139, + "x": 417, + "y": 819, + "r": 11 + }, + { + "id": 140, + "x": 284, + "y": 829, + "r": 11 + }, + { + "id": 141, + "x": 727, + "y": 836, + "r": 9 + }, + { + "id": 142, + "x": 470, + "y": 845, + "r": 11 + }, + { + "id": 143, + "x": 814, + "y": 848, + "r": 9 + }, + { + "id": 144, + "x": 151, + "y": 851, + "r": 17 + }, + { + "id": 145, + "x": 1102, + "y": 856, + "r": 11 + }, + { + "id": 146, + "x": 373, + "y": 868, + "r": 11 + }, + { + "id": 147, + "x": 1005, + "y": 884, + "r": 10 + }, + { + "id": 148, + "x": 453, + "y": 891, + "r": 11 + }, + { + "id": 149, + "x": 577, + "y": 903, + "r": 12 + }, + { + "id": 150, + "x": 782, + "y": 903, + "r": 9 + }, + { + "id": 151, + "x": 622, + "y": 921, + "r": 9 + }, + { + "id": 152, + "x": 299, + "y": 937, + "r": 11 + }, + { + "id": 153, + "x": 676, + "y": 943, + "r": 12 + } + ], + "edges": [ + { + "source": 1, + "target": 2 + }, + { + "source": 1, + "target": 8 + }, + { + "source": 2, + "target": 4 + }, + { + "source": 2, + "target": 11 + }, + { + "source": 3, + "target": 6 + }, + { + "source": 3, + "target": 9 + }, + { + "source": 4, + "target": 11 + }, + { + "source": 5, + "target": 7 + }, + { + "source": 5, + "target": 19 + }, + { + "source": 6, + "target": 10 + }, + { + "source": 6, + "target": 14 + }, + { + "source": 6, + "target": 20 + }, + { + "source": 7, + "target": 21 + }, + { + "source": 8, + "target": 17 + }, + { + "source": 8, + "target": 23 + }, + { + "source": 9, + "target": 14 + }, + { + "source": 9, + "target": 24 + }, + { + "source": 10, + "target": 13 + }, + { + "source": 10, + "target": 20 + }, + { + "source": 11, + "target": 18 + }, + { + "source": 11, + "target": 26 + }, + { + "source": 12, + "target": 13 + }, + { + "source": 12, + "target": 15 + }, + { + "source": 12, + "target": 30 + }, + { + "source": 13, + "target": 30 + }, + { + "source": 14, + "target": 24 + }, + { + "source": 15, + "target": 44 + }, + { + "source": 16, + "target": 25 + }, + { + "source": 16, + "target": 32 + }, + { + "source": 17, + "target": 23 + }, + { + "source": 17, + "target": 26 + }, + { + "source": 18, + "target": 31 + }, + { + "source": 19, + "target": 21 + }, + { + "source": 21, + "target": 29 + }, + { + "source": 22, + "target": 33 + }, + { + "source": 22, + "target": 40 + }, + { + "source": 23, + "target": 34 + }, + { + "source": 25, + "target": 32 + }, + { + "source": 25, + "target": 41 + }, + { + "source": 27, + "target": 33 + }, + { + "source": 27, + "target": 43 + }, + { + "source": 28, + "target": 39 + }, + { + "source": 28, + "target": 45 + }, + { + "source": 29, + "target": 37 + }, + { + "source": 30, + "target": 35 + }, + { + "source": 30, + "target": 44 + }, + { + "source": 30, + "target": 50 + }, + { + "source": 31, + "target": 46 + }, + { + "source": 33, + "target": 40 + }, + { + "source": 33, + "target": 43 + }, + { + "source": 34, + "target": 38 + }, + { + "source": 35, + "target": 42 + }, + { + "source": 35, + "target": 50 + }, + { + "source": 35, + "target": 54 + }, + { + "source": 36, + "target": 40 + }, + { + "source": 36, + "target": 46 + }, + { + "source": 37, + "target": 47 + }, + { + "source": 38, + "target": 49 + }, + { + "source": 39, + "target": 45 + }, + { + "source": 39, + "target": 51 + }, + { + "source": 39, + "target": 53 + }, + { + "source": 40, + "target": 55 + }, + { + "source": 41, + "target": 52 + }, + { + "source": 42, + "target": 50 + }, + { + "source": 44, + "target": 54 + }, + { + "source": 45, + "target": 51 + }, + { + "source": 47, + "target": 48 + }, + { + "source": 47, + "target": 57 + }, + { + "source": 48, + "target": 57 + }, + { + "source": 49, + "target": 56 + }, + { + "source": 50, + "target": 54 + }, + { + "source": 50, + "target": 65 + }, + { + "source": 52, + "target": 68 + }, + { + "source": 53, + "target": 69 + }, + { + "source": 55, + "target": 62 + }, + { + "source": 55, + "target": 63 + }, + { + "source": 56, + "target": 59 + }, + { + "source": 56, + "target": 64 + }, + { + "source": 57, + "target": 72 + }, + { + "source": 58, + "target": 59 + }, + { + "source": 58, + "target": 60 + }, + { + "source": 60, + "target": 62 + }, + { + "source": 61, + "target": 63 + }, + { + "source": 61, + "target": 67 + }, + { + "source": 63, + "target": 67 + }, + { + "source": 64, + "target": 66 + }, + { + "source": 64, + "target": 76 + }, + { + "source": 65, + "target": 78 + }, + { + "source": 66, + "target": 69 + }, + { + "source": 66, + "target": 76 + }, + { + "source": 67, + "target": 79 + }, + { + "source": 68, + "target": 77 + }, + { + "source": 68, + "target": 81 + }, + { + "source": 68, + "target": 82 + }, + { + "source": 69, + "target": 70 + }, + { + "source": 69, + "target": 86 + }, + { + "source": 70, + "target": 77 + }, + { + "source": 71, + "target": 74 + }, + { + "source": 71, + "target": 88 + }, + { + "source": 72, + "target": 78 + }, + { + "source": 73, + "target": 79 + }, + { + "source": 73, + "target": 84 + }, + { + "source": 74, + "target": 80 + }, + { + "source": 74, + "target": 85 + }, + { + "source": 75, + "target": 83 + }, + { + "source": 75, + "target": 87 + }, + { + "source": 76, + "target": 93 + }, + { + "source": 77, + "target": 82 + }, + { + "source": 78, + "target": 80 + }, + { + "source": 79, + "target": 84 + }, + { + "source": 79, + "target": 90 + }, + { + "source": 81, + "target": 82 + }, + { + "source": 83, + "target": 91 + }, + { + "source": 85, + "target": 88 + }, + { + "source": 85, + "target": 94 + }, + { + "source": 86, + "target": 89 + }, + { + "source": 86, + "target": 93 + }, + { + "source": 87, + "target": 95 + }, + { + "source": 87, + "target": 96 + }, + { + "source": 88, + "target": 94 + }, + { + "source": 89, + "target": 93 + }, + { + "source": 90, + "target": 92 + }, + { + "source": 91, + "target": 92 + }, + { + "source": 91, + "target": 96 + }, + { + "source": 94, + "target": 100 + }, + { + "source": 94, + "target": 106 + }, + { + "source": 95, + "target": 96 + }, + { + "source": 97, + "target": 98 + }, + { + "source": 97, + "target": 102 + }, + { + "source": 98, + "target": 105 + }, + { + "source": 99, + "target": 107 + }, + { + "source": 99, + "target": 110 + }, + { + "source": 100, + "target": 106 + }, + { + "source": 101, + "target": 102 + }, + { + "source": 101, + "target": 104 + }, + { + "source": 102, + "target": 105 + }, + { + "source": 103, + "target": 108 + }, + { + "source": 103, + "target": 109 + }, + { + "source": 104, + "target": 108 + }, + { + "source": 104, + "target": 114 + }, + { + "source": 105, + "target": 107 + }, + { + "source": 105, + "target": 113 + }, + { + "source": 106, + "target": 112 + }, + { + "source": 106, + "target": 117 + }, + { + "source": 106, + "target": 126 + }, + { + "source": 107, + "target": 110 + }, + { + "source": 107, + "target": 113 + }, + { + "source": 108, + "target": 114 + }, + { + "source": 109, + "target": 111 + }, + { + "source": 110, + "target": 112 + }, + { + "source": 111, + "target": 115 + }, + { + "source": 114, + "target": 116 + }, + { + "source": 114, + "target": 119 + }, + { + "source": 115, + "target": 118 + }, + { + "source": 116, + "target": 119 + }, + { + "source": 117, + "target": 126 + }, + { + "source": 118, + "target": 124 + }, + { + "source": 120, + "target": 121 + }, + { + "source": 120, + "target": 129 + }, + { + "source": 121, + "target": 129 + }, + { + "source": 122, + "target": 125 + }, + { + "source": 122, + "target": 133 + }, + { + "source": 123, + "target": 124 + }, + { + "source": 123, + "target": 132 + }, + { + "source": 125, + "target": 132 + }, + { + "source": 125, + "target": 137 + }, + { + "source": 126, + "target": 144 + }, + { + "source": 127, + "target": 134 + }, + { + "source": 127, + "target": 138 + }, + { + "source": 128, + "target": 130 + }, + { + "source": 128, + "target": 131 + }, + { + "source": 128, + "target": 136 + }, + { + "source": 129, + "target": 134 + }, + { + "source": 129, + "target": 139 + }, + { + "source": 130, + "target": 141 + }, + { + "source": 131, + "target": 136 + }, + { + "source": 132, + "target": 135 + }, + { + "source": 132, + "target": 137 + }, + { + "source": 133, + "target": 145 + }, + { + "source": 134, + "target": 138 + }, + { + "source": 134, + "target": 142 + }, + { + "source": 135, + "target": 137 + }, + { + "source": 135, + "target": 147 + }, + { + "source": 136, + "target": 143 + }, + { + "source": 137, + "target": 145 + }, + { + "source": 137, + "target": 147 + }, + { + "source": 138, + "target": 149 + }, + { + "source": 139, + "target": 142 + }, + { + "source": 139, + "target": 146 + }, + { + "source": 139, + "target": 148 + }, + { + "source": 140, + "target": 146 + }, + { + "source": 140, + "target": 152 + }, + { + "source": 141, + "target": 150 + }, + { + "source": 142, + "target": 148 + }, + { + "source": 143, + "target": 150 + }, + { + "source": 146, + "target": 148 + }, + { + "source": 146, + "target": 152 + }, + { + "source": 149, + "target": 151 + }, + { + "source": 149, + "target": 153 + }, + { + "source": 151, + "target": 153 + } + ], + "adjacency": { + "1": [ + 2, + 8 + ], + "2": [ + 1, + 4, + 11 + ], + "3": [ + 6, + 9 + ], + "4": [ + 2, + 11 + ], + "5": [ + 7, + 19 + ], + "6": [ + 3, + 10, + 14, + 20 + ], + "7": [ + 5, + 21 + ], + "8": [ + 1, + 17, + 23 + ], + "9": [ + 3, + 14, + 24 + ], + "10": [ + 6, + 13, + 20 + ], + "11": [ + 2, + 4, + 18, + 26 + ], + "12": [ + 13, + 15, + 30 + ], + "13": [ + 10, + 12, + 30 + ], + "14": [ + 6, + 9, + 24 + ], + "15": [ + 12, + 44 + ], + "16": [ + 25, + 32 + ], + "17": [ + 8, + 23, + 26 + ], + "18": [ + 11, + 31 + ], + "19": [ + 5, + 21 + ], + "20": [ + 6, + 10 + ], + "21": [ + 7, + 19, + 29 + ], + "22": [ + 33, + 40 + ], + "23": [ + 8, + 17, + 34 + ], + "24": [ + 9, + 14 + ], + "25": [ + 16, + 32, + 41 + ], + "26": [ + 11, + 17 + ], + "27": [ + 33, + 43 + ], + "28": [ + 39, + 45 + ], + "29": [ + 21, + 37 + ], + "30": [ + 12, + 13, + 35, + 44, + 50 + ], + "31": [ + 18, + 46 + ], + "32": [ + 16, + 25 + ], + "33": [ + 22, + 27, + 40, + 43 + ], + "34": [ + 23, + 38 + ], + "35": [ + 30, + 42, + 50, + 54 + ], + "36": [ + 40, + 46 + ], + "37": [ + 29, + 47 + ], + "38": [ + 34, + 49 + ], + "39": [ + 28, + 45, + 51, + 53 + ], + "40": [ + 22, + 33, + 36, + 55 + ], + "41": [ + 25, + 52 + ], + "42": [ + 35, + 50 + ], + "43": [ + 27, + 33 + ], + "44": [ + 15, + 30, + 54 + ], + "45": [ + 28, + 39, + 51 + ], + "46": [ + 31, + 36 + ], + "47": [ + 37, + 48, + 57 + ], + "48": [ + 47, + 57 + ], + "49": [ + 38, + 56 + ], + "50": [ + 30, + 35, + 42, + 54, + 65 + ], + "51": [ + 39, + 45 + ], + "52": [ + 41, + 68 + ], + "53": [ + 39, + 69 + ], + "54": [ + 35, + 44, + 50 + ], + "55": [ + 40, + 62, + 63 + ], + "56": [ + 49, + 59, + 64 + ], + "57": [ + 47, + 48, + 72 + ], + "58": [ + 59, + 60 + ], + "59": [ + 56, + 58 + ], + "60": [ + 58, + 62 + ], + "61": [ + 63, + 67 + ], + "62": [ + 55, + 60 + ], + "63": [ + 55, + 61, + 67 + ], + "64": [ + 56, + 66, + 76 + ], + "65": [ + 50, + 78 + ], + "66": [ + 64, + 69, + 76 + ], + "67": [ + 61, + 63, + 79 + ], + "68": [ + 52, + 77, + 81, + 82 + ], + "69": [ + 53, + 66, + 70, + 86 + ], + "70": [ + 69, + 77 + ], + "71": [ + 74, + 88 + ], + "72": [ + 57, + 78 + ], + "73": [ + 79, + 84 + ], + "74": [ + 71, + 80, + 85 + ], + "75": [ + 83, + 87 + ], + "76": [ + 64, + 66, + 93 + ], + "77": [ + 68, + 70, + 82 + ], + "78": [ + 65, + 72, + 80 + ], + "79": [ + 67, + 73, + 84, + 90 + ], + "80": [ + 74, + 78 + ], + "81": [ + 68, + 82 + ], + "82": [ + 68, + 77, + 81 + ], + "83": [ + 75, + 91 + ], + "84": [ + 73, + 79 + ], + "85": [ + 74, + 88, + 94 + ], + "86": [ + 69, + 89, + 93 + ], + "87": [ + 75, + 95, + 96 + ], + "88": [ + 71, + 85, + 94 + ], + "89": [ + 86, + 93 + ], + "90": [ + 79, + 92 + ], + "91": [ + 83, + 92, + 96 + ], + "92": [ + 90, + 91 + ], + "93": [ + 76, + 86, + 89 + ], + "94": [ + 85, + 88, + 100, + 106 + ], + "95": [ + 87, + 96 + ], + "96": [ + 87, + 91, + 95 + ], + "97": [ + 98, + 102 + ], + "98": [ + 97, + 105 + ], + "99": [ + 107, + 110 + ], + "100": [ + 94, + 106 + ], + "101": [ + 102, + 104 + ], + "102": [ + 97, + 101, + 105 + ], + "103": [ + 108, + 109 + ], + "104": [ + 101, + 108, + 114 + ], + "105": [ + 98, + 102, + 107, + 113 + ], + "106": [ + 94, + 100, + 112, + 117, + 126 + ], + "107": [ + 99, + 105, + 110, + 113 + ], + "108": [ + 103, + 104, + 114 + ], + "109": [ + 103, + 111 + ], + "110": [ + 99, + 107, + 112 + ], + "111": [ + 109, + 115 + ], + "112": [ + 106, + 110 + ], + "113": [ + 105, + 107 + ], + "114": [ + 104, + 108, + 116, + 119 + ], + "115": [ + 111, + 118 + ], + "116": [ + 114, + 119 + ], + "117": [ + 106, + 126 + ], + "118": [ + 115, + 124 + ], + "119": [ + 114, + 116 + ], + "120": [ + 121, + 129 + ], + "121": [ + 120, + 129 + ], + "122": [ + 125, + 133 + ], + "123": [ + 124, + 132 + ], + "124": [ + 118, + 123 + ], + "125": [ + 122, + 132, + 137 + ], + "126": [ + 106, + 117, + 144 + ], + "127": [ + 134, + 138 + ], + "128": [ + 130, + 131, + 136 + ], + "129": [ + 120, + 121, + 134, + 139 + ], + "130": [ + 128, + 141 + ], + "131": [ + 128, + 136 + ], + "132": [ + 123, + 125, + 135, + 137 + ], + "133": [ + 122, + 145 + ], + "134": [ + 127, + 129, + 138, + 142 + ], + "135": [ + 132, + 137, + 147 + ], + "136": [ + 128, + 131, + 143 + ], + "137": [ + 125, + 132, + 135, + 145, + 147 + ], + "138": [ + 127, + 134, + 149 + ], + "139": [ + 129, + 142, + 146, + 148 + ], + "140": [ + 146, + 152 + ], + "141": [ + 130, + 150 + ], + "142": [ + 134, + 139, + 148 + ], + "143": [ + 136, + 150 + ], + "144": [ + 126 + ], + "145": [ + 133, + 137 + ], + "146": [ + 139, + 140, + 148, + 152 + ], + "147": [ + 135, + 137 + ], + "148": [ + 139, + 142, + 146 + ], + "149": [ + 138, + 151, + 153 + ], + "150": [ + 141, + 143 + ], + "151": [ + 149, + 153 + ], + "152": [ + 140, + 146 + ], + "153": [ + 149, + 151 + ] + } +} \ No newline at end of file diff --git a/data/raw/maps/taxi_cv_out/taxi_cv_out/junctions.csv b/data/raw/maps/taxi_cv_out/taxi_cv_out/junctions.csv new file mode 100644 index 0000000000000000000000000000000000000000..d9694374bef59014ecd04ca23868c8a3d084a72f --- /dev/null +++ b/data/raw/maps/taxi_cv_out/taxi_cv_out/junctions.csv @@ -0,0 +1,154 @@ +id,x,y,radius,neighbors +1,915,64,11,2 8 +2,987,74,12,1 4 11 +3,424,80,12,6 9 +4,1018,95,13,2 11 +5,626,112,12,7 19 +6,356,119,12,3 10 14 20 +7,559,123,12,5 21 +8,889,125,11,1 17 23 +9,431,127,11,3 14 24 +10,286,140,11,6 13 20 +11,1016,140,12,2 4 18 26 +12,137,145,11,13 15 30 +13,231,151,11,10 12 30 +14,393,152,11,6 9 24 +15,100,153,12,12 44 +16,715,154,12,25 32 +17,941,155,11,8 23 26 +18,1073,161,11,11 31 +19,627,163,11,5 21 +20,315,170,11,6 10 +21,575,173,11,7 19 29 +22,1195,173,12,33 40 +23,893,178,11,8 17 34 +24,422,184,11,9 14 +25,698,187,11,16 32 41 +26,1004,189,12,11 17 +27,1343,189,11,33 43 +28,821,191,11,39 45 +29,541,199,11,21 37 +30,211,200,11,12 13 35 44 50 +31,1088,205,12,18 46 +32,739,206,11,16 25 +33,1262,215,11,22 27 40 43 +34,907,218,11,23 38 +35,239,236,9,30 42 50 54 +36,1139,236,11,40 46 +37,495,239,11,29 47 +38,946,247,10,34 49 +39,797,248,12,28 45 51 53 +40,1193,249,11,22 33 36 55 +41,656,253,11,25 52 +42,284,254,11,35 50 +43,1305,255,12,27 33 +44,137,256,9,15 30 54 +45,850,257,10,28 39 51 +46,1089,257,11,31 36 +47,415,261,11,37 48 57 +48,370,267,11,47 57 +49,931,267,11,38 56 +50,247,277,11,30 35 42 54 65 +51,818,281,11,39 45 +52,621,291,11,41 68 +53,745,293,11,39 69 +54,202,305,11,35 44 50 +55,1193,308,9,40 62 63 +56,937,310,11,49 59 64 +57,401,311,12,47 48 72 +58,1054,311,11,59 60 +59,1013,313,11,56 58 +60,1108,322,10,58 62 +61,1267,322,10,63 67 +62,1137,329,11,55 60 +63,1208,331,11,55 61 67 +64,892,337,11,56 66 76 +65,275,343,11,50 78 +66,839,343,10,64 69 76 +67,1240,350,11,61 63 79 +68,568,353,11,52 77 81 82 +69,764,355,11,53 66 70 86 +70,676,369,11,69 77 +71,59,375,12,74 88 +72,388,377,11,57 78 +73,1315,385,12,79 84 +74,157,386,11,71 80 85 +75,1115,389,11,83 87 +76,842,392,11,64 66 93 +77,586,395,9,68 70 82 +78,296,397,9,65 72 80 +79,1246,403,10,67 73 84 90 +80,220,406,11,74 78 +81,494,406,11,68 82 +82,549,416,10,68 77 81 +83,1157,419,11,75 91 +84,1336,419,11,73 79 +85,145,428,9,74 88 94 +86,771,434,11,69 89 93 +87,1085,441,11,75 95 96 +88,100,448,12,71 85 94 +89,755,461,13,86 93 +90,1256,467,11,79 92 +91,1166,470,11,83 92 96 +92,1219,472,9,90 91 +93,849,473,9,76 86 89 +94,111,477,12,85 88 100 106 +95,1005,490,11,87 96 +96,1096,494,12,87 91 95 +97,386,511,9,98 102 +98,355,535,9,97 105 +99,254,538,12,107 110 +100,133,555,11,94 106 +101,472,556,10,102 104 +102,431,560,13,97 101 105 +103,649,575,11,108 109 +104,559,577,9,101 108 114 +105,376,590,11,98 102 107 113 +106,143,603,11,94 100 112 117 126 +107,326,607,9,99 105 110 113 +108,571,616,11,103 104 114 +109,679,621,9,103 111 +110,259,623,9,99 107 112 +111,769,634,11,109 115 +112,206,646,9,106 110 +113,352,650,9,105 107 +114,568,650,9,104 108 116 119 +115,811,676,9,111 118 +116,548,688,9,114 119 +117,99,693,9,106 126 +118,874,693,11,115 124 +119,602,703,9,114 116 +120,410,718,11,121 129 +121,469,727,9,120 129 +122,1168,727,13,125 133 +123,979,729,11,124 132 +124,921,733,11,118 123 +125,1071,739,9,122 132 137 +126,93,741,9,106 117 144 +127,542,763,10,134 138 +128,797,764,9,130 131 136 +129,445,766,11,120 121 134 139 +130,707,767,9,128 141 +131,829,767,9,128 136 +132,991,779,11,123 125 135 137 +133,1151,787,10,122 145 +134,489,790,9,127 129 138 142 +135,959,806,9,132 137 147 +136,838,811,11,128 131 143 +137,1021,812,9,125 132 135 145 147 +138,530,815,9,127 134 149 +139,417,819,11,129 142 146 148 +140,284,829,11,146 152 +141,727,836,9,130 150 +142,470,845,11,134 139 148 +143,814,848,9,136 150 +144,151,851,17,126 +145,1102,856,11,133 137 +146,373,868,11,139 140 148 152 +147,1005,884,10,135 137 +148,453,891,11,139 142 146 +149,577,903,12,138 151 153 +150,782,903,9,141 143 +151,622,921,9,149 153 +152,299,937,11,140 146 +153,676,943,12,149 151 diff --git a/data/raw/maps/taxi_cv_out/taxi_map_cv_graph.py b/data/raw/maps/taxi_cv_out/taxi_map_cv_graph.py new file mode 100644 index 0000000000000000000000000000000000000000..1a1846a5bb49da91bfbb11fcb970ba713a7081e5 --- /dev/null +++ b/data/raw/maps/taxi_cv_out/taxi_map_cv_graph.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +""" +Detect circular taxi-map junctions, build a graph of direct road connections, +and create a labelled junction image without drawing connection lines. + +Example: + python taxi_map_cv_graph.py --image Taxi.png --out out --show-labels +""" + +import argparse +import csv +import json +import math +from collections import defaultdict +from pathlib import Path + +import cv2 +import networkx as nx +import numpy as np +from skimage.morphology import skeletonize + + +def merge_circles(circles, min_dist=14): + """Merge duplicate Hough detections, preferring larger/stronger circles.""" + if not circles: + return [] + circles = sorted(circles, key=lambda c: c[2], reverse=True) + kept = [] + for x, y, r in circles: + if all(math.hypot(x - k[0], y - k[1]) > min_dist for k in kept): + kept.append((int(x), int(y), int(r))) + # Stable numbering: top-to-bottom, then left-to-right. + kept.sort(key=lambda c: (c[1], c[0])) + return kept + + +def detect_junctions(img_bgr, args): + """Detect circular junction disks with Hough circles plus sanity filtering.""" + gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) + gray = cv2.medianBlur(gray, 5) + + raw = cv2.HoughCircles( + gray, + cv2.HOUGH_GRADIENT, + dp=args.hough_dp, + minDist=args.min_dist, + param1=args.hough_param1, + param2=args.hough_param2, + minRadius=args.min_radius, + maxRadius=args.max_radius, + ) + if raw is None: + return [] + + hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) + candidates = [] + for x, y, r in np.round(raw[0]).astype(int): + if not (args.min_radius <= r <= args.max_radius): + continue + # Optional crop exclusion, useful for map ornaments/title boxes. + if args.ignore_bottom_fraction and y > img_bgr.shape[0] * (1.0 - args.ignore_bottom_fraction): + continue + + yy, xx = np.ogrid[:img_bgr.shape[0], :img_bgr.shape[1]] + disk = (xx - x) ** 2 + (yy - y) ** 2 <= max(4, r - 2) ** 2 + h = hsv[:, :, 0][disk] + s = hsv[:, :, 1][disk] + v = hsv[:, :, 2][disk] + + # Junction centers are yellow/cream. This rejects many texture false positives. + yellowish = ((h >= args.node_h_min) & (h <= args.node_h_max) & + (s >= args.node_s_min) & (v >= args.node_v_min)).mean() + if yellowish >= args.min_yellow_fraction: + candidates.append((x, y, r)) + + return merge_circles(candidates, min_dist=args.merge_dist) + + +def road_mask(img_bgr, args): + """Segment the yellow taxi-road network.""" + hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) + lower = np.array([args.road_h_min, args.road_s_min, args.road_v_min], dtype=np.uint8) + upper = np.array([args.road_h_max, 255, 255], dtype=np.uint8) + mask = cv2.inRange(hsv, lower, upper) + + # Close small gaps and remove tiny specks. + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k, iterations=2) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k, iterations=1) + return mask + + +def build_graph(img_bgr, junctions, args): + """ + Build edges by removing each circular node from the road mask. + Each remaining connected yellow component is a road segment; the nodes + touching that segment become neighbors in the graph. + """ + mask = road_mask(img_bgr, args) + + # Remove junction interiors so road segments between them become components. + cut = mask.copy() + for x, y, r in junctions: + cv2.circle(cut, (x, y), int(r + args.node_cut_pad), 0, -1) + + # Skeletonization makes thick roads thinner and reduces accidental broad contacts. + skel = skeletonize(cut > 0).astype(np.uint8) * 255 + skel = cv2.dilate(skel, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), iterations=1) + + nlabels, labels, stats, _ = cv2.connectedComponentsWithStats(skel, connectivity=8) + G = nx.Graph() + for idx, (x, y, r) in enumerate(junctions, start=1): + G.add_node(idx, x=int(x), y=int(y), r=int(r)) + + for comp_id in range(1, nlabels): + area = stats[comp_id, cv2.CC_STAT_AREA] + if area < args.min_segment_pixels: + continue + comp = (labels == comp_id).astype(np.uint8) * 255 + comp = cv2.dilate(comp, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (args.touch_dilate, args.touch_dilate)), iterations=1) + + touched = [] + for idx, (x, y, r) in enumerate(junctions, start=1): + ring = np.zeros(comp.shape, dtype=np.uint8) + cv2.circle(ring, (x, y), int(r + args.touch_radius_pad), 255, -1) + if cv2.countNonZero(cv2.bitwise_and(comp, ring)) > 0: + touched.append(idx) + + # Usually touched has exactly two nodes. If a road component touches more, + # connect nearest pairs within that component to avoid a complete clique. + if len(touched) == 2: + G.add_edge(touched[0], touched[1]) + elif len(touched) > 2: + pts = {i: np.array([junctions[i - 1][0], junctions[i - 1][1]]) for i in touched} + for i in touched: + ds = sorted((np.linalg.norm(pts[i] - pts[j]), j) for j in touched if j != i) + for _, j in ds[:2]: + G.add_edge(i, j) + return G + + +def write_outputs(img_bgr, junctions, G, out_dir): + out_dir.mkdir(parents=True, exist_ok=True) + + labelled = img_bgr.copy() + for idx, (x, y, r) in enumerate(junctions, start=1): + label = str(idx) + font = cv2.FONT_HERSHEY_SIMPLEX + scale = 0.42 if len(label) < 3 else 0.34 + thickness = 1 + (tw, th), _ = cv2.getTextSize(label, font, scale, thickness) + # Text only: no edge/connection lines. A tiny light backing improves readability. + cv2.circle(labelled, (x, y), max(8, r - 2), (245, 225, 160), -1) + cv2.putText(labelled, label, (x - tw // 2, y + th // 2), font, scale, (20, 20, 20), thickness, cv2.LINE_AA) + cv2.imwrite(str(out_dir / "junctions_labelled.png"), labelled) + + with open(out_dir / "junctions.csv", "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow(["id", "x", "y", "radius", "neighbors"]) + for idx, (x, y, r) in enumerate(junctions, start=1): + w.writerow([idx, x, y, r, " ".join(map(str, sorted(G.neighbors(idx))))]) + + graph_json = { + "nodes": [{"id": i, **G.nodes[i]} for i in G.nodes], + "edges": [{"source": int(a), "target": int(b)} for a, b in sorted(G.edges)], + "adjacency": {str(i): sorted(map(int, G.neighbors(i))) for i in G.nodes}, + } + with open(out_dir / "graph.json", "w", encoding="utf-8") as f: + json.dump(graph_json, f, indent=2) + + nx.write_graphml(G, out_dir / "graph.graphml") + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--image", required=True, help="Input map image, e.g. Taxi.png") + p.add_argument("--out", default="taxi_cv_out", help="Output folder") + + # Circle detection parameters. + p.add_argument("--min-radius", type=int, default=8) + p.add_argument("--max-radius", type=int, default=18, help="Raise to include larger circles; lower to exclude ornaments/stations") + p.add_argument("--min-dist", type=int, default=22) + p.add_argument("--merge-dist", type=int, default=14) + p.add_argument("--hough-dp", type=float, default=1.2) + p.add_argument("--hough-param1", type=float, default=100) + p.add_argument("--hough-param2", type=float, default=30, help="Lower detects more circles; higher detects fewer") + p.add_argument("--ignore-bottom-fraction", type=float, default=0.06, help="Ignore detections in bottom ornament strip; set 0 to disable") + p.add_argument("--node-h-min", type=int, default=12) + p.add_argument("--node-h-max", type=int, default=38) + p.add_argument("--node-s-min", type=int, default=35) + p.add_argument("--node-v-min", type=int, default=135) + p.add_argument("--min-yellow-fraction", type=float, default=0.35) + + # Road segmentation / connection parameters. + p.add_argument("--road-h-min", type=int, default=10) + p.add_argument("--road-h-max", type=int, default=42) + p.add_argument("--road-s-min", type=int, default=35) + p.add_argument("--road-v-min", type=int, default=130) + p.add_argument("--node-cut-pad", type=int, default=5) + p.add_argument("--touch-radius-pad", type=int, default=9) + p.add_argument("--touch-dilate", type=int, default=7) + p.add_argument("--min-segment-pixels", type=int, default=20) + args = p.parse_args() + + img = cv2.imread(args.image) + if img is None: + raise SystemExit(f"Could not read image: {args.image}") + + junctions = detect_junctions(img, args) + G = build_graph(img, junctions, args) + write_outputs(img, junctions, G, Path(args.out)) + + print(f"Detected junctions: {len(junctions)}") + print(f"Detected edges: {G.number_of_edges()}") + print(f"Outputs written to: {Path(args.out).resolve()}") + print("Query example: open graph.json and read adjacency['1'], or use NetworkX: list(G.neighbors(1)).") + + +if __name__ == "__main__": + main() diff --git a/data/voices/voice_01.wav b/data/voices/voice_01.wav new file mode 100644 index 0000000000000000000000000000000000000000..9514e0845206c501138986519a0fdda1f5b0d303 --- /dev/null +++ b/data/voices/voice_01.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:237557ebb197ee52bc23180ff63252932c820df43f4ef99471d5cd89460dd76c +size 230186 diff --git a/data/voices/voice_02.wav b/data/voices/voice_02.wav new file mode 100644 index 0000000000000000000000000000000000000000..12955cb2aa36bbbdc070848a76a8459b49f5af20 --- /dev/null +++ b/data/voices/voice_02.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4b0bed7a7200c8a8eae4d73f5d3990736a5d393a179b0d29dad8949aa7cfaab +size 199546 diff --git a/data/voices/voice_03.wav b/data/voices/voice_03.wav new file mode 100644 index 0000000000000000000000000000000000000000..7121a4ca924adfb8403d9fab5904610a595ea4bf --- /dev/null +++ b/data/voices/voice_03.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4fb7f4d1431261ac997a27de5e0ef96c651b4dd449a7eada55421c2df848feb1 +size 149946 diff --git a/data/voices/voice_04.wav b/data/voices/voice_04.wav new file mode 100644 index 0000000000000000000000000000000000000000..75709193a8ac56fa9c4dc9f10a2eb1d711250748 --- /dev/null +++ b/data/voices/voice_04.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:122b9807826354aa9f53fbefc8e4442bd4d0899fb9cb55bc22a6db1c252cc0bd +size 256866 diff --git a/data/voices/voice_05.wav b/data/voices/voice_05.wav new file mode 100644 index 0000000000000000000000000000000000000000..7500092a284f2642e73391050c01eb11ca3c2850 --- /dev/null +++ b/data/voices/voice_05.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:803833154928f71567f2eed5d579ecb83531156a4bd1c168ba4372cc940640f7 +size 197112 diff --git a/data/voices/voice_06.wav b/data/voices/voice_06.wav new file mode 100644 index 0000000000000000000000000000000000000000..4e338d64e7b5bc9f087896dcd87c65a4f8107f77 --- /dev/null +++ b/data/voices/voice_06.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f219a83dd756d4080a28468111180db0668aae5eefed35d20591c52e65d2c87f +size 154128 diff --git a/data/voices/voices.json b/data/voices/voices.json new file mode 100644 index 0000000000000000000000000000000000000000..8b8952b5d4c9d7d64ee94911a6c83db414eada9e --- /dev/null +++ b/data/voices/voices.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "sample_rate": 22050, + "voices": [ + {"id": "voice_01", "label": "Measured lower voice", "file": "voice_01.wav"}, + {"id": "voice_02", "label": "Direct lower voice", "file": "voice_02.wav"}, + {"id": "voice_03", "label": "Quick lower voice", "file": "voice_03.wav"}, + {"id": "voice_04", "label": "Measured higher voice", "file": "voice_04.wav"}, + {"id": "voice_05", "label": "Conversational higher voice", "file": "voice_05.wav"}, + {"id": "voice_06", "label": "Quick higher voice", "file": "voice_06.wav"} + ] +} diff --git a/docs/DEMO_VIDEO_SCRIPT.md b/docs/DEMO_VIDEO_SCRIPT.md new file mode 100644 index 0000000000000000000000000000000000000000..2d4cd180850a5fe299584769fc80d70617d16d88 --- /dev/null +++ b/docs/DEMO_VIDEO_SCRIPT.md @@ -0,0 +1,191 @@ +# Phantom Grid — Demo Video Script & Production Reference + +> **Purpose:** A complete, hand-off-ready reference for an editor/agent to assemble a +> ~90–120 second demo video for **Phantom Grid** (the local, browser-based London +> investigation game) and its new first-run tutorial. +> +> This document is the **single source of truth** for the video. It lists every shot, +> what asset to use, exactly what voiceover/on-screen text goes with it, and — where a +> static screenshot is not enough — a clearly marked **`[CAPTURE NEEDED]`** placeholder +> with precise instructions for recording the missing footage. + +--- + +## 1. Format & delivery specs + +| Field | Value | +| --- | --- | +| Target length | 90–120 s (trailer-paced) | +| Resolution | 1920×1080, 30 fps (screenshots were captured at 1680×945 @2x → crop/scale to fit) | +| Aspect | 16:9 | +| Output | `docs/demo/phantom_grid_demo.mp4` (H.264, ~10–12 Mbps), plus a 1080×1080 square cut for social if time allows | +| Captions | Burn-in optional; also export an `.srt` from the voiceover script in §6 | +| Audio | One music bed + light UI SFX from the game; voiceover optional (script provided) | + +**Tone:** noir detective / "Lantern Watch Bureau" period-dossier framing. Warm gold on deep +teal-green (the game's own palette). Confident, brisk, a little mysterious. + +**Brand palette (for lower-thirds / titles):** +- Gold text `#ffe39a`, accent gold `#d9ae50` +- Crimson accent `#d4513e` / `#a73a29` +- Deep background `#071f21`, panel border `#9a6926` +- Display font: a serif close to Georgia / "Cinzel" for titles; UI text is system serif. + +--- + +## 2. Assets already captured (ready to use) + +All under [`ui/web/static/assets/tutorial/`](../ui/web/static/assets/tutorial/). Real frames +from the running game, 1680×945 @2x (≈3360×1890 px): + +| File | Shows | Best used for | +| --- | --- | --- | +| `01_board_overview.png` | Full game board: Wanted poster, units, map, notepad, turn counter | Establishing shot / "the board" | +| `02_briefing.png` | Case briefing dossier (crime, suspect, last-seen trail) | "Every case starts with a briefing" | +| `03_map_layers.png` | Map on the **Taxi** transport layer | Transport layers / routes | +| `04_tactics_tray.png` | The 5 tactics with unit counts (Roadblock, Junction Lockdown, Patrol Unit, Search Team, Lookout Board) | Tactics explainer | +| `06_notice.png` | "Issue Public Notice" composer dialog | Public-notice mechanic | +| `07_witness_interview.png` | Witness interview panel (profile, statement, chat input, voice controls) | Witness interview | +| `08_witnesses_map.png` | Map in Witness Mode covered with witness pins | "Read the city" / leads | + +> **Note:** `05_placed_tactic.png` was intentionally removed; the tactics story is told +> better with the tray (`04`) plus the **`[CAPTURE NEEDED]`** drag clip (Shot 6). + +**To re-capture or refresh any still:** run the project app + AI backend, then +`tools/shotenv/Scripts/python.exe tools/capture_tutorial_shots.py` +(see [`tools/capture_tutorial_shots.py`](../tools/capture_tutorial_shots.py)). Output lands +back in `assets/tutorial/`. + +--- + +## 3. How to capture the MISSING footage (`[CAPTURE NEEDED]`) + +The demo needs **motion** that stills can't convey. Two ways to record: + +**Option A — Playwright video (deterministic, no narrator cursor):** +Playwright can record a context to webm. Reuse the working selectors/flows in +[`tools/capture_tutorial_shots.py`](../tools/capture_tutorial_shots.py). Minimal recorder: + +```python +# tools/record_clips.py (to be written by the video agent) +from playwright.sync_api import sync_playwright +with sync_playwright() as pw: + b = pw.chromium.launch(headless=False) # headed = smoother cursor + ctx = b.new_context(viewport={"width":1680,"height":945}, + record_video_dir="docs/demo/clips", + record_video_size={"width":1680,"height":945}) + page = ctx.new_page() + page.goto("http://127.0.0.1:7860") + # ...drive the exact interaction for the shot (see per-shot notes below)... + ctx.close(); b.close() # video is flushed on context close +``` + +Prerequisites (same as the screenshot tool): the app is running at +`http://127.0.0.1:7860` and the AI backend reports healthy +(`GET /api/omni/status` → `"ready": true`). Starting a case and advancing turns +**requires** the local model to be up. + +**Option B — OBS / native screen capture:** record a real play session at 1080p, 30 fps, +hiding the OS cursor trail. Use this for anything with audio (the witness voice reply). + +**Selector cheat-sheet (verified working):** +- Start a case from the ready overlay: `#setupStartButton` (force-click; it pulses) +- Dismiss briefing: `#beginInvestigationButton` +- Layer tabs: buttons in `#layerTabs` (text "Normal/Taxi/Bus/Subway") +- Tactics in tray: `#tacticTray > *`; drag onto `#mapCanvas` +- Lookout board placement opens `#noticeDialog`; publish with `#raiseLookoutButton` +- Witness Mode toggle: `#witnessModeButton`; pins in `#witnessLayer [data-witness-id]` +- Advance a turn: `#advanceButton` +- Tutorial: auto-opens first run; replay via `#helpButton`; nav `#tutorialNext`/`#tutorialBack` + +--- + +## 4. Shot list (the actual edit, in order) + +Legend: 🟢 = asset ready · 🔴 = `[CAPTURE NEEDED]` + +| # | Len | Visual | On-screen text | Source | +| --- | --- | --- | --- | --- | +| 1 | 0:00–0:06 | Title card: logo "PHANTOM GRID" over a slow push-in on the board, vignette | **PHANTOM GRID** / "A London manhunt, played on your desk." | 🔴 **[CAPTURE NEEDED: title animation]** built over `01_board_overview.png` (Ken Burns zoom 100%→108%) | +| 2 | 0:06–0:14 | The case briefing dossier reveals (cards slide/stamp in) | "Every case opens with a dossier." | 🟢 `02_briefing.png` (animate the three cards in; or 🔴 record the real reveal — see Shot notes) | +| 3 | 0:14–0:22 | Full board establishing; subtle callouts pop on Wanted / Map / Turn counter | "You're the Commissioner. Catch the thief before time runs out." | 🟢 `01_board_overview.png` + motion-graphic callout arrows | +| 4 | 0:22–0:32 | Map layers switching Normal→Taxi→Bus→Subway | "The suspect can only move along the transport grid." | 🔴 **[CAPTURE NEEDED: layer-switch clip]** (fallback still: `03_map_layers.png`) | +| 5 | 0:32–0:40 | Witness Mode: city fills with witness pins | "Sightings light up across the city — some true, some false." | 🟢 `08_witnesses_map.png` (push-in) or 🔴 record the toggle | +| 6 | 0:40–0:50 | Dragging a tactic chip from the tray onto a junction; pin lands | "Deploy your units: roadblocks, patrols, search teams." | 🔴 **[CAPTURE NEEDED: tactic drag clip]** + cutaway to 🟢 `04_tactics_tray.png` | +| 7 | 0:50–0:58 | Issue Public Notice dialog; type wording; Publish | "Issue a public appeal to draw out fresh leads." | 🟢 `06_notice.png` or 🔴 record the type+publish | +| 8 | 0:58–1:12 | Witness interview: type a question, AI witness replies (ideally with voice audio) | "Interview witnesses — ask what they saw, where it went, when." | 🔴 **[CAPTURE NEEDED: live interview clip w/ audio]** (fallback still: `07_witness_interview.png`) | +| 9 | 1:12–1:20 | Advance Turn → suspect moves; tension beat; a Search Team triggers the catch | "Close the net. One move too slow and they vanish." | 🔴 **[CAPTURE NEEDED: advance-turn + win/lose moment]** | +| 10 | 1:20–1:30 | The first-run tutorial flips through 2–3 slides | "New here? A built-in tutorial walks you through it." | 🔴 **[CAPTURE NEEDED: tutorial slide flips]** (stills available as fallback: the 7 tutorial slides render the assets in §2) | +| 11 | 1:30–1:40 | End card: logo + setup line + URL | "Runs locally. Your case, your machine." / `127.0.0.1:7860` | 🔴 **[CAPTURE NEEDED: end card]** | + +> Trim to 90 s by dropping Shots 7 and 10 and tightening 8–9. Extend to 2 min by letting +> the interview (Shot 8) breathe with real audio. + +--- + +## 5. Per-shot capture notes for `[CAPTURE NEEDED]` items + +- **Shot 1 / 3 (title + callouts):** pure motion-graphics over the ready still + `01_board_overview.png`. No new game capture required. +- **Shot 2 (briefing reveal, optional live):** start a case via `#setupStartButton`; the + briefing `#caseIntroDialog` animates open. Record ~3 s, then it can be sped up. +- **Shot 4 (layer switch):** after `#beginInvestigationButton`, click the `#layerTabs` + buttons in order with ~1 s between. Record the map area (`.map-shell`). +- **Shot 6 (tactic drag):** mouse-down on a `#tacticTray` chip, move over `#mapCanvas` + to a junction, mouse-up. Headed mode (Option A) shows the cursor; or use OBS. Capture + the "pin lands + event ticker confirms" beat (~3–4 s). +- **Shot 8 (interview, highest value):** open a witness (`#witnessLayer [data-witness-id]` + → interview dialog), type a question in `#witnessMessage`, hit Send, wait for the AI + reply to render in `#witnessTranscript`. If demonstrating **voice**, enable voice output + in Settings and use OBS (Option B) so the audio reply is recorded. Note: surfacing an + *interviewable* witness depends on crowd density — if pins show "crowd too dense", + advance a turn or two, or (dev-only) raise `PHANTOM_GRID_INDIVIDUAL_WITNESS_THRESHOLD` + to make all reports interviewable for the recording, then revert. +- **Shot 9 (advance + outcome):** press `#advanceButton` to show the turn tick and suspect + movement. For the **win moment**, place a `search_team` on the suspect's likely junction + and advance; the case ends and the story reveal becomes available. Capture the result + banner / `#storyDialog` opening as the payoff. (A loss/"vanished" beat also works for + tension — director's choice.) +- **Shot 10 (tutorial flips):** fresh browser (clears `localStorage` so it auto-opens), or + click `#helpButton`. Record clicking `#tutorialNext` through 2–3 slides; the dots and + "1/7 … Start Playing" progression read well on camera. +- **Shot 11 (end card):** motion-graphics card; reuse logo + palette from §1. + +--- + +## 6. Voiceover script (timed to §4) + +> Keep delivery measured; ~135 wpm. Lines map 1:1 to the shot numbers. + +1. "London. A thief is loose on the grid — and you're the one they sent to catch him." +2. "Every case begins with a dossier: the crime, the suspect, the last place they were seen." +3. "You're the Commissioner. Read the trail, and move before the clock runs out." +4. "Your quarry can only travel the transport grid — taxi, bus, and underground lines." +5. "Across the city, sightings surface. Some are real. Some are decoys." +6. "Deploy your units — roadblocks, patrols, and search teams — onto the junctions that matter." +7. "Post a public notice, and the right words will draw out fresh witnesses." +8. "Then question them. What did they see? Which way did it go? Every answer narrows the net." +9. "Advance the turn… and hope you've closed the gap. One move too slow, and he's gone." +10. "New to the Bureau? A built-in tutorial walks you through your first case." +11. "Phantom Grid. It runs entirely on your machine. Open the file. Start the hunt." + +--- + +## 7. Music & SFX + +- **Music:** one cinematic-noir/spy bed, ~110–120 bpm, builds at Shot 8→9. License-clear. +- **SFX (from the game where possible):** the app emits cues such as `lookout_raise`, + `witness_popup`, `blockade_set`, `map_select`, `turn_advance` — pull matching beats for + pin-drops, notice-publish, tactic-place, and turn-tick. A soft "stamp" on the briefing + card and a low "snare/impact" on the catch (Shot 9). + +--- + +## 8. Pre-flight checklist for the video agent + +- [ ] App running at `http://127.0.0.1:7860`; `GET /api/omni/status` → `ready: true`. +- [ ] Confirm the 7 stills in §2 exist; re-run `tools/capture_tutorial_shots.py` if stale. +- [ ] Record the 6 `[CAPTURE NEEDED]` clips (Shots 1/3 are graphics-only) → `docs/demo/clips/`. +- [ ] Build the edit per §4; lay voiceover (§6) and music/SFX (§7). +- [ ] Export `docs/demo/phantom_grid_demo.mp4` (1080p) + `.srt`. +- [ ] If a dev-only setting was changed for Shot 8, revert it (check `.env`). diff --git a/docs/HACKATHON_SUBMISSION_READINESS.md b/docs/HACKATHON_SUBMISSION_READINESS.md new file mode 100644 index 0000000000000000000000000000000000000000..b2f9cb11345ad12112950b93a85649703d22c243 --- /dev/null +++ b/docs/HACKATHON_SUBMISSION_READINESS.md @@ -0,0 +1,105 @@ +# Phantom Grid — Build Small Hackathon Submission Readiness Report + +**Date:** 2026-06-15 +**Project:** Phantom Grid (Shadow Commission: London) +**Target:** Hugging Face **Build Small Hackathon** + +--- + +## TL;DR + +**Not submittable as-is.** The game logic, AI usage, and model-size compliance are in good +shape, but the project fails the hackathon's two hard delivery requirements: it is **not +deployed as a Gradio Space in the official org**, and it **cannot run on Hugging Face Spaces +in its current form** (Windows-only, requires native local compilation of `llama.cpp` and a +~12 GB local model download). The required **demo video**, **social post**, and **Space README +metadata (track tags / badges)** are also missing. + +The official deadline is **June 15, 23:59 UTC — today.** Closing the deployment gap before the +deadline is not realistic; this is a multi-day porting effort. + +--- + +## Hackathon Requirements Checklist + +| # | Requirement | Status | Notes | +|---|-------------|:------:|-------| +| 1 | Every model **under 32B parameters** | ✅ Pass | Uses MiniCPM4.1-8B (text, default) and MiniCPM-o-4.5 (~8B, optional voice). Both well under 32B. | +| 2 | App is a **Gradio app** | ✅ Pass | Uses `gr.Server()` with a custom HTML/JS frontend. The field guide **explicitly encourages this**: *"gr.Server is your friend — go well beyond the stock components and make it yours"* (the **Off Brand** bonus badge). Custom, non-traditional Gradio interfaces are rewarded, not penalized. | +| 3 | Deployed as a **Hugging Face Space** in the `build-small-hackathon` org | ❌ Fail | No Space exists. No Space README YAML header, no Spaces-compatible config. | +| 4 | App actually **runs on Spaces** (Docker or Gradio SDK, Linux) | ❌ Fail | Windows-only runtime. See blockers below. | +| 5 | **Demo video** showing the app working | ❌ Missing | Not produced. | +| 6 | **Social media post** linked from the Space README | ❌ Missing | Not produced. | +| 7 | README with **track tag** (Backyard AI / Thousand Token Wood) + **prize badges** | ❌ Missing | Current `README.md` is a local-setup guide; lacks Space frontmatter, track tags, badges, and video/social links. | +| 8 | Submit before **June 15, 23:59 UTC** | ⏰ At risk | Deadline is today. | + +--- + +## Critical Blockers (Deployment) + +The architecture is built for a **local Windows machine**, which is fundamentally +incompatible with the Hugging Face Spaces Linux container runtime: + +1. **Windows-specific code throughout.** 51 occurrences of `.exe`, plus `ctypes.windll`, + `msvcrt` file locking, and `subprocess.CREATE_NO_WINDOW` in `app.py` and + `scripts/provision_local_runtime.py`. These will not run on a Linux Space. + +2. **Runtime provisioning at first launch.** The app expects to download sources, **compile + `llama.cpp-omni` natively** (cmake/ninja/zig), install PyTorch, and pull a ~12 GB GGUF model + into a local `runtime/` directory. Spaces cannot perform multi-minute native builds and large + downloads as part of normal app startup, and the free/ZeroGPU tier has no persistent build env + for this. + +3. **Launcher is `.ps1` / `.cmd`.** Entry is PowerShell-driven (`run_game.ps1`, + `run_game.cmd`), not a Spaces `app.py` Gradio SDK entrypoint or a portable Dockerfile. + (The only Dockerfile present is inside the vendored `runtime/MiniCPM-o-Demo/`, not the app's.) + +4. **No Spaces hardware story.** The model needs GPU or slow CPU inference plus the gateway + process. There is no configuration mapping this to ZeroGPU or a Spaces GPU tier. + +--- + +## What's Already Good + +- **Model compliance** is solid — both models are comfortably under the 32B cap. +- **Real, non-trivial AI use** — LLM drives witness interviews and story generation, matching + the "AI doing the fun thing" spirit of the *delightful / Thousand Token Wood* track. +- **Working game** locally on Windows: map, notices, witnesses, tactics, turn engine, save/load, + and a test suite (`pytest`). +- `gradio` and `huggingface_hub` are already dependencies, so the toolchain is partially aligned. + +--- + +## What It Would Take to Submit (Path Forward) + +Ordered by necessity. Items 1–4 are mandatory for a valid entry. + +1. **Make it run on Linux/Spaces.** Replace the build-from-source runtime with a hosted + inference path: + - Easiest: swap the local llama.cpp backend for the **Hugging Face Inference API / Inference + Endpoints** (or a hosted OpenAI-compatible endpoint) using `huggingface_hub`, gated to an + under-32B model. This removes native compilation and the 12 GB download entirely. + - Alternative: a **Dockerfile** Space that ships a prebuilt `llama-server` + a Q4 GGUF, + downloaded via `hf_hub_download` at build time, on a GPU Space. Heavier and slower to set up. +2. **Provide a Spaces entrypoint.** A **Docker Space** is the right fit (the org allows Docker + "as long as the interface is a Gradio Space", and `gr.Server` qualifies). The custom HTML + frontend is **fine to keep** — it even qualifies for the *Off Brand* bonus badge — so no + rewrite to stock Gradio components is needed. +3. **Add a Space README** with YAML frontmatter (`sdk: gradio` or `sdk: docker`, + `app_file`/`app_port`), the **track tag**, **prize-category badges**, a short description, + and links to the demo video and social post. +4. **Record a demo video** and **publish one social post**, then link both from the README. +5. **Strip the repo for upload** — exclude `.venv/`, `runtime/`, and other vendored multi-GB + trees from the Space (these are present locally and would bloat/break the push). + +--- + +## Recommendation + +Do **not** attempt to submit before today's deadline — the Linux/Spaces port (Blocker #1–4) +is the dominant risk and cannot be completed and verified responsibly in the time remaining. + +If a later round or deadline extension applies, the fastest credible path is: +**HF Inference API backend → Gradio Blocks (or Docker) Space → README + video + social post.** +The game itself is the hard part and it already works; the remaining work is deployment and +packaging, not gameplay. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000000000000000000000000000000000..0f5371469b661e2faf5d5ab2368034803bcd9bc7 --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,24 @@ +# Implementation Plan + +## Now + +- Keep the current fictional map assets under `data/raw/maps`. +- Generate `data/processed/junction_registry.json` from the normal map graph. +- Generate `data/processed/game_graph.json` by merging taxi, bus, and subway layer edges. +- Keep `data/processed/map_atlas.json` as an editable landmark/district registry. It starts empty and can later be filled manually or by a builder. +- Use a local llama.cpp server for all LLM calls. The binary and model paths live in settings. +- For witness memory corruption, keep the behavior prompt-based: the prompt asks the LLM to slightly corrupt the current witness info without fully rewriting it. + +## Next Playable Slice + +1. Gradio map viewer with layer switching. +2. Click or type a junction ID and show legal transport moves. +3. New game creation with hidden culprit state. +4. Junction check win condition. +5. Route and mode block validation. +6. Mock lookout and witness batches. +7. Witness threshold gating. +8. Witness-question prompt through local llama.cpp. +9. Culprit-move prompt through local llama.cpp. +10. Turn-end witness memory corruption prompt. + diff --git a/docs/MINICPM_OMNI_SETUP.md b/docs/MINICPM_OMNI_SETUP.md new file mode 100644 index 0000000000000000000000000000000000000000..f480ca970d0fc4585a27f6f0ce60df634d930085 --- /dev/null +++ b/docs/MINICPM_OMNI_SETUP.md @@ -0,0 +1,89 @@ +# MiniCPM-o 4.5 Setup + +> ⚠️ **Optional voice mode — language warning.** MiniCPM-o is *not* the default +> backend. The default is the text-only `llama_cpp_server` provider running +> OpenBMB **MiniCPM4.1-8B** (see `.env.example`), because the MiniCPM-o omni +> model frequently hallucinates and drifts into **Chinese** — especially in its +> TTS/audio branch — even when prompted in English. Use MiniCPM-o only if you +> need synthesized witness voices, and expect occasional Chinese output despite +> the English-only mitigations in `llm/omni_client.py` and `app.py`. + +Phantom Grid expects three external components. Keep these outside the project so model weights and compiled binaries are not copied into source control. + +## 1. Download the GGUF snapshot + +Install the Hugging Face CLI and preserve the repository's nested module folders: + +```powershell +py -m pip install -U huggingface_hub +huggingface-cli download openbmb/MiniCPM-o-4_5-gguf --local-dir D:\Models\MiniCPM-o-4_5-gguf +``` + +The directory must contain one or more root LLM quantizations and all companion modules: + +```text +MiniCPM-o-4_5-gguf/ + MiniCPM-o-4_5-Q4_K_M.gguf + audio/ + tts/ + token2wav-gguf/ + vision/ +``` + +The Settings model scan lists root quantizations only and reports whether the audio, TTS, and Token2Wav modules are present. + +## 2. Build llama.cpp-omni + +The official Comni integration currently uses the `feat/web-demo` branch: + +```powershell +git clone https://github.com/tc-mb/llama.cpp-omni.git D:\Tools\llama.cpp-omni +Set-Location D:\Tools\llama.cpp-omni +git checkout feat/web-demo +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --config Release --target llama-server -j +``` + +The Comni branch expects `build\bin\llama-server` (or `build\bin\Release\llama-server.exe` for multi-config Windows builds). The packaged first-run installer uses project-local CMake, Ninja, and Zig for a CPU-capable build without requiring Visual Studio. + +## 3. Install the Comni gateway + +```powershell +git clone https://github.com/OpenBMB/MiniCPM-o-Demo.git D:\Tools\MiniCPM-o-Demo +Set-Location D:\Tools\MiniCPM-o-Demo +git checkout Comni +py -3.10 -m venv .venv\base +.\.venv\base\Scripts\python.exe -m pip install -U pip +.\.venv\base\Scripts\python.exe -m pip install "torch==2.8.0" "torchaudio==2.8.0" +.\.venv\base\Scripts\python.exe -m pip install -r requirements.txt +Copy-Item config.example.json config.json +``` + +The Phantom Grid launcher updates `config.json` at launch with the selected model, context length, GPU layers, ports, and external paths. It starts one worker and an HTTP gateway at `127.0.0.1:8006` by default. + +## 4. Configure Phantom Grid + +Open Settings and fill in: + +- Comni checkout: `D:\Tools\MiniCPM-o-Demo` +- llama.cpp-omni root: `D:\Tools\llama.cpp-omni` +- MiniCPM model directory: `D:\Models\MiniCPM-o-4_5-gguf` +- Quantization: choose a scanned root GGUF +- Context: `4096` to `32768` +- GPU layers: `auto`, `0`, or a non-negative integer + +Press **Start MiniCPM-o**. First model load can take a minute or more. The browser will refuse to create or advance an AI case until the gateway health check succeeds. + +## Context adaptation + +The selected context is also the game's memory budget. Smaller contexts retain fewer recent story segments and interview turns, while older events are compacted into a continuity synopsis. Larger contexts preserve more recent detail. Story decisions, observable facts, and persisted case history are never discarded. + +## Reference voices + +Development reference WAVs live in `data/voices`. Each witness receives a stable voice ID and that WAV is supplied to MiniCPM-o for TTS and live interviews. Review `data/voices/README.md` before distributing a build. + +## Upstream references + +- https://github.com/OpenBMB/MiniCPM-o-Demo/tree/Comni +- https://github.com/tc-mb/llama.cpp-omni/tree/feat/web-demo +- https://huggingface.co/openbmb/MiniCPM-o-4_5-gguf diff --git a/docs/SHIP_AS_HF_SPACE_PROMPT.md b/docs/SHIP_AS_HF_SPACE_PROMPT.md new file mode 100644 index 0000000000000000000000000000000000000000..c21f9fbb6e80176296c9a9608ab5413d1da81c26 --- /dev/null +++ b/docs/SHIP_AS_HF_SPACE_PROMPT.md @@ -0,0 +1,103 @@ +# Handoff Prompt — Ship Phantom Grid as a Hugging Face Space + +> Paste the block below to the agent taking over. It is self-contained. + +--- + +## Task + +Ship the **Phantom Grid** game (this repo) as a **Docker Hugging Face Space** in the +`build-small-hackathon` org, for the Build Small Hackathon. The app already works locally on +Windows; your job is to make it run on a Linux Space and submit it. **Do not rewrite the +gameplay** — only do deployment/packaging. + +## Hard facts (already verified — do not re-litigate) + +- **Models are compliant:** MiniCPM4.1-8B (text, default) and MiniCPM-o-4.5 (~8B, optional + voice). Both under the 32B cap. Keep the text model as the default backend. +- **Custom interface is allowed and encouraged.** The app uses `gr.Server()` with a custom + HTML/JS frontend. The field guide says *"gr.Server is your friend — go well beyond the stock + components"* (Off Brand badge). **Do NOT convert it to stock Gradio Blocks.** +- **llama.cpp is the blessed inference path.** The org's recommended pattern is + `llama-server -hf :Q4_K_M` exposing an OpenAI-compatible API at `:8080/v1`, with the + Gradio app pointing at it. The app already supports this via its + `llama_cpp_server` / `external_llama_cpp_server` provider (`PHANTOM_GRID_LLAMACPP_BASE_URL`). +- **Account:** logged-in HF user is `unity4ar`, a member of `build-small-hackathon`. ⚠️ The + cached token is **read-only** — obtain a **write** token and `huggingface-cli login` before + pushing, or repo creation/push will fail. +- **HF home on this machine:** `D:\AppData\Local\huggingface`. + +## The core problem to solve + +The current app cannot run on a Linux Space because it is Windows-only and builds its runtime at +first launch: +- Windows APIs: `ctypes.windll`, `msvcrt`, `subprocess.CREATE_NO_WINDOW`, `.exe` paths in + `app.py` and `scripts/provision_local_runtime.py`. +- First-launch native compile of `llama.cpp-omni` + ~12 GB model download into `runtime/`. +- PowerShell/cmd launchers (`run_game.ps1`, `run_game.cmd`). + +## Required deliverables (all mandatory for a valid submission) + +1. **A Docker Space that runs on Linux** (Docker is allowed "as long as the interface is a + Gradio Space"; `gr.Server` qualifies). +2. **Space README** with YAML frontmatter (`sdk: docker`, `app_port`), the **track tag** + (this is the *delightful / Thousand Token Wood* track — an AI-driven game), **prize-category + badges**, a short description, and links to the demo video and social post. +3. **Demo video** of the app working (judges may not be able to run it live). +4. **One social-media post**, linked from the README. + +## Implementation plan + +1. **Write a `Dockerfile`** at repo root (Linux base, e.g. `python:3.12-slim` or an + `nvidia/cuda` base if using a GPU Space): + - Install `requirements.txt` (drop Windows-only bits; `audioop-lts` is Py3.13-only and + optional). + - Obtain `llama-server` for Linux — either `pip install llama-cpp-python[server]` or fetch a + prebuilt llama.cpp Linux binary. **Do not** compile the vendored `llama.cpp-omni` tree. + - Download the GGUF at **build time** via `huggingface_hub.hf_hub_download` (text model: + a MiniCPM 8B Q4_K_M GGUF) into a known path — no first-run provisioning. + - Copy app code. **Exclude** `.venv/`, `runtime/`, `tools/*/Lib`, and other multi-GB vendored + trees (add a `.dockerignore`). +2. **Add a Linux entrypoint** (shell script or a small supervisor) that: + - Starts `llama-server` on `:8080` pointing at the downloaded GGUF. + - Sets env so the app uses the external server: + `PHANTOM_GRID_LLM_PROVIDER=llama_cpp_server` (or `external_llama_cpp_server`), + `PHANTOM_GRID_LLAMACPP_BASE_URL=http://127.0.0.1:8080/v1`, + `PHANTOM_GRID_WITNESS_CHAT_TTS=0` (text backend has no voice). + - Launches `app.py` and binds the Gradio/`gr.Server` port to the Space's `app_port` + (default 7860; expose it). +3. **Make `app.py` import on Linux.** Guard or remove the Windows-only code paths that run at + import/startup (`msvcrt`, `ctypes.windll`, `CREATE_NO_WINDOW`, `.exe` lookups). The in-app + "setup/provisioner" screen should be disabled or hidden on Spaces since the model is baked + into the image — `new_case` requires a healthy backend, so ensure the baked server reports + healthy. +4. **Pick hardware.** A GPU Space is realistic for an 8B model; CPU works but is slow. If using + ZeroGPU, remember the 10-apps-per-user cap. Document the choice in the README. +5. **Create & push the Space** to `build-small-hackathon/` with a **write** token: + `huggingface-cli login`, then create the Space (SDK: Docker) and `git push` (or use + `huggingface_hub.HfApi.create_repo(repo_type="space", space_sdk="docker")` + upload). +6. **Verify it boots** on the Space, the server reports healthy, and a new case starts. +7. **Record the demo video**, **publish the social post**, and finalize the README with both + links + track tag + badges. + +## Acceptance criteria + +- The Space builds and runs on HF Spaces (Linux), starts a case, and answers a witness question + using the under-32B model. +- README has track tag, badges, description, video link, social link. +- Space lives under the `build-small-hackathon` org. +- No Windows-only call runs in the container; no multi-GB build-from-source at startup. + +## Pitfalls + +- Read-only token → push fails. Get a write token first. +- Pushing `.venv/`/`runtime/` → bloated/broken Space. Use `.dockerignore`. +- Model download at startup instead of build time → Space times out. Bake it into the image. +- Leaving `PHANTOM_GRID_LLM_PROVIDER=minicpm_omni` → needs the heavy omni stack. Use the plain + text llama.cpp server provider. + + + + + +dont upload anything but make a script to do all the requirements to ship everything... i will copy the present folder to another system and will run this script from that system....but test to make sure that it will run....only one step left must be to run this script \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..ff6188572d705293e8814697e15c9e00ec72810f --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Linux entrypoint for the Phantom Grid Docker Space. +# 1. Start the baked-in llama.cpp OpenAI-compatible server on :8080. +# 2. Wait until it reports healthy (/v1/models). +# 3. Launch the gr.Server app, bound to 0.0.0.0:$PORT for the Space. +set -euo pipefail + +MODEL_PATH="${MODEL_PATH:-/app/models/MiniCPM4.1-8B-Q4_K_M.gguf}" +LLAMA_PORT="${LLAMA_PORT:-8080}" +CTX="${PHANTOM_GRID_LLAMACPP_CONTEXT_LENGTH:-8192}" +APP_PORT="${PORT:-7860}" + +echo "[entrypoint] Starting llama.cpp server on :${LLAMA_PORT} with ${MODEL_PATH}" +python -m llama_cpp.server \ + --model "${MODEL_PATH}" \ + --host 127.0.0.1 \ + --port "${LLAMA_PORT}" \ + --n_ctx "${CTX}" \ + --n_gpu_layers "${LLAMA_N_GPU_LAYERS:-0}" \ + & +LLAMA_PID=$! + +cleanup() { kill "${LLAMA_PID}" 2>/dev/null || true; } +trap cleanup EXIT INT TERM + +echo "[entrypoint] Waiting for llama.cpp /v1/models to become ready..." +for i in $(seq 1 180); do + if curl -sf "http://127.0.0.1:${LLAMA_PORT}/v1/models" >/dev/null 2>&1; then + echo "[entrypoint] llama.cpp server is ready." + break + fi + if ! kill -0 "${LLAMA_PID}" 2>/dev/null; then + echo "[entrypoint] llama.cpp server exited unexpectedly." >&2 + exit 1 + fi + sleep 2 +done + +echo "[entrypoint] Launching Phantom Grid app on 0.0.0.0:${APP_PORT}" +exec python app.py diff --git a/game/__init__.py b/game/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7e842c628d754fc5fd2d83ae3c304873d46e0496 --- /dev/null +++ b/game/__init__.py @@ -0,0 +1,28 @@ +from .state import ( + CulpritMove, + CulpritState, + GameState, + JunctionCheck, + LookoutNotice, + PoliceBlock, + WitnessBatch, + WitnessRecord, +) +from .session import add_block, check_junction, end_turn, issue_notice, new_game, question_witness + +__all__ = [ + "add_block", + "check_junction", + "CulpritMove", + "CulpritState", + "end_turn", + "GameState", + "issue_notice", + "JunctionCheck", + "LookoutNotice", + "new_game", + "PoliceBlock", + "question_witness", + "WitnessBatch", + "WitnessRecord", +] diff --git a/game/case_catalog.py b/game/case_catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..b685be5aa6fd85ed447c5cd17583fdffe725670f --- /dev/null +++ b/game/case_catalog.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import random +import threading +from typing import Any + + +CASE_CATALOG: list[dict[str, Any]] = [ + { + "case_id": "glass_finch", + "case_title": "The Glass Finch Affair", + "crime": "theft of the Beaumont glass automaton", + "stolen_item": "a crystal songbird automaton", + "victim": "the Beaumont Gallery", + "scene": "a private gallery viewing room", + "detail": "Only a scatter of glass feathers and a cut telephone wire remained.", + "culprit_alias": "The Glass Finch", + "description": "A sharp-featured woman in a cream trench coat and emerald scarf, carrying a slim black portfolio case.", + "image_url": "/static/assets/suspects/glass_finch.png", + }, + { + "case_id": "copper_saint", + "case_title": "The Copper Saint Robbery", + "crime": "theft of the St. Aldwyn reliquary", + "stolen_item": "a copper-and-enamel reliquary", + "victim": "St. Aldwyn's Chapter House", + "scene": "a locked cathedral treasury", + "detail": "Wax from a false seal was found beside an open service hatch.", + "culprit_alias": "The Copper Saint", + "description": "A wary man in a rust overcoat and navy flat cap, wearing brass-rim spectacles and carrying a violin case.", + "image_url": "/static/assets/suspects/copper_saint.png", + }, + { + "case_id": "violet_cipher", + "case_title": "The Violet Cipher", + "crime": "theft of a diplomatic code wheel", + "stolen_item": "the Foreign Office violet code wheel", + "victim": "the Crown Signals Bureau", + "scene": "a guarded decoding room", + "detail": "A copied key card and a silver cigarette ash were the only clues.", + "culprit_alias": "The Violet Cipher", + "description": "A poised woman in a charcoal suit and violet veiled cloche hat, carrying a silver cigarette case.", + "image_url": "/static/assets/suspects/violet_cipher.png", + }, + { + "case_id": "ivory_rook", + "case_title": "The Ivory Rook Gambit", + "crime": "theft of an ancient ivory chess rook", + "stolen_item": "the lost rook from the Caliph's chess set", + "victim": "the Royal Antiquities Society", + "scene": "a guarded archive", + "detail": "A rolled museum plan had been exchanged for a flawless forgery.", + "culprit_alias": "The Ivory Rook", + "description": "An older man in a camel coat, burgundy bow tie, and black homburg, carrying a walking cane and rolled plans.", + "image_url": "/static/assets/suspects/ivory_rook.png", + }, + { + "case_id": "saffron_pen", + "case_title": "The Saffron Pen Forgery", + "crime": "theft of the Prime Minister's sealed treaty", + "stolen_item": "a signed copy of the Calcutta Accord", + "victim": "the Cabinet Records Office", + "scene": "a document conservation room", + "detail": "The treaty was replaced by a copy whose ink was still warm.", + "culprit_alias": "The Saffron Pen", + "description": "A moustached man in a bottle-green rain cape and mustard scarf, carrying a leather document tube.", + "image_url": "/static/assets/suspects/saffron_pen.png", + }, + { + "case_id": "blue_hour", + "case_title": "The Blue Hour Break-In", + "crime": "burglary of the Kingsley sapphire vault", + "stolen_item": "the uncut Kingsley sapphire", + "victim": "Harrow & Blythe Jewellers", + "scene": "an underground cutting vault", + "detail": "The safe dial was clean, but blue wool fibres clung to the hinge.", + "culprit_alias": "The Blue Hour", + "description": "A composed woman in a midnight-blue coat and pale headscarf, wearing pearl earrings and carrying a metal tool case.", + "image_url": "/static/assets/suspects/blue_hour.png", + }, + { + "case_id": "brass_moth", + "case_title": "The Brass Moth Mystery", + "crime": "theft of an experimental clockwork navigator", + "stolen_item": "the Halden brass navigator", + "victim": "master clockmaker Elias Halden", + "scene": "a workshop above Bellmaker Lane", + "detail": "Every clock stopped at 11:47 as the workshop lights went dark.", + "culprit_alias": "The Brass Moth", + "description": "An observant man in an olive duffle coat, round spectacles, and burgundy scarf, carrying a mahogany clock box.", + "image_url": "/static/assets/suspects/brass_moth.png", + }, + { + "case_id": "scarlet_lark", + "case_title": "The Scarlet Lark Reel", + "crime": "theft of a classified newsreel", + "stolen_item": "a film canister containing a secret weapons test", + "victim": "Crown News Pictures", + "scene": "a Soho film laboratory", + "detail": "A delivery motorcycle vanished seconds before the fire alarm sounded.", + "culprit_alias": "The Scarlet Lark", + "description": "A defiant woman in a black motoring coat, scarlet beret, and white gloves, carrying a cylindrical film canister.", + "image_url": "/static/assets/suspects/scarlet_lark.png", + }, + { + "case_id": "thames_ghost", + "case_title": "The Thames Ghost Cargo", + "crime": "theft of a customs evidence pouch", + "stolen_item": "a pouch of confiscated black pearls", + "victim": "the Port of London Authority", + "scene": "a river-police evidence room", + "detail": "River mud marked the floor although every dock gate was locked.", + "culprit_alias": "The Thames Ghost", + "description": "A guarded man in a slate pea coat, cream fisherman's jumper, and dark knit cap, carrying a tarred canvas satchel.", + "image_url": "/static/assets/suspects/thames_ghost.png", + }, + { + "case_id": "madame_mercury", + "case_title": "Madame Mercury's Deception", + "crime": "theft of the Marlowe ruby suite", + "stolen_item": "a lacquered box of Burmese rubies", + "victim": "Lady Marlowe", + "scene": "a Mayfair hotel strongroom", + "detail": "The night clerk remembered two identical guests leaving minutes apart.", + "culprit_alias": "Madame Mercury", + "description": "An imperious older woman in a silver-grey fur-collared coat and teal turban, carrying a lacquered red jewel box.", + "image_url": "/static/assets/suspects/madame_mercury.png", + }, + { + "case_id": "green_signal", + "case_title": "The Green Signal Heist", + "crime": "theft of the Night Mail master key", + "stolen_item": "the master key to the Royal Night Mail", + "victim": "British Rail Postal Service", + "scene": "a station sorting office", + "detail": "A signal lantern flashed green on a line closed since midnight.", + "culprit_alias": "The Green Signal", + "description": "A restless young man in a dark green overcoat over a porter's jacket, carrying a timetable case and signal lantern.", + "image_url": "/static/assets/suspects/green_signal.png", + }, + { + "case_id": "amber_quill", + "case_title": "The Amber Quill Manuscript", + "crime": "theft of an illuminated royal manuscript", + "stolen_item": "the gold-leaf Chronicle of Eleanor", + "victim": "the Kensington Manuscript Library", + "scene": "a climate-controlled reading room", + "detail": "Amber sealing wax was pressed with a ring no archivist recognised.", + "culprit_alias": "The Amber Quill", + "description": "A watchful woman in a chocolate-brown coat, amber headscarf, and cat-eye spectacles, carrying a wrapped manuscript.", + "image_url": "/static/assets/suspects/amber_quill.png", + }, + { + "case_id": "velvet_mask", + "case_title": "The Velvet Mask Caper", + "crime": "theft of the Garrick death mask", + "stolen_item": "a priceless carved theatrical mask", + "victim": "the Royal Players' Archive", + "scene": "a locked West End prop room", + "detail": "A stage cue rang in an empty theatre just before the mask disappeared.", + "culprit_alias": "The Velvet Mask", + "description": "A theatrical man in a plum dinner jacket and black opera cloak, carrying a carved mask and compact attache case.", + "image_url": "/static/assets/suspects/velvet_mask.png", + }, + { + "case_id": "rose_diamond", + "case_title": "The Rose Diamond Cut", + "crime": "theft of the uncut Rose Diamond", + "stolen_item": "the newly discovered Rose Diamond", + "victim": "the Hatton Garden Exchange", + "scene": "a gem examination chamber", + "detail": "Fine diamond dust traced a path to a service stair no guest should know.", + "culprit_alias": "The Rose Diamond", + "description": "An unsmiling woman in a dove-grey fitted coat, dusty rose scarf, and round sunglasses, carrying a padded sample case.", + "image_url": "/static/assets/suspects/rose_diamond.png", + }, + { + "case_id": "white_raven", + "case_title": "The White Raven Dispatch", + "crime": "theft of a sealed diplomatic dispatch", + "stolen_item": "the ambassador's blue dispatch pouch", + "victim": "the Nordic Embassy", + "scene": "a guarded diplomatic reception", + "detail": "The embassy seal was intact, but the pouch inside contained blank paper.", + "culprit_alias": "The White Raven", + "description": "A silver-haired man in a white dinner jacket beneath a black overcoat, carrying a sealed blue dispatch pouch.", + "image_url": "/static/assets/suspects/white_raven.png", + }, +] + + +_CASE_ROTATION: list[dict[str, Any]] = [] +_LAST_CASE_ID: str | None = None +_CASE_ROTATION_LOCK = threading.Lock() + + +def choose_case() -> dict[str, Any]: + global _CASE_ROTATION, _LAST_CASE_ID + with _CASE_ROTATION_LOCK: + if not _CASE_ROTATION: + _CASE_ROTATION = list(CASE_CATALOG) + random.SystemRandom().shuffle(_CASE_ROTATION) + if len(_CASE_ROTATION) > 1 and _CASE_ROTATION[-1]["case_id"] == _LAST_CASE_ID: + _CASE_ROTATION[0], _CASE_ROTATION[-1] = _CASE_ROTATION[-1], _CASE_ROTATION[0] + selected = _CASE_ROTATION.pop() + _LAST_CASE_ID = selected["case_id"] + return dict(selected) diff --git a/game/context_budget.py b/game/context_budget.py new file mode 100644 index 0000000000000000000000000000000000000000..ec6324e3bc57a8100ba290536d12964327935f1e --- /dev/null +++ b/game/context_budget.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +MIN_CONTEXT = 4096 +MAX_CONTEXT = 32768 + + +@dataclass(frozen=True) +class ContextBudget: + context_length: int + output_tokens: int + prompt_tokens: int + recent_story_segments: int + recent_interview_turns: int + synopsis_tokens: int + + @classmethod + def for_context(cls, context_length: int) -> "ContextBudget": + clean = normalize_context_length(context_length) + output = min(max(round(clean * 0.15), 512), 2048) + prompt = clean - output + scale = clean / MIN_CONTEXT + return cls( + context_length=clean, + output_tokens=output, + prompt_tokens=prompt, + recent_story_segments=min(max(int(scale * 2), 2), 12), + recent_interview_turns=min(max(int(scale * 3), 3), 24), + synopsis_tokens=min(max(int(prompt * 0.18), 384), 1800), + ) + + def task_prompt_limit(self, task: str) -> int: + shares = { + "decision": 0.48, + "story": 0.78, + "witness": 0.58, + "interview": 0.72, + "summary": 0.48, + } + return max(1024, int(self.prompt_tokens * shares.get(task, 0.60))) + + +def normalize_context_length(value: int | str) -> int: + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise ValueError("Context length must be an integer.") from exc + if parsed < MIN_CONTEXT or parsed > MAX_CONTEXT: + raise ValueError(f"Context length must be between {MIN_CONTEXT} and {MAX_CONTEXT}.") + return max(MIN_CONTEXT, min(MAX_CONTEXT, round(parsed / 1024) * 1024)) + + +def trim_text_to_tokens(text: str, max_tokens: int) -> str: + # A conservative local approximation keeps budgeting independent of a tokenizer. + max_chars = max_tokens * 3 + if len(text) <= max_chars: + return text + return text[-max_chars:] diff --git a/game/culprit_engine.py b/game/culprit_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..6e3ecda34f694b0f6588067f135619b6f5442575 --- /dev/null +++ b/game/culprit_engine.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from dataclasses import asdict + +from grid_map.graph_loader import legal_moves_from + +from .state import CulpritMove, GameState + + +def choose_rule_based_move(state: GameState) -> CulpritMove: + blocks = [asdict(block) for block in state.active_blocks] + legal_moves = [move for move in legal_moves_from(state.culprit.current_junction, blocks) if not move.blocked] + if not legal_moves: + return CulpritMove( + turn_number=state.turn_number, + from_junction=state.culprit.current_junction, + to_junction=state.culprit.current_junction, + mode="remain", + route=[state.culprit.current_junction], + risk_level="high", + ) + + checked = {check.junction_id for check in state.junction_checks[-6:]} + notice_junctions = { + int(plan["junction_id"]) + for notice in state.notices[-3:] + for plan in notice.response_plan + } + search_team_junctions = {t.junction_id for t in state.placed_tactics if t.tactic_type == "search_team"} + patrol_unit_junctions = {t.junction_id for t in state.placed_tactics if t.tactic_type == "patrol_unit"} + + def score(move) -> tuple[int, int, str]: + pressure = 0 + if move.destination in search_team_junctions: + pressure += 10 + if move.destination in checked: + pressure += 5 + if move.destination in patrol_unit_junctions: + pressure += 4 + if move.destination in notice_junctions: + pressure += 2 + mode_preference = {"subway": 0, "bus": 1, "taxi": 2}.get(move.mode, 3) + return (pressure, mode_preference, move.mode) + + chosen = sorted(legal_moves, key=score)[0] + risk = "low" if score(chosen)[0] == 0 else "medium" + return CulpritMove( + turn_number=state.turn_number, + from_junction=state.culprit.current_junction, + to_junction=chosen.destination, + mode=chosen.mode, + route=list(chosen.via), + risk_level=risk, + ) + + +def apply_culprit_move(state: GameState, move: CulpritMove) -> None: + state.culprit.current_junction = move.to_junction + state.culprit.route_history.append(move) + state.game_log.append( + { + "turn_number": state.turn_number, + "kind": "culprit_move_private", + "message": f"Culprit moved from {move.from_junction} to {move.to_junction} by {move.mode}.", + } + ) diff --git a/game/memory_corruption.py b/game/memory_corruption.py new file mode 100644 index 0000000000000000000000000000000000000000..c879d888d4d07351bc9627eb267d4d10d3409f7e --- /dev/null +++ b/game/memory_corruption.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + +from config import PROJECT_ROOT, load_settings +from .state import WitnessRecord + + +PROMPT_PATH = PROJECT_ROOT / "llm" / "prompts" / "memory_corruption.md" + + +def build_corruption_prompt(witness: WitnessRecord, turn_number: int) -> str: + prompt_template = PROMPT_PATH.read_text(encoding="utf-8") + settings = load_settings() + next_level = min(1.0, witness.corruption_level + settings.memory_corruption_per_turn) + return prompt_template.format( + turn_number=turn_number, + corruption_level=f"{next_level:.2f}", + stable_facts="\n".join(f"- {fact}" for fact in witness.stable_facts) or "- None listed", + fragile_facts="\n".join(f"- {fact}" for fact in witness.fragile_facts) or "- None listed", + current_summary=witness.current_summary, + ) + + +def planned_corruption_output_path(game_dir: Path, witness_id: str, turn_number: int) -> Path: + return game_dir / "witnesses" / "corruption" / f"turn_{turn_number:03d}_{witness_id}.json" + diff --git a/game/notice_engine.py b/game/notice_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..ab4b08693a72ff3164baa59874c86fe94e280c3f --- /dev/null +++ b/game/notice_engine.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import re + +from grid_map.graph_loader import adjacent_junctions, all_junction_ids + +from .state import GameState, LookoutNotice + + +GENERIC_WORDS = { + "anyone", + "someone", + "suspicious", + "nervous", + "person", + "people", + "bag", + "coat", + "area", + "nearby", +} + +SPECIFIC_WORDS = { + "raincoat", + "folder", + "red", + "grey", + "gray", + "helmet", + "backpack", + "courier", + "tan", + "blue", +} + + +def create_lookout_notice(state: GameState, text: str, anchor_junction: int | None = None) -> LookoutNotice: + notice_number = len(state.notices) + 1 + parsed = parse_notice(text, state, anchor_junction=anchor_junction) + return LookoutNotice( + notice_id=f"notice_{notice_number:03d}", + turn_number=state.turn_number, + text=text.strip(), + parsed_location=parsed["parsed_location"], + parsed_description=parsed["parsed_description"], + genericness_score=parsed["genericness_score"], + false_positive_likelihood=parsed["false_positive_likelihood"], + response_plan=parsed["response_plan"], + ) + + +def parse_notice(text: str, state: GameState, anchor_junction: int | None = None) -> dict: + clean = " ".join(text.strip().split()) + lowered = clean.lower() + mentioned = [int(value) for value in re.findall(r"\bjunction\s*(\d+)\b|\bj\s*(\d+)\b", lowered) for value in value if value] + if not mentioned: + mentioned = [int(value) for value in re.findall(r"\b(\d{1,3})\b", lowered)] + + valid_ids = set(all_junction_ids()) + mentioned = [junction_id for junction_id in mentioned if junction_id in valid_ids] + + words = set(re.findall(r"[a-z]+", lowered)) + generic_hits = len(words & GENERIC_WORDS) + specific_hits = len(words & SPECIFIC_WORDS) + anchor = anchor_junction if anchor_junction in valid_ids else None + has_location = bool(mentioned or anchor) + + if mentioned or anchor: + anchors = list(dict.fromkeys([*([anchor] if anchor is not None else []), *mentioned[:3]])) + relevant = _expand_junctions(anchors) + parsed_location = ", ".join(f"Junction {junction_id}" for junction_id in anchors) + elif any(word in lowered for word in ("all", "everyone", "city", "anywhere")): + relevant = _citywide_sample(state.culprit.current_junction) + parsed_location = "city-wide" + else: + relevant = _expand_junctions([state.culprit.current_junction]) + parsed_location = "near current public search area" + + genericness = _clamp(0.55 + generic_hits * 0.12 - specific_hits * 0.10 - (0.18 if has_location else 0.0)) + false_positive = _clamp(0.35 + genericness * 0.55 - specific_hits * 0.04) + response_plan = _response_plan(relevant, state, genericness, false_positive) + return { + "parsed_location": parsed_location, + "parsed_description": clean, + "genericness_score": round(genericness, 2), + "false_positive_likelihood": round(false_positive, 2), + "response_plan": response_plan, + } + + +def _expand_junctions(junctions: list[int]) -> list[int]: + expanded: list[int] = list(dict.fromkeys(junctions)) + for junction_id in junctions: + for neighbor in adjacent_junctions(junction_id)[:4]: + if neighbor not in expanded: + expanded.append(neighbor) + return expanded + + +def _citywide_sample(anchor: int) -> list[int]: + ids = all_junction_ids() + stride = max(len(ids) // 14, 1) + sampled = ids[::stride][:14] + if anchor not in sampled: + sampled.append(anchor) + return sorted(set(sampled)) + + +def _response_plan(junctions: list[int], state: GameState, genericness: float, false_positive: float) -> list[dict]: + plan: list[dict] = [] + culprit_junction = state.culprit.current_junction + recent_route = {move.to_junction for move in state.culprit.route_history[-3:]} + recent_route.add(culprit_junction) + lookout_influence = _lookout_influence(state) + + for index, junction_id in enumerate(junctions): + near_culprit = junction_id in recent_route + base = 1 + int(genericness * 6) + if index == 0: + base += 2 + if near_culprit: + base += 2 + if junction_id in lookout_influence: + base += lookout_influence[junction_id] + witnesses = max(1, base) + relevance_bias = 0.18 + (0.55 if near_culprit else 0.0) + (0.18 * (1.0 - false_positive)) + if junction_id in lookout_influence: + relevance_bias += 0.08 + plan.append( + { + "junction_id": junction_id, + "estimated_witnesses": witnesses, + "relevance_bias": round(_clamp(relevance_bias), 2), + } + ) + return plan + + +def _lookout_influence(state: GameState) -> dict[int, int]: + influence: dict[int, int] = {} + for tactic in state.placed_tactics: + if tactic.tactic_type != "lookout_board": + continue + influence[tactic.junction_id] = max(influence.get(tactic.junction_id, 0), 3) + for neighbor in adjacent_junctions(tactic.junction_id)[:4]: + influence[neighbor] = max(influence.get(neighbor, 0), 1) + return influence + + +def _clamp(value: float, minimum: float = 0.0, maximum: float = 1.0) -> float: + return min(max(value, minimum), maximum) diff --git a/game/police_actions.py b/game/police_actions.py new file mode 100644 index 0000000000000000000000000000000000000000..6058f48d98417170721081f9bec76534b47471b7 --- /dev/null +++ b/game/police_actions.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import asdict + +from .state import PoliceBlock + + +def create_edge_block(block_id: str, turn_number: int, from_junction: int, to_junction: int, mode: str | None, turns: int = 2) -> PoliceBlock: + return PoliceBlock( + block_id=block_id, + turn_created=turn_number, + block_type="edge_block", + from_junction=from_junction, + to_junction=to_junction, + mode=mode, + turns_remaining=turns, + ) + + +def create_mode_block(block_id: str, turn_number: int, mode: str, junction_id: int | None = None, turns: int = 1) -> PoliceBlock: + return PoliceBlock( + block_id=block_id, + turn_created=turn_number, + block_type="mode_block", + mode=mode, + junction_id=junction_id, + turns_remaining=turns, + ) + + +def create_junction_block(block_id: str, turn_number: int, junction_id: int, turns: int = 1) -> PoliceBlock: + return PoliceBlock( + block_id=block_id, + turn_created=turn_number, + block_type="junction_block", + junction_id=junction_id, + turns_remaining=turns, + ) + + +def tick_blocks(blocks: list[PoliceBlock]) -> list[PoliceBlock]: + updated: list[PoliceBlock] = [] + for block in blocks: + block.turns_remaining -= 1 + if block.turns_remaining > 0: + updated.append(block) + return updated + + +def blocks_for_prompt(blocks: list[PoliceBlock]) -> list[dict]: + return [asdict(block) for block in blocks if block.turns_remaining > 0] diff --git a/game/rules.py b/game/rules.py new file mode 100644 index 0000000000000000000000000000000000000000..860ff43b5dcfdfe469e2d1fc80cea80adce023c0 --- /dev/null +++ b/game/rules.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from config import load_settings +from .state import JunctionCheck + + +def can_review_individual_witnesses(total_witnesses: int) -> bool: + return total_witnesses <= load_settings().individual_witness_threshold + + +def checks_remaining_this_turn(turn_number: int, checks: list[JunctionCheck | dict]) -> int: + settings = load_settings() + used = 0 + for check in checks: + check_turn = check.get("turn_number") if isinstance(check, dict) else check.turn_number + if check_turn == turn_number: + used += 1 + return max(settings.checks_per_turn - used, 0) diff --git a/game/save_load.py b/game/save_load.py new file mode 100644 index 0000000000000000000000000000000000000000..8cdf82ea9f27b3b8b64fd6291edaefdbcd159231 --- /dev/null +++ b/game/save_load.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from config import load_settings +from .state import GameState +from grid_map.storage import read_json, write_json + + +def game_dir_for(game_id: str) -> Path: + return load_settings().games_dir / game_id + + +def save_game_state(game_dir: Path, state: dict[str, Any] | GameState) -> None: + data = state.to_dict() if isinstance(state, GameState) else state + game_dir.mkdir(parents=True, exist_ok=True) + write_json(game_dir / "game_state.json", data) + culprit = data.get("culprit", {}) + write_json(game_dir / "culprit_private_state.json", culprit) + write_json(game_dir / "story" / "story_segments.json", data.get("story_segments", [])) + write_json(game_dir / "story" / "story_memory.json", data.get("story_memory", {})) + write_json(game_dir / "story" / "case_landmarks.json", data.get("case_landmarks", [])) + write_json(game_dir / "story" / "potential_witnesses.json", data.get("potential_witnesses", [])) + for notice in data.get("notices", []): + write_json(game_dir / "notices" / f"{notice['notice_id']}.json", notice) + for batch in data.get("witness_batches", []): + write_json(game_dir / "witnesses" / f"{batch['batch_id']}.json", batch) + write_json(game_dir / "logs" / "game_log.json", data.get("game_log", [])) + + +def load_game_state(game_dir: Path) -> dict[str, Any]: + return read_json(game_dir / "game_state.json") + + +def load_state(game_id: str) -> GameState: + return GameState.from_dict(load_game_state(game_dir_for(game_id))) diff --git a/game/session.py b/game/session.py new file mode 100644 index 0000000000000000000000000000000000000000..04d8ce45c3904e6aad63a7adad5cc9f0053fb2c8 --- /dev/null +++ b/game/session.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import random +from datetime import datetime + +from config import load_settings +from grid_map.graph_loader import all_junction_ids, legal_moves_from +from .notice_engine import create_lookout_notice +from .police_actions import create_edge_block, create_junction_block, create_mode_block +from .rules import checks_remaining_this_turn +from .save_load import game_dir_for, save_game_state +from .state import CulpritState, GameState, JunctionCheck, PlacedTactic, PoliceBlock, WitnessBatch +from .story_engine import initialize_case_story, story_reveal +from .turn_engine import advance_turn +from .win_conditions import apply_junction_check +from .witness_engine import answer_witness_question, generate_witness_batch + + +TACTIC_LIMITS = { + "roadblock": 3, + "junction_lockdown": 3, + "patrol_unit": 2, + "search_team": 2, + "lookout_board": 2, +} + + +def new_game( + initial_description: str, + starting_junction: int | None = None, + use_model: bool = False, + case_profile: dict | None = None, +) -> GameState: + settings = load_settings() + if starting_junction is None: + starting_junction = random.choice(all_junction_ids()) + previous_candidates = [move.destination for move in legal_moves_from(starting_junction) if move.destination != starting_junction] + last_seen_junction = random.choice(previous_candidates) if previous_candidates else starting_junction + game_id = datetime.now().strftime("game_%Y%m%d_%H%M%S_%f") + state = GameState( + game_id=game_id, + turn_number=1, + max_turns=settings.max_turns, + phase="commissioner_action", + initial_description=initial_description.strip(), + culprit=CulpritState( + current_junction=starting_junction, + current_disguise=initial_description.strip(), + remaining_disguise_changes=settings.starting_disguise_changes, + ), + last_seen_junction=last_seen_junction, + case_profile=dict(case_profile or {}), + game_log=[{"turn_number": 1, "kind": "new_game", "message": f"New investigation opened at turn 1."}], + effective_context_length=settings.llamacpp_context_length, + last_notice_text=initial_description.strip(), + ) + initialize_case_story(state, use_model=use_model) + persist(state) + return state + + +def issue_notice(state: GameState, text: str, anchor_junction: int | None = None) -> tuple[GameState, WitnessBatch]: + notice = create_lookout_notice(state, text, anchor_junction=anchor_junction) + state.notices.append(notice) + batch = generate_witness_batch(state, notice) + state.witness_batches.append(batch) + state.last_notice_text = text.strip() + state.game_log.append( + { + "turn_number": state.turn_number, + "kind": "notice", + "message": f"{notice.notice_id}: {batch.total_witnesses} witnesses responded.", + } + ) + persist(state) + return state, batch + + +def check_junction(state: GameState, junction_id: int) -> tuple[GameState, str]: + remaining = checks_remaining_this_turn(state.turn_number, state.junction_checks) + if remaining <= 0: + return state, "No junction checks remain this turn." + result = apply_junction_check(state, junction_id) + check_id = f"check_t{state.turn_number:03d}_{len(state.junction_checks) + 1:03d}" + state.junction_checks.append(JunctionCheck(check_id=check_id, turn_number=state.turn_number, junction_id=junction_id, result=result)) + visible = "Culprit found. Commissioner wins." if result == "culprit_found" else "No confirmed sighting." + state.game_log.append({"turn_number": state.turn_number, "kind": "junction_check", "message": f"Checked Junction {junction_id}: {visible}"}) + persist(state) + return state, visible + + +def add_block( + state: GameState, + block_type: str, + junction_id: int | None = None, + from_junction: int | None = None, + to_junction: int | None = None, + mode: str | None = None, + turns: int = 1, +) -> tuple[GameState, str]: + settings = load_settings() + if len(state.active_blocks) >= settings.max_active_blocks: + return state, "Maximum active blocks already reached." + block_id = f"block_t{state.turn_number:03d}_{len(state.active_blocks) + 1:03d}" + block = _make_block(block_id, state.turn_number, block_type, junction_id, from_junction, to_junction, mode, turns) + state.active_blocks.append(block) + message = describe_block(block) + state.game_log.append({"turn_number": state.turn_number, "kind": "block", "message": message}) + persist(state) + return state, message + + +def question_witness(state: GameState, witness_id: str, question: str, use_model: bool = False) -> tuple[GameState, str]: + witness = find_witness(state, witness_id) + if witness is None: + return state, "Witness not found." + answer = answer_witness_question(witness, question, state.turn_number, use_model=use_model) + if witness_id not in state.viewed_witness_ids: + state.viewed_witness_ids.append(witness_id) + state.game_log.append({"turn_number": state.turn_number, "kind": "witness_question", "message": f"Questioned {witness_id}."}) + persist(state) + return state, answer + + +def place_tactic( + state: GameState, + tactic_type: str, + junction_id: int, + x: int, + y: int, + layer: str | None = None, +) -> tuple[GameState, str]: + if tactic_type not in TACTIC_LIMITS: + return state, "Unknown tactic." + if _remaining_tactic_count(state, tactic_type) <= 0: + return state, f"No {tactic_type.replace('_', ' ')} units remain." + if junction_id not in all_junction_ids(): + return state, "Choose a valid map junction." + + tactic_id = f"tactic_t{state.turn_number:03d}_{len(state.placed_tactics) + 1:03d}" + linked_block_id = None + message = f"Placed {tactic_type.replace('_', ' ')} at Junction {junction_id}." + + if tactic_type == "roadblock": + moves = legal_moves_from(junction_id, [block.__dict__ for block in state.active_blocks]) + layer_mode = layer if layer in {"taxi", "bus", "subway"} else None + candidates = [move for move in moves if not move.blocked] + if layer_mode is not None: + scoped = [move for move in candidates if move.mode == layer_mode] + if not scoped: + return state, f"No open {layer_mode} route at Junction {junction_id}." + candidates = scoped + open_move = candidates[0] if candidates else None + if open_move is None: + return state, "No open route is available for a roadblock here." + state, message = add_block( + state, + "edge_block", + from_junction=junction_id, + to_junction=open_move.destination, + mode=open_move.mode, + turns=2, + ) + linked_block_id = state.active_blocks[-1].block_id if state.active_blocks else None + elif tactic_type == "junction_lockdown": + state, message = add_block(state, "junction_block", junction_id=junction_id, turns=2) + linked_block_id = state.active_blocks[-1].block_id if state.active_blocks else None + + state.placed_tactics.append( + PlacedTactic( + tactic_id=tactic_id, + tactic_type=tactic_type, + turn_created=state.turn_number, + junction_id=junction_id, + x=int(x), + y=int(y), + linked_block_id=linked_block_id, + ) + ) + state.game_log.append({"turn_number": state.turn_number, "kind": "tactic", "message": message}) + persist(state) + return state, message + + +def remove_tactic(state: GameState, tactic_id: str) -> tuple[GameState, str]: + tactic = next((item for item in state.placed_tactics if item.tactic_id == tactic_id), None) + if tactic is None: + return state, "Tactic not found." + state.placed_tactics = [item for item in state.placed_tactics if item.tactic_id != tactic_id] + if tactic.linked_block_id: + state.active_blocks = [block for block in state.active_blocks if block.block_id != tactic.linked_block_id] + message = f"Removed {tactic.tactic_type.replace('_', ' ')} from Junction {tactic.junction_id}." + state.game_log.append({"turn_number": state.turn_number, "kind": "tactic_removed", "message": message}) + persist(state) + return state, message + + +def end_turn(state: GameState, use_model: bool = False) -> tuple[GameState, str]: + message = advance_turn(state, use_model=use_model) + persist(state) + return state, message + + +def persist(state: GameState) -> None: + save_game_state(game_dir_for(state.game_id), state) + + +def update_notes(state: GameState, notes: str) -> GameState: + state.user_notes = notes[:20000] + persist(state) + return state + + +def finalize_game(state: GameState, reason: str) -> dict: + if not state.result: + state.result = "abandoned" if reason in {"stopped", "restarted"} else reason + state.finalized_reason = reason + state.phase = "complete" + state.game_log.append({"turn_number": state.turn_number, "kind": "game_finalized", "message": f"Case finalized: {reason}."}) + persist(state) + return story_reveal(state) + + +def find_witness(state: GameState, witness_id: str): + for batch in state.witness_batches: + for witness in batch.witnesses: + if witness.witness_id == witness_id: + return witness + return None + + +def _remaining_tactic_count(state: GameState, tactic_type: str) -> int: + placed = sum(1 for tactic in state.placed_tactics if tactic.tactic_type == tactic_type) + return max(TACTIC_LIMITS[tactic_type] - placed, 0) + + +def describe_block(block: PoliceBlock) -> str: + if block.block_type == "edge_block": + mode = f" by {block.mode}" if block.mode else "" + return f"Blocked edge {block.from_junction} -> {block.to_junction}{mode} for {block.turns_remaining} turn(s)." + if block.block_type == "mode_block": + scope = f" near Junction {block.junction_id}" if block.junction_id else "" + return f"Blocked {block.mode}{scope} for {block.turns_remaining} turn(s)." + return f"Blocked Junction {block.junction_id} for {block.turns_remaining} turn(s)." + + +def _make_block( + block_id: str, + turn_number: int, + block_type: str, + junction_id: int | None, + from_junction: int | None, + to_junction: int | None, + mode: str | None, + turns: int, +) -> PoliceBlock: + if block_type == "edge_block": + if from_junction is None or to_junction is None: + raise ValueError("edge_block requires from_junction and to_junction") + return create_edge_block(block_id, turn_number, from_junction, to_junction, mode, turns) + if block_type == "mode_block": + if not mode: + raise ValueError("mode_block requires mode") + return create_mode_block(block_id, turn_number, mode, junction_id, turns) + if block_type == "junction_block": + if junction_id is None: + raise ValueError("junction_block requires junction_id") + return create_junction_block(block_id, turn_number, junction_id, turns) + raise ValueError(f"Unknown block type: {block_type}") diff --git a/game/state.py b/game/state.py new file mode 100644 index 0000000000000000000000000000000000000000..d8b610464dbe8b7a5bf0aa13a83efe306c33c9e6 --- /dev/null +++ b/game/state.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + + +@dataclass +class CulpritMove: + turn_number: int + from_junction: int + to_junction: int + mode: str + route: list[int] + changed_disguise: bool = False + risk_level: str = "unknown" + + +@dataclass +class CulpritState: + current_junction: int + current_disguise: str + remaining_disguise_changes: int + route_history: list[CulpritMove] = field(default_factory=list) + + +@dataclass +class CaseLandmark: + venue_id: str + name: str + category: str + junction_id: int + canonical_place_id: str | None = None + description: str = "" + + +@dataclass +class ObservableFact: + fact_id: str + turn_number: int + junction_id: int + kind: str + text: str + tags: list[str] = field(default_factory=list) + place_id: str | None = None + + +@dataclass +class StorySegment: + segment_id: str + turn_number: int + from_junction: int + to_junction: int + mode: str + route: list[int] + changed_disguise: bool + previous_disguise: str + new_disguise: str + narrative: str + private_reasoning: str = "" + observable_facts: list[ObservableFact] = field(default_factory=list) + context_profile: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class StoryMemory: + continuity_synopsis: str = "" + recent_segment_ids: list[str] = field(default_factory=list) + permanent_facts: list[str] = field(default_factory=list) + + +@dataclass +class PotentialWitness: + potential_id: str + turn_created: int + junction_id: int + observed_fact_ids: list[str] + profile: dict[str, Any] + reliability: float + memory_strength: float + voice_id: str + summary: str + search_tags: list[str] = field(default_factory=list) + surfaced_notice_id: str | None = None + + +@dataclass +class LookoutNotice: + notice_id: str + turn_number: int + text: str + parsed_location: str | None = None + parsed_description: str | None = None + genericness_score: float = 0.0 + false_positive_likelihood: float = 0.0 + response_plan: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class WitnessQuestion: + question: str + answer: str + turn_number: int + + +@dataclass +class WitnessRecord: + witness_id: str + notice_id: str + turn_created: int + junction_id: int + personality: dict[str, Any] + reliability: float + memory_strength: float + corruption_level: float + relevance_score: float + original_summary: str + current_summary: str + stable_facts: list[str] = field(default_factory=list) + fragile_facts: list[str] = field(default_factory=list) + question_history: list[WitnessQuestion] = field(default_factory=list) + name: str = "Unknown witness" + occupation: str = "local resident" + voice_id: str = "voice_01" + observed_fact_ids: list[str] = field(default_factory=list) + conversation_summary: str = "" + is_false_positive: bool = False + + +@dataclass +class WitnessBatch: + batch_id: str + notice_id: str + turn_number: int + total_witnesses: int + individual_review_allowed: bool + witnesses: list[WitnessRecord] = field(default_factory=list) + + +@dataclass +class PoliceBlock: + block_id: str + turn_created: int + block_type: str + turns_remaining: int + mode: str | None = None + from_junction: int | None = None + to_junction: int | None = None + junction_id: int | None = None + district: str | None = None + + +@dataclass +class PlacedTactic: + tactic_id: str + tactic_type: str + turn_created: int + junction_id: int + x: int + y: int + linked_block_id: str | None = None + + +@dataclass +class JunctionCheck: + check_id: str + turn_number: int + junction_id: int + result: str + + +@dataclass +class GameState: + game_id: str + turn_number: int + max_turns: int + phase: str + initial_description: str + culprit: CulpritState + last_seen_junction: int | None = None + case_profile: dict[str, Any] = field(default_factory=dict) + case_introduction: dict[str, Any] = field(default_factory=dict) + notices: list[LookoutNotice] = field(default_factory=list) + witness_batches: list[WitnessBatch] = field(default_factory=list) + active_blocks: list[PoliceBlock] = field(default_factory=list) + placed_tactics: list[PlacedTactic] = field(default_factory=list) + viewed_witness_ids: list[str] = field(default_factory=list) + junction_checks: list[JunctionCheck] = field(default_factory=list) + game_log: list[dict[str, Any]] = field(default_factory=list) + result: str | None = None + story_segments: list[StorySegment] = field(default_factory=list) + story_memory: StoryMemory = field(default_factory=StoryMemory) + case_landmarks: list[CaseLandmark] = field(default_factory=list) + potential_witnesses: list[PotentialWitness] = field(default_factory=list) + user_notes: str = "" + last_notice_text: str = "" + finalized_reason: str | None = None + effective_context_length: int = 8192 + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "GameState": + culprit_data = data["culprit"] + route_history = [CulpritMove(**move) for move in culprit_data.get("route_history", [])] + culprit = CulpritState( + current_junction=culprit_data["current_junction"], + current_disguise=culprit_data["current_disguise"], + remaining_disguise_changes=culprit_data["remaining_disguise_changes"], + route_history=route_history, + ) + + notices = [LookoutNotice(**notice) for notice in data.get("notices", [])] + batches = [_witness_batch_from_dict(batch) for batch in data.get("witness_batches", [])] + blocks = [PoliceBlock(**block) for block in data.get("active_blocks", [])] + placed_tactics = [PlacedTactic(**tactic) for tactic in data.get("placed_tactics", [])] + checks = [JunctionCheck(**check) for check in data.get("junction_checks", [])] + story_segments = [_story_segment_from_dict(item) for item in data.get("story_segments", [])] + story_memory = StoryMemory(**data.get("story_memory", {})) + case_landmarks = [CaseLandmark(**item) for item in data.get("case_landmarks", [])] + potential_witnesses = [PotentialWitness(**item) for item in data.get("potential_witnesses", [])] + return cls( + game_id=data["game_id"], + turn_number=data["turn_number"], + max_turns=data["max_turns"], + phase=data["phase"], + initial_description=data["initial_description"], + culprit=culprit, + last_seen_junction=data.get("last_seen_junction"), + case_profile=data.get("case_profile", {}), + case_introduction=data.get("case_introduction", {}), + notices=notices, + witness_batches=batches, + active_blocks=blocks, + placed_tactics=placed_tactics, + viewed_witness_ids=data.get("viewed_witness_ids", []), + junction_checks=checks, + game_log=data.get("game_log", []), + result=data.get("result"), + story_segments=story_segments, + story_memory=story_memory, + case_landmarks=case_landmarks, + potential_witnesses=potential_witnesses, + user_notes=data.get("user_notes", ""), + last_notice_text=data.get("last_notice_text", ""), + finalized_reason=data.get("finalized_reason"), + effective_context_length=int(data.get("effective_context_length", 8192)), + ) + + +def _witness_batch_from_dict(data: dict[str, Any]) -> WitnessBatch: + witnesses: list[WitnessRecord] = [] + for witness in data.get("witnesses", []): + questions = [WitnessQuestion(**question) for question in witness.get("question_history", [])] + witness = {**witness, "question_history": questions} + witnesses.append(WitnessRecord(**witness)) + return WitnessBatch( + batch_id=data["batch_id"], + notice_id=data["notice_id"], + turn_number=data["turn_number"], + total_witnesses=data["total_witnesses"], + individual_review_allowed=data["individual_review_allowed"], + witnesses=witnesses, + ) + + +def _story_segment_from_dict(data: dict[str, Any]) -> StorySegment: + facts = [ObservableFact(**fact) for fact in data.get("observable_facts", [])] + return StorySegment(**{**data, "observable_facts": facts}) diff --git a/game/story_engine.py b/game/story_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..6c0c392d4e5de6c918eaf46ac6db4e8f0b896bfc --- /dev/null +++ b/game/story_engine.py @@ -0,0 +1,503 @@ +from __future__ import annotations + +import copy +import json +import random +import re +from dataclasses import asdict +from typing import Any + +from config import load_settings +from grid_map.atlas import places_for_junction, primary_place_for_junction +from grid_map.graph_loader import legal_moves_from +from llm.omni_client import OmniClient + +from .context_budget import ContextBudget, trim_text_to_tokens +from .culprit_engine import choose_rule_based_move +from .state import ( + CaseLandmark, + CulpritMove, + GameState, + ObservableFact, + PotentialWitness, + StorySegment, +) + + +VENUE_TEMPLATES = { + "market": [("secondhand_clothes", "Secondhand Clothes Stall", "thrift shop"), ("tea_counter", "Tea Counter", "cafe")], + "street": [("phone_booth", "Public Telephone Booth", "phone booth"), ("news_kiosk", "Evening News Kiosk", "kiosk")], + "square": [("taxi_rank", "Taxi Rank", "taxi rank"), ("corner_cafe", "Corner Cafe", "cafe")], + "park": [("park_shelter", "Park Shelter", "shelter")], + "waterfront": [("dock_cafe", "Dockworkers' Cafe", "cafe"), ("ticket_office", "Ferry Ticket Office", "ticket office")], + "industrial": [("workwear_shop", "Workwear Store", "clothing shop")], +} + +WITNESS_NAMES = [ + ("Eleanor Price", "shop assistant", "careful"), + ("Arthur Bell", "cab driver", "blunt"), + ("Mabel Finch", "telephone operator", "talkative"), + ("Thomas Reed", "porter", "nervous"), + ("Clara Shaw", "cafe owner", "observant"), + ("George Vale", "newspaper seller", "skeptical"), +] + +CRIME_TEMPLATES = [ + { + "case_title": "The Midnight Star Affair", + "crime": "theft of the Midnight Star diamond", + "stolen_item": "the Midnight Star, a rare blue diamond", + "victim": "the Ashcroft Collection", + "scene": "a locked exhibition room", + "detail": "The display glass was cut cleanly, but the alarm wire had been replaced with a length of black thread.", + }, + { + "case_title": "The Vanishing Crown", + "crime": "theft of a royal coronation miniature", + "stolen_item": "a jewel-encrusted coronation miniature", + "victim": "the Royal Antiquities Society", + "scene": "a guarded archive", + "detail": "A cup of untouched tea and a forged curator's pass were the only things left behind.", + }, + { + "case_title": "The Black Ledger Job", + "crime": "burglary of a private banking ledger", + "stolen_item": "a coded ledger naming the city's secret creditors", + "victim": "Bramwell & Finch Bank", + "scene": "the basement records vault", + "detail": "The vault remained locked; someone had removed the ledger through a narrow ventilation grille.", + }, + { + "case_title": "The Clockmaker's Ransom", + "crime": "theft of an experimental gold chronometer", + "stolen_item": "the only working Halden chronometer", + "victim": "master clockmaker Elias Halden", + "scene": "a workshop above Bellmaker Lane", + "detail": "Every clock in the workshop had been stopped at precisely 11:47.", + }, +] + + +def initialize_case_story(state: GameState, use_model: bool = False) -> None: + ensure_case_introduction(state, use_model=use_model) + last_seen_junction = state.last_seen_junction or state.culprit.current_junction + place = primary_place_for_junction(last_seen_junction) + place_name = place["name"] if place else f"Junction {last_seen_junction}" + fact = ObservableFact( + fact_id="fact_t001_opening", + turn_number=1, + junction_id=last_seen_junction, + kind="last_seen", + text=f"A person matching {state.initial_description} was last seen near {place_name}.", + tags=_tags(state.initial_description, place_name, "last seen"), + place_id=place.get("id") if place else None, + ) + segment = StorySegment( + segment_id="story_t001_opening", + turn_number=1, + from_junction=last_seen_junction, + to_junction=state.culprit.current_junction, + mode="unknown", + route=[last_seen_junction, state.culprit.current_junction], + changed_disguise=False, + previous_disguise=state.initial_description, + new_disguise=state.initial_description, + narrative=( + f"{state.case_introduction['culprit_alias']} was last reported near {place_name}, dressed as: " + f"{state.initial_description}. Before the investigation opened, the suspect slipped away to another junction." + ), + observable_facts=[fact], + context_profile=_context_profile(), + ) + state.story_segments.append(segment) + state.story_memory.recent_segment_ids.append(segment.segment_id) + state.story_memory.permanent_facts.append(fact.text) + state.potential_witnesses.extend(_derive_witnesses(state, segment, use_model=use_model)) + + +def ensure_case_introduction(state: GameState, use_model: bool = False) -> bool: + if state.case_introduction: + return False + state.case_introduction = _create_case_introduction(state, use_model=use_model) + return True + + +def _create_case_introduction(state: GameState, use_model: bool) -> dict[str, Any]: + current = state.last_seen_junction or state.culprit.current_junction + rng = random.Random(f"{state.game_id}:{current}") + template = state.case_profile or rng.choice(CRIME_TEMPLATES) + alias = template.get("culprit_alias") or rng.choice(["The Wraith", "Velvet Jack", "The Lantern Thief", "The Grey Fox", "The Night Clerk"]) + moves = legal_moves_from(current) + nearby_ids = [] + for move in moves: + if move.destination not in nearby_ids: + nearby_ids.append(move.destination) + if len(nearby_ids) == 2: + break + trail_ids = [*reversed(nearby_ids), current] + while len(trail_ids) < 3: + trail_ids.insert(0, current) + + labels = ["Earlier report", "Possible escape route", "Last confirmed sighting"] + details = [ + "A hurried figure was noticed shortly after the alarm was raised.", + "A witness reported the suspect moving through the area without stopping.", + f"The clearest sighting matches the description: {state.initial_description}", + ] + sightings = [] + for index, junction_id in enumerate(trail_ids[-3:]): + place = primary_place_for_junction(junction_id) + sightings.append({ + "label": labels[index], + "junction_id": junction_id, + "location": f"{place['name']} / Junction {junction_id}" if place else f"Junction {junction_id}", + "detail": details[index], + "confidence": ["unconfirmed", "probable", "confirmed"][index], + }) + + fallback = { + **template, + "culprit_alias": alias, + "suspect_image": template.get("image_url", "/static/assets/reference/suspect_portrait_placeholder.png"), + "kicker": f"London wakes to the news that {template['stolen_item']} has vanished.", + "narrative": ( + f"Before dawn, {alias} slipped into {template['scene']} and stole {template['stolen_item']} " + f"from {template['victim']}. {template['detail']} By the time the constables arrived, the thief had " + "already melted into the streets, leaving only a broken trail of sightings behind." + ), + "last_seen": sightings, + } + if not use_model: + return fallback + + payload = { + "crime_facts": template, + "culprit_alias": alias, + "suspect_description": state.initial_description, + "public_sighting_trail": sightings, + } + system = ( + "Write a punchy, family-friendly noir opening for a detective board game. Return JSON only with " + "case_title, kicker, narrative, culprit_alias, crime, stolen_item, victim. Preserve every supplied fact, " + "alias, and location; do not add or remove sightings. Keep narrative under 110 words." + ) + try: + data = OmniClient.from_settings().json_chat(system, json.dumps(payload), task="story", temperature=0.7) + intro = {**fallback} + for key in ("case_title", "kicker", "narrative", "culprit_alias", "crime", "stolen_item", "victim"): + value = str(data.get(key) or "").strip() + if value: + intro[key] = value + intro["last_seen"] = sightings + return intro + except (TypeError, ValueError, json.JSONDecodeError): + return fallback + + +def generate_turn_bundle(state: GameState, use_model: bool = False) -> tuple[CulpritMove, StorySegment, list[PotentialWitness], list[CaseLandmark]]: + working = copy.deepcopy(state) + move = _choose_decision(working, use_model=use_model) + previous_disguise = working.culprit.current_disguise + if move.changed_disguise and working.culprit.remaining_disguise_changes <= 0: + raise ValueError("Model requested a disguise change when none remain.") + story, venues = _create_story(working, move, previous_disguise, use_model=use_model) + _validate_story_against_move(story, move) + _ensure_route_facts(story) + witnesses = _derive_witnesses(working, story, use_model=use_model) + return move, story, witnesses, venues + + +def _ensure_route_facts(story: StorySegment) -> None: + if story.from_junction == story.to_junction: + return + if any(fact.junction_id == story.from_junction for fact in story.observable_facts): + return + story.observable_facts.insert(0, ObservableFact( + fact_id=f"fact_t{story.turn_number:03d}_departure", + turn_number=story.turn_number, + junction_id=story.from_junction, + kind="departure", + text=( + f"A person matching {story.previous_disguise} left Junction {story.from_junction} " + f"by {story.mode}." + ), + tags=_tags(story.previous_disguise, story.mode, "left", "departure"), + )) + + +def apply_turn_bundle( + state: GameState, + move: CulpritMove, + story: StorySegment, + witnesses: list[PotentialWitness], + venues: list[CaseLandmark], +) -> None: + state.culprit.current_junction = move.to_junction + state.culprit.route_history.append(move) + if move.changed_disguise: + state.culprit.current_disguise = story.new_disguise + state.culprit.remaining_disguise_changes -= 1 + state.story_segments.append(story) + state.case_landmarks.extend(venue for venue in venues if all(item.venue_id != venue.venue_id for item in state.case_landmarks)) + state.potential_witnesses.extend(witnesses) + state.story_memory.recent_segment_ids.append(story.segment_id) + for fact in story.observable_facts: + state.story_memory.permanent_facts.append(fact.text) + compact_story_memory(state) + state.game_log.append({ + "turn_number": state.turn_number, + "kind": "culprit_move_private", + "message": f"Culprit moved from {move.from_junction} to {move.to_junction} by {move.mode}.", + }) + + +def compact_story_memory(state: GameState) -> None: + budget = ContextBudget.for_context(state.effective_context_length) + recent = state.story_segments[-budget.recent_story_segments :] + state.story_memory.recent_segment_ids = [segment.segment_id for segment in recent] + older = state.story_segments[: -budget.recent_story_segments] + if older: + summary_lines = [ + f"T{s.turn_number}: J{s.from_junction} to J{s.to_junction} by {s.mode}; disguise: {s.new_disguise}." + for s in older + ] + combined = " ".join(summary_lines) + state.story_memory.continuity_synopsis = trim_text_to_tokens(combined, budget.synopsis_tokens) + + +def story_reveal(state: GameState) -> dict[str, Any]: + return { + "game_id": state.game_id, + "result": state.result, + "finalized_reason": state.finalized_reason, + "initial_description": state.initial_description, + "segments": [asdict(segment) for segment in state.story_segments], + "case_landmarks": [asdict(landmark) for landmark in state.case_landmarks], + } + + +def _choose_decision(state: GameState, use_model: bool) -> CulpritMove: + fallback = choose_rule_based_move(state) + if not use_model: + return _maybe_change_disguise(state, fallback) + moves = [move for move in legal_moves_from(state.culprit.current_junction, [asdict(block) for block in state.active_blocks]) if not move.blocked] + if not moves: + return fallback + prompt = { + "current_junction": state.culprit.current_junction, + "current_disguise": state.culprit.current_disguise, + "remaining_disguise_changes": state.culprit.remaining_disguise_changes, + "legal_moves": [{"destination": m.destination, "mode": m.mode, "route": list(m.via)} for m in moves], + "recent_police_attention": [entry for entry in state.game_log[-8:] if entry.get("kind") != "culprit_move_private"], + "continuity_facts": state.story_memory.permanent_facts[-12:], + } + system = "Choose exactly one supplied legal move. Optionally change disguise. Return JSON only: destination, mode, route, change_disguise, new_disguise, risk_level." + last_error = "" + for _ in range(2): + request = json.dumps({**prompt, "previous_validation_error": last_error}) + try: + data = OmniClient.from_settings().json_chat(system, request, task="decision", temperature=0.25) + return _validated_decision(state, moves, data) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + last_error = str(exc) + return _maybe_change_disguise(state, fallback) + + +def _validated_decision(state: GameState, moves: list[Any], data: dict[str, Any]) -> CulpritMove: + if "destination" in data and "mode" in data: + destination = int(data["destination"]) + mode = str(data["mode"]) + else: + action = str(data.get("action") or "").lower() + match = re.search(r"\b(bus|taxi|subway|walk|foot)\b\D+(\d+)\b", action) + if not match: + raise ValueError("Decision must include destination and mode.") + mode = "walk" if match.group(1) == "foot" else match.group(1) + destination = int(match.group(2)) + chosen = next((move for move in moves if move.destination == destination and move.mode == mode), None) + if chosen is None: + raise ValueError("Destination and mode must match one supplied legal move.") + change = bool(data.get("change_disguise")) + new_disguise = str(data.get("new_disguise") or "").strip() + if change and (not new_disguise or state.culprit.remaining_disguise_changes <= 0): + raise ValueError("Disguise change is unavailable or missing a new disguise.") + move = CulpritMove( + turn_number=state.turn_number, + from_junction=state.culprit.current_junction, + to_junction=destination, + mode=mode, + route=list(chosen.via), + changed_disguise=change, + risk_level=str(data.get("risk_level") or "unknown"), + ) + setattr(move, "proposed_disguise", new_disguise) + if change: + _force_stationary(move, state.culprit.current_junction) + return move + + +def _maybe_change_disguise(state: GameState, move: CulpritMove) -> CulpritMove: + if state.culprit.remaining_disguise_changes and state.turn_number % 4 == 0: + move.changed_disguise = True + setattr(move, "proposed_disguise", "a brown leather jacket over dark trousers, carrying no visible folder") + _force_stationary(move, state.culprit.current_junction) + return move + + +def _force_stationary(move: CulpritMove, current_junction: int) -> None: + move.to_junction = current_junction + move.from_junction = current_junction + move.mode = "remain" + move.route = [current_junction] + + +def _create_story(state: GameState, move: CulpritMove, previous_disguise: str, use_model: bool) -> tuple[StorySegment, list[CaseLandmark]]: + place = primary_place_for_junction(move.to_junction) + venue = _ensure_subvenue(state, move.to_junction, place, move.changed_disguise) + venues = [venue] if venue else [] + proposed = getattr(move, "proposed_disguise", "") + new_disguise = proposed if move.changed_disguise else previous_disguise + if not use_model: + return _deterministic_story(state, move, previous_disguise, new_disguise, place, venue), venues + + payload = { + "immutable_decision": { + "from_junction": move.from_junction, + "to_junction": move.to_junction, + "mode": move.mode, + "route": move.route, + "changed_disguise": move.changed_disguise, + "new_disguise": new_disguise, + }, + "canonical_places": places_for_junction(move.to_junction), + "case_subvenue": asdict(venue) if venue else None, + "continuity_synopsis": state.story_memory.continuity_synopsis, + "recent_story": [segment.narrative for segment in state.story_segments[-ContextBudget.for_context(state.effective_context_length).recent_story_segments :]], + } + system = "Write the next hidden John Doe story around the immutable decision. Return JSON only: narrative, private_reasoning, observable_facts[{kind,text,tags,place_id}]. Never change route, mode, destination, or disguise." + try: + data = OmniClient.from_settings().json_chat(system, json.dumps(payload), task="story", temperature=0.55) + facts = _facts_from_model(state, move, data.get("observable_facts", [])) + narrative = str(data["narrative"]) + private_reasoning = str(data.get("private_reasoning", "")) + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + return _deterministic_story(state, move, previous_disguise, new_disguise, place, venue), venues + story = StorySegment( + segment_id=f"story_t{state.turn_number:03d}", turn_number=state.turn_number, + from_junction=move.from_junction, to_junction=move.to_junction, mode=move.mode, route=move.route, + changed_disguise=move.changed_disguise, previous_disguise=previous_disguise, new_disguise=new_disguise, + narrative=narrative, private_reasoning=private_reasoning, + observable_facts=facts, context_profile=_context_profile(), + ) + return story, venues + + +def _deterministic_story(state: GameState, move: CulpritMove, previous: str, new: str, place: dict[str, Any] | None, venue: CaseLandmark | None) -> StorySegment: + place_name = place["name"] if place else f"Junction {move.to_junction}" + venue_name = venue.name if venue else place_name + stationary = move.from_junction == move.to_junction + if stationary: + action = f"John Doe stayed at {place_name} near Junction {move.to_junction} this turn." + else: + action = f"John Doe travelled by {move.mode} from Junction {move.from_junction} to {place_name} at Junction {move.to_junction}." + facts = [ObservableFact( + fact_id=f"fact_t{state.turn_number:03d}_001", turn_number=state.turn_number, + junction_id=move.to_junction, kind="movement" if not stationary else "lingered", + text=( + f"A person matching {previous} was seen lingering near {place_name}." + if stationary + else f"A person matching {previous} arrived near {place_name} by {move.mode}." + ), + tags=_tags(previous, place_name, move.mode if not stationary else "lingered", "arrived" if not stationary else "lingered"), + place_id=place.get("id") if place else None, + )] + if move.changed_disguise: + action += f" At {venue_name}, he replaced his visible clothing and emerged wearing {new}." + facts.append(ObservableFact( + fact_id=f"fact_t{state.turn_number:03d}_002", turn_number=state.turn_number, + junction_id=move.to_junction, kind="disguise_change", + text=f"A person entered {venue_name} dressed as {previous} and later emerged wearing {new}.", + tags=_tags(previous, new, venue_name, "clothing", "changed"), place_id=venue.venue_id if venue else None, + )) + return StorySegment( + segment_id=f"story_t{state.turn_number:03d}", turn_number=state.turn_number, + from_junction=move.from_junction, to_junction=move.to_junction, mode=move.mode, route=move.route, + changed_disguise=move.changed_disguise, previous_disguise=previous, new_disguise=new, + narrative=action, private_reasoning="He chose the route to reduce police attention.", + observable_facts=facts, context_profile=_context_profile(), + ) + + +def _derive_witnesses(state: GameState, story: StorySegment, use_model: bool) -> list[PotentialWitness]: + witnesses: list[PotentialWitness] = [] + for index, fact in enumerate(story.observable_facts): + name, occupation, style = WITNESS_NAMES[(len(state.potential_witnesses) + index) % len(WITNESS_NAMES)] + witnesses.append(PotentialWitness( + potential_id=f"potential_{fact.fact_id}_{index + 1}", turn_created=story.turn_number, + junction_id=fact.junction_id, observed_fact_ids=[fact.fact_id], + profile={"name": name, "occupation": occupation, "style": style, "confidence": "measured"}, + reliability=round(0.58 + (index % 4) * 0.09, 2), memory_strength=round(0.62 + (index % 3) * 0.08, 2), + voice_id=f"voice_{((len(state.potential_witnesses) + index) % 6) + 1:02d}", summary=fact.text, + search_tags=fact.tags + [name.lower(), occupation.lower()], + )) + if fact.kind == "disguise_change": + alt_name, alt_occupation, alt_style = WITNESS_NAMES[(len(state.potential_witnesses) + index + 2) % len(WITNESS_NAMES)] + witnesses.append(PotentialWitness( + potential_id=f"potential_{fact.fact_id}_nearby", turn_created=story.turn_number, + junction_id=fact.junction_id, observed_fact_ids=[fact.fact_id], + profile={"name": alt_name, "occupation": alt_occupation, "style": alt_style, "confidence": "uncertain"}, + reliability=0.54, memory_strength=0.68, + voice_id=f"voice_{((len(state.potential_witnesses) + index + 2) % 6) + 1:02d}", + summary=f"From nearby, {alt_name} noticed only part of this event: {fact.text}", search_tags=fact.tags, + )) + return witnesses + + +def _ensure_subvenue(state: GameState, junction_id: int, place: dict[str, Any] | None, needed: bool) -> CaseLandmark | None: + existing = next((venue for venue in state.case_landmarks if venue.junction_id == junction_id), None) + if existing: + return existing + category = (place or {}).get("category", "street") + choices = VENUE_TEMPLATES.get(category, VENUE_TEMPLATES["street"]) + template_id, name, venue_category = choices[junction_id % len(choices)] + if not needed and junction_id % 3: + return None + return CaseLandmark( + venue_id=f"case_j{junction_id}_{template_id}", name=name, category=venue_category, + junction_id=junction_id, canonical_place_id=(place or {}).get("id"), + description=f"A case-specific {venue_category} near {(place or {}).get('name', f'Junction {junction_id}')}", + ) + + +def _facts_from_model(state: GameState, move: CulpritMove, raw_facts: list[dict[str, Any]]) -> list[ObservableFact]: + facts: list[ObservableFact] = [] + for index, raw in enumerate(raw_facts[:6], start=1): + facts.append(ObservableFact( + fact_id=f"fact_t{state.turn_number:03d}_{index:03d}", turn_number=state.turn_number, + junction_id=move.to_junction, kind=str(raw.get("kind") or "observation"), + text=str(raw.get("text") or "").strip(), tags=[str(tag).lower() for tag in raw.get("tags", [])], + place_id=raw.get("place_id"), + )) + if not facts or any(not fact.text for fact in facts): + raise ValueError("Story must contain at least one non-empty observable fact.") + return facts + + +def _validate_story_against_move(story: StorySegment, move: CulpritMove) -> None: + if (story.from_junction, story.to_junction, story.mode, story.route, story.changed_disguise) != ( + move.from_junction, move.to_junction, move.mode, move.route, move.changed_disguise + ): + raise ValueError("Story output changed the immutable movement decision.") + + +def _context_profile() -> dict[str, Any]: + settings = load_settings() + budget = ContextBudget.for_context(settings.llamacpp_context_length) + return {"context_length": budget.context_length, "output_tokens": budget.output_tokens, "prompt_tokens": budget.prompt_tokens} + + +def _tags(*values: str) -> list[str]: + words: set[str] = set() + for value in values: + words.update(word.strip(".,:;!?()[]\"").lower() for word in value.split() if len(word) > 2) + return sorted(words) diff --git a/game/turn_engine.py b/game/turn_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..febeed4ce476386cf9fb7d1b50b6c423118b044d --- /dev/null +++ b/game/turn_engine.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from .police_actions import tick_blocks +from .state import GameState, JunctionCheck +from .story_engine import apply_turn_bundle, generate_turn_bundle +from .witness_engine import ( + corrupt_witnesses_slightly, + generate_ambient_witness_batch, + generate_patrol_witness_batch, +) +from .win_conditions import apply_junction_check, culprit_has_escaped + + +def advance_turn(state: GameState, use_model: bool = False) -> str: + if state.result: + return "Game is already complete." + + pre_catch = _search_team_catch(state, phase="stakeout") + if pre_catch is not None: + return _finalize_capture(state, pre_catch, "A search team was already watching this junction when the turn opened.") + + move, story, witnesses, venues = generate_turn_bundle(state, use_model=use_model) + setattr(move, "previous_disguise", state.culprit.current_disguise) + apply_turn_bundle(state, move, story, witnesses, venues) + patrol_batch = generate_patrol_witness_batch(state, move) + if patrol_batch: + state.witness_batches.append(patrol_batch) + ambient_batch = generate_ambient_witness_batch(state, [witness.potential_id for witness in witnesses]) + if ambient_batch: + state.witness_batches.append(ambient_batch) + corrupt_witnesses_slightly(state) + state.active_blocks = tick_blocks(state.active_blocks) + active_block_ids = {block.block_id for block in state.active_blocks} + state.placed_tactics = [ + tactic + for tactic in state.placed_tactics + if tactic.linked_block_id is None or tactic.linked_block_id in active_block_ids + ] + state.turn_number += 1 + state.phase = "commissioner_action" + + post_catch = _search_team_catch(state, phase="intercept") + if post_catch is not None: + return _finalize_capture(state, post_catch, "A search team intercepted the culprit after their move.") + + if culprit_has_escaped(state): + state.result = "culprit_escape" + state.phase = "complete" + message = "The culprit avoided detection until the turn limit expired." + else: + message = f"Turn advanced. Public report: no confirmed capture. Turns remaining: {state.max_turns - state.turn_number + 1}." + if ambient_batch: + message += f" {ambient_batch.total_witnesses} new witness report(s) surfaced across the city." + if patrol_batch: + message += f" {patrol_batch.total_witnesses} patrol officer report(s) logged." + + state.game_log.append({"turn_number": state.turn_number, "kind": "turn_advance", "message": message}) + return message + + +def _search_team_catch(state: GameState, phase: str) -> int | None: + search_team_junctions = {t.junction_id for t in state.placed_tactics if t.tactic_type == "search_team"} + if state.culprit.current_junction in search_team_junctions: + return state.culprit.current_junction + return None + + +def _finalize_capture(state: GameState, junction_id: int, reason: str) -> str: + apply_junction_check(state, junction_id) + check_id = f"check_t{state.turn_number:03d}_search_team" + state.junction_checks.append(JunctionCheck( + check_id=check_id, + turn_number=state.turn_number, + junction_id=junction_id, + result="culprit_found", + )) + message = f"Commissioner wins. {reason} Junction {junction_id} secured." + state.game_log.append({"turn_number": state.turn_number, "kind": "turn_advance", "message": message}) + return message diff --git a/game/win_conditions.py b/game/win_conditions.py new file mode 100644 index 0000000000000000000000000000000000000000..957e61bbb6c7eac7ec1c8c7356bc3d54fb899d20 --- /dev/null +++ b/game/win_conditions.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from .state import GameState + + +def apply_junction_check(state: GameState, junction_id: int) -> str: + if state.culprit.current_junction == junction_id: + state.result = "commissioner_win" + state.phase = "complete" + return "culprit_found" + return "no_confirmed_sighting" + + +def culprit_has_escaped(state: GameState) -> bool: + return state.turn_number >= state.max_turns and state.result is None + diff --git a/game/witness_engine.py b/game/witness_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..7c8d390b8fb016fca05bc8b21e8b02c6b909803c --- /dev/null +++ b/game/witness_engine.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +import random + +from config import load_settings +from llm.omni_client import OmniClient +from game.context_budget import ContextBudget, trim_text_to_tokens +from grid_map.graph_loader import adjacent_junctions, all_junction_ids + +from .rules import can_review_individual_witnesses +from .state import GameState, LookoutNotice, PotentialWitness, WitnessBatch, WitnessQuestion, WitnessRecord + + +PERSONALITIES = [ + {"style": "careful", "confidence": "measured", "quirk": "keeps correcting small details"}, + {"style": "talkative", "confidence": "overconfident", "quirk": "compares people to customers"}, + {"style": "nervous", "confidence": "uncertain", "quirk": "remembers colors better than faces"}, + {"style": "blunt", "confidence": "low", "quirk": "answers in short fragments"}, +] + +FALSE_WITNESS_PROFILES = [ + ("Harriet Moss", "passer-by", {"style": "nervous", "confidence": "uncertain", "quirk": "remembers colors better than faces"}), + ("Leonard Pike", "delivery clerk", {"style": "blunt", "confidence": "measured", "quirk": "describes routes before appearances"}), + ("Nora Wren", "market customer", {"style": "talkative", "confidence": "overconfident", "quirk": "compares strangers to regular shoppers"}), + ("Samuel Croft", "retired conductor", {"style": "careful", "confidence": "measured", "quirk": "corrects himself when timing is uncertain"}), +] + +FALSE_ACCOUNT_TEMPLATES = [ + "At Junction {junction}, {name} saw a delivery cyclist in a navy waterproof coat carrying a flat brown parcel toward the bus stops. The coat looked dark rather than grey, and {name} never saw a red folder.", + "At Junction {junction}, {name} noticed a hurried shopper in a tan coat holding a red shopping bag near the taxi queue. The person did not wear a raincoat and remained in the area.", + "At Junction {junction}, {name} saw a station worker in a grey work jacket carrying newspapers in a red sleeve. The worker walked toward the subway entrance but did not match the nervous behavior in the notice.", + "At Junction {junction}, {name} remembers a commuter with a black umbrella and a burgundy document case. The commuter boarded a bus, but the clothing and carried item only partly resembled the notice.", +] + +STYLE_QUIRKS = { + "careful": "keeps correcting small details", + "talkative": "adds comparisons from daily work", + "nervous": "remembers colors better than faces", + "blunt": "answers in short fragments", + "observant": "focuses on one concrete visual detail", + "skeptical": "avoids claiming more than was actually seen", +} + + +def generate_witness_batch(state: GameState, notice: LookoutNotice) -> WitnessBatch: + witnesses = _surface_matching_witnesses(state, notice) + witnesses.extend(_false_positive_witnesses(state, notice, len(witnesses))) + + total = len(witnesses) + return WitnessBatch( + batch_id=f"batch_{notice.notice_id}", + notice_id=notice.notice_id, + turn_number=state.turn_number, + total_witnesses=total, + individual_review_allowed=can_review_individual_witnesses(total), + witnesses=witnesses, + ) + + +def generate_ambient_witness_batch(state: GameState, potential_ids: list[str]) -> WitnessBatch | None: + """Surface sparse public reports after a turn, favoring the culprit's route.""" + rng = random.Random(f"{state.game_id}:ambient:{state.turn_number}") + candidates = [ + potential for potential in state.potential_witnesses + if potential.potential_id in potential_ids and not potential.surfaced_notice_id + ] + witnesses: list[WitnessRecord] = [] + source_id = f"ambient_t{state.turn_number:03d}" + + for potential in candidates: + board, description_matches = _board_influence_for_potential(state, potential) + probability = _ambient_route_probability(board, description_matches) + if rng.random() <= probability: + potential.surfaced_notice_id = source_id + witnesses.append(_record_from_potential(potential, source_id, state.turn_number, probability)) + + # Sample several places at a low rate so reports can emerge across London, + # while ensuring players see at least one off-route report every two turns. + excluded = {potential.junction_id for potential in candidates} + for tactic in state.placed_tactics: + if tactic.tactic_type == "lookout_board": + excluded.update({tactic.junction_id, *adjacent_junctions(tactic.junction_id)[:4]}) + city_report_count = sum(1 for _ in range(6) if rng.random() < 0.08) + if city_report_count == 0: + city_report_count = 1 + for _ in range(city_report_count): + witnesses.append(_ambient_false_witness(state, source_id, rng, len(witnesses), None, excluded)) + for tactic in state.placed_tactics: + if tactic.tactic_type == "lookout_board" and rng.random() < 0.42: + witnesses.append(_ambient_false_witness(state, source_id, rng, len(witnesses), tactic.junction_id)) + + if not witnesses: + return None + return WitnessBatch( + batch_id=f"batch_{source_id}", notice_id=source_id, turn_number=state.turn_number, + total_witnesses=len(witnesses), + individual_review_allowed=can_review_individual_witnesses(len(witnesses)), witnesses=witnesses, + ) + + +def generate_patrol_witness_batch( + state: GameState, + move, +) -> WitnessBatch | None: + """Surface high-reliability patrol-officer reports if the culprit passed near a patrol unit.""" + patrol_junctions = [ + tactic.junction_id for tactic in state.placed_tactics if tactic.tactic_type == "patrol_unit" + ] + if not patrol_junctions: + return None + route_set = set(move.route or []) | {move.from_junction, move.to_junction} + nearby_set: set[int] = set() + for junction in route_set: + nearby_set.add(junction) + nearby_set.update(adjacent_junctions(junction)) + source_id = f"patrol_t{state.turn_number:03d}" + witnesses: list[WitnessRecord] = [] + for index, patrol_junction in enumerate(patrol_junctions): + if patrol_junction not in nearby_set: + continue + on_route = patrol_junction in route_set + summary = _patrol_summary(state, move, patrol_junction, on_route) + witnesses.append(WitnessRecord( + witness_id=f"w_patrol_{state.turn_number:03d}_{patrol_junction}_{index + 1:02d}", + notice_id=source_id, + turn_created=state.turn_number, + junction_id=patrol_junction, + personality={"style": "observant", "confidence": "high", "quirk": "trained to recall clothing and direction"}, + reliability=0.96 if on_route else 0.88, + memory_strength=0.92, + corruption_level=0.0, + relevance_score=0.95 if on_route else 0.75, + original_summary=summary, + current_summary=summary, + stable_facts=[ + f"patrol officer was posted at Junction {patrol_junction}", + f"culprit was last seen wearing {move.previous_disguise if hasattr(move, 'previous_disguise') else state.culprit.current_disguise}", + "patrol report from turn " + str(state.turn_number), + ], + fragile_facts=["exact time of sighting", "secondary clothing detail"], + name=f"Constable on Patrol at J{patrol_junction}", + occupation="police constable", + voice_id=f"voice_{(patrol_junction % 6) + 1:02d}", + observed_fact_ids=[], + is_false_positive=False, + )) + if not witnesses: + return None + return WitnessBatch( + batch_id=f"batch_{source_id}", + notice_id=source_id, + turn_number=state.turn_number, + total_witnesses=len(witnesses), + individual_review_allowed=True, + witnesses=witnesses, + ) + + +def _patrol_summary(state, move, patrol_junction: int, on_route: bool) -> str: + disguise = getattr(move, "previous_disguise", None) or state.culprit.current_disguise + if move.from_junction == move.to_junction: + return ( + f"Patrol at Junction {patrol_junction} reports a person matching {disguise} lingered near " + f"Junction {move.to_junction} this turn. They did not appear to board any transport." + ) + if on_route: + return ( + f"Patrol at Junction {patrol_junction} clearly saw a person matching {disguise} pass through, " + f"heading from Junction {move.from_junction} toward Junction {move.to_junction} by {move.mode}." + ) + return ( + f"Patrol at Junction {patrol_junction} caught a partial sighting of someone matching {disguise} " + f"moving by {move.mode} toward Junction {move.to_junction}, but did not see them directly." + ) + + +def answer_witness_question(witness: WitnessRecord, question: str, turn_number: int, use_model: bool = False) -> str: + if use_model: + answer = _model_witness_answer(witness, question) + if not answer: + answer = deterministic_witness_answer(witness, question) + witness.question_history.append(WitnessQuestion(question=question, answer=answer, turn_number=turn_number)) + return answer + answer = deterministic_witness_answer(witness, question) + witness.question_history.append(WitnessQuestion(question=question, answer=answer, turn_number=turn_number)) + return answer + + +def deterministic_witness_answer(witness: WitnessRecord, question: str) -> str: + lowered = question.lower() + question_words = set(_words(question)) + summary = witness.current_summary + if question_words & {"hello", "hi", "hey"} or "good morning" in lowered or "good evening" in lowered: + answer = f"Hello. I am {witness.name}; ask me what I noticed." + elif any(word in lowered for word in ("carry", "holding", "object", "folder", "bag")): + answer = _extract_answer( + summary, + ["carrying", "holding", "parcel", "folder", "bag", "packet", "backpack", "document case", "newspapers"], + ) + elif any(word in lowered for word in ("where", "junction", "location")): + answer = f"I was at Junction {witness.junction_id}. I can only speak to what I noticed there." + elif any(word in lowered for word in ("direction", "went", "move", "route", "transport", "bus", "taxi", "subway")): + answer = _extract_answer(summary, ["toward", "route", "bus", "taxi", "subway", "left", "moved"]) + elif any(word in lowered for word in ("clothes", "coat", "wearing", "disguise")): + answer = _extract_answer(summary, ["coat", "raincoat", "grey", "gray", "tan", "helmet"]) + else: + answer = f"I remember it like this: {summary}" + return _apply_personality(witness, answer) + + +def witness_by_id(state: GameState, witness_id: str) -> WitnessRecord | None: + for batch in state.witness_batches: + for witness in batch.witnesses: + if witness.witness_id == witness_id: + return witness + return None + + +def _surface_matching_witnesses(state: GameState, notice: LookoutNotice) -> list[WitnessRecord]: + text_words = set(_distinctive_words(notice.text)) + planned = [int(item["junction_id"]) for item in notice.response_plan] + candidates = [] + for potential in state.potential_witnesses: + if potential.surfaced_notice_id: + continue + tag_words = set(_distinctive_words(" ".join(potential.search_tags) + " " + potential.summary)) + overlap = len(text_words & tag_words) + if overlap == 0: + continue + distance = _distance_from_plan(potential.junction_id, planned) + if distance is None or distance > 1: + continue + description_score = min(overlap / max(min(len(text_words), 6), 1), 1.0) * 0.58 + location_score = 0.30 if distance == 0 else 0.16 + reliability_score = potential.reliability * 0.12 + score = description_score + location_score + reliability_score + if score >= 0.42: + candidates.append((score, potential)) + candidates.sort(key=lambda item: (-item[0], item[1].potential_id)) + witnesses: list[WitnessRecord] = [] + for index, (score, potential) in enumerate(candidates[:18], start=1): + potential.surfaced_notice_id = notice.notice_id + witnesses.append(_record_from_potential(potential, notice.notice_id, state.turn_number, score, notice)) + return witnesses + + +def _record_from_potential( + potential: PotentialWitness, + source_id: str, + turn_number: int, + relevance: float, + notice: LookoutNotice | None = None, +) -> WitnessRecord: + profile = potential.profile + return WitnessRecord( + witness_id=f"w_{potential.potential_id}", notice_id=source_id, + turn_created=turn_number, junction_id=potential.junction_id, + personality=_complete_personality(profile), reliability=potential.reliability, + memory_strength=potential.memory_strength, corruption_level=0.0, + relevance_score=_bounded(relevance), original_summary=potential.summary, + current_summary=potential.summary, + stable_facts=[f"witness was at Junction {potential.junction_id}", *potential.observed_fact_ids], + fragile_facts=_fragile_facts_from_notice(notice) if notice else ["clothing detail", "direction of travel"], + name=profile.get("name", "Witness"), occupation=profile.get("occupation", "local resident"), + voice_id=potential.voice_id, observed_fact_ids=potential.observed_fact_ids, is_false_positive=False, + ) + + +def _ambient_route_probability(board: bool, description_matches: bool) -> float: + if description_matches: + return 0.98 + return 0.8 if board else 0.6 + + +def _board_influence_for_potential(state: GameState, potential: PotentialWitness) -> tuple[bool, bool]: + potential_words = set(_distinctive_words(" ".join(potential.search_tags) + " " + potential.summary)) + influenced = False + matched = False + for tactic in state.placed_tactics: + if tactic.tactic_type != "lookout_board": + continue + covered = {tactic.junction_id, *adjacent_junctions(tactic.junction_id)[:4]} + if potential.junction_id not in covered: + continue + influenced = True + board_notice = next( + ( + notice for notice in reversed(state.notices) + if notice.response_plan and int(notice.response_plan[0]["junction_id"]) == tactic.junction_id + ), + None, + ) + if board_notice: + notice_words = set(_distinctive_words(board_notice.text)) + if len(notice_words & potential_words) >= 2: + matched = True + return influenced, matched + + +def _ambient_false_witness( + state: GameState, + source_id: str, + rng: random.Random, + offset: int, + anchor: int | None, + excluded: set[int] | None = None, +) -> WitnessRecord: + profile_index = rng.randrange(len(FALSE_WITNESS_PROFILES)) + name, occupation, personality = FALSE_WITNESS_PROFILES[profile_index] + if anchor is None: + choices = [junction_id for junction_id in all_junction_ids() if junction_id not in (excluded or set())] + junction_id = rng.choice(choices or all_junction_ids()) + else: + nearby = [anchor, *adjacent_junctions(anchor)[:4]] + junction_id = rng.choice(nearby) + summary = FALSE_ACCOUNT_TEMPLATES[profile_index].format(junction=junction_id, name=name) + return WitnessRecord( + witness_id=f"w_false_{source_id}_{junction_id}_{offset + 1:02d}", notice_id=source_id, + turn_created=state.turn_number, junction_id=junction_id, personality=dict(personality), + reliability=round(0.42 + profile_index * 0.05, 2), memory_strength=0.55, + corruption_level=0.0, relevance_score=0.16, original_summary=summary, current_summary=summary, + stable_facts=[f"witness was at Junction {junction_id}", f"unprompted report from turn {state.turn_number}"], + fragile_facts=["clothing detail", "object detail", "direction of travel"], + name=name, occupation=occupation, voice_id=f"voice_{profile_index + 1:02d}", + observed_fact_ids=[], is_false_positive=True, + ) + + +def _false_positive_witnesses(state: GameState, notice: LookoutNotice, real_count: int) -> list[WitnessRecord]: + planned = [int(item["junction_id"]) for item in notice.response_plan] + junctions = planned or [state.culprit.current_junction] + desired = 1 + int(notice.false_positive_likelihood >= 0.45) + int(notice.false_positive_likelihood >= 0.7) + if real_count >= 3: + desired = 1 + witnesses: list[WitnessRecord] = [] + notice_index = max(len(state.notices), 1) + for offset in range(desired): + profile_index = (notice_index + offset - 1) % len(FALSE_WITNESS_PROFILES) + name, occupation, personality = FALSE_WITNESS_PROFILES[profile_index] + junction_id = junctions[offset % len(junctions)] + template = FALSE_ACCOUNT_TEMPLATES[profile_index] + summary = template.format(junction=junction_id, name=name) + witnesses.append(WitnessRecord( + witness_id=f"w_false_{notice.notice_id}_{junction_id}_{offset + 1:02d}", + notice_id=notice.notice_id, turn_created=state.turn_number, junction_id=junction_id, + personality=dict(personality), reliability=round(0.42 + profile_index * 0.05, 2), + memory_strength=round(0.50 + (profile_index % 3) * 0.07, 2), corruption_level=0.0, + relevance_score=round(0.12 + notice.false_positive_likelihood * 0.18, 2), + original_summary=summary, current_summary=summary, + stable_facts=[f"witness was at Junction {junction_id}", f"account originated from {notice.notice_id}"], + fragile_facts=_fragile_facts_from_notice(notice), name=name, occupation=occupation, + voice_id=f"voice_{profile_index + 1:02d}", observed_fact_ids=[], is_false_positive=True, + )) + return witnesses + + +def _model_witness_answer(witness: WitnessRecord, question: str) -> str: + settings = load_settings() + budget = ContextBudget.for_context(settings.llamacpp_context_length) + # Build a plain English prompt. JSON-encoded prompts degrade to Chinese + # filler on Q4_K_M MiniCPM-o-4.5 — see app.api_witness_message for the + # same workaround. + history = witness.question_history[-budget.recent_interview_turns :] + history_block = ( + "\n".join(f" Detective: {item.question}\n You: {item.answer}" for item in history) + if history else " (no prior questions)" + ) + stable_block = ", ".join(witness.stable_facts) if witness.stable_facts else "(none recorded)" + personality_block = ", ".join(f"{k}: {v}" for k, v in witness.personality.items()) or "ordinary" + synopsis = trim_text_to_tokens(witness.conversation_summary, budget.synopsis_tokens) + synopsis_block = f"Earlier summary: {synopsis}\n" if synopsis else "" + user = ( + f"You are {witness.name}, a {witness.occupation} ({personality_block}).\n" + f"What you saw / know: {witness.current_summary}\n" + f"Stable facts: {stable_block}\n" + f"{synopsis_block}" + f"Conversation so far:\n{history_block}\n" + f"The detective now asks: {question!r}\n" + f"Reply in character, in English, in one or two short sentences. " + f"Express uncertainty naturally if you are not sure." + ) + system = ( + "You are roleplaying a witness in an English-language detective game. " + "Speak only English. Reply briefly. Use only the facts the user gives " + "you. Let the supplied personality shape wording and confidence. Never " + "invent details or reveal hidden game state." + ) + return OmniClient.from_settings().chat(system, user, task="interview", temperature=0.55).text.strip() + + +def _words(text: str) -> list[str]: + import re + return re.findall(r"[a-z0-9]+", text.lower()) + + +def _distinctive_words(text: str) -> list[str]: + ignored = { + "a", "an", "and", "at", "by", "for", "from", "high", "in", "junction", "matching", + "near", "of", "on", "person", "reports", "request", "seen", "someone", "the", "to", + "was", "with", + } + return [word for word in _words(text) if word not in ignored and not word.isdigit()] + + +def _distance_from_plan(junction_id: int, planned: list[int]) -> int | None: + if junction_id in planned: + return 0 + if any(junction_id in adjacent_junctions(item) for item in planned): + return 1 + return None + + +def _apply_personality(witness: WitnessRecord, answer: str) -> str: + style = str(witness.personality.get("style", "careful")) + if style == "blunt": + return answer.split(". ", 1)[0].rstrip(".") + "." + if style == "nervous": + return f"I think so, but I may be mixing up a detail. {answer}" + if style == "talkative": + return f"What stayed with me was this: {answer}" + if style == "skeptical": + return f"I would not make more of it than this: {answer}" + if style == "observant": + return f"The detail I noted was this: {answer}" + return f"As carefully as I can put it: {answer}" + + +def _complete_personality(profile: dict) -> dict: + style = str(profile.get("style") or "careful") + return { + "style": style, + "confidence": str(profile.get("confidence") or "measured"), + "quirk": str(profile.get("quirk") or STYLE_QUIRKS.get(style, "sticks to concrete details")), + } + + +def corrupt_witnesses_slightly(state: GameState) -> None: + settings = load_settings() + for batch in state.witness_batches: + for witness in batch.witnesses: + witness.corruption_level = _bounded(witness.corruption_level + settings.memory_corruption_per_turn) + if witness.corruption_level < 0.2: + witness.current_summary = witness.current_summary.replace("saw ", "remembers seeing ", 1) + elif witness.corruption_level < 0.5: + witness.current_summary = witness.current_summary.replace("red folder", "red item") + witness.current_summary = witness.current_summary.replace("grey raincoat", "grey or dark coat") + else: + witness.current_summary = ( + f"The witness is still sure they were at Junction {witness.junction_id}, " + "but clothing, timing, and direction details have become unreliable." + ) + + +def _relevance_for_index(relevance_bias: float, index: int) -> float: + return _bounded(relevance_bias - (index % 4) * 0.11 + (0.05 if index == 0 else 0.0)) + + +def _summary_for_witness(state: GameState, notice: LookoutNotice, junction_id: int, relevance: float, reliability: float) -> str: + culprit_here = junction_id == state.culprit.current_junction or any(move.to_junction == junction_id for move in state.culprit.route_history[-3:]) + if culprit_here and relevance >= 0.55: + return ( + f"A witness at Junction {junction_id} saw someone matching the notice: {state.culprit.current_disguise}. " + "They seemed alert to police attention and moved near one of the transport routes." + ) + if relevance >= 0.35: + return ( + f"A witness at Junction {junction_id} reports a partial match to '{notice.parsed_description}'. " + "They remember a nervous person and one visual detail, but the direction of travel is uncertain." + ) + if reliability < 0.55: + return ( + f"A witness at Junction {junction_id} confidently reports a sighting, but the account appears to combine " + "two unrelated commuters and should be treated cautiously." + ) + return ( + f"A witness at Junction {junction_id} saw someone ordinary who only loosely matches the notice. " + "The report is probably a false positive." + ) + + +def _fragile_facts_from_notice(notice: LookoutNotice) -> list[str]: + facts = [] + text = (notice.parsed_description or notice.text).lower() + for candidate in ("grey raincoat", "red folder", "nervous", "bag", "bus route", "taxi", "subway"): + if all(part in text for part in candidate.split()): + facts.append(candidate) + return facts or ["clothing detail", "object detail", "direction of travel"] + + +def _extract_answer(summary: str, keywords: list[str]) -> str: + sentences = [sentence.strip() for sentence in summary.replace("\n", " ").split(".") if sentence.strip()] + for sentence in sentences: + lowered = sentence.lower() + if any(keyword in lowered for keyword in keywords): + return f"{sentence}. I would not swear every detail is perfect." + return "I am not sure from what I remember. I do not want to add details I did not actually notice." + + +def _bounded(value: float) -> float: + return round(min(max(value, 0.0), 1.0), 2) diff --git a/grid_map/__init__.py b/grid_map/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9874076e8a519c83eacc1c882399f7078f9ed0b6 --- /dev/null +++ b/grid_map/__init__.py @@ -0,0 +1,6 @@ +from .graph_loader import load_game_graph, legal_moves_from +from .map_loader import load_map_metadata +from .storage import read_json, write_json + +__all__ = ["load_game_graph", "legal_moves_from", "load_map_metadata", "read_json", "write_json"] + diff --git a/grid_map/atlas.py b/grid_map/atlas.py new file mode 100644 index 0000000000000000000000000000000000000000..d961d7c427cb6439a6285abca3d779fa26c8f664 --- /dev/null +++ b/grid_map/atlas.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from typing import Any + +from config import load_settings +from .graph_loader import all_junction_ids +from .storage import read_json + + +def load_map_atlas() -> dict[str, Any]: + return read_json(load_settings().map_atlas_path) + + +def validate_map_atlas(atlas: dict[str, Any] | None = None) -> list[str]: + atlas = atlas or load_map_atlas() + valid_junctions = set(all_junction_ids()) + errors: list[str] = [] + district_ids: set[str] = set() + landmark_ids: set[str] = set() + + for district in atlas.get("districts", []): + district_id = str(district.get("id", "")).strip() + if not district_id or district_id in district_ids: + errors.append(f"Invalid or duplicate district id: {district_id!r}") + district_ids.add(district_id) + errors.extend(_junction_errors(district, valid_junctions, district_id)) + errors.extend(_anchor_errors(district, district_id)) + + for landmark in atlas.get("landmarks", []): + landmark_id = str(landmark.get("id", "")).strip() + if not landmark_id or landmark_id in landmark_ids: + errors.append(f"Invalid or duplicate landmark id: {landmark_id!r}") + landmark_ids.add(landmark_id) + district_id = landmark.get("district_id") + if district_id and district_id not in district_ids: + errors.append(f"Landmark {landmark_id} references unknown district {district_id}") + errors.extend(_junction_errors(landmark, valid_junctions, landmark_id)) + errors.extend(_anchor_errors(landmark, landmark_id)) + return errors + + +def places_for_junction(junction_id: int) -> list[dict[str, Any]]: + atlas = load_map_atlas() + matches: list[dict[str, Any]] = [] + for entry in [*atlas.get("districts", []), *atlas.get("landmarks", [])]: + junctions = set(entry.get("junction_ids", [])) + if entry.get("junction_id") is not None: + junctions.add(entry["junction_id"]) + junctions.update(entry.get("nearby_junction_ids", [])) + if junction_id in junctions: + matches.append(entry) + return matches + + +def primary_place_for_junction(junction_id: int) -> dict[str, Any] | None: + places = places_for_junction(junction_id) + landmarks = [place for place in places if place.get("category") != "district"] + return (landmarks or places or [None])[0] + + +def public_atlas_payload() -> dict[str, Any]: + atlas = load_map_atlas() + return { + "schema_version": atlas.get("schema_version", 1), + "map_id": atlas.get("map_id", "london_main"), + "districts": atlas.get("districts", []), + "landmarks": atlas.get("landmarks", []), + } + + +def _junction_errors(entry: dict[str, Any], valid: set[int], entry_id: str) -> list[str]: + values = list(entry.get("junction_ids", [])) + list(entry.get("nearby_junction_ids", [])) + if entry.get("junction_id") is not None: + values.append(entry["junction_id"]) + return [f"{entry_id} references unknown junction {value}" for value in values if value not in valid] + + +def _anchor_errors(entry: dict[str, Any], entry_id: str) -> list[str]: + anchor = entry.get("anchor") or {} + x = anchor.get("x") + y = anchor.get("y") + if not isinstance(x, (int, float)) or not isinstance(y, (int, float)): + return [f"{entry_id} has an invalid anchor"] + if not 0 <= x <= 1450 or not 0 <= y <= 1090: + return [f"{entry_id} anchor is outside the map"] + return [] diff --git a/grid_map/atlas_builder.py b/grid_map/atlas_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..67794fe87619208d31589a6d8e5e33ebc4a66d8f --- /dev/null +++ b/grid_map/atlas_builder.py @@ -0,0 +1,14 @@ +from __future__ import annotations + + +def empty_atlas() -> dict: + return { + "schema_version": 1, + "landmarks": [], + "districts": [], + "notes": [ + "Add fictional landmarks here as the board is named.", + "Lookout parsing should prefer this file over free-form image inference when possible.", + ], + } + diff --git a/grid_map/graph_loader.py b/grid_map/graph_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..d9df0824d925f80ce929a657ce7f839a9d7bbfab --- /dev/null +++ b/grid_map/graph_loader.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from config import load_settings +from .models import LegalMove +from .storage import read_json + + +def load_game_graph() -> dict: + settings = load_settings() + return read_json(settings.game_graph_path) + + +def all_junction_ids() -> list[int]: + graph = load_game_graph() + return sorted(int(node["id"]) for node in graph.get("nodes", [])) + + +def adjacent_junctions(junction_id: int) -> list[int]: + graph = load_game_graph() + adjacency = graph.get("adjacency", {}).get(str(junction_id), []) + return sorted({int(item["destination"]) for item in adjacency}) + + +def legal_moves_from(junction_id: int, blocked_edges: list[dict] | None = None) -> list[LegalMove]: + graph = load_game_graph() + blocked_edges = blocked_edges or [] + moves: list[LegalMove] = [] + + for edge in graph.get("edges", []): + source = int(edge["source"]) + target = int(edge["target"]) + if junction_id not in (source, target): + continue + + destination = target if source == junction_id else source + for mode in edge.get("modes", []): + blocked = _is_blocked(junction_id, destination, mode, blocked_edges) + moves.append(LegalMove(destination=destination, via=(junction_id, destination), mode=mode, blocked=blocked)) + + return sorted(moves, key=lambda move: (move.destination, move.mode)) + + +def _is_blocked(source: int, target: int, mode: str, blocks: list[dict]) -> bool: + for block in blocks: + block_type = block.get("block_type") + if block_type == "mode_block" and block.get("mode") == mode: + return True + if block_type == "junction_block" and block.get("junction_id") in (source, target): + return True + if block_type == "edge_block": + same_edge = {block.get("from_junction"), block.get("to_junction")} == {source, target} + same_mode = block.get("mode") in (None, mode) + if same_edge and same_mode: + return True + return False diff --git a/grid_map/local_map_cropper.py b/grid_map/local_map_cropper.py new file mode 100644 index 0000000000000000000000000000000000000000..8db2646ddcb8c676cf6a5648b08448c93ded5294 --- /dev/null +++ b/grid_map/local_map_cropper.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from pathlib import Path + + +def planned_crop_path(game_id: str, turn_number: int, junction_id: int, output_dir: Path) -> Path: + return output_dir / game_id / "map_crops" / f"turn_{turn_number:03d}_j{junction_id}.png" + diff --git a/grid_map/map_loader.py b/grid_map/map_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..92ce56aab459e6154840010ea15ef59c139fb40d --- /dev/null +++ b/grid_map/map_loader.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pathlib import Path + +from config import load_settings +from .storage import read_json + + +def load_map_metadata() -> dict: + settings = load_settings() + return read_json(settings.map_metadata_path) + + +def image_for_layer(layer: str) -> str: + settings = load_settings() + metadata = load_map_metadata() + images = metadata.get("images", {}) + if layer not in images: + raise KeyError(f"Unknown map layer: {layer}") + # Processed metadata may store Windows-style separators; normalize so the + # paths resolve on Linux (e.g. inside a Docker Space) as well. + path = Path(str(images[layer]).replace("\\", "/")) + if not path.is_absolute(): + path = settings.project_root / path + return str(path) diff --git a/grid_map/models.py b/grid_map/models.py new file mode 100644 index 0000000000000000000000000000000000000000..29cd818a2fbada3954e87e46cf29cc35819b65e6 --- /dev/null +++ b/grid_map/models.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Junction: + junction_id: int + x: int + y: int + radius: int + modes_present: tuple[str, ...] + + +@dataclass(frozen=True) +class TransportEdge: + source: int + target: int + modes: tuple[str, ...] + + +@dataclass(frozen=True) +class LegalMove: + destination: int + via: tuple[int, int] + mode: str + blocked: bool = False + diff --git a/grid_map/storage.py b/grid_map/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..c4e369c6dc96aec7aa6ebfec0dd7db09cff1a6fe --- /dev/null +++ b/grid_map/storage.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def read_json(path: Path) -> Any: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, sort_keys=True) + handle.write("\n") + diff --git a/grid_map/validator.py b/grid_map/validator.py new file mode 100644 index 0000000000000000000000000000000000000000..ec4c348877518256160310fba4d3760d5e5070b1 --- /dev/null +++ b/grid_map/validator.py @@ -0,0 +1,19 @@ +from __future__ import annotations + + +def validate_graph(graph: dict) -> list[str]: + errors: list[str] = [] + node_ids = {int(node["id"]) for node in graph.get("nodes", [])} + + for edge in graph.get("edges", []): + source = int(edge["source"]) + target = int(edge["target"]) + if source not in node_ids: + errors.append(f"Edge source {source} is missing from nodes") + if target not in node_ids: + errors.append(f"Edge target {target} is missing from nodes") + if not edge.get("modes"): + errors.append(f"Edge {source}-{target} has no transport modes") + + return errors + diff --git a/llm/__init__.py b/llm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ddd5e264456133ee1e3545f7a8f186016dfdd518 --- /dev/null +++ b/llm/__init__.py @@ -0,0 +1,4 @@ +from .client import LocalLlamaClient + +__all__ = ["LocalLlamaClient"] + diff --git a/llm/audio.py b/llm/audio.py new file mode 100644 index 0000000000000000000000000000000000000000..c946ca66cd285ac2b4875a1e4be9717def89bb0e --- /dev/null +++ b/llm/audio.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import base64 +import wave +from array import array +from pathlib import Path + + +def wav_to_float32_base64(path: Path, target_rate: int = 16000) -> tuple[str, float]: + """Return mono float32 PCM expected by the MiniCPM-o Comni APIs. + + ``audioop`` was removed from the standard library in Python 3.13, so it is + imported lazily here: only the optional MiniCPM-o voice path needs it. The + default text backend never calls this function, keeping the app importable + on modern Python without the ``audioop-lts`` backport installed. + """ + try: + import audioop # stdlib <=3.12; provided by the audioop-lts backport on 3.13+ + except ModuleNotFoundError as exc: # pragma: no cover - exercised only on 3.13+ without backport + raise RuntimeError( + "Audio/voice features require the 'audioop' module. On Python 3.13+ " + "install the backport with: pip install audioop-lts" + ) from exc + + with wave.open(str(path), "rb") as source: + channels = source.getnchannels() + sample_width = source.getsampwidth() + source_rate = source.getframerate() + pcm = source.readframes(source.getnframes()) + + if channels > 1: + pcm = audioop.tomono(pcm, sample_width, 0.5, 0.5) + if sample_width != 2: + pcm = audioop.lin2lin(pcm, sample_width, 2) + if source_rate != target_rate: + pcm, _ = audioop.ratecv(pcm, 2, 1, source_rate, target_rate, None) + + int_samples = array("h") + int_samples.frombytes(pcm) + float_samples = array("f", (sample / 32768.0 for sample in int_samples)) + duration = len(float_samples) / target_rate + return base64.b64encode(float_samples.tobytes()).decode("ascii"), duration diff --git a/llm/client.py b/llm/client.py new file mode 100644 index 0000000000000000000000000000000000000000..afeb30c82a8ab2a06461ba2beb065dc7d9983d3e --- /dev/null +++ b/llm/client.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any + +from config import Settings, load_settings + + +@dataclass +class LocalLlamaClient: + settings: Settings + + @classmethod + def from_settings(cls) -> "LocalLlamaClient": + return cls(load_settings()) + + def chat(self, system_prompt: str, user_prompt: str, temperature: float = 0.4) -> str: + url = f"{self.settings.llamacpp_base_url.rstrip('/')}/chat/completions" + payload = { + "model": self.settings.llm_model, + "temperature": temperature, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + } + request = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + data = json.loads(response.read().decode("utf-8")) + except urllib.error.URLError as exc: + raise RuntimeError(f"Could not reach local llama.cpp server at {url}") from exc + + return data["choices"][0]["message"]["content"] + + def json_chat(self, system_prompt: str, user_prompt: str, temperature: float = 0.2) -> dict[str, Any]: + content = self.chat(system_prompt, user_prompt, temperature=temperature) + return parse_json_object(content) + + +def parse_json_object(text: str) -> dict[str, Any]: + stripped = text.strip() + if stripped.startswith("```"): + stripped = stripped.strip("`") + if stripped.lower().startswith("json"): + stripped = stripped[4:].strip() + start = stripped.find("{") + end = stripped.rfind("}") + if start == -1 or end == -1: + raise ValueError(f"LLM response did not contain a JSON object: {text[:200]}") + return json.loads(stripped[start : end + 1]) + diff --git a/llm/devices.py b/llm/devices.py new file mode 100644 index 0000000000000000000000000000000000000000..c37c40ace83a597c152cde9ada95704f581cb2b6 --- /dev/null +++ b/llm/devices.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import json +import platform +import shutil +import subprocess +from dataclasses import dataclass + + +# Candidate quantizations OpenBMB ships for MiniCPM-o-4_5. The provisioner +# downloads exactly one of these. Q4_K_M is the safe default — small, fast, +# and what the HF repo guarantees. Other entries are best-effort: if OpenBMB +# hasn't published that variant the provisioner raises a clear error. +QUANTIZATION_CATALOG: list[dict] = [ + { + "id": "MiniCPM-o-4_5-Q4_K_M.gguf", + "quant": "Q4_K_M", + "label": "Q4_K_M (recommended) — ~4 GB, 4-bit, fast", + "size_gb": 4.0, + "tier": "recommended", + }, + { + "id": "MiniCPM-o-4_5-Q5_K_M.gguf", + "quant": "Q5_K_M", + "label": "Q5_K_M — ~5 GB, 5-bit, balanced", + "size_gb": 5.0, + "tier": "balanced", + }, + { + "id": "MiniCPM-o-4_5-Q6_K.gguf", + "quant": "Q6_K", + "label": "Q6_K — ~5.5 GB, 6-bit, higher fidelity", + "size_gb": 5.5, + "tier": "balanced", + }, + { + "id": "MiniCPM-o-4_5-Q8_0.gguf", + "quant": "Q8_0", + "label": "Q8_0 — ~7 GB, 8-bit, near-lossless", + "size_gb": 7.0, + "tier": "quality", + }, + { + "id": "MiniCPM-o-4_5-F16.gguf", + "quant": "F16", + "label": "F16 — ~14 GB, full precision, best quality", + "size_gb": 14.0, + "tier": "quality", + }, +] + + +# The default text-only backend model. OpenBMB MiniCPM4.1-8B in GGUF, served by +# a standard llama.cpp server (no audio). Q4_K_M is the only published GGUF and +# is the recommended footprint (~4.97 GB). Unlike the MiniCPM-o omni model it +# stays in English reliably instead of drifting into Chinese. +TEXT_MODEL_REPO = "openbmb/MiniCPM4.1-8B-GGUF" +TEXT_MODEL_FILE = "MiniCPM4.1-8B-Q4_K_M.gguf" +TEXT_MODEL_DIRNAME = "MiniCPM4.1-8B-gguf" +TEXT_MODEL = { + "repo": TEXT_MODEL_REPO, + "file": TEXT_MODEL_FILE, + "dirname": TEXT_MODEL_DIRNAME, + "label": "MiniCPM4.1-8B Q4_K_M (recommended) — ~4.97 GB, text-only, English-reliable", + "size_gb": 4.97, +} + + +def text_model() -> dict: + return dict(TEXT_MODEL) + + +# Reasonable defaults for the GPU layers selector. "auto" lets llama.cpp pick; +# 0 forces CPU-only; explicit counts offload the first N transformer layers. +GPU_LAYER_PRESETS: list[dict] = [ + {"id": "auto", "label": "Auto (let llama.cpp choose)"}, + {"id": "99", "label": "All layers on GPU (fastest if VRAM allows)"}, + {"id": "32", "label": "32 layers on GPU (~mid VRAM)"}, + {"id": "16", "label": "16 layers on GPU (lower VRAM)"}, + {"id": "0", "label": "0 layers on GPU (CPU only)"}, +] + + +CONTEXT_LENGTH_PRESETS: list[dict] = [ + {"id": 4096, "label": "4,096 tokens — lightest"}, + {"id": 8192, "label": "8,192 tokens — recommended"}, + {"id": 16384, "label": "16,384 tokens — longer cases"}, + {"id": 24576, "label": "24,576 tokens"}, + {"id": 32768, "label": "32,768 tokens — maximum"}, +] + + +@dataclass(frozen=True) +class RuntimeDevice: + id: str + label: str + vendor: str # "auto" | "cpu" | "nvidia" | "amd" | "apple" + index: int | None = None + vram_mb: int | None = None + + +def detect_devices() -> list[dict]: + """Probe the system for runtime devices the model can target. + + Always returns at least the meta options ('auto', 'cpu'). GPU detection + is best-effort: a missing vendor toolchain (nvidia-smi, rocm-smi) means + that vendor is omitted, not an error. Probes use a short timeout so a + hung tool can't block the first-run picker. + """ + devices: list[RuntimeDevice] = [ + RuntimeDevice(id="auto", label="Auto-detect best device", vendor="auto"), + RuntimeDevice(id="cpu", label="CPU only (slow, no GPU acceleration)", vendor="cpu"), + ] + devices.extend(_probe_nvidia()) + devices.extend(_probe_amd()) + devices.extend(_probe_apple()) + return [_serialise(d) for d in devices] + + +def quantization_catalog() -> list[dict]: + return [dict(item) for item in QUANTIZATION_CATALOG] + + +def gpu_layer_presets() -> list[dict]: + return [dict(item) for item in GPU_LAYER_PRESETS] + + +def context_length_presets() -> list[dict]: + return [dict(item) for item in CONTEXT_LENGTH_PRESETS] + + +def _serialise(device: RuntimeDevice) -> dict: + payload: dict = {"id": device.id, "label": device.label, "vendor": device.vendor} + if device.index is not None: + payload["index"] = device.index + if device.vram_mb is not None: + payload["vram_mb"] = device.vram_mb + return payload + + +def _probe_nvidia() -> list[RuntimeDevice]: + nvidia_smi = shutil.which("nvidia-smi") + if not nvidia_smi: + return [] + try: + completed = subprocess.run( + [nvidia_smi, "--query-gpu=index,name,memory.total", "--format=csv,noheader,nounits"], + capture_output=True, text=True, timeout=4, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + if completed.returncode != 0: + return [] + devices: list[RuntimeDevice] = [] + for line in completed.stdout.splitlines(): + parts = [part.strip() for part in line.split(",")] + if len(parts) < 3: + continue + try: + index = int(parts[0]) + name = parts[1].strip() + vram_mb = int(float(parts[2])) + except ValueError: + continue + if name.lower().startswith("nvidia "): + name = name[len("nvidia "):] + vram_gb = vram_mb / 1024 if vram_mb else 0 + devices.append(RuntimeDevice( + id=f"cuda:{index}", + label=f"NVIDIA {name} ({vram_gb:.1f} GB)" if vram_gb else f"NVIDIA {name}", + vendor="nvidia", + index=index, + vram_mb=vram_mb or None, + )) + return devices + + +def _probe_amd() -> list[RuntimeDevice]: + rocm_smi = shutil.which("rocm-smi") + if not rocm_smi: + return [] + try: + completed = subprocess.run( + [rocm_smi, "--showproductname", "--showmeminfo", "vram", "--json"], + capture_output=True, text=True, timeout=4, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return [] + if completed.returncode != 0: + return [] + try: + data = json.loads(completed.stdout or "{}") + except (ValueError, json.JSONDecodeError): + return [] + devices: list[RuntimeDevice] = [] + for key, entry in (data or {}).items(): + if not isinstance(entry, dict) or not key.lower().startswith("card"): + continue + digits = "".join(ch for ch in key if ch.isdigit()) + if not digits: + continue + index = int(digits) + name = str(entry.get("Card series") or entry.get("Card model") or "AMD GPU").strip() or "AMD GPU" + vram_bytes = str(entry.get("VRAM Total Memory (B)") or "0") + try: + vram_mb = int(int(vram_bytes) / 1024 / 1024) + except ValueError: + vram_mb = 0 + vram_label = f" ({vram_mb / 1024:.1f} GB)" if vram_mb else "" + devices.append(RuntimeDevice( + id=f"rocm:{index}", + label=f"AMD {name}{vram_label}", + vendor="amd", + index=index, + vram_mb=vram_mb or None, + )) + return devices + + +def _probe_apple() -> list[RuntimeDevice]: + if platform.system() != "Darwin" or platform.machine() not in {"arm64", "aarch64"}: + return [] + return [RuntimeDevice(id="metal", label="Apple Silicon Metal (unified memory)", vendor="apple")] + + +def resolve_device_env(device_id: str, gpu_layers: str) -> dict[str, str]: + """Translate the picker's device choice into env-var overrides for the launcher. + + Returns a dict of variables to merge into the launcher subprocess env. The + important ones: + - "auto" -> no overrides (llama.cpp picks). + - "cpu" -> force CPU by blanking CUDA/HIP/ROCR visibility AND zero layers. + - "cuda:N" -> pin to NVIDIA index N via CUDA_VISIBLE_DEVICES. + - "rocm:N" -> pin to AMD index N via HIP_VISIBLE_DEVICES + ROCR_VISIBLE_DEVICES. + - "metal" -> no overrides (Metal is default on Apple Silicon). + """ + device = (device_id or "auto").strip().lower() + if device == "cpu" or str(gpu_layers).strip() == "0": + return { + "CUDA_VISIBLE_DEVICES": "", + "HIP_VISIBLE_DEVICES": "", + "ROCR_VISIBLE_DEVICES": "", + } + if device.startswith("cuda:"): + return {"CUDA_VISIBLE_DEVICES": device.split(":", 1)[1]} + if device.startswith("rocm:"): + index = device.split(":", 1)[1] + return {"HIP_VISIBLE_DEVICES": index, "ROCR_VISIBLE_DEVICES": index} + return {} diff --git a/llm/omni_client.py b/llm/omni_client.py new file mode 100644 index 0000000000000000000000000000000000000000..e71ac0b8a8c8fe1aa94b9679a9e37823045cd156 --- /dev/null +++ b/llm/omni_client.py @@ -0,0 +1,468 @@ +from __future__ import annotations + +import json +import re +import urllib.error +import urllib.parse +import urllib.request +import base64 +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from config import Settings, load_settings +from llm.audio import wav_to_float32_base64 + + +LLM_MODEL_PATTERN = re.compile(r"MiniCPM-o-4_5-(.+)\.gguf$", re.IGNORECASE) +COMPANION_HINTS = ("audio", "vision", "tts", "token2wav", "vpm", "apm") + + +@dataclass +class OmniResponse: + text: str + audio_data: str | None = None + audio_sample_rate: int | None = None + + +@dataclass +class OmniClient: + settings: Settings + + @classmethod + def from_settings(cls) -> "OmniClient": + return cls(load_settings()) + + def health(self, timeout: float = 0.75) -> dict[str, Any]: + if self.settings.llm_provider in {"llama_cpp_server", "external_llama_cpp_server"}: + base_url = self.settings.llamacpp_base_url.rstrip("/") + try: + with urllib.request.urlopen(f"{base_url}/models", timeout=timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + return { + "reachable": response.status == 200, + "ready": response.status == 200 and bool(payload.get("data")), + "detail": payload, + } + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: + return {"reachable": False, "ready": False, "detail": f"{exc.__class__.__name__}: {exc}"} + return self.omni_health(timeout) + + def omni_health(self, timeout: float = 0.75) -> dict[str, Any]: + base_url = self.settings.omni_gateway_url.rstrip("/") + try: + with urllib.request.urlopen(f"{base_url}/health", timeout=timeout) as response: + health_payload = json.loads(response.read().decode("utf-8")) + healthy = response.status == 200 and str(health_payload.get("status", "")).lower() in {"ok", "healthy"} + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: + return {"reachable": False, "ready": False, "detail": f"{exc.__class__.__name__}: {exc}"} + try: + with urllib.request.urlopen(f"{base_url}/status", timeout=timeout) as response: + status_payload = json.loads(response.read().decode("utf-8")) + total = int(status_payload.get("total_workers", 0)) + unavailable = sum(int(status_payload.get(key, 0)) for key in ("loading_workers", "error_workers", "offline_workers")) + ready = healthy and total > 0 and unavailable < total + return { + "reachable": True, + "ready": ready, + "detail": {"health": health_payload, "workers": status_payload}, + } + except (urllib.error.URLError, TimeoutError, OSError, ValueError, json.JSONDecodeError) as exc: + return { + "reachable": healthy, + "ready": False, + "detail": {"health": health_payload, "workers": f"{exc.__class__.__name__}: {exc}"}, + } + + def chat( + self, + system_prompt: str, + user_prompt: str, + *, + task: str = "story", + temperature: float = 0.4, + tts: bool = False, + ref_audio_path: str | None = None, + messages: list[dict[str, Any]] | None = None, + ) -> OmniResponse: + from game.context_budget import ContextBudget + + budget = ContextBudget.for_context(self.settings.llamacpp_context_length) + if self.settings.llm_provider == "minicpm_omni": + response = self._omni_gateway_chat( + system_prompt, + user_prompt, + messages=messages, + temperature=temperature, + max_tokens=budget.output_tokens, + tts=False, + ref_audio_path=None, + ) + if not tts: + return response + audio = self.synthesize(response.text, ref_audio_path=ref_audio_path) + return OmniResponse(text=response.text, audio_data=audio.audio_data, audio_sample_rate=audio.audio_sample_rate) + text = self._text_completion( + system_prompt, + user_prompt, + messages=messages, + temperature=temperature, + max_tokens=budget.output_tokens, + json_mode=task in {"decision", "story", "witness", "case"}, + ) + if not tts: + return OmniResponse(text=text) + + # MiniCPM-o's TTS branch has a strong Chinese training prior and + # frequently ignores generic "repeat verbatim" instructions when given + # English input. Mitigations: (1) drop temperature to 0 so it can't + # creatively drift, (2) put a sharp English-only directive in the + # system block (which is what the gateway extracts for the voice-clone + # prompt), (3) also embed the English text in the user message so the + # literal target text is present in two places, (4) cap max_new_tokens + # to discourage long divergence. + english_text = text.strip() + system_prompt = ( + "You are an English text-to-speech narrator. Read aloud, in English, " + "EXACTLY the text the user provides. Do not translate. Do not add " + "words. Do not speak Chinese or any other language. If the user " + "text is short, your output is exactly that short text." + ) + user_prompt = english_text + messages = None + content = [dict(item) for item in (messages or [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ])] + ref_audio_data: str | None = None + ref_duration = 0.0 + if ref_audio_path: + ref_audio = Path(ref_audio_path) + if ref_audio.exists(): + ref_audio_data, ref_duration = wav_to_float32_base64(ref_audio) + for item in content: + if item.get("role") != "system": + continue + prompt = item.get("content") + prompt_text = prompt if isinstance(prompt, str) else system_prompt + item["content"] = [ + {"type": "text", "text": "Clone the voice in this reference audio."}, + { + "type": "audio", + "data": ref_audio_data, + "name": ref_audio.name, + "duration": ref_duration, + }, + {"type": "text", "text": prompt_text}, + ] + break + # Generation knobs sized for "speak this exact short text" — we don't + # want the model exploring; we want it to read what we gave it. + tts_max_tokens = min(budget.output_tokens, max(64, len(english_text.split()) * 6)) + payload = { + "messages": content, + "streaming": True, + "lang": "en", + "generation": { + "max_new_tokens": tts_max_tokens, + "temperature": 0.0, + "do_sample": False, + "repeat_penalty": 1.0, + }, + "tts": { + "enabled": tts, + "mode": "audio_assistant", + "lang": "en", + **({"ref_audio_data": ref_audio_data} if tts and ref_audio_data else {}), + }, + "use_tts_template": tts, + "omni_mode": False, + "enable_thinking": False, + } + gateway = self.settings.omni_gateway_url.rstrip("/") + if gateway.startswith("https://"): + gateway = "wss://" + gateway[8:] + elif gateway.startswith("http://"): + gateway = "ws://" + gateway[7:] + + text_chunks: list[str] = [] + audio_chunks: list[bytes] = [] + final_text = "" + sample_rate: int | None = None + try: + from websockets.sync.client import connect + from websockets.exceptions import WebSocketException + + with connect( + f"{gateway}/ws/chat", + open_timeout=15, + close_timeout=5, + max_size=128 * 1024 * 1024, + ) as websocket: + websocket.send(json.dumps(payload)) + while True: + raw = websocket.recv(timeout=300) + data = json.loads(raw) + message_type = data.get("type") + if message_type == "error": + raise RuntimeError(data.get("error") or "MiniCPM-o request failed.") + if message_type == "chunk": + if data.get("text_delta"): + text_chunks.append(str(data["text_delta"])) + if data.get("audio_data"): + audio_chunks.append(base64.b64decode(data["audio_data"])) + if data.get("audio_sample_rate"): + sample_rate = int(data["audio_sample_rate"]) + if message_type == "done": + final_text = str(data.get("text") or "") + if data.get("audio_data"): + audio_chunks.append(base64.b64decode(data["audio_data"])) + if data.get("audio_sample_rate"): + sample_rate = int(data["audio_sample_rate"]) + break + except (OSError, TimeoutError, WebSocketException) as exc: + raise RuntimeError("MiniCPM-o gateway could not be reached.") from exc + return OmniResponse( + text=text, + audio_data=base64.b64encode(b"".join(audio_chunks)).decode("ascii") if audio_chunks else None, + audio_sample_rate=sample_rate or (24000 if audio_chunks else None), + ) + + def synthesize(self, text: str, *, ref_audio_path: str | None = None) -> OmniResponse: + clean = " ".join(text.split()).strip() + if not clean: + return OmniResponse(text="") + system_prompt = ( + "Clone the voice in the provided English audio prompt. Read the user's text aloud " + "in English exactly as written. Do not answer it, translate it, explain it, or add words." + ) + return self._omni_gateway_chat( + system_prompt, + clean, + messages=None, + temperature=0.0, + max_tokens=max(64, min(256, len(clean.split()) * 6)), + tts=True, + ref_audio_path=ref_audio_path, + ) + + def _omni_gateway_chat( + self, + system_prompt: str, + user_prompt: str, + *, + messages: list[dict[str, Any]] | None, + temperature: float, + max_tokens: int, + tts: bool, + ref_audio_path: str | None, + ) -> OmniResponse: + content = [dict(item) for item in (messages or [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ])] + ref_audio_data: str | None = None + if tts and ref_audio_path: + ref_audio = Path(ref_audio_path) + if ref_audio.exists(): + ref_audio_data, ref_duration = wav_to_float32_base64(ref_audio) + for item in content: + if item.get("role") == "system": + prompt = item.get("content") + item["content"] = [ + {"type": "text", "text": "Clone the voice in this reference audio."}, + {"type": "audio", "data": ref_audio_data, "name": ref_audio.name, "duration": ref_duration}, + {"type": "text", "text": prompt if isinstance(prompt, str) else system_prompt}, + ] + break + payload = { + "messages": content, + "streaming": True, + "lang": "en", + "generation": { + "max_new_tokens": max_tokens, + "temperature": temperature, + "do_sample": temperature > 0, + "repeat_penalty": 1.05, + }, + "tts": { + "enabled": tts, + "mode": "audio_assistant", + "lang": "en", + **({"ref_audio_data": ref_audio_data} if ref_audio_data else {}), + }, + "use_tts_template": tts, + "omni_mode": False, + "enable_thinking": False, + } + gateway = self.settings.omni_gateway_url.rstrip("/") + gateway = ("wss://" + gateway[8:]) if gateway.startswith("https://") else ("ws://" + gateway[7:]) + last_error: RuntimeError | None = None + for attempt in range(12): + try: + return self._run_gateway_chat(gateway, payload) + except RuntimeError as exc: + last_error = exc + if "worker busy" not in str(exc).lower() or attempt == 11: + raise + time.sleep(2) + raise last_error or RuntimeError("MiniCPM-o request failed.") + + @staticmethod + def _run_gateway_chat(gateway: str, payload: dict[str, Any]) -> OmniResponse: + from websockets.sync.client import connect + from websockets.exceptions import WebSocketException + + text_chunks: list[str] = [] + audio_chunks: list[bytes] = [] + final_text = "" + sample_rate: int | None = None + try: + with connect(f"{gateway}/ws/chat", open_timeout=15, close_timeout=5, max_size=128 * 1024 * 1024) as websocket: + websocket.send(json.dumps(payload)) + while True: + data = json.loads(websocket.recv(timeout=300)) + message_type = data.get("type") + if message_type == "error": + raise RuntimeError(data.get("error") or "MiniCPM-o request failed.") + if data.get("text_delta"): + text_chunks.append(str(data["text_delta"])) + if data.get("audio_data"): + audio_chunks.append(base64.b64decode(data["audio_data"])) + if data.get("audio_sample_rate"): + sample_rate = int(data["audio_sample_rate"]) + if message_type == "done": + final_text = str(data.get("text") or "") + break + except (OSError, TimeoutError, WebSocketException) as exc: + raise RuntimeError("MiniCPM-o gateway could not be reached.") from exc + text = final_text.strip() or "".join(text_chunks).strip() + return OmniResponse( + text=text, + audio_data=base64.b64encode(b"".join(audio_chunks)).decode("ascii") if audio_chunks else None, + audio_sample_rate=sample_rate or (24000 if audio_chunks else None), + ) + + def _text_completion( + self, + system_prompt: str, + user_prompt: str, + *, + messages: list[dict[str, Any]] | None, + temperature: float, + max_tokens: int, + json_mode: bool = False, + ) -> str: + content = [dict(item) for item in (messages or [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ])] + if self.settings.llm_provider in {"llama_cpp_server", "external_llama_cpp_server"}: + text_url = f"{self.settings.llamacpp_base_url.rstrip('/')}/chat/completions" + model = self.settings.llm_model or (self.settings.llamacpp_model_path.name if self.settings.llamacpp_model_path else "local") + # Hybrid-reasoning models (MiniCPM4.x, Qwen3, ...) emit a hidden + # block by default and, with a small max_tokens, can spend + # the whole budget reasoning and return empty content. The bundled + # omni-fork llama-server is too old for --jinja/--reasoning-budget, + # so for those models we disable thinking the model-native way by + # appending "/no_think" to the system turn. We only do this for + # models that recognize the directive — any other user-supplied GGUF + # (Gemma, Llama, Mistral, ...) is sent through untouched. + if _is_reasoning_model(model, self.settings.llamacpp_model_path): + content = _ensure_no_think(content) + else: + gateway = urllib.parse.urlparse(self.settings.omni_gateway_url) + host = gateway.hostname or "127.0.0.1" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + scheme = "https" if gateway.scheme == "https" else "http" + text_url = f"{scheme}://{host}:19060/v1/chat/completions" + model = self.settings.minicpm_quantization or self.settings.llm_model + payload = { + "model": model, + "messages": content, + "temperature": temperature, + "max_tokens": max_tokens, + "stream": False, + **({"response_format": {"type": "json_object"}} if json_mode else {}), + } + request = urllib.request.Request( + text_url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=300) as response: + data = json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc: + backend = "llama.cpp" if self.settings.llm_provider in {"llama_cpp_server", "external_llama_cpp_server"} else "MiniCPM-o llama.cpp" + raise RuntimeError(f"{backend} text endpoint could not be reached.") from exc + try: + return str(data["choices"][0]["message"]["content"]).strip() + except (KeyError, IndexError, TypeError) as exc: + raise RuntimeError("MiniCPM-o returned an invalid text response.") from exc + + def json_chat(self, system_prompt: str, user_prompt: str, *, task: str, temperature: float = 0.2) -> dict[str, Any]: + return parse_json_object(self.chat(system_prompt, user_prompt, task=task, temperature=temperature).text) + + +# Substrings (case-insensitive) identifying model families that honor the +# "/no_think" directive. Matched against both the model label and the GGUF +# filename so it works for managed and external llama.cpp backends alike. +_NO_THINK_MODEL_HINTS = ("minicpm4", "minicpm-4", "minicpm_4", "qwen3", "qwen-3") + + +def _is_reasoning_model(model: str | None, model_path: Path | None) -> bool: + haystack = f"{model or ''} {model_path.name if model_path else ''}".lower() + return any(hint in haystack for hint in _NO_THINK_MODEL_HINTS) + + +def _ensure_no_think(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Append the MiniCPM/Qwen "/no_think" directive so hybrid-reasoning text + models skip the hidden block. Targets the system turn (string + content); falls back to a new system turn when none is present.""" + directive = "/no_think" + for item in messages: + if item.get("role") == "system" and isinstance(item.get("content"), str): + if directive not in item["content"]: + item["content"] = f"{item['content'].rstrip()} {directive}".strip() + return messages + return [{"role": "system", "content": directive}, *messages] + + +def scan_minicpm_models(model_dir: Path | None) -> dict[str, Any]: + if model_dir is None or not model_dir.exists(): + return {"models": [], "companions": [], "complete": False} + models: list[dict[str, Any]] = [] + companions: list[str] = [] + for path in sorted(model_dir.rglob("*.gguf")): + match = LLM_MODEL_PATTERN.match(path.name) + lowered = path.name.lower() + relative = path.relative_to(model_dir).as_posix() + if match and path.parent == model_dir and not any(hint in lowered for hint in COMPANION_HINTS): + models.append({ + "filename": path.name, + "path": str(path), + "quantization": match.group(1), + "size_bytes": path.stat().st_size, + }) + else: + companions.append(relative) + companion_text = " ".join(companions).lower() + complete = bool(models) and all(group in companion_text for group in ("audio/", "tts/", "token2wav-gguf/")) + return {"models": models, "companions": companions, "complete": complete} + + +def parse_json_object(text: str) -> dict[str, Any]: + stripped = text.strip() + if stripped.startswith("```"): + stripped = stripped.strip("`") + if stripped.lower().startswith("json"): + stripped = stripped[4:].strip() + start = stripped.find("{") + end = stripped.rfind("}") + if start < 0 or end < start: + raise ValueError("Model response did not contain a JSON object.") + return json.loads(stripped[start : end + 1]) diff --git a/llm/prompts/culprit_move.md b/llm/prompts/culprit_move.md new file mode 100644 index 0000000000000000000000000000000000000000..5fcd55ca377e7ad35ccb4f3aaab8ef671ba64226 --- /dev/null +++ b/llm/prompts/culprit_move.md @@ -0,0 +1,13 @@ +You are the hidden culprit in a pursuit investigation game. + +Choose exactly one legal unblocked move from the available moves. You may change disguise only if disguise changes remain. Avoid direct police attention, but do not invent routes that are not listed. + +Return structured JSON only with: +- chosen_destination +- transport_mode +- route +- change_disguise +- new_disguise +- reasoning_summary +- risk_level + diff --git a/llm/prompts/lookout_parser.md b/llm/prompts/lookout_parser.md new file mode 100644 index 0000000000000000000000000000000000000000..04dca3fe35c5193c492aee32a8341029fb479cc4 --- /dev/null +++ b/llm/prompts/lookout_parser.md @@ -0,0 +1,6 @@ +You interpret a Commissioner lookout notice for a fictional city map. + +Use the landmark atlas, junction registry, recent suspect description, and the new notice to estimate which junctions should receive witness responses. Vague notices should produce more false positives. Precise notices should produce fewer, more useful responses. + +Return structured JSON only. + diff --git a/llm/prompts/memory_corruption.md b/llm/prompts/memory_corruption.md new file mode 100644 index 0000000000000000000000000000000000000000..5c43c0e5e595dc0aea2f9cb474fe456ad67b91e7 --- /dev/null +++ b/llm/prompts/memory_corruption.md @@ -0,0 +1,31 @@ +You slightly corrupt witness information for a detective board game. + +Current turn: {turn_number} +New corruption level: {corruption_level} + +Stable facts, which should stay intact: +{stable_facts} + +Fragile facts, which may become blurrier: +{fragile_facts} + +Current witness summary: +{current_summary} + +Rules: +- Slightly corrupt the information only. +- Preserve stable facts. +- Do not completely rewrite the statement. +- Blur confidence, timing, direction, or small visual details. +- Do not add new hard facts. +- Keep the witness voice natural and uncertain. +- Return JSON only. + +Return: +{{ + "corruption_level": {corruption_level}, + "corrupted_summary": "...", + "changed_fragile_facts": ["..."], + "preserved_stable_facts": ["..."] +}} + diff --git a/llm/prompts/witness_answer.md b/llm/prompts/witness_answer.md new file mode 100644 index 0000000000000000000000000000000000000000..75ced1922137d1ff1782fd2fa98aa5927456486f --- /dev/null +++ b/llm/prompts/witness_answer.md @@ -0,0 +1,4 @@ +You roleplay a witness in a detective game. + +Answer only from the witness summary and personality provided. Do not invent new facts. Express uncertainty naturally. Do not reveal hidden game state. + diff --git a/llm/prompts/witness_summary.md b/llm/prompts/witness_summary.md new file mode 100644 index 0000000000000000000000000000000000000000..b253b548d563d9b82471fcf0cf7beb68e4b0e902 --- /dev/null +++ b/llm/prompts/witness_summary.md @@ -0,0 +1,6 @@ +You create prepared witness summaries for a detective game. + +Create the summary before the player can inspect individual witnesses. Some witnesses may be relevant, irrelevant, mistaken, or partially confused depending on the supplied relevance score and reliability. + +Return structured JSON only. + diff --git a/llm/schemas.py b/llm/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..d78c8590319b7c9f1f08ee503dc662a3fd273e55 --- /dev/null +++ b/llm/schemas.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Literal, TypedDict + + +class CulpritMoveOutput(TypedDict): + chosen_destination: int + transport_mode: str + route: list[int] + change_disguise: bool + new_disguise: str | None + reasoning_summary: str + risk_level: Literal["low", "medium", "high"] + + +class CorruptedWitnessOutput(TypedDict): + corruption_level: float + corrupted_summary: str + changed_fragile_facts: list[str] + preserved_stable_facts: list[str] + diff --git a/llm/structured_outputs.py b/llm/structured_outputs.py new file mode 100644 index 0000000000000000000000000000000000000000..66597ec5b9b2d48050dbcb77119fff7d502f8c34 --- /dev/null +++ b/llm/structured_outputs.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from typing import Any + + +def require_keys(data: dict[str, Any], keys: list[str]) -> None: + missing = [key for key in keys if key not in data] + if missing: + raise ValueError(f"Structured LLM output missing keys: {', '.join(missing)}") + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000000000000000000000000000000000..5ee6477165727539aa0701c22b47da2ceaedeff5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/requirements-space.txt b/requirements-space.txt new file mode 100644 index 0000000000000000000000000000000000000000..6e0db087711c53a7ffe34730f7afb8ee51892904 --- /dev/null +++ b/requirements-space.txt @@ -0,0 +1,11 @@ +# Runtime dependencies for the Linux / Docker Hugging Face Space. +# Deliberately excludes the Windows-only and build-from-source bits from +# requirements.txt (cmake/ninja/ziglang/audioop-lts) — the Space uses a +# prebuilt llama.cpp server (llama-cpp-python) and the text-only backend, +# which has no audio path. +gradio>=6.17.3 +huggingface_hub>=1.2 +pydantic>=2.7 +websockets>=14.0 +fastapi +uvicorn diff --git a/scripts/_check_wav.py b/scripts/_check_wav.py new file mode 100644 index 0000000000000000000000000000000000000000..b39e58402f30ca783497dc4cfd62dc61de0d4ec6 --- /dev/null +++ b/scripts/_check_wav.py @@ -0,0 +1,12 @@ +import contextlib +import wave +from pathlib import Path + +for p in [ + Path(r"C:\temp\shadow_commission_london_maps_backup_20260611_183156\runtime\MiniCPM-o-Demo\assets\ref_audio\ref_en_dlc_1.wav"), + Path(r"C:\temp\shadow_commission_london_maps_backup_20260611_183156\runtime\MiniCPM-o-Demo\assets\ref_audio\ref_minicpm_signature.wav"), + Path(r"C:\temp\shadow_commission_london_maps_backup_20260611_183156\data\voices\voice_01.wav"), +]: + with contextlib.closing(wave.open(str(p), "r")) as w: + dur = w.getnframes() / w.getframerate() + print(f"{p.name}: {w.getframerate()} Hz, {w.getnchannels()} ch, {w.getsampwidth() * 8}-bit, {dur:.2f}s") diff --git a/scripts/_inspect_witness.py b/scripts/_inspect_witness.py new file mode 100644 index 0000000000000000000000000000000000000000..615f34c8688c739531442e6aaafa2efad8370e42 --- /dev/null +++ b/scripts/_inspect_witness.py @@ -0,0 +1,20 @@ +import json, sys, glob +from pathlib import Path + +games = sorted(Path("data/games").glob("game_*"), key=lambda p: p.stat().st_mtime, reverse=True) +if not games: + print("no games") + sys.exit(0) +g = games[0] +print(f"game: {g.name}\n") +for batch_file in sorted((g / "witnesses").glob("*.json")): + data = json.loads(batch_file.read_text(encoding="utf-8")) + print(f"batch {batch_file.name}: {len(data.get('witnesses', []))} witnesses") + for w in data.get("witnesses", []): + print(f" --- {w['witness_id']} | {w.get('name')} | junction={w.get('junction_id')}") + print(f" summary: {w['current_summary'][:160]}") + history = w.get("question_history", []) + print(f" Q/A history ({len(history)} turns):") + for h in history: + print(f" Q: {h['question']!r}") + print(f" A: {h['answer']!r}") diff --git a/scripts/_quick_llm_test.py b/scripts/_quick_llm_test.py new file mode 100644 index 0000000000000000000000000000000000000000..71de1a797c245a202150b400c1ff45466ab2e186 --- /dev/null +++ b/scripts/_quick_llm_test.py @@ -0,0 +1,50 @@ +"""Hit the underlying llama-server endpoint with a trivial English prompt to +see if the LLM itself is broken or only the witness path is.""" +import json, sys, time, urllib.request +from pathlib import Path +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) +from config import load_settings + +settings = load_settings() +out = REPO / "runtime" / "tmp" / "llm_probe.txt" +out.parent.mkdir(parents=True, exist_ok=True) + +prompts = [ + ("system: You are a helpful assistant. Respond ONLY in English.\nuser: What is 2 + 2? Reply with the digit.", + [{"role": "system", "content": "You are a helpful assistant. Respond ONLY in English."}, + {"role": "user", "content": "What is 2 + 2? Reply with the digit."}]), + ("system: Reply in English.\nuser: Name a single fruit.", + [{"role": "system", "content": "Reply in English."}, + {"role": "user", "content": "Name a single fruit."}]), + ("system: -\nuser: Hello, how are you?", + [{"role": "user", "content": "Hello, how are you?"}]), +] + +lines: list[str] = [] +for label, msgs in prompts: + payload = { + "model": settings.minicpm_quantization or settings.llm_model, + "messages": msgs, + "temperature": 0.3, + "max_tokens": 80, + "stream": False, + } + req = urllib.request.Request( + "http://127.0.0.1:19060/v1/chat/completions", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + start = time.perf_counter() + with urllib.request.urlopen(req, timeout=60) as resp: + data = json.loads(resp.read().decode("utf-8")) + elapsed = time.perf_counter() - start + text = data["choices"][0]["message"]["content"].strip() + lines.append(f"=== {label}") + lines.append(f"elapsed: {elapsed:.2f}s") + lines.append(f"reply: {text!r}") + lines.append(f"raw : {text}") + lines.append("") +out.write_text("\n".join(lines), encoding="utf-8") +print(f"wrote: {out}") diff --git a/scripts/_replay_witness_questions.py b/scripts/_replay_witness_questions.py new file mode 100644 index 0000000000000000000000000000000000000000..fa4123d14b61cb449bad068b139363c8bfa0eabb --- /dev/null +++ b/scripts/_replay_witness_questions.py @@ -0,0 +1,73 @@ +"""Replay the user's two witness questions to the live model with the *exact* +prompt structure app.api_witness_message builds, so we can see whether: + + (a) the model is being called and is returning the same paraphrase both times + (because the fallback witness's summary genuinely contains almost no info), or + (b) something is short-circuiting / caching / falling back without calling the + model at all. + +Pulls the persisted witness record from the most recent game directory so the +prompt matches what the live UI sent. Calls OmniClient.chat() directly. +""" + +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) + +from llm.omni_client import OmniClient # noqa: E402 + +def main() -> int: + games = sorted((REPO / "data" / "games").glob("game_*"), key=lambda p: p.stat().st_mtime, reverse=True) + if not games: + print("no games"); return 1 + g = games[0] + print(f"using game: {g.name}") + batch = json.loads(next((g / "witnesses").glob("*.json")).read_text(encoding="utf-8")) + witness = batch["witnesses"][0] + print(f"witness: {witness['witness_id']} | summary: {witness['current_summary']}\n") + + client = OmniClient.from_settings() + if not client.health(timeout=5.0).get("ready"): + print("gateway not ready"); return 1 + + SYSTEM = ( + "You are this fictional witness. Answer only from allowed knowledge. " + "Never invent or reveal hidden game state. Keep the reply natural, " + "concise, and in English." + ) + base_prompt = { + "profile": {"name": witness["name"], "occupation": witness["occupation"], **witness["personality"]}, + "allowed_knowledge": witness["current_summary"], + "stable_facts": witness["stable_facts"], + "recent_conversation": [], + "question": "", + } + + questions = ["what did you see", "where was he", "what was he carrying"] + out = REPO / "runtime" / "tmp" / "witness_replay.txt" + out.parent.mkdir(parents=True, exist_ok=True) + lines: list[str] = [] + for q in questions: + prompt = dict(base_prompt) + prompt["question"] = q + start = time.perf_counter() + resp = client.chat(SYSTEM, json.dumps(prompt), task="interview", temperature=0.55, tts=False) + elapsed = time.perf_counter() - start + lines.append(f"Q ({elapsed:.2f}s): {q!r}") + lines.append(f"A repr: {resp.text.strip()!r}") + lines.append(f"A raw : {resp.text.strip()}") + lines.append("") + out.write_text("\n".join(lines), encoding="utf-8") + print(f"wrote: {out}") + print(f"\n{out.read_text(encoding='utf-8')}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/_smoke_text_model.py b/scripts/_smoke_text_model.py new file mode 100644 index 0000000000000000000000000000000000000000..d075e66ae703476e69ecbb8738e42ed27c633c3b --- /dev/null +++ b/scripts/_smoke_text_model.py @@ -0,0 +1,47 @@ +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from config import Settings +from llm.omni_client import OmniClient, _is_reasoning_model + +parser = argparse.ArgumentParser() +parser.add_argument("--model-path", required=True) +parser.add_argument("--model-name", default="") +parser.add_argument("--base-url", default="http://127.0.0.1:8080/v1") +args = parser.parse_args() + +model_path = Path(args.model_path) +model_name = args.model_name or model_path.name + +settings = Settings( + llm_provider="llama_cpp_server", + llamacpp_model_path=model_path, + llm_model=model_name, + llamacpp_base_url=args.base_url, + witness_chat_tts=False, +) +client = OmniClient(settings) + +print("model:", model_name) +print("reasoning-gated (/no_think injected):", _is_reasoning_model(model_name, model_path)) +print("health ready:", client.health().get("ready")) + +r = client.chat( + "You are a terse witness in an English detective game. Answer in one short English sentence.", + "What color was the folder the suspect carried?", + task="interview", temperature=0.2, +) +print("INTERVIEW:", repr(r.text)) + +try: + d = client.json_chat( + "You output only a JSON object with keys: summary (string), confidence (number 0-1).", + "Summarize this sighting: a man in a grey raincoat boarded a bus at 9pm carrying a blue folder.", + task="witness", + ) + print("JSON OK:", json.dumps(d)[:300]) +except Exception as e: + print("JSON ERR:", type(e).__name__, e) diff --git a/scripts/_test_witness_api.py b/scripts/_test_witness_api.py new file mode 100644 index 0000000000000000000000000000000000000000..89538c8b0e5dc241e5fc0d61350ab5b3c5fd696a --- /dev/null +++ b/scripts/_test_witness_api.py @@ -0,0 +1,33 @@ +"""Hit the LIVE /api/witness/.../message endpoint twice with different +questions on the same Harriet Moss witness and confirm we get distinct +English answers.""" +import json, sys, time, urllib.request +from pathlib import Path +REPO = Path(__file__).resolve().parent.parent + +GAME = "game_20260614_075442_190041" +WIT = "w_false_notice_001_74" + +questions = ["what did you see", "where was he", "what was he carrying"] +out = REPO / "runtime" / "tmp" / "witness_api_test.txt" +out.parent.mkdir(parents=True, exist_ok=True) +lines = [] +for q in questions: + payload = json.dumps({"message": q}).encode("utf-8") + req = urllib.request.Request( + f"http://127.0.0.1:7860/api/witness/{GAME}/{WIT}/message", + data=payload, headers={"Content-Type": "application/json"}, method="POST", + ) + start = time.perf_counter() + try: + with urllib.request.urlopen(req, timeout=120) as resp: + data = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + lines.append(f"Q: {q} HTTP {e.code}: {e.read().decode('utf-8', errors='replace')}") + continue + elapsed = time.perf_counter() - start + lines.append(f"Q ({elapsed:.2f}s): {q}") + lines.append(f"A: {data.get('answer')}") + lines.append("") +out.write_text("\n".join(lines), encoding="utf-8") +print(f"wrote: {out}") diff --git a/scripts/_verify_witness_fix.py b/scripts/_verify_witness_fix.py new file mode 100644 index 0000000000000000000000000000000000000000..1d7a6320f4eb6fbc93e939ba944ee50a4926a892 --- /dev/null +++ b/scripts/_verify_witness_fix.py @@ -0,0 +1,78 @@ +"""Run the new plain-English witness prompt directly against the live LLM +(stateless /v1/chat/completions) so we can verify the fix produces distinct +sensible English answers before restarting the FastAPI server.""" +import json, sys, time, urllib.request +from pathlib import Path +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) +from config import load_settings + +settings = load_settings() +out = REPO / "runtime" / "tmp" / "witness_fix_verify.txt" +out.parent.mkdir(parents=True, exist_ok=True) + +WITNESS = { + "name": "Harriet Moss", + "occupation": "passer-by", + "personality": {"style": "nervous", "confidence": "uncertain", "quirk": "remembers colors better than faces"}, + "current_summary": "A passer-by near Junction 74 saw an ordinary commuter who only loosely matched the notice.", + "stable_facts": ["witness was at Junction 74"], +} + +SYSTEM = ( + "You are roleplaying a witness in an English-language detective game. " + "Speak only English. Reply in one or two short sentences. Use only the " + "facts the user gives you. Never invent details. If you don't know, " + "say you don't know." +) + + +def build_user(question, history): + history_block = ( + "\n".join(f" Detective: {q}\n You: {a}" for q, a in history) + if history else " (no prior questions)" + ) + stable_block = ", ".join(WITNESS["stable_facts"]) + personality_block = ", ".join(f"{k}: {v}" for k, v in WITNESS["personality"].items()) + return ( + f"You are {WITNESS['name']}, a {WITNESS['occupation']} ({personality_block}).\n" + f"What you saw / know: {WITNESS['current_summary']}\n" + f"Stable facts: {stable_block}\n" + f"Conversation so far:\n{history_block}\n" + f"The detective now asks: {question!r}\n" + f"Reply in character, in English, in one or two short sentences." + ) + + +def call(messages): + payload = { + "model": settings.minicpm_quantization or settings.llm_model, + "messages": messages, + "temperature": 0.55, + "max_tokens": 120, + "stream": False, + } + req = urllib.request.Request( + "http://127.0.0.1:19060/v1/chat/completions", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + start = time.perf_counter() + with urllib.request.urlopen(req, timeout=90) as resp: + data = json.loads(resp.read().decode("utf-8")) + return data["choices"][0]["message"]["content"].strip(), time.perf_counter() - start + + +history = [] +lines = [] +for q in ["what did you see", "where was he", "what was he carrying", "what time was this"]: + user = build_user(q, history) + answer, elapsed = call([{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}]) + lines.append(f"Q ({elapsed:.2f}s): {q}") + lines.append(f"A: {answer}") + lines.append("") + history.append((q, answer)) + +out.write_text("\n".join(lines), encoding="utf-8") +print(f"wrote: {out}") diff --git a/scripts/_witness_prompt_test.py b/scripts/_witness_prompt_test.py new file mode 100644 index 0000000000000000000000000000000000000000..f9b1946770e631520aa84de93856d043198220d0 --- /dev/null +++ b/scripts/_witness_prompt_test.py @@ -0,0 +1,74 @@ +"""Test witness path with (a) JSON-encoded prompt vs (b) plain English prompt.""" +import json, sys, time, urllib.request +from pathlib import Path +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) +from config import load_settings + +settings = load_settings() +out = REPO / "runtime" / "tmp" / "witness_prompt_test.txt" +out.parent.mkdir(parents=True, exist_ok=True) + +SYSTEM_OLD = ( + "You are this fictional witness. Answer only from allowed knowledge. " + "Never invent or reveal hidden game state. Keep the reply natural, " + "concise, and in English." +) +SYSTEM_NEW = ( + "You are roleplaying a witness in an English detective game. Reply in ONE " + "short English sentence based only on the facts the user gives you. " + "Output English only. Do not output Chinese." +) +SUMMARY = "A passer-by near Junction 74 saw an ordinary commuter who only loosely matched the notice." +QUESTIONS = ["what did you see", "where was he", "what was he carrying"] + + +def call(messages, label, lines): + payload = { + "model": settings.minicpm_quantization or settings.llm_model, + "messages": messages, + "temperature": 0.55, + "max_tokens": 120, + "stream": False, + } + req = urllib.request.Request( + "http://127.0.0.1:19060/v1/chat/completions", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + start = time.perf_counter() + with urllib.request.urlopen(req, timeout=90) as resp: + data = json.loads(resp.read().decode("utf-8")) + elapsed = time.perf_counter() - start + text = data["choices"][0]["message"]["content"].strip() + lines.append(f" Q: {label} ({elapsed:.2f}s)") + lines.append(f" A: {text}") + lines.append("") + + +lines: list[str] = [] + +lines.append("===== CONFIG A: current code — JSON-encoded user message") +for q in QUESTIONS: + user = json.dumps({ + "profile": {"name": "Harriet Moss", "occupation": "passer-by", "style": "nervous"}, + "allowed_knowledge": SUMMARY, + "stable_facts": ["witness was at Junction 74"], + "recent_conversation": [], + "question": q, + }) + call([{"role": "system", "content": SYSTEM_OLD}, {"role": "user", "content": user}], q, lines) + +lines.append("===== CONFIG B: plain English prompt, same data") +for q in QUESTIONS: + user = ( + f"You are Harriet Moss, a nervous passer-by who only saw this:\n" + f"\"{SUMMARY}\"\n" + f"The detective asks you: {q!r}.\n" + f"Reply in one short English sentence, based only on what you saw." + ) + call([{"role": "system", "content": SYSTEM_NEW}, {"role": "user", "content": user}], q, lines) + +out.write_text("\n".join(lines), encoding="utf-8") +print(f"wrote: {out}") diff --git a/scripts/build_processed_data.py b/scripts/build_processed_data.py new file mode 100644 index 0000000000000000000000000000000000000000..20cb527e54fc84656ab2a424d8eddcc20bc5db5b --- /dev/null +++ b/scripts/build_processed_data.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import csv +import json +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from config import load_settings +from grid_map.atlas_builder import empty_atlas +from grid_map.storage import write_json +from grid_map.validator import validate_graph + + +LAYERS = { + "normal": "normal_cv_out", + "taxi": "taxi_cv_out", + "bus": "bus_cv_out", + "subway": "subway_cv_out", +} + +TRANSPORT_LAYERS = ("taxi", "bus", "subway") + + +def main() -> None: + settings = load_settings() + layer_graphs = {layer: _read_graph(settings.raw_maps_dir / folder / "graph.json") for layer, folder in LAYERS.items()} + normal_junctions = _read_junctions(settings.raw_maps_dir / LAYERS["normal"] / "junctions.csv") + + registry = build_junction_registry(normal_junctions, layer_graphs) + game_graph = build_game_graph(registry, layer_graphs) + errors = validate_graph(game_graph) + if errors: + raise SystemExit("\n".join(errors)) + + metadata = build_metadata(settings.raw_maps_dir, layer_graphs) + + write_json(settings.junction_registry_path, registry) + write_json(settings.game_graph_path, game_graph) + write_json(settings.map_metadata_path, metadata) + if not settings.map_atlas_path.exists(): + write_json(settings.map_atlas_path, empty_atlas()) + + print(f"Wrote {settings.junction_registry_path}") + print(f"Wrote {settings.game_graph_path}") + print(f"Wrote {settings.map_metadata_path}") + print(f"Atlas ready at {settings.map_atlas_path}") + + +def _read_graph(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + return json.load(handle) + + +def _read_junctions(path: Path) -> dict[int, dict[str, Any]]: + junctions: dict[int, dict[str, Any]] = {} + with path.open("r", encoding="utf-8", newline="") as handle: + for row in csv.DictReader(handle): + junction_id = int(row["id"]) + junctions[junction_id] = { + "id": junction_id, + "x": int(float(row["x"])), + "y": int(float(row["y"])), + "radius": int(float(row["radius"])), + "neighbors": [int(value) for value in row.get("neighbors", "").split() if value], + } + return junctions + + +def build_junction_registry(normal_junctions: dict[int, dict[str, Any]], layer_graphs: dict[str, dict]) -> dict[str, Any]: + modes_by_junction: dict[int, set[str]] = {junction_id: set() for junction_id in normal_junctions} + for layer, graph in layer_graphs.items(): + for node in graph.get("nodes", []): + modes_by_junction.setdefault(int(node["id"]), set()).add(layer) + + junctions = [] + for junction_id, data in sorted(normal_junctions.items()): + junctions.append( + { + "id": junction_id, + "x": data["x"], + "y": data["y"], + "radius": data["radius"], + "base_neighbors": data["neighbors"], + "layers_present": sorted(modes_by_junction.get(junction_id, [])), + "nearest_landmarks": [], + "district": None, + } + ) + + return {"schema_version": 1, "junctions": junctions} + + +def build_game_graph(registry: dict[str, Any], layer_graphs: dict[str, dict]) -> dict[str, Any]: + edge_modes: dict[tuple[int, int], set[str]] = {} + for layer in TRANSPORT_LAYERS: + for edge in layer_graphs[layer].get("edges", []): + source = int(edge["source"]) + target = int(edge["target"]) + key = tuple(sorted((source, target))) + edge_modes.setdefault(key, set()).add(layer) + + edges = [ + {"source": source, "target": target, "modes": sorted(modes)} + for (source, target), modes in sorted(edge_modes.items()) + ] + adjacency: dict[str, list[dict[str, Any]]] = {} + for edge in edges: + source = edge["source"] + target = edge["target"] + adjacency.setdefault(str(source), []).append({"destination": target, "modes": edge["modes"]}) + adjacency.setdefault(str(target), []).append({"destination": source, "modes": edge["modes"]}) + + nodes = [ + { + "id": junction["id"], + "x": junction["x"], + "y": junction["y"], + "radius": junction["radius"], + } + for junction in registry["junctions"] + ] + return {"schema_version": 1, "nodes": nodes, "edges": edges, "adjacency": adjacency} + + +def build_metadata(raw_maps_dir: Path, layer_graphs: dict[str, dict]) -> dict[str, Any]: + def portable(path: Path) -> str: + return str(path.relative_to(PROJECT_ROOT)) + + return { + "schema_version": 1, + "generated_at": datetime.now(timezone.utc).isoformat(), + "images": {layer: portable(_display_image_for_layer(raw_maps_dir, layer)) for layer in LAYERS}, + "source_images": { + "normal": portable(raw_maps_dir / "images" / "normal.png"), + "taxi": portable(raw_maps_dir / "images" / "taxi.png"), + "bus": portable(raw_maps_dir / "images" / "bus.png"), + "subway": portable(raw_maps_dir / "images" / "subway.png"), + }, + "layers": { + layer: { + "folder": portable(raw_maps_dir / LAYERS[layer]), + "node_count": len(graph.get("nodes", [])), + "edge_count": len(graph.get("edges", [])), + } + for layer, graph in layer_graphs.items() + }, + } + + +def _display_image_for_layer(raw_maps_dir: Path, layer: str) -> Path: + folder = raw_maps_dir / LAYERS[layer] + for candidate in (folder / "graph_on_map.png", folder / "junctions_labelled.png", raw_maps_dir / "images" / f"{layer}.png"): + if candidate.exists(): + return candidate + return raw_maps_dir / "images" / f"{layer}.png" + + +if __name__ == "__main__": + main() diff --git a/scripts/detect_local_llm.py b/scripts/detect_local_llm.py new file mode 100644 index 0000000000000000000000000000000000000000..65e938f6dd5920dfea42f8e9f30ea07254fb26aa --- /dev/null +++ b/scripts/detect_local_llm.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + + +SEARCH_ROOTS = [ + Path.home() / "Downloads", + Path.home() / "models", + Path.home() / "Models", + Path.home() / ".cache", + Path.home() / ".cache" / "lm-studio", + Path.home() / ".lmstudio", + Path.home() / ".lmstudio-home", + Path.home() / ".llamafile", +] + + +def main() -> None: + add_lmstudio_home_pointer() + server = shutil.which("llama-server") or shutil.which("llama-server.exe") + cli = shutil.which("llama-cli") or shutil.which("llama-cli.exe") + print(f"llama-server: {server or 'not found on PATH'}") + print(f"llama-cli: {cli or 'not found on PATH'}") + print() + print("Likely Gemma-family GGUF models:") + for model in find_models(limit=20): + print(model) + + +def find_models(limit: int) -> list[Path]: + found: list[Path] = [] + for root in SEARCH_ROOTS: + if not root.exists(): + continue + try: + for path in bounded_rglob(root, "*.gguf", max_dirs=3000): + name = path.name.lower() + if "gemma" in name or "4b" in name: + found.append(path) + if len(found) >= limit: + return found + except OSError: + continue + return found + + +def add_lmstudio_home_pointer() -> None: + pointer = Path.home() / ".lmstudio-home-pointer" + if not pointer.exists(): + return + try: + target = Path(pointer.read_text(encoding="utf-8").strip()) + except OSError: + return + if target.exists() and target not in SEARCH_ROOTS: + SEARCH_ROOTS.append(target) + + +def bounded_rglob(root: Path, pattern: str, max_dirs: int) -> list[Path]: + results: list[Path] = [] + pending = [root] + visited = 0 + while pending and visited < max_dirs: + current = pending.pop() + visited += 1 + try: + for child in current.iterdir(): + if child.is_dir(): + pending.append(child) + elif child.match(pattern): + results.append(child) + except OSError: + continue + return results + + +if __name__ == "__main__": + main() diff --git a/scripts/digitize_maps.py b/scripts/digitize_maps.py new file mode 100644 index 0000000000000000000000000000000000000000..abcbc418925925f34e9ab96a1f8135e85b639932 --- /dev/null +++ b/scripts/digitize_maps.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +""" +Multi-map digitizer for Scotland Yard. +Aligns Bus, Subway, and Map images to Taxi.png, projects the 153 master junctions, +snaps them to local circle centroids, builds transport graphs for each type, and writes outputs. +""" + +import argparse +import csv +import json +import math +from pathlib import Path +import cv2 +import networkx as nx +import numpy as np +from skimage.morphology import skeletonize + +class DigitizerArgs: + # Circle snapping settings + node_h_min = 10 + node_h_max = 42 + node_s_min = 25 + node_v_min = 110 + + # Road segmentation / connection parameters + # Taxi Yellow mask + taxi_h_min = 10 + taxi_h_max = 42 + taxi_s_min = 35 + taxi_v_min = 130 + + # Bus Green mask + bus_h_min = 35 + bus_h_max = 85 + bus_s_min = 40 + bus_v_min = 40 + + # Subway Red mask + sub_h_min = 0 + sub_h_max = 12 + sub_h_min_wrap = 160 + sub_h_max_wrap = 180 + sub_s_min = 50 + sub_v_min = 50 + + # Connection parameters + node_cut_pad = 5 + touch_radius_pad = 9 + touch_dilate = 7 + min_segment_pixels = 20 + +def load_master_junctions(csv_path): + """Load reference coordinates from junctions.csv.""" + junctions = [] + with open(csv_path, "r", newline="", encoding="utf-8") as f: + r = csv.reader(f) + header = next(r) + for row in r: + if not row: + continue + jid, x, y, radius = int(row[0]), int(row[1]), int(row[2]), int(row[3]) + junctions.append((jid, x, y, radius)) + # Sort by ID to ensure consistency + junctions.sort(key=lambda item: item[0]) + return junctions + +def compute_homography(ref_img, tgt_img): + """Compute Homography matrix mapping ref_img coordinates to tgt_img using ORB.""" + ref_gray = cv2.cvtColor(ref_img, cv2.COLOR_BGR2GRAY) + tgt_gray = cv2.cvtColor(tgt_img, cv2.COLOR_BGR2GRAY) + + orb = cv2.ORB_create(nfeatures=5000) + kp_ref, des_ref = orb.detectAndCompute(ref_gray, None) + kp_tgt, des_tgt = orb.detectAndCompute(tgt_gray, None) + + if des_ref is None or des_tgt is None: + print("Warning: ORB descriptors are empty. Using identity matrix.") + return np.eye(3, dtype=np.float32) + + bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) + matches = bf.match(des_ref, des_tgt) + matches = sorted(matches, key=lambda x: x.distance) + + if len(matches) < 10: + print("Warning: Too few matches found. Using identity matrix.") + return np.eye(3, dtype=np.float32) + + points_ref = np.zeros((len(matches), 2), dtype=np.float32) + points_tgt = np.zeros((len(matches), 2), dtype=np.float32) + for i, m in enumerate(matches): + points_ref[i, :] = kp_ref[m.queryIdx].pt + points_tgt[i, :] = kp_tgt[m.trainIdx].pt + + h, mask = cv2.findHomography(points_ref, points_tgt, cv2.RANSAC, 5.0) + if h is None: + print("Warning: Homography estimation failed. Using identity matrix.") + return np.eye(3, dtype=np.float32) + return h + +def snap_coordinate(img_bgr, x, y, search_r=20, args=DigitizerArgs): + """Find the local yellowish/cream circle centroid and snap coordinates to it.""" + hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) + h, w = img_bgr.shape[:2] + + x_start = max(0, int(x - search_r)) + x_end = min(w, int(x + search_r + 1)) + y_start = max(0, int(y - search_r)) + y_end = min(h, int(y + search_r + 1)) + + crop_hsv = hsv[y_start:y_end, x_start:x_end] + + # Mask for yellow/cream circle color + mask = (crop_hsv[:, :, 0] >= args.node_h_min) & (crop_hsv[:, :, 0] <= args.node_h_max) & \ + (crop_hsv[:, :, 1] >= args.node_s_min) & (crop_hsv[:, :, 2] >= args.node_v_min) + + if np.sum(mask) == 0: + return int(round(x)), int(round(y)) + + nlabels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask.astype(np.uint8)) + if nlabels <= 1: + return int(round(x)), int(round(y)) + + # Find centroid closest to the center of the crop + crop_center_y = (y_end - y_start) / 2.0 + crop_center_x = (x_end - x_start) / 2.0 + best_dist = float('inf') + best_cx, best_cy = x, y + + for i in range(1, nlabels): + cx, cy = centroids[i] + dist = math.hypot(cx - crop_center_x, cy - crop_center_y) + if dist < best_dist: + best_dist = dist + best_cx = x_start + cx + best_cy = y_start + cy + + return int(round(best_cx)), int(round(best_cy)) + +def segment_roads(img_bgr, mode, args=DigitizerArgs): + """Segment roads using color thresholding based on the map mode.""" + hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) + + if mode == "taxi": + lower = np.array([args.taxi_h_min, args.taxi_s_min, args.taxi_v_min], dtype=np.uint8) + upper = np.array([args.taxi_h_max, 255, 255], dtype=np.uint8) + mask = cv2.inRange(hsv, lower, upper) + elif mode == "bus": + lower = np.array([args.bus_h_min, args.bus_s_min, args.bus_v_min], dtype=np.uint8) + upper = np.array([args.bus_h_max, 255, 255], dtype=np.uint8) + mask = cv2.inRange(hsv, lower, upper) + elif mode == "subway": + lower1 = np.array([args.sub_h_min, args.sub_s_min, args.sub_v_min], dtype=np.uint8) + upper1 = np.array([args.sub_h_max, 255, 255], dtype=np.uint8) + mask1 = cv2.inRange(hsv, lower1, upper1) + + lower2 = np.array([args.sub_h_min_wrap, args.sub_s_min, args.sub_v_min], dtype=np.uint8) + upper2 = np.array([args.sub_h_max_wrap, 255, 255], dtype=np.uint8) + mask2 = cv2.inRange(hsv, lower2, upper2) + + mask = cv2.bitwise_or(mask1, mask2) + else: + # Default to empty mask for other modes + mask = np.zeros(img_bgr.shape[:2], dtype=np.uint8) + + # Morphological clean up + k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k, iterations=2) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k, iterations=1) + return mask + +def build_graph(img_bgr, junctions, mode, args=DigitizerArgs): + """Build graph edges by skeletonizing road mask and checking connectivity.""" + mask = segment_roads(img_bgr, mode, args) + + # Remove junction interiors so roads are disconnected components + cut = mask.copy() + for jid, x, y, r in junctions: + cv2.circle(cut, (x, y), int(r + args.node_cut_pad), 0, -1) + + # Skeletonization + skel = skeletonize(cut > 0).astype(np.uint8) * 255 + skel = cv2.dilate(skel, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), iterations=1) + + nlabels, labels, stats, _ = cv2.connectedComponentsWithStats(skel, connectivity=8) + G = nx.Graph() + for jid, x, y, r in junctions: + G.add_node(jid, x=int(x), y=int(y), r=int(r)) + + for comp_id in range(1, nlabels): + area = stats[comp_id, cv2.CC_STAT_AREA] + if area < args.min_segment_pixels: + continue + + comp = (labels == comp_id).astype(np.uint8) * 255 + comp = cv2.dilate(comp, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (args.touch_dilate, args.touch_dilate)), iterations=1) + + touched = [] + for jid, x, y, r in junctions: + ring = np.zeros(comp.shape, dtype=np.uint8) + cv2.circle(ring, (x, y), int(r + args.touch_radius_pad), 255, -1) + if cv2.countNonZero(cv2.bitwise_and(comp, ring)) > 0: + touched.append(jid) + + if len(touched) == 2: + G.add_edge(touched[0], touched[1]) + elif len(touched) > 2: + pts = {i: np.array([junctions[i - 1][1], junctions[i - 1][2]]) for i in touched} + # Add edges between nearest pairs in the component + for i in touched: + ds = sorted((np.linalg.norm(pts[i] - pts[j]), j) for j in touched if j != i) + for _, j in ds[:2]: + G.add_edge(i, j) + return G + +def write_outputs(img_bgr, junctions, G, out_dir): + """Write labelled map image, junctions.csv, graph.json, and graph.graphml.""" + out_dir.mkdir(parents=True, exist_ok=True) + + # Label circles on the image + labelled = img_bgr.copy() + for jid, x, y, r in junctions: + label = str(jid) + font = cv2.FONT_HERSHEY_SIMPLEX + # Use a slightly larger, highly readable scale for labels + scale = 0.50 if len(label) < 3 else 0.40 + thickness = 1 + (tw, th), _ = cv2.getTextSize(label, font, scale, thickness) + + # Compute dynamic radius: + # 1. Base radius has +3 padding to cover the circle on the map fully. + # 2. Text radius is the distance from center to corners to keep the text inside the circle. + text_radius = int(math.ceil(math.hypot(tw / 2.0, th / 2.0))) + draw_r = max(r + 3, text_radius + 3) + + # Draw backing circle + cv2.circle(labelled, (x, y), draw_r, (245, 225, 160), -1) + # Draw text label centered + cv2.putText(labelled, label, (x - tw // 2, y + th // 2), font, scale, (20, 20, 20), thickness, cv2.LINE_AA) + + cv2.imwrite(str(out_dir / "junctions_labelled.png"), labelled) + + # CSV file + with open(out_dir / "junctions.csv", "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow(["id", "x", "y", "radius", "neighbors"]) + for jid, x, y, r in junctions: + neighbors_str = " ".join(map(str, sorted(G.neighbors(jid)))) + w.writerow([jid, x, y, r, neighbors_str]) + + # JSON file + graph_json = { + "nodes": [{"id": jid, **G.nodes[jid]} for jid in G.nodes], + "edges": [{"source": int(a), "target": int(b)} for a, b in sorted(G.edges)], + "adjacency": {str(jid): sorted(map(int, G.neighbors(jid))) for jid in G.nodes}, + } + with open(out_dir / "graph.json", "w", encoding="utf-8") as f: + json.dump(graph_json, f, indent=2) + + # GraphML file + nx.write_graphml(G, out_dir / "graph.graphml") + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--maps-dir", default=".", help="Directory containing Taxi.png, Bus.png, Subway.png, Map.png") + args = p.parse_args() + + maps_dir = Path(args.maps_dir) + + # Find junctions.csv reference + ref_csv_paths = [ + maps_dir / "taxi_cv_out" / "taxi_cv_out" / "junctions.csv", + maps_dir / "taxi_cv_out" / "junctions.csv", + maps_dir / "junctions.csv" + ] + csv_path = None + for p_path in ref_csv_paths: + if p_path.exists(): + csv_path = p_path + break + + if not csv_path: + raise SystemExit("Error: Could not find reference junctions.csv in taxi_cv_out folders or maps-dir.") + + print(f"Loading master junctions from: {csv_path}") + master_juncs = load_master_junctions(csv_path) + print(f"Loaded {len(master_juncs)} reference junctions.") + + # Load reference Taxi image + taxi_img_path = maps_dir / "Taxi.png" + if not taxi_img_path.exists(): + raise SystemExit(f"Error: {taxi_img_path} not found.") + ref_img = cv2.imread(str(taxi_img_path)) + + # Definitions of target maps + targets = [ + {"name": "Taxi.png", "mode": "taxi", "out": "taxi_cv_out"}, + {"name": "Bus.png", "mode": "bus", "out": "bus_cv_out"}, + {"name": "Subway.png", "mode": "subway", "out": "subway_cv_out"}, + {"name": "Map.png", "mode": "map", "out": "map_cv_out"}, + ] + + # We will build and store graphs to merge them for the main Map + graphs = {} + snapped_junctions_dict = {} + + for tgt in targets: + name = tgt["name"] + mode = tgt["mode"] + out_folder = maps_dir / tgt["out"] + img_path = maps_dir / name + + if not img_path.exists(): + print(f"Skipping {name} (file not found).") + continue + + print(f"\nProcessing {name} (mode={mode})...") + img = cv2.imread(str(img_path)) + + # 1. Coordinate alignment & snapping + if name == "Taxi.png": + # For reference map, we can use coordinates directly + # Or snap to ensure they are perfectly centered on local yellow nodes + snapped = [] + for jid, tx, ty, tr in master_juncs: + sx, sy = snap_coordinate(img, tx, ty) + snapped.append((jid, sx, sy, tr)) + else: + # Align using homography relative to Taxi.png + h = compute_homography(ref_img, img) + snapped = [] + for jid, tx, ty, tr in master_juncs: + pt = np.array([tx, ty], dtype=np.float32).reshape(1, 1, 2) + mpt = cv2.perspectiveTransform(pt, h).reshape(2) + sx, sy = snap_coordinate(img, mpt[0], mpt[1]) + snapped.append((jid, sx, sy, tr)) + + snapped_junctions_dict[mode] = snapped + print(f" Mapped and snapped {len(snapped)} junctions.") + + # 2. Build graph (for Taxi, Bus, Subway) + if mode != "map": + G = build_graph(img, snapped, mode) + graphs[mode] = G + print(f" Graph constructed: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges.") + write_outputs(img, snapped, G, out_folder) + print(f" Outputs saved to: {out_folder}") + else: + # Map.png combined graph is the union of Taxi, Bus, Subway graphs + G_map = nx.Graph() + + # Add nodes using the Map.png snapped coordinates + for jid, x, y, r in snapped: + G_map.add_node(jid, x=int(x), y=int(y), r=int(r)) + + # Add edges from all transport graphs + edge_count = 0 + for g_mode, G_transport in graphs.items(): + for u, v in G_transport.edges(): + if not G_map.has_edge(u, v): + G_map.add_edge(u, v) + edge_count += 1 + + graphs["map"] = G_map + print(f" Map graph merged: {G_map.number_of_nodes()} nodes, {G_map.number_of_edges()} edges (union of taxi/bus/subway).") + write_outputs(img, snapped, G_map, out_folder) + print(f" Outputs saved to: {out_folder}") + + print("\nDigitization complete!") + +if __name__ == "__main__": + main() diff --git a/scripts/launch_minicpm_omni.py b/scripts/launch_minicpm_omni.py new file mode 100644 index 0000000000000000000000000000000000000000..a8a323d3646738e0162b1bc1246e86801cacf3b6 --- /dev/null +++ b/scripts/launch_minicpm_omni.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import json +import os +import shutil +import signal +import subprocess +import sys +import time +import urllib.parse +from pathlib import Path + + +def main() -> int: + root = Path(os.environ.get("MINICPM_COMNI_ROOT", "")).expanduser().resolve() + if not root.exists() or not (root / "worker.py").exists() or not (root / "gateway.py").exists(): + print("MINICPM_COMNI_ROOT must point to an OpenBMB/MiniCPM-o-Demo Comni checkout.", file=sys.stderr) + return 2 + + model_dir = Path(os.environ.get("MINICPM_MODEL_DIR", "")).expanduser().resolve() + llm_model = os.environ.get("MINICPM_LLM_MODEL", "").strip() + llama_root = Path(os.environ.get("MINICPM_LLAMACPP_ROOT", "")).expanduser().resolve() + context = int(os.environ.get("MINICPM_CTX_SIZE", "8192")) + gpu_layers_raw = os.environ.get("MINICPM_N_GPU_LAYERS", "auto").strip().lower() + gpu_layers = 99 if gpu_layers_raw == "auto" else int(gpu_layers_raw) + gateway_url = os.environ.get("MINICPM_GATEWAY_URL", "http://127.0.0.1:8006") + gateway_port = urllib.parse.urlparse(gateway_url).port or 8006 + worker_port = gateway_port + 1 + _stop_stale_internal_server(19060, llama_root) + + if not (model_dir / llm_model).exists(): + print(f"Selected model does not exist: {model_dir / llm_model}", file=sys.stderr) + return 2 + if not llama_root.exists(): + print(f"llama.cpp-omni root does not exist: {llama_root}", file=sys.stderr) + return 2 + + config_path = root / "config.json" + example_config_path = root / "config.example.json" + seed_config_path = config_path if config_path.exists() else example_config_path + config = json.loads(seed_config_path.read_text(encoding="utf-8")) if seed_config_path.exists() else {} + config["backend"] = "cpp" + config.setdefault("model", {})["model_path"] = str(model_dir) + config.setdefault("service", {}).update({ + "gateway_port": gateway_port, + "worker_base_port": worker_port, + "num_workers": 1, + }) + config.setdefault("cpp_backend", {}).update({ + "llamacpp_root": str(llama_root), + "model_dir": str(model_dir), + "llm_model": llm_model, + "ctx_size": context, + "n_gpu_layers": gpu_layers, + }) + config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + + python = _external_python(root) + log_dir = root / "tmp" + log_dir.mkdir(exist_ok=True) + env = os.environ.copy() + env["PYTHONPATH"] = str(root) + gpu_device = os.environ.get("MINICPM_GPU_DEVICE", "auto").strip().lower() + + # MiniCPM-o-Demo's cpp_backend defaults to tts_gpu_layers=100 and + # token2wav_device=gpu:0. On a 6 GB card the main LLM already fills VRAM, + # so loading TTS on GPU OOMs and crashes llama-server during omni_init. + # If the user hasn't picked an explicit policy, probe the GPU and keep + # TTS+T2W on CPU whenever the device has less than ~8 GB free — the + # text-generation hot path still runs on GPU, which is what matters. + if "MINICPM_TTS_GPU_LAYERS" not in env or "MINICPM_TOKEN2WAV_DEVICE" not in env: + keep_tts_on_cpu = _tts_should_use_cpu(gpu_device, gpu_layers) + env.setdefault("MINICPM_TTS_GPU_LAYERS", "0" if keep_tts_on_cpu else "100") + env.setdefault("MINICPM_TOKEN2WAV_DEVICE", "cpu" if keep_tts_on_cpu else "gpu:0") + # Honor the picker's device choice. CPU and zero-layer modes blank every + # visibility var so llama.cpp falls back to host execution regardless of + # which backend was compiled in. cuda:N / rocm:N pin the chosen index. + if gpu_device == "cpu" or gpu_layers == 0: + env["CUDA_VISIBLE_DEVICES"] = "" + env["HIP_VISIBLE_DEVICES"] = "" + env["ROCR_VISIBLE_DEVICES"] = "" + elif gpu_device.startswith("cuda:"): + env["CUDA_VISIBLE_DEVICES"] = gpu_device.split(":", 1)[1] + elif gpu_device.startswith("rocm:"): + index = gpu_device.split(":", 1)[1] + env["HIP_VISIBLE_DEVICES"] = index + env["ROCR_VISIBLE_DEVICES"] = index + + worker_log = (log_dir / "phantom_grid_worker.log").open("a", encoding="utf-8") + gateway_log = (log_dir / "phantom_grid_gateway.log").open("a", encoding="utf-8") + worker = subprocess.Popen( + [str(python), "worker.py", "--port", str(worker_port), "--gpu-id", "0", "--worker-index", "0"], + cwd=root, env=env, stdout=worker_log, stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + gateway = subprocess.Popen( + [str(python), "gateway.py", "--port", str(gateway_port), "--workers", f"127.0.0.1:{worker_port}", "--http"], + cwd=root, env=env, stdout=gateway_log, stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + children = [gateway, worker] + + def stop_children(*_: object) -> None: + for child in children: + if child.poll() is None: + child.terminate() + deadline = time.time() + 8 + for child in children: + try: + child.wait(timeout=max(0.1, deadline - time.time())) + except subprocess.TimeoutExpired: + child.kill() + raise SystemExit(0) + + signal.signal(signal.SIGINT, stop_children) + signal.signal(signal.SIGTERM, stop_children) + print(f"MiniCPM-o worker PID {worker.pid}; gateway PID {gateway.pid}", flush=True) + while all(child.poll() is None for child in children): + time.sleep(1) + stop_children() + return 1 + + +def _tts_should_use_cpu(gpu_device: str, gpu_layers: int) -> bool: + # CPU is the right place for TTS when there's no GPU offload at all. + if gpu_device == "cpu" or gpu_layers == 0: + return True + # Probe NVIDIA total VRAM. <8 GB → main LLM fills it; TTS must run on CPU. + nvidia_smi = shutil.which("nvidia-smi") + if not nvidia_smi: + return False + try: + completed = subprocess.run( + [nvidia_smi, "--query-gpu=memory.total", "--format=csv,noheader,nounits"], + capture_output=True, text=True, timeout=4, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False + if completed.returncode != 0: + return False + try: + total_mib = max(int(line.strip()) for line in completed.stdout.splitlines() if line.strip()) + except ValueError: + return False + return total_mib < 8192 + + +def _external_python(root: Path) -> Path: + configured = os.environ.get("MINICPM_COMNI_PYTHON") + candidates = [ + Path(configured) if configured else None, + root / ".venv" / "base" / "Scripts" / "python.exe", + root / ".venv" / "base" / "bin" / "python", + root / ".venv" / "Scripts" / "python.exe", + root / ".venv" / "bin" / "python", + ] + for candidate in candidates: + if candidate and candidate.exists(): + return candidate + return Path(sys.executable) + + +def _stop_stale_internal_server(port: int, llama_root: Path) -> None: + """Clear an orphaned llama-server left by an interrupted Comni worker.""" + if os.name != "nt": + return + escaped_root = str(llama_root).replace("'", "''") + script = ( + f"$root=[IO.Path]::GetFullPath('{escaped_root}'); " + f"Get-NetTCPConnection -State Listen -LocalPort {port} -ErrorAction SilentlyContinue | " + "ForEach-Object { $p=Get-CimInstance Win32_Process -Filter \"ProcessId=$($_.OwningProcess)\"; " + "if ($p -and $p.Name -eq 'llama-server.exe' -and $p.ExecutablePath -and " + "[IO.Path]::GetFullPath($p.ExecutablePath).StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) " + "{ Stop-Process -Id $p.ProcessId -Force } }" + ) + try: + subprocess.run( + ["powershell", "-NoProfile", "-Command", script], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/new_game.py b/scripts/new_game.py new file mode 100644 index 0000000000000000000000000000000000000000..f93c3df820c275bc5992cbba45f612dfaf3ddc62 --- /dev/null +++ b/scripts/new_game.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from config import load_settings +from game.state import CulpritState, GameState +from grid_map.storage import write_json + + +def create_new_game(initial_description: str, starting_junction: int = 100) -> Path: + settings = load_settings() + game_id = datetime.now().strftime("game_%Y%m%d_%H%M%S") + game_dir = settings.games_dir / game_id + state = GameState( + game_id=game_id, + turn_number=1, + max_turns=settings.max_turns, + phase="commissioner_action", + initial_description=initial_description, + culprit=CulpritState( + current_junction=starting_junction, + current_disguise=initial_description, + remaining_disguise_changes=settings.starting_disguise_changes, + ), + ) + write_json(game_dir / "game_state.json", state.to_dict()) + write_json(game_dir / "culprit_private_state.json", state.culprit.__dict__) + for subdir in ("notices", "witnesses", "turns", "logs"): + (game_dir / subdir).mkdir(parents=True, exist_ok=True) + return game_dir + + +if __name__ == "__main__": + path = create_new_game("A nervous-looking person in a grey raincoat carrying a red folder.") + print(f"Created {path}") diff --git a/scripts/provision_local_runtime.py b/scripts/provision_local_runtime.py new file mode 100644 index 0000000000000000000000000000000000000000..9570a0a137e05b7486b3f23f5ef46a99f87e9543 --- /dev/null +++ b/scripts/provision_local_runtime.py @@ -0,0 +1,623 @@ +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import urllib.request +import time +import zipfile +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from llm.devices import TEXT_MODEL # noqa: E402 + +MODEL_REPO = "openbmb/MiniCPM-o-4_5-gguf" +COMNI_REPO = "https://github.com/OpenBMB/MiniCPM-o-Demo.git" +LLAMA_REPO = "https://github.com/tc-mb/llama.cpp-omni.git" +COMNI_ARCHIVE = "https://github.com/OpenBMB/MiniCPM-o-Demo/archive/refs/heads/Comni.zip" +LLAMA_ARCHIVE = "https://github.com/tc-mb/llama.cpp-omni/archive/refs/heads/feat/web-demo.zip" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--runtime-root", required=True) + parser.add_argument( + "--model-file", + default="MiniCPM-o-4_5-Q4_K_M.gguf", + help="The MiniCPM-o-4_5-{quant}.gguf filename in openbmb/MiniCPM-o-4_5-gguf to download.", + ) + args = parser.parse_args() + root = Path(args.runtime_root).resolve() + model_file = args.model_file.strip() or "MiniCPM-o-4_5-Q4_K_M.gguf" + root.mkdir(parents=True, exist_ok=True) + worker_lock = acquire_worker_lock(root / "setup.worker.lock") + if worker_lock is None: + return 0 + status_path = root / "setup_status.json" + log_path = root / "setup.log" + pid_path = root / "setup.pid" + pid_path.write_text(str(os.getpid()), encoding="ascii") + + def report(stage: str, message: str, *, progress: int, state: str = "running") -> None: + payload = { + "state": state, + "stage": stage, + "message": message, + "progress": progress, + "updated_at": time.time(), + } + try: + temporary = status_path.with_suffix(".tmp") + temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + _atomic_replace(temporary, status_path) + except OSError as exc: + # Status updates are best-effort: a stuck AV/indexer/reader race + # must not abort an in-progress install. The next report() refreshes. + _append_log(log_path, f"[status] could not refresh setup_status.json: {exc}\n") + _append_log(log_path, f"[{stage}] {message}\n") + + try: + cmake = find_tool("cmake") + comni = root / "MiniCPM-o-Demo" + llama = root / "llama.cpp-omni" + models = root / "models" / "MiniCPM-o-4_5-gguf" + + report("comni", "Downloading the MiniCPM-o gateway...", progress=5) + download_source(COMNI_ARCHIVE, comni, root / "downloads", report, "comni", 5, 14) + apply_comni_compatibility(comni) + + report("llama", "Downloading llama.cpp-omni...", progress=15) + download_source(LLAMA_ARCHIVE, llama, root / "downloads", report, "llama", 15, 24) + apply_source_compatibility(llama) + + server = find_llama_server(llama) + if server is None: + report("llama", "Building the local llama.cpp server...", progress=25) + build_dir = llama / "build" + if build_dir.exists(): + shutil.rmtree(build_dir) + configure = [str(cmake), "-B", "build", "-DCMAKE_BUILD_TYPE=Release", "-DLLAMA_CURL=OFF"] + if os.name == "nt": + configure.extend(windows_toolchain(root)) + run(configure, llama, log_path, report, "llama", "Configuring the local AI build", 25) + run( + [str(cmake), "--build", "build", "--config", "Release", "--target", "llama-server", "-j"], + llama, log_path, report, "llama", "Compiling the local AI server", 35, + ) + server = find_llama_server(llama) + if server is None: + raise RuntimeError("llama-server was not produced by the build.") + + python = comni_python(comni) + if not python.exists(): + report("python", "Creating the private MiniCPM-o Python environment...", progress=45) + run( + [sys.executable, "-m", "venv", str(python.parent.parent)], + root, log_path, report, "python", "Creating the private Python environment", 45, + ) + marker = comni / ".phantom_grid_dependencies_ready" + if not marker.exists(): + report("python", "Installing MiniCPM-o runtime dependencies...", progress=52) + run( + [str(python), "-m", "pip", "install", "--upgrade", "pip"], + comni, log_path, report, "python", "Updating the private package installer", 52, + ) + run( + [str(python), "-m", "pip", "install", "torch==2.8.0", "torchaudio==2.8.0"], + comni, log_path, report, "python", "Installing PyTorch (large download)", 55, + ) + run( + [str(python), "-m", "pip", "install", "-r", "requirements.txt"], + comni, log_path, report, "python", "Installing MiniCPM-o dependencies", 60, + ) + marker.write_text("ready\n", encoding="ascii") + + report("model", "Downloading the default text model (MiniCPM4.1-8B Q4_K_M, ~4.97 GB)...", progress=62) + download_text_model(root / "models", report) + + report("model", f"Downloading MiniCPM-o model files ({model_file}). This is the large step...", progress=65) + models.mkdir(parents=True, exist_ok=True) + download_model_files(models, report, llm_filename=model_file) + report("complete", "Local AI runtime is installed.", progress=100, state="complete") + pid_path.unlink(missing_ok=True) + release_worker_lock(worker_lock) + return 0 + except Exception as exc: + report("error", str(exc), progress=0, state="error") + pid_path.unlink(missing_ok=True) + release_worker_lock(worker_lock) + return 1 + + +def download_text_model(models_root: Path, report) -> None: + # Fetch the default text-only backend model (OpenBMB MiniCPM4.1-8B Q4_K_M) + # served by a plain llama.cpp server. Resumable single-file download so an + # interrupted setup picks up where it left off. + from huggingface_hub import hf_hub_url, model_info + + repo = TEXT_MODEL["repo"] + filename = TEXT_MODEL["file"] + destination = models_root / TEXT_MODEL["dirname"] + destination.mkdir(parents=True, exist_ok=True) + info = model_info(repo, files_metadata=True) + sibling = next((s for s in info.siblings if s.rfilename == filename), None) + if sibling is None: + available = ", ".join(s.rfilename for s in info.siblings if s.rfilename.endswith(".gguf")) or "none" + raise RuntimeError(f"'{filename}' is not published in {repo}. Available: {available}.") + size = int(sibling.size or 0) + target = destination / filename + if target.exists() and target.stat().st_size == size: + return + partial = target.with_suffix(target.suffix + ".part") + offset = partial.stat().st_size if partial.exists() else 0 + headers = {"User-Agent": "Phantom-Grid/1.0"} + if offset: + headers["Range"] = f"bytes={offset}-" + request = urllib.request.Request(hf_hub_url(repo, filename), headers=headers) + with urllib.request.urlopen(request, timeout=60) as response: + append = offset > 0 and response.status == 206 + if not append: + offset = 0 + with partial.open("ab" if append else "wb") as handle: + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + handle.write(chunk) + offset += len(chunk) + percent = 62 + int((offset / size) * 2) if size else 62 + report( + "model", + f"Downloading {filename} ({offset / 1024**3:.1f} / {size / 1024**3:.1f} GB)...", + progress=min(percent, 64), + ) + if size and partial.stat().st_size != size: + raise RuntimeError(f"Incomplete download for {filename}: {partial.stat().st_size} of {size} bytes.") + _atomic_replace(partial, target) + + +def download_model_files(destination: Path, report, *, llm_filename: str = "MiniCPM-o-4_5-Q4_K_M.gguf") -> None: + from huggingface_hub import hf_hub_url, model_info + + info = model_info(MODEL_REPO, files_metadata=True) + available_llms = { + sibling.rfilename + for sibling in info.siblings + if sibling.rfilename.startswith("MiniCPM-o-4_5-") and sibling.rfilename.endswith(".gguf") + and not any( + sibling.rfilename.startswith(prefix) + for prefix in ("audio/", "vision/", "tts/", "token2wav-gguf/") + ) + } + if llm_filename not in available_llms: + available_list = ", ".join(sorted(available_llms)) or "none" + raise RuntimeError( + f"Selected quantization '{llm_filename}' is not published in {MODEL_REPO}. " + f"Available: {available_list}. Pick another variant in the first-run picker." + ) + wanted = [] + for sibling in info.siblings: + name = sibling.rfilename + if name == llm_filename or ( + name.startswith(("audio/", "vision/", "tts/", "token2wav-gguf/")) and name.endswith(".gguf") + ): + wanted.append((name, int(sibling.size or 0))) + total = sum(size for _, size in wanted) + completed = sum( + min((destination / name).stat().st_size, size) + for name, size in wanted + if (destination / name).exists() + ) + for name, size in wanted: + target = destination / name + if target.exists() and target.stat().st_size == size: + continue + target.parent.mkdir(parents=True, exist_ok=True) + partial = target.with_suffix(target.suffix + ".part") + offset = partial.stat().st_size if partial.exists() else 0 + headers = {"User-Agent": "Phantom-Grid/1.0"} + if offset: + headers["Range"] = f"bytes={offset}-" + request = urllib.request.Request(hf_hub_url(MODEL_REPO, name), headers=headers) + with urllib.request.urlopen(request, timeout=60) as response: + append = offset > 0 and response.status == 206 + if not append: + offset = 0 + with partial.open("ab" if append else "wb") as handle: + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + handle.write(chunk) + offset += len(chunk) + done = completed + min(offset, size) + percent = 65 + int((done / total) * 34) if total else 65 + report( + "model", + f"Downloading {name} ({done / 1024**3:.1f} / {total / 1024**3:.1f} GB)...", + progress=min(percent, 99), + ) + if partial.stat().st_size != size: + raise RuntimeError(f"Incomplete download for {name}: {partial.stat().st_size} of {size} bytes.") + _atomic_replace(partial, target) + completed += size + + +def acquire_worker_lock(path: Path): + handle = path.open("a+b") + handle.seek(0) + if handle.tell() == 0: + handle.write(b"0") + handle.flush() + try: + if os.name == "nt": + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + return handle + except OSError: + handle.close() + return None + + +def release_worker_lock(handle) -> None: + try: + if os.name == "nt": + import msvcrt + + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + finally: + handle.close() + + +def find_tool(name: str) -> Path: + detected = shutil.which(name) + if detected: + return Path(detected) + executable_name = f"{name}.exe" if os.name == "nt" else name + bundled = Path(sys.executable).parent / executable_name + if not bundled.exists(): + raise RuntimeError(f"{name} is required to install the local AI runtime but was not found on PATH.") + return bundled + + +def download_source( + url: str, + destination: Path, + downloads: Path, + report, + stage: str, + progress_start: int, + progress_end: int, +) -> None: + if destination.exists(): + return + downloads.mkdir(parents=True, exist_ok=True) + archive = downloads / f"{destination.name}.zip" + partial = archive.with_suffix(".zip.part") + offset = partial.stat().st_size if partial.exists() else 0 + headers = {"User-Agent": "Phantom-Grid/1.0"} + if offset: + headers["Range"] = f"bytes={offset}-" + request = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(request, timeout=60) as response: + append = offset > 0 and response.status == 206 + if not append: + offset = 0 + remaining = int(response.headers.get("Content-Length") or 0) + total = offset + remaining if remaining else 0 + started = time.monotonic() + last_report = 0.0 + with partial.open("ab" if append else "wb") as handle: + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + handle.write(chunk) + offset += len(chunk) + now = time.monotonic() + if now - last_report >= 1: + fraction = offset / total if total else 0 + progress = progress_start + int(fraction * (progress_end - progress_start)) + report( + stage, + f"Downloading {destination.name} ({offset / 1024**2:.0f} MB" + f"{' / ' + format(total / 1024**2, '.0f') + ' MB' if total else ''}; " + f"{int(now - started)}s)...", + progress=min(progress, progress_end), + ) + last_report = now + _atomic_replace(partial, archive) + report(stage, f"Extracting {destination.name}...", progress=progress_end) + extract_root = downloads / f"{destination.name}-extract" + if extract_root.exists(): + shutil.rmtree(extract_root) + extract_root.mkdir() + with zipfile.ZipFile(archive) as bundle: + bundle.extractall(extract_root) + roots = [item for item in extract_root.iterdir() if item.is_dir()] + if len(roots) != 1: + raise RuntimeError(f"Unexpected source archive layout for {destination.name}.") + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(roots[0]), str(destination)) + shutil.rmtree(extract_root) + + +def run( + command: list[str], + cwd: Path, + log_path: Path, + report, + stage: str, + message: str, + progress: int, +) -> None: + with log_path.open("a", encoding="utf-8") as handle: + handle.write(f"$ {' '.join(command)}\n") + handle.flush() + process = subprocess.Popen(command, cwd=cwd, stdout=handle, stderr=subprocess.STDOUT) + started = time.monotonic() + while process.poll() is None: + elapsed = int(time.monotonic() - started) + report(stage, f"{message}... {elapsed // 60}m {elapsed % 60:02d}s elapsed", progress=progress) + time.sleep(2) + if process.returncode: + raise RuntimeError(f"Command failed ({process.returncode}): {' '.join(command)}. See {log_path}.") + + +def _is_transient_sharing_error(exc: OSError) -> bool: + # Windows ERROR_ACCESS_DENIED (5), ERROR_SHARING_VIOLATION (32), and + # ERROR_LOCK_VIOLATION (33) — what AV, the Search indexer, or a concurrent + # reader produce when they briefly hold a handle on the file. On POSIX + # winerror is None so this is False; os.replace is atomic there. + return getattr(exc, "winerror", None) in (5, 32, 33) + + +def _atomic_replace(source: Path, destination: Path, *, attempts: int = 20) -> None: + # Survives Windows file-sharing races on rename: real-time AV and the + # Search indexer routinely open new files in fresh directories for + # scanning, briefly blocking os.replace. Retries with backoff (~5 s + # budget). POSIX exits on the first iteration. + delay = 0.05 + last_error: OSError | None = None + for _ in range(attempts): + try: + os.replace(source, destination) + return + except OSError as exc: + if not _is_transient_sharing_error(exc): + raise + last_error = exc + time.sleep(delay) + delay = min(delay * 1.6, 0.5) + assert last_error is not None + raise last_error + + +def _append_log(log_path: Path, line: str) -> None: + try: + with log_path.open("a", encoding="utf-8") as handle: + handle.write(line) + except OSError: + pass + + +def _msvc_cuda_args() -> list[str] | None: + # Return cmake configure flags for MSVC + CUDA, or None if either isn't + # available. We probe for both VS BuildTools/Community (via vswhere) and + # the NVIDIA CUDA Toolkit, then point cmake's toolset spec at CUDA's MSBuild + # integration files (which live in extras\visual_studio_integration). This + # avoids the common "No CUDA toolset found" error when CUDA's .props files + # weren't auto-copied into the VS BuildTools BuildCustomizations folder. + if os.name != "nt": + return None + vs_install = _find_visual_studio() + cuda_root = _find_cuda_root() + if vs_install is None or cuda_root is None: + return None + # CMAKE_CUDA_ARCHITECTURES selection: cover the realistic NVIDIA GeForce + # lineup users are likely on. Drop pre-Turing (sm_61) since CUDA 12+ + # builds are noticeably slower and most current GPUs are 75+. + architectures = "75;86;89;90" + cuda_posix = str(cuda_root).replace("\\", "/") + # /Zc:preprocessor switches MSVC's cl.exe to the standards-conforming + # preprocessor. CUDA 13.x CCCL headers (cuda/std/__cccl/preprocessor.h) + # hard-fail compilation under MSVC's traditional preprocessor; passing + # the conforming one through nvcc via -Xcompiler is the canonical fix. + return [ + "-G", "Visual Studio 17 2022", + "-A", "x64", + "-T", f"host=x64,cuda={cuda_posix}", + "-DGGML_CUDA=ON", + f"-DCMAKE_CUDA_ARCHITECTURES={architectures}", + "-DCMAKE_CUDA_FLAGS=-Xcompiler /Zc:preprocessor", + "-DCMAKE_CXX_FLAGS=/Zc:preprocessor", + "-DCMAKE_C_FLAGS=/Zc:preprocessor", + ] + + +def _find_visual_studio() -> Path | None: + program_files_x86 = os.environ.get("ProgramFiles(x86)") or r"C:\Program Files (x86)" + vswhere = Path(program_files_x86) / "Microsoft Visual Studio" / "Installer" / "vswhere.exe" + if not vswhere.exists(): + return None + try: + completed = subprocess.run( + [str(vswhere), "-latest", "-products", "*", "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"], + capture_output=True, text=True, timeout=10, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + install_path = completed.stdout.strip().splitlines() + if not install_path or not install_path[0]: + return None + candidate = Path(install_path[0]) + return candidate if candidate.exists() else None + + +def _find_cuda_root() -> Path | None: + candidate = os.environ.get("CUDA_PATH") + if candidate: + path = Path(candidate) + if (path / "bin" / "nvcc.exe").exists(): + return path + program_files = os.environ.get("ProgramFiles") or r"C:\Program Files" + base = Path(program_files) / "NVIDIA GPU Computing Toolkit" / "CUDA" + if not base.exists(): + return None + versions = sorted( + (entry for entry in base.iterdir() if entry.is_dir() and entry.name.startswith("v")), + key=lambda entry: entry.name, reverse=True, + ) + for version in versions: + if (version / "bin" / "nvcc.exe").exists(): + return version + return None + + +def apply_comni_compatibility(root: Path) -> None: + # MiniCPM-o-Demo hardcodes TTS+T2W on GPU which OOMs on cards with <8 GB + # VRAM once the main LLM has loaded. Re-route the two knobs through env + # vars so launch_minicpm_omni.py can pick CPU TTS for small-VRAM machines. + backend = root / "core" / "processors" / "cpp_backend.py" + if not backend.exists(): + return + source = backend.read_text(encoding="utf-8") + replacements = [ + ( + ' "tts_gpu_layers": 100,\n', + ' "tts_gpu_layers": int(os.environ.get("MINICPM_TTS_GPU_LAYERS", "100")),\n', + ), + ( + ' "token2wav_device": "gpu:0",\n', + ' "token2wav_device": os.environ.get("MINICPM_TOKEN2WAV_DEVICE", "gpu:0"),\n', + ), + ] + changed = source + for old, new in replacements: + if new not in changed: + changed = changed.replace(old, new) + if changed != source: + backend.write_text(changed, encoding="utf-8") + + +def apply_source_compatibility(root: Path) -> None: + header = root / "tools" / "omni" / "omni.h" + if not header.exists(): + return + text = header.read_text(encoding="utf-8") + old = "// Windows compatibility: pid_t is not defined on MSVC\n#ifdef _WIN32\n typedef int pid_t;\n#endif" + prior = "// pid_t is absent in MSVC, but is supplied by Zig/Clang on Windows.\n#if defined(_WIN32) && defined(_MSC_VER)\n typedef int pid_t;\n#endif" + new = "// pid_t is absent in MSVC, but is supplied by Zig/Clang on Windows.\n#if defined(_WIN32) && defined(_MSC_VER)\n typedef int pid_t;\n#elif defined(_WIN32)\n #include \n#endif" + updated = text.replace(old, new).replace(prior, new) + if updated != text: + header.write_text(updated, encoding="utf-8") + + replacements = { + # omni.cpp needs STB_IMAGE_IMPLEMENTATION so stbi_load_from_memory has + # a body when omni.dll links. Earlier versions of this script stripped + # the define (it doubled with mtmd-helper.cpp under Zig+Clang), but + # under MSVC each translation unit needs its own copy or the omni + # target hits LNK2019 on stbi_*. + root / "tools" / "omni" / "audition.cpp": [ + ("bool preprocess_audio(\n", "bool preprocess_audio_omni(\n"), + ("whisper_preprocessor::preprocess_audio(\n", "whisper_preprocessor::preprocess_audio_omni(\n"), + ], + root / "tools" / "omni" / "audition.h": [ + ("bool preprocess_audio(\n", "bool preprocess_audio_omni(\n"), + ], + root / "tools" / "omni" / "omni-impl.h": [("g_logger_state", "omni_g_logger_state")], + root / "tools" / "omni" / "vision.cpp": [("g_logger_state", "omni_g_logger_state")], + } + for path, edits in replacements.items(): + if not path.exists(): + continue + source = path.read_text(encoding="utf-8") + changed = source + for old_text, new_text in edits: + if old_text == "g_logger_state" and "omni_g_logger_state" in changed: + continue + changed = changed.replace(old_text, new_text) + if changed != source: + path.write_text(changed, encoding="utf-8") + + audition = root / "tools" / "omni" / "audition.cpp" + if audition.exists(): + source = audition.read_text(encoding="utf-8") + if "#define MINIAUDIO_IMPLEMENTATION" not in source: + source = source.replace("#ifndef OMNI_AUDIO_DEBUG", "#define MINIAUDIO_IMPLEMENTATION\n#ifndef OMNI_AUDIO_DEBUG", 1) + if "#define ma_atomic_global_lock omni_ma_atomic_global_lock" not in source: + source = source.replace( + "#define MINIAUDIO_IMPLEMENTATION", + "#define ma_atomic_global_lock omni_ma_atomic_global_lock\n#define MINIAUDIO_IMPLEMENTATION", + 1, + ) + audition.write_text(source, encoding="utf-8") + + +def find_llama_server(root: Path) -> Path | None: + candidates = ( + root / "build" / "bin" / "Release" / "llama-omni-server.exe", + root / "build" / "bin" / "llama-omni-server.exe", + root / "build" / "bin" / "llama-omni-server", + root / "build" / "bin" / "Release" / "llama-server.exe", + root / "build" / "bin" / "llama-server.exe", + root / "build" / "bin" / "llama-server", + ) + return next((path for path in candidates if path.exists()), None) + + +def windows_toolchain(root: Path) -> list[str]: + # Prefer MSVC + CUDA when both are present — that's the only path to a + # GPU-accelerated llama-server on Windows. Zig+Clang is a CPU-only fallback + # for machines without VS BuildTools / NVIDIA CUDA installed. + cuda_args = _msvc_cuda_args() + if cuda_args is not None: + return cuda_args + import ziglang + + zig = Path(ziglang.__file__).parent / "zig.exe" + ninja = find_tool("ninja") + wrappers = root / "toolchain" + wrappers.mkdir(parents=True, exist_ok=True) + cc = wrappers / "zig-cc.cmd" + cxx = wrappers / "zig-cxx.cmd" + ar = wrappers / "zig-ar.cmd" + ranlib = wrappers / "zig-ranlib.cmd" + cc.write_text(f'@"{zig}" cc %*\n', encoding="ascii") + cxx.write_text(f'@"{zig}" c++ %*\n', encoding="ascii") + ar.write_text(f'@"{zig}" ar %*\n', encoding="ascii") + ranlib.write_text(f'@"{zig}" ranlib %*\n', encoding="ascii") + return [ + "-G", "Ninja", + f"-DCMAKE_MAKE_PROGRAM={ninja}", + f"-DCMAKE_C_COMPILER={cc}", + f"-DCMAKE_CXX_COMPILER={cxx}", + f"-DCMAKE_AR={ar}", + f"-DCMAKE_RANLIB={ranlib}", + ] + + +def comni_python(root: Path) -> Path: + if os.name == "nt": + return root / ".venv" / "base" / "Scripts" / "python.exe" + return root / ".venv" / "base" / "bin" / "python" + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_tts_lang.py b/scripts/smoke_tts_lang.py new file mode 100644 index 0000000000000000000000000000000000000000..43c6303c39843fba3626649a18c47ac5588c42b8 --- /dev/null +++ b/scripts/smoke_tts_lang.py @@ -0,0 +1,105 @@ +"""Compare TTS output language across three reference-audio configurations. + +The upstream MiniCPM-o docs say there is no `language` parameter; output language +is steered by (a) the system-prompt template (we already pass lang=en) and (b) +the *reference audio* used for voice cloning. This script runs the same English +prompt under three configurations and scans the worker log for CJK code points +so we can see which reference, if any, actually produces English TTS. + +Configs: + 1. No ref_audio_path — baseline (previous test) + 2. data/voices/voice_01.wav — locally-generated + Windows-TTS English voice + 3. runtime/MiniCPM-o-Demo/assets/ref_audio/ + ref_en_dlc_1.wav — upstream-shipped English + reference clip +""" + +from __future__ import annotations + +import re +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) + +from llm.omni_client import OmniClient # noqa: E402 + +WORKER_LOG = REPO / "runtime" / "MiniCPM-o-Demo" / "tmp" / "phantom_grid_worker.log" +LLM_TTS_LINE = re.compile(r"LLM->TTS:\s+text='([^']*)'") +CJK_RANGE = re.compile(r"[一-鿿㐀-䶿]") +CJK_ESCAPE = re.compile(r"\\u(?:[4-9][0-9a-fA-F]{3}|[a-fA-F][0-9a-fA-F]{3})") + +CONFIGS = [ + ("no reference", None), + ("local voice_01.wav", REPO / "data" / "voices" / "voice_01.wav"), + ("upstream ref_en_dlc_1.wav", + REPO / "runtime" / "MiniCPM-o-Demo" / "assets" / "ref_audio" / "ref_en_dlc_1.wav"), +] + +SYSTEM = ( + "You are a witness in a noir detective game. Reply in one short English " + "sentence. Speak only English." +) +USER = "Did you see anything unusual at the canal last night?" + + +def tts_chunks_since(offset: int) -> tuple[list[str], int]: + if not WORKER_LOG.exists(): + return [], offset + with WORKER_LOG.open("rb") as fh: + fh.seek(offset) + chunk = fh.read() + new_offset = fh.tell() + texts: list[str] = [] + for line in chunk.decode("utf-8", errors="replace").splitlines(): + match = LLM_TTS_LINE.search(line) + if match: + texts.append(match.group(1)) + return texts, new_offset + + +def main() -> int: + client = OmniClient.from_settings() + if not client.health(timeout=5.0).get("ready"): + print("gateway not ready"); return 1 + + print(f"{'config':32s} {'elapsed':>9s} {'chunks':>7s} {'CJK':>5s} example") + print("-" * 110) + results = [] + for label, ref_path in CONFIGS: + if ref_path is not None and not ref_path.exists(): + print(f"{label:32s} SKIP (ref not found: {ref_path})") + continue + log_offset = WORKER_LOG.stat().st_size if WORKER_LOG.exists() else 0 + start = time.perf_counter() + resp = client.chat( + SYSTEM, USER, + task="witness", + tts=True, + temperature=0.4, + ref_audio_path=str(ref_path) if ref_path else None, + ) + elapsed = time.perf_counter() - start + chunks, _ = tts_chunks_since(log_offset) + cjk_chunks = [c for c in chunks if CJK_RANGE.search(c) or CJK_ESCAPE.search(c)] + example = (cjk_chunks[0] if cjk_chunks else (chunks[0] if chunks else ""))[:60] + print(f"{label:32s} {elapsed:8.2f}s {len(chunks):7d} {len(cjk_chunks):5d} {example}") + results.append((label, len(chunks), len(cjk_chunks))) + + print() + print("verdict:") + for label, total, cjk in results: + if total == 0: + print(f" {label}: no TTS chunks produced — ambiguous") + elif cjk == 0: + print(f" {label}: PASS — English TTS") + else: + print(f" {label}: FAIL — {cjk}/{total} chunks contained CJK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_witness.py b/scripts/smoke_witness.py new file mode 100644 index 0000000000000000000000000000000000000000..fdc3e5a036dc06d85357ae38371e5e4737ad5e45 --- /dev/null +++ b/scripts/smoke_witness.py @@ -0,0 +1,126 @@ +"""End-to-end smoke test for the witness chat path. + +Bypasses the game state machine and hits the same OmniClient.chat() entrypoint +the witness UI uses so we can: + + (1) confirm the *default* witness path (TTS off) is fast on the GPU, + (2) document the *opt-in* TTS path's current behaviour by scanning the worker + log for Mandarin characters that the LLM->TTS pipeline emits (those bytes + never reach the websocket "text" field, so we have to read the log). + +Writes the resulting wav to runtime/tmp/smoke_witness.wav for manual playback. +""" + +from __future__ import annotations + +import base64 +import re +import struct +import sys +import time +import wave +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO)) + +from llm.omni_client import OmniClient # noqa: E402 + + +WORKER_LOG = REPO / "runtime" / "MiniCPM-o-Demo" / "tmp" / "phantom_grid_worker.log" +LLM_TTS_LINE = re.compile(r"LLM->TTS:\s+text='([^']*)'") +# Worker log stores non-ASCII as Python-style \uXXXX escapes, so we have to +# match both literal CJK code points and the escape form. +CJK_RANGE = re.compile(r"[一-鿿㐀-䶿]") +CJK_ESCAPE = re.compile(r"\\u(?:[4-9][0-9a-fA-F]{3}|[a-fA-F][0-9a-fA-F]{3})") + + +def time_call(label, fn): + print(f"== {label}") + start = time.perf_counter() + out = fn() + elapsed = time.perf_counter() - start + print(f" elapsed: {elapsed:.2f}s") + return out, elapsed + + +def tail_llm_tts_text(since_offset: int) -> tuple[list[str], int]: + if not WORKER_LOG.exists(): + return [], since_offset + with WORKER_LOG.open("rb") as fh: + fh.seek(since_offset) + chunk = fh.read() + new_offset = fh.tell() + texts: list[str] = [] + for line in chunk.decode("utf-8", errors="replace").splitlines(): + match = LLM_TTS_LINE.search(line) + if match: + texts.append(match.group(1)) + return texts, new_offset + + +def main() -> int: + client = OmniClient.from_settings() + health = client.health(timeout=5.0) + print("gateway reachable:", health.get("reachable"), "ready:", health.get("ready")) + if not health.get("ready"): + print("gateway not ready; aborting") + return 1 + + system_text = ( + "You are a witness in a noir detective game. Reply in one short English " + "sentence. Do not use Chinese characters." + ) + user_text = "Did you see anything unusual at the canal last night?" + + log_offset = WORKER_LOG.stat().st_size if WORKER_LOG.exists() else 0 + + text_resp, text_elapsed = time_call( + "text-only chat (default app path, TTS off)", + lambda: client.chat(system_text, user_text, task="witness", tts=False, temperature=0.4), + ) + print(" text:", text_resp.text[:240]) + + short_user = "Reply in one short English sentence and nothing else." + tts_resp, tts_elapsed = time_call( + "chat + TTS (opt-in, audio_assistant mode)", + lambda: client.chat(system_text, short_user, task="witness", tts=True, temperature=0.4), + ) + print(" text:", tts_resp.text[:240]) + print(" audio sample rate:", tts_resp.audio_sample_rate) + print(" audio b64 length:", len(tts_resp.audio_data or "")) + + if tts_resp.audio_data: + pcm = base64.b64decode(tts_resp.audio_data) + wav_path = REPO / "runtime" / "tmp" / "smoke_witness.wav" + wav_path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(wav_path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(tts_resp.audio_sample_rate or 24000) + if len(pcm) % 4 == 0 and pcm[:4] != b"RIFF": + floats = struct.unpack(f"<{len(pcm) // 4}f", pcm) + pcm = b"".join(struct.pack("TTS lines the worker emitted during + # this run and look for CJK code points in the text the model produced. + tts_texts, _ = tail_llm_tts_text(log_offset) + cjk_lines = [t for t in tts_texts if CJK_RANGE.search(t) or CJK_ESCAPE.search(t)] + print("=" * 60) + print(f"text-only elapsed : {text_elapsed:6.2f}s (the *default* app path)") + print(f"chat+TTS elapsed : {tts_elapsed:6.2f}s (opt-in only)") + print(f"LLM->TTS chunks seen: {len(tts_texts)}") + print(f" chunks w/ CJK : {len(cjk_lines)}") + if cjk_lines: + print(f" example CJK chunk : {cjk_lines[0][:120]}") + print() + fast_default = text_elapsed < 15 + print("default path fast? :", "PASS" if fast_default else "FAIL") + print("opt-in TTS English? :", "PASS" if not cjk_lines else "EXPECTED-FAIL (audio_assistant model is Chinese-prior)") + return 0 if fast_default else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_map_atlas.py b/scripts/validate_map_atlas.py new file mode 100644 index 0000000000000000000000000000000000000000..1bafb5d07148527095bbdf7f6fac0fd2d77027b9 --- /dev/null +++ b/scripts/validate_map_atlas.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from grid_map.atlas import validate_map_atlas + + +def main() -> int: + errors = validate_map_atlas() + if errors: + for error in errors: + print(f"ERROR: {error}") + return 1 + print("Map atlas is valid.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ship_space.sh b/ship_space.sh new file mode 100644 index 0000000000000000000000000000000000000000..4455d51248d3f68fc07c542691a00f5b3a880bec --- /dev/null +++ b/ship_space.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +############################################################################### +# ship_space.sh — one-shot shipper for Phantom Grid -> Hugging Face Space. +# +# Copy this whole folder to a Linux machine (or run under WSL) and run: +# +# export HF_TOKEN=hf_xxx_your_WRITE_token # must be a WRITE token +# ./ship_space.sh +# +# It will: +# 1. Preflight: check tooling + that the HF token can WRITE. +# 2. (optional) Build the Docker image locally and smoke-test it. +# 3. Create the Space build-small-hackathon/phantom-grid (SDK: docker). +# 4. Upload the app (excluding .venv/runtime/etc) + the Space README. +# 5. Print the Space URL and the remaining manual steps. +# +# Flags: +# --build Build the Docker image locally before pushing. +# --smoke Build + run the container and probe health (implies --build). +# --dry-run Do everything EXCEPT create_repo / upload (no network writes). +# --no-push Alias for --dry-run. +# -h | --help Show this help. +# +# Env overrides: +# HF_TOKEN write token (required to push) +# HF_ORG default: build-small-hackathon +# HF_SPACE default: phantom-grid +############################################################################### +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$HERE" + +HF_ORG="${HF_ORG:-build-small-hackathon}" +HF_SPACE="${HF_SPACE:-phantom-grid}" +REPO_ID="${HF_ORG}/${HF_SPACE}" +IMAGE_TAG="phantom-grid:local" + +DO_BUILD=0 +DO_SMOKE=0 +DRY_RUN=0 + +for arg in "$@"; do + case "$arg" in + --build) DO_BUILD=1 ;; + --smoke) DO_SMOKE=1; DO_BUILD=1 ;; + --dry-run|--no-push) DRY_RUN=1 ;; + -h|--help) + sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) echo "Unknown flag: $arg" >&2; exit 2 ;; + esac +done + +log() { printf '\033[1;36m[ship]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[ship] WARN:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[ship] ERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +############################################################################### +# 1. Preflight +############################################################################### +log "Preflight checks..." +command -v python3 >/dev/null || die "python3 is required." + +# huggingface_hub is needed to create + upload the Space. +if ! python3 -c "import huggingface_hub" 2>/dev/null; then + log "Installing huggingface_hub..." + python3 -m pip install --quiet --upgrade "huggingface_hub>=1.2" +fi + +# Validate required deliverable files exist. +for f in Dockerfile entrypoint.sh .dockerignore requirements-space.txt README_SPACE.md app.py; do + [ -f "$f" ] || die "Missing required file: $f" +done +log "All required deployment files present." + +# Validate the app + entrypoint are syntactically sound before shipping. +python3 -m py_compile app.py config/settings.py grid_map/map_loader.py llm/omni_client.py \ + || die "Python syntax check failed." +bash -n entrypoint.sh || die "entrypoint.sh has a syntax error." +log "Syntax checks passed." + +############################################################################### +# 2. Optional local Docker build / smoke test +############################################################################### +if [ "$DO_BUILD" = 1 ]; then + command -v docker >/dev/null || die "--build requested but docker is not installed." + log "Building Docker image ${IMAGE_TAG} (downloads the GGUF — this is slow)..." + docker build -t "$IMAGE_TAG" . + log "Docker build succeeded." +fi + +if [ "$DO_SMOKE" = 1 ]; then + log "Smoke-testing the container..." + CID="$(docker run -d -p 7860:7860 "$IMAGE_TAG")" + cleanup_smoke() { docker rm -f "$CID" >/dev/null 2>&1 || true; } + trap cleanup_smoke EXIT + log "Container ${CID:0:12} started; waiting for the app to answer on :7860..." + ok=0 + for i in $(seq 1 150); do + if curl -sf "http://127.0.0.1:7860/api/snapshot" >/dev/null 2>&1; then ok=1; break; fi + if ! docker ps -q --no-trunc | grep -q "$CID"; then + docker logs "$CID" | tail -40 >&2 + die "Container exited during smoke test." + fi + sleep 4 + done + [ "$ok" = 1 ] || { docker logs "$CID" | tail -40 >&2; die "App did not become healthy in time."; } + log "Smoke test passed: /api/snapshot responded." + cleanup_smoke + trap - EXIT +fi + +############################################################################### +# 3 + 4. Create the Space and upload +############################################################################### +if [ "$DRY_RUN" = 1 ]; then + log "--dry-run: skipping create_repo + upload. Everything else passed." + log "Would push to: https://huggingface.co/spaces/${REPO_ID}" + exit 0 +fi + +[ -n "${HF_TOKEN:-}" ] || die "HF_TOKEN is not set. Export a WRITE token: export HF_TOKEN=hf_..." + +log "Creating + uploading Space ${REPO_ID} ..." +HF_ORG="$HF_ORG" HF_SPACE="$HF_SPACE" python3 - <<'PY' +import os, sys +from huggingface_hub import HfApi + +token = os.environ["HF_TOKEN"] +org = os.environ["HF_ORG"] +space = os.environ["HF_SPACE"] +repo_id = f"{org}/{space}" +api = HfApi(token=token) + +# Verify the token can write. +who = api.whoami() +perm = who.get("auth", {}).get("accessToken", {}).get("role") or who.get("auth", {}).get("type") +print(f"[ship] Logged in as: {who.get('name')} (token role: {perm})") +if perm == "read": + sys.exit("[ship] ERROR: this is a READ token. Create a WRITE token at " + "https://huggingface.co/settings/tokens and re-export HF_TOKEN.") + +api.create_repo(repo_id=repo_id, repo_type="space", space_sdk="docker", exist_ok=True) +print(f"[ship] Space ready: https://huggingface.co/spaces/{repo_id}") + +ignore = [ + ".git*", ".venv/*", "venv/*", "runtime/*", "tools/*", + "**/__pycache__/*", "*.pyc", "tmp/*", "*.log", "*.err.log", + "data/backups/*", "data/games/*", "data/archives/*", + "data/raw/archives/*", "data/*.log", "data/*.png", + "run_game.ps1", "run_game.cmd", + "README.md", "README_SPACE.md", "C:*", +] +api.upload_folder( + repo_id=repo_id, repo_type="space", folder_path=".", + ignore_patterns=ignore, commit_message="Ship Phantom Grid Docker Space", +) +# The Space README (with the docker frontmatter + track tag) goes in as README.md. +api.upload_file( + path_or_fileobj="README_SPACE.md", path_in_repo="README.md", + repo_id=repo_id, repo_type="space", commit_message="Add Space README", +) +print(f"[ship] Upload complete. Build will start automatically.") +print(f"[ship] Watch the build: https://huggingface.co/spaces/{repo_id}?logs=build") +PY + +log "Done. Remaining manual steps:" +log " - Record the demo video and paste its link into README_SPACE.md (then re-run, or edit on the Space)." +log " - Publish the social post and add its link too." +log " - Confirm the Space build goes RUNNING and a new case starts." diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..df02c4fdbb48e9763b373963f1a7999903e86887 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,2 @@ +"""UI components for the Phantom Grid investigation console.""" + diff --git a/ui/components.py b/ui/components.py new file mode 100644 index 0000000000000000000000000000000000000000..475113b759b16c1b3bb6a08f7c2a70a92f93e71c --- /dev/null +++ b/ui/components.py @@ -0,0 +1,2 @@ +"""Shared Gradio component helpers will live here as the console grows.""" + diff --git a/ui/game_log_panel.py b/ui/game_log_panel.py new file mode 100644 index 0000000000000000000000000000000000000000..d49938f5f92dbf4617b7e1498118d93c4bf0a7c3 --- /dev/null +++ b/ui/game_log_panel.py @@ -0,0 +1,2 @@ +"""Game log rendering will live here.""" + diff --git a/ui/map_view.py b/ui/map_view.py new file mode 100644 index 0000000000000000000000000000000000000000..32be3e32a3eb9cc1d73089272d06c3833ce563ce --- /dev/null +++ b/ui/map_view.py @@ -0,0 +1,2 @@ +"""Map layer rendering and click handling will live here.""" + diff --git a/ui/notice_panel.py b/ui/notice_panel.py new file mode 100644 index 0000000000000000000000000000000000000000..e4a55fbbb696b10297ba19124df8bde2ef84eb58 --- /dev/null +++ b/ui/notice_panel.py @@ -0,0 +1,2 @@ +"""Lookout notice controls will live here.""" + diff --git a/ui/police_action_panel.py b/ui/police_action_panel.py new file mode 100644 index 0000000000000000000000000000000000000000..4f0604a957e99e83bdfdb0b570bae325b77177c1 --- /dev/null +++ b/ui/police_action_panel.py @@ -0,0 +1,2 @@ +"""Route block and junction check controls will live here.""" + diff --git a/ui/styles.css b/ui/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..61cee7e8ae6e6a56caf3c8feec6a0592ec79ff17 --- /dev/null +++ b/ui/styles.css @@ -0,0 +1,2 @@ +/* Reserved for the investigation console styling. */ + diff --git a/ui/web/index.html b/ui/web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..f1ea70b11543d85cc3cfc829b16fb5fde5e919ab --- /dev/null +++ b/ui/web/index.html @@ -0,0 +1,400 @@ + + + + + + Phantom Grid + + + +
+
+
+ +
+ +
+

Phantom Grid

+

Catch John Doe before he vanishes again!

+
+ +
+ Turn + - + Evening +
+ + + +
+ +
+ + +
+
+

Drag Tactics Onto The Map

+
+
+ +
+ +
+
+ London junction map +
+
+
+
+
+ + 145% + + + + + + + +
+
Drop tactics on junctions. Drag the map to navigate.
+
+
+
+
+ + +
+ +
Opening the board...
+
+ +
+
+ Phantom Grid Local AI +

Preparing Your Investigation Desk

+

Checking the bundled llama.cpp runtime and MiniCPM-o model...

+ + + + + Checking setup + + +
+
+ + + + +
+
+
+ Lantern Watch Bureau / Field Manual +

How To Play

+
+ +
+
+
+ +
+
+ +

+
    +
    +
    +
    + +
    + + + +
    +
    +
    +
    + + +
    +
    +
    + Lantern Watch Bureau / Priority Dossier +

    A New Case

    +

    +
    + Case Open +
    +
    +
    + 01 + The Crime +

    +

    +
    Stolen
    Victim
    +
    +
    + 02 + The Thief + Current suspect portrait +

    +

    +
    +
    + 03 + The Trail +

    Last Seen

    +
      +
      +
      +
      +

      Study the trail. The thief is already moving.

      + +
      +
      +
      + + +
      +
      +

      Settings

      + +
      + +
      + + + + + + + + + + + + + + + + +
      + +

      AI backend status unknown.

      +
      + + + + +
      +
      +
      + + +
      +
      +
      Public appeal at selected junction

      Issue Public Notice

      + +
      + +

      The wording controls which existing witnesses recognize the appeal.

      +
      + + +
      +
      +
      + + +
      +
      +
      + Witness Interview +

      Witness

      +

      +
      +
      Text ready
      +
      +
      +
      +
      +
      + + + + +
      +
      + + +
      +
      +
      +
      + + +
      +
      Private Case Record

      John Doe: Turn-by-Turn Story

      +
      +
      +
      +
      + + + + diff --git a/ui/web/static/app.css b/ui/web/static/app.css new file mode 100644 index 0000000000000000000000000000000000000000..46a1479404b31d8c29e686312803d87a378272bc --- /dev/null +++ b/ui/web/static/app.css @@ -0,0 +1,1711 @@ +:root { + color-scheme: dark; + --teal: #073f42; + --teal-dark: #06292a; + --gold: #d6a33c; + --gold-bright: #ffd46f; + --paper: #f2d99c; + --paper-deep: #c9944d; + --ink: #2a160b; + --red: #a42b21; + --blue: #10537d; + --green: #176e5e; + --shadow: 0 18px 48px rgba(0, 0, 0, 0.42); +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + color: #fff0bc; + font-family: Georgia, "Times New Roman", serif; + background: + linear-gradient(90deg, rgba(6, 41, 42, 0.9), rgba(65, 36, 10, 0.65)), + #241508; + overflow: hidden; +} + +button, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: default; +} + +.game-board { + width: min(1920px, 100vw); + height: 100vh; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + margin: 0 auto; + padding: 0 0 8px; + background: + repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 18px), + linear-gradient(180deg, #0c4141 0 15%, #261307 15% 100%); + border: 3px solid #8e5f18; + box-shadow: inset 0 0 0 3px #e0a83d, var(--shadow); +} + +.top-banner { + height: 128px; + display: grid; + grid-template-columns: 288px minmax(0, 1fr) 172px 94px; + align-items: stretch; + gap: 10px; + padding: 6px 10px; + background: + linear-gradient(180deg, rgba(255, 233, 145, 0.16), transparent 32%), + linear-gradient(90deg, #063538, #07484b 52%, #063538); + border-bottom: 5px solid #b47b25; + box-shadow: inset 0 -2px 0 #ffd476; +} + +.bureau-crest { + position: relative; + display: grid; + place-items: center; + overflow: hidden; +} + +.bureau-crest img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: fill; +} + +.title-panel { + position: relative; + display: grid; + place-items: center; + align-content: center; + min-width: 0; +} + +.title-panel:before, +.title-panel:after { + content: ""; + position: absolute; + top: 58%; + width: 120px; + height: 11px; + background: linear-gradient(90deg, transparent, var(--gold-bright), transparent); +} + +.title-panel:before { + left: 34px; +} + +.title-panel:after { + right: 34px; +} + +.title-panel h1 { + margin: 0; + color: #ffc85f; + font-size: clamp(3.4rem, 7vw, 6.3rem); + line-height: 0.86; + letter-spacing: 0; + text-transform: uppercase; + text-shadow: + 0 3px 0 #5f3209, + 0 6px 0 #171008, + 0 0 18px rgba(255, 214, 105, 0.45); + white-space: nowrap; +} + +.title-panel p { + margin: 10px 0 0; + color: #ffd46f; + font-size: 1.16rem; + font-weight: 950; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.title-panel h1[contenteditable="true"], +.title-panel p[contenteditable="true"] { + max-width: 100%; + outline: 0; + cursor: text; +} + +.title-panel h1[contenteditable="true"]:focus, +.title-panel p[contenteditable="true"]:focus { + text-decoration: underline; + text-decoration-color: rgba(255, 212, 111, 0.8); + text-underline-offset: 0.12em; +} + +.turn-panel, +.gear-button { + border: 4px solid #b98127; + background: + linear-gradient(180deg, rgba(255, 213, 109, 0.15), transparent), + #08393c; + box-shadow: inset 0 0 0 2px #52320b, 0 4px 0 #301a05; +} + +.turn-panel { + display: grid; + place-items: center; + align-content: center; + color: #ffd46f; + text-transform: uppercase; +} + +.turn-panel span, +.turn-panel small { + font-weight: 950; + letter-spacing: 0.08em; +} + +.turn-panel strong { + color: #fff0bc; + font-size: 2.45rem; + line-height: 1; +} + +.gear-button { + display: grid; + place-items: center; + color: #ffdf86; + font-size: 3.3rem; +} + +.gear-button.muted { + opacity: 0.62; +} + +.table-grid { + height: auto; + min-height: 0; + display: grid; + grid-template-columns: 286px minmax(0, 1fr) 334px; + gap: 8px; + padding: 8px; +} + +.left-rail, +.right-rail, +.center-stage { + min-height: 0; +} + +.left-rail, +.right-rail { + display: grid; + gap: 10px; +} + +.left-rail { + grid-template-rows: minmax(0, 1fr) 92px 62px 46px 42px; +} + +.right-rail { + grid-template-rows: minmax(0, 0.92fr) minmax(0, 1fr); +} + +.wanted-card, +.active-units, +.lookout-board, +.statements-panel { + border: 4px solid #b98127; + box-shadow: inset 0 0 0 2px #55320b, var(--shadow); +} + +.wanted-card { + min-height: 0; + display: grid; + justify-items: center; + gap: 7px; + padding: 14px; + color: var(--ink); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.45), transparent 24%), + var(--paper); +} + +.wanted-card h2 { + margin: 0; + color: #8b2217; + font-size: 3rem; + line-height: 0.95; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.suspect-image { + width: 198px; + height: min(224px, 28vh); + object-fit: cover; + border: 2px solid #9e6d2c; + box-shadow: inset 0 0 0 3px rgba(255, 239, 185, 0.5); +} + +.wanted-card dl { + width: 100%; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px 8px; + margin: 0; +} + +.wanted-card dt { + color: #251304; + font-weight: 950; + text-transform: uppercase; +} + +.wanted-card dd { + min-width: 0; + margin: 0; + color: #5c1f13; + font-weight: 800; + line-height: 1.02; +} + +#wantedLastSeen { + color: #8b2217; +} + +.wanted-card strong { + color: #4b2109; + font-size: 1.55rem; +} + +.wanted-card small { + color: #4b2109; + font-weight: 950; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.active-units { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto minmax(0, 1fr); + align-items: center; + gap: 4px 8px; + padding: 9px 10px; + background: + repeating-linear-gradient(170deg, rgba(255, 255, 255, 0.04) 0 1px, transparent 1px 14px), + var(--teal); +} + +.active-units h2, +.lookout-board h2, +.statements-panel h2 { + margin: 0; + color: #ffd46f; + font-size: 1.3rem; + letter-spacing: 0.08em; + text-transform: uppercase; + text-shadow: 0 2px 0 #311a06; +} + +.active-units h2 { + grid-column: 1 / -1; + font-size: 1.08rem; +} + +.unit-row { + min-width: 0; + display: grid; + grid-template-columns: repeat(6, 15px); + gap: 4px; + align-content: center; +} + +.unit-dot { + width: 15px; + height: 19px; + display: block; + border-radius: 50% 50% 7px 7px; + border: 1px solid rgba(255, 239, 185, 0.75); +} + +.unit-dot.ready { + background: + radial-gradient(circle at 50% 24%, #a6e4ff 0 3px, transparent 4px), + linear-gradient(135deg, #1784bd, #0b4265); +} + +.unit-dot.used { + background: + radial-gradient(circle at 50% 24%, #c2c2b6 0 3px, transparent 4px), + linear-gradient(135deg, #777d77, #3d413d); + opacity: 0.72; +} + +.active-units strong { + justify-self: end; + color: #ffdf86; + font-size: 1.08rem; + white-space: nowrap; +} + +.advance-button, +.new-case-button, +.search-button { + min-height: 50px; + color: #fff4c2; + font-size: 1.45rem; + font-weight: 950; + text-transform: uppercase; + border: 3px solid #a97522; + box-shadow: inset 0 0 0 2px rgba(255, 239, 185, 0.24), 0 5px 0 #2c1907; +} + +.advance-button { + background: linear-gradient(180deg, #1d6796, #0c436e); +} + +.advance-button.processing, +.new-case-button.processing { + background: linear-gradient(180deg, #c8862c, #7a4e0d); + color: #1a0e02; + cursor: progress; + animation: advance-pulse 1.1s ease-in-out infinite; +} + +@keyframes advance-pulse { + 0%, 100% { box-shadow: inset 0 0 0 2px rgba(255, 239, 185, 0.24), 0 5px 0 #2c1907, 0 0 0 0 rgba(255, 215, 100, 0.0); } + 50% { box-shadow: inset 0 0 0 2px rgba(255, 239, 185, 0.6), 0 5px 0 #2c1907, 0 0 16px 4px rgba(255, 215, 100, 0.55); } +} + +.new-case-button { + background: linear-gradient(180deg, #25765f, #104d43); +} + +.search-button { + background: linear-gradient(180deg, #c33d32, #8b2119); +} + +.center-stage { + display: grid; + grid-template-rows: 130px minmax(0, 1fr); + gap: 8px; +} + +.tactic-strip { + padding: 8px 12px 10px; + color: var(--ink); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.45), transparent 42%), + var(--paper); + border: 4px solid #b98127; + box-shadow: inset 0 0 0 2px #56330c, var(--shadow); +} + +.tactic-strip h2 { + margin: 0 0 8px; + color: #143d3d; + font-size: 1.55rem; + text-align: center; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.tactic-tray { + display: grid; + grid-template-columns: repeat(5, minmax(132px, 1fr)); + gap: 9px; +} + +.tactic-card { + position: relative; + height: 78px; + display: grid; + grid-template-columns: 66px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + align-items: center; + gap: 0 8px; + padding: 8px 10px; + color: var(--ink); + text-align: left; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.46), transparent 50%), + #f2d99c; + border: 3px solid #a97522; + box-shadow: inset 0 0 0 2px rgba(80, 43, 8, 0.25), 0 4px 0 rgba(54, 30, 5, 0.55); +} + +.tactic-card:disabled { + filter: grayscale(0.65); + opacity: 0.58; +} + +.tactic-card img { + grid-row: 1 / 3; + width: 62px; + height: 62px; + object-fit: contain; +} + +.tactic-card span { + min-width: 0; + color: #201005; + font-size: 0.96rem; + font-weight: 950; + line-height: 1.02; + text-transform: uppercase; +} + +.tactic-card strong { + color: #201005; + font-size: 1.15rem; +} + +.tactic-preview { + position: absolute; + left: 50%; + bottom: calc(100% + 10px); + z-index: 30; + width: min(260px, 70vw); + display: none; + transform: translateX(-50%); + padding: 10px 12px; + color: #173a35; + font-size: 0.92rem; + font-style: normal; + line-height: 1.25; + background: #fff1bd; + border: 3px solid #a97522; + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.38); +} + +.tactic-preview:after { + content: ""; + position: absolute; + left: 50%; + bottom: -10px; + width: 16px; + height: 16px; + transform: translateX(-50%) rotate(45deg); + background: #fff1bd; + border-right: 3px solid #a97522; + border-bottom: 3px solid #a97522; +} + +.tactic-card:hover .tactic-preview, +.tactic-card:focus-visible .tactic-preview { + display: block; +} + +.map-shell { + min-height: 0; + display: grid; + grid-template-rows: 38px minmax(0, 1fr) 62px; + padding: 0; +} + +.layer-tabs { + display: flex; + align-items: end; + gap: 4px; + padding-left: 14px; +} + +.layer-tabs button { + min-width: 112px; + height: 40px; + color: #ffdf86; + font-size: 1.16rem; + font-weight: 950; + background: linear-gradient(180deg, #0c5d5d, #063738); + border: 3px solid #a97522; + border-bottom: 0; + text-transform: capitalize; +} + +.layer-tabs button.active { + color: #251304; + background: linear-gradient(180deg, #ffd461, #d4a231); +} + +.map-wrap { + position: relative; + min-height: 0; + overflow: hidden; + background: #0a1414; + border: 5px solid #b98127; + border-radius: 16px 16px 0 0; + box-shadow: inset 0 0 0 3px #4c2c08, var(--shadow); + cursor: grab; + touch-action: none; +} + +.map-wrap:active { + cursor: grabbing; +} + +.map-canvas { + position: absolute; + inset: 0; + transform-origin: 0 0; + will-change: transform; +} + +#mapImage { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + user-select: none; + pointer-events: none; +} + +.overlay-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.map-controls { + position: absolute; + right: 12px; + top: 12px; + z-index: 18; + display: flex; + align-items: center; + gap: 5px; + max-width: calc(100% - 24px); + padding: 5px; + color: var(--ink); + background: rgba(242, 217, 156, 0.94); + border: 2px solid #a97522; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34); +} + +.map-controls button, +.map-controls output { + min-width: 34px; + height: 30px; + display: grid; + place-items: center; + color: var(--ink); + font-weight: 950; + background: #ffd46f; + border: 1px solid #8f5d18; +} + +.map-controls output { + min-width: 52px; + background: #fff0bc; +} + +.map-control-divider { + width: 1px; + height: 24px; + background: #8f5d18; +} + +.map-controls .map-visibility-toggle, +.map-controls #witnessModeButton { + min-width: auto; + padding: 0 8px; + font-size: 0.72rem; +} + +.map-controls .map-visibility-toggle:not(.active) { + color: #6c5c42; + background: #c8b98f; + text-decoration: line-through; +} + +.map-controls #witnessModeButton.active { + color: #fff4c2; + background: #176e5e; +} + +.focus-marker { + position: absolute; + width: 30px; + height: 30px; + transform: translate(-50%, -50%); + border: 3px solid #ffdf86; + border-radius: 50%; + box-shadow: 0 0 0 4px rgba(25, 105, 100, 0.35), 0 0 14px rgba(255, 212, 111, 0.62); +} + +.map-token, +.witness-token { + position: absolute; + pointer-events: auto; + border: 0; + background: transparent; + filter: drop-shadow(0 10px 8px rgba(0, 0, 0, 0.52)); +} + +.map-token { + width: 40px; + height: 48px; + transform: translate( + calc(-50% + var(--token-offset-x, 0px)), + calc(-82% + var(--token-offset-y, 0px)) + ); +} + +.map-token.junction_lockdown { + width: 58px; + height: 44px; +} + +.map-token.roadblock { + width: 32px; + height: 26px; +} + +.map-token.patrol_unit { + width: 48px; + height: 54px; +} + +.map-token.search_team { + width: 50px; + height: 50px; +} + +.map-token.lookout_board { + width: 48px; + height: 58px; +} + +.witness-token { + width: 38px; + height: 52px; + transform: translate( + calc(-50% + var(--token-offset-x, 0px)), + calc(-86% + var(--token-offset-y, 0px)) + ); +} + +#selectionLayer { z-index: 10; } +#tacticLayer { z-index: 11; } +#witnessLayer { z-index: 12; } + +.map-token.co-located, +.witness-token.co-located { + filter: drop-shadow(0 10px 8px rgba(0, 0, 0, 0.62)); +} + +.map-token img, +.witness-token img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.witness-token strong { + position: absolute; + right: -1px; + top: 4px; + min-width: 16px; + min-height: 16px; + display: grid; + place-items: center; + color: #fff4c2; + font-size: 0.62rem; + border-radius: 50%; + background: #8e2118; + border: 1px solid #ffdf86; +} + +.witness-token.viewed strong { + background: #176e5e; +} + +.witness-cluster-token { + z-index: 4; + width: 42px; + height: 56px; + transform: translate(-50%, -86%); +} + +.witness-cluster-token strong { + right: -3px; + top: 1px; + min-width: 19px; + min-height: 19px; + font-size: 0.68rem; +} + +.witness-cluster-member { + opacity: 0; + pointer-events: none; +} + +.cluster-report-list { + display: grid; + gap: 7px; + max-height: 230px; + overflow: auto; +} + +.cluster-report-button { + display: grid; + gap: 3px; + padding: 8px; + color: #251304; + text-align: left; + background: #fff0bc; + border: 2px solid #a97522; +} + +.cluster-report-button.viewed { + border-color: #176e5e; + background: #d7eee0; +} + +.cluster-report-button span { + font-size: 0.76rem; + line-height: 1.25; +} + +.cluster-report-button em { + font-style: normal; + font-size: 0.7rem; + font-weight: 700; + color: #6b3812; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.map-message { + position: absolute; + left: 14px; + right: 14px; + bottom: 12px; + min-height: 34px; + display: flex; + align-items: center; + padding: 0 12px; + color: #fff4c2; + font-weight: 800; + background: rgba(7, 63, 66, 0.88); + border: 2px solid rgba(255, 212, 111, 0.7); + pointer-events: none; +} + +.legend-strip { + min-height: 62px; + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 6px; + padding: 7px 9px; + color: var(--ink); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.45), transparent), + var(--paper); + border: 5px solid #b98127; + border-top: 0; + box-shadow: inset 0 0 0 2px #55320b; +} + +.legend-item { + min-width: 0; + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + grid-template-rows: 1fr 1fr; + align-items: center; + column-gap: 6px; +} + +.legend-item img { + grid-row: 1 / 3; + width: 42px; + height: 52px; + object-fit: contain; +} + +.legend-item strong { + overflow: hidden; + font-size: 0.76rem; + font-weight: 950; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.legend-item span { + overflow: hidden; + font-family: "Segoe Print", "Bradley Hand ITC", cursive; + font-size: 0.72rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lookout-board, +.statements-panel { + min-height: 0; + display: grid; + gap: 8px; + padding: 12px; + background: + repeating-linear-gradient(172deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 16px), + var(--teal); +} + +.lookout-board { + grid-template-rows: auto minmax(0, 1fr); +} + +.statements-panel { + grid-template-rows: auto minmax(0, 1fr); +} + +.paper-note { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto auto; + gap: 8px; + padding: 14px; + color: var(--ink); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.52), transparent 34%), + var(--paper); + border: 3px solid #a97522; + box-shadow: inset 0 0 0 2px rgba(86, 51, 12, 0.28); +} + +.paper-note h3 { + margin: 0; + color: #8b2217; + text-align: center; + text-transform: uppercase; +} + +.paper-note textarea { + width: 100%; + min-height: 126px; + resize: none; + color: #193b34; + font-family: "Segoe Print", "Bradley Hand ITC", cursive; + font-size: 1.05rem; + line-height: 1.42; + background: + repeating-linear-gradient(180deg, transparent 0 30px, rgba(35, 87, 72, 0.12) 31px), + rgba(255, 249, 218, 0.18); + border: 1px dashed rgba(80, 43, 8, 0.35); + outline: none; +} + +.paper-note button, +.ask-button, +.remove-button { + min-height: 36px; + color: #fff4c2; + font-weight: 950; + background: linear-gradient(180deg, #25765f, #104d43); + border: 2px solid #a97522; +} + +.paper-note p { + margin: 0; + color: #5b3110; + font-weight: 800; +} + +.statement-list { + min-height: 0; + display: grid; + align-content: start; + gap: 8px; + overflow: auto; +} + +.statement-card { + position: relative; + min-height: 78px; + padding: 9px 44px 9px 10px; + color: var(--ink); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.45), transparent 50%), + #f0d69a; + border: 2px solid #a97522; +} + +.statement-card div { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.statement-card strong { + font-size: 0.92rem; + text-transform: uppercase; +} + +.statement-card span { + color: #176e5e; + font-size: 0.72rem; + font-weight: 950; + text-transform: uppercase; +} + +.statement-card p { + margin: 5px 0 0; + font-family: "Segoe Print", "Bradley Hand ITC", cursive; + font-size: 0.84rem; + line-height: 1.22; +} + +.statement-card mark { + position: absolute; + right: 9px; + top: 50%; + width: 30px; + height: 30px; + display: grid; + place-items: center; + transform: translateY(-50%); + color: #176e5e; + font-size: 1.35rem; + font-weight: 950; + background: transparent; + border: 3px solid #176e5e; + border-radius: 50%; +} + +.statement-card.empty mark { + display: none; +} + +.event-ticker { + margin: 0 8px; + min-height: 28px; + display: flex; + align-items: center; + padding: 0 12px; + color: #ffdf86; + background: rgba(0, 0, 0, 0.34); + border: 2px solid rgba(255, 212, 111, 0.45); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-popup { + position: fixed; + z-index: 80; + width: 280px; + display: grid; + gap: 8px; + padding: 12px; + color: var(--ink); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.48), transparent 46%), + var(--paper); + border: 4px solid #a97522; + box-shadow: var(--shadow); +} + +.drag-ghost { + position: fixed; + z-index: 120; + width: 136px; + min-height: 54px; + display: grid; + grid-template-columns: 46px minmax(0, 1fr); + align-items: center; + gap: 7px; + padding: 6px 9px; + color: var(--ink); + font-weight: 950; + text-transform: uppercase; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.48), transparent), + var(--paper); + border: 3px solid #a97522; + box-shadow: 0 16px 34px rgba(0, 0, 0, 0.45); + pointer-events: none; +} + +.drag-ghost img { + width: 44px; + height: 44px; + object-fit: contain; +} + +.drag-ghost span { + min-width: 0; + font-size: 0.78rem; + line-height: 1.05; +} + +.detail-popup[hidden] { + display: none; +} + +.detail-popup > img { + width: 58px; + height: 68px; + object-fit: contain; +} + +.detail-popup h3 { + margin: 0; + color: #8b2217; + font-size: 1.1rem; + text-transform: uppercase; +} + +.detail-popup p { + margin: 0; + line-height: 1.32; +} + +.detail-popup dl { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px 8px; + margin: 0; +} + +.detail-popup dt { + font-weight: 950; + text-transform: uppercase; +} + +.detail-popup dd { + margin: 0; +} + +.popup-close { + position: absolute; + right: 6px; + top: 5px; + width: 28px; + height: 28px; + color: #fff4c2; + background: #8b2217; + border: 2px solid #a97522; +} + +.remove-button { + background: linear-gradient(180deg, #c33d32, #8b2119); +} + +.settings-dialog { + width: min(760px, calc(100vw - 28px)); + max-height: calc(100dvh - 28px); + color: var(--ink); + background: transparent; + border: 0; + padding: 0; +} + +.settings-dialog::backdrop { + background: rgba(0, 0, 0, 0.58); +} + +.settings-panel { + display: grid; + width: 100%; + box-sizing: border-box; + gap: 14px; + padding: 16px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.42), transparent 42%), + var(--paper); + border: 5px solid #a97522; + box-shadow: var(--shadow); + max-height: calc(100dvh - 28px); + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; +} + +.settings-panel > * { + min-width: 0; +} + +.settings-panel header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.settings-panel h2 { + margin: 0; + color: #8b2217; + font-size: 2rem; + text-transform: uppercase; +} + +.settings-panel header button { + width: 34px; + height: 34px; + color: #fff4c2; + background: #8b2217; + border: 2px solid #a97522; +} + +.settings-grid { + display: grid; + min-width: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.settings-grid label { + min-width: 0; + display: grid; + gap: 4px; + color: #482309; + font-weight: 950; + text-transform: uppercase; +} + +.settings-wide { + grid-column: 1 / -1; +} + +.settings-subgrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 12px; + background: rgba(139, 34, 23, 0.07); + border: 2px dashed rgba(139, 34, 23, 0.45); +} + +.settings-subgrid[hidden] { + display: none; +} + +.settings-hint { + margin: 0; + color: #5f3215; + font-size: 0.82rem; + font-weight: 750; + line-height: 1.35; + text-transform: none; +} + +.settings-grid input, +.settings-grid select { + width: 100%; + min-width: 0; + box-sizing: border-box; + min-height: 36px; + color: var(--ink); + background: #fff0bc; + border: 2px solid #a97522; + padding: 6px 8px; +} + +.llama-status { + margin: 0; + padding: 10px; + color: #173a35; + font-weight: 850; + background: rgba(23, 110, 94, 0.12); + border-left: 5px solid #176e5e; + overflow-wrap: anywhere; +} + +.settings-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.settings-actions button { + min-height: 38px; + padding: 0 12px; + color: #fff4c2; + font-weight: 950; + background: linear-gradient(180deg, #25765f, #104d43); + border: 2px solid #a97522; +} + +@media (max-width: 700px) { + .settings-grid, + .settings-subgrid { + grid-template-columns: 1fr; + } +} + +.setup-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 24px; + background: rgba(7, 17, 19, 0.82); + backdrop-filter: blur(6px); +} + +.setup-overlay[hidden] { + display: none; +} + +.setup-card { + width: min(620px, 92vw); + display: grid; + gap: 16px; + padding: 34px; + color: #3e210d; + text-align: center; + background: linear-gradient(145deg, #f8e7ad, #d7b86b); + border: 5px solid #6d3c12; + box-shadow: inset 0 0 0 3px #f8da82, 0 24px 70px #000; +} + +.setup-card small { + font-weight: 950; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.setup-card h2 { + margin: 0; + font-size: clamp(2rem, 5vw, 3.4rem); + line-height: 0.95; + text-transform: uppercase; +} + +.setup-card p { + margin: 0; + font-size: 1.08rem; + font-weight: 750; +} + +.setup-card progress { + width: 100%; + height: 24px; + accent-color: #176e5e; +} + +.setup-start-button { + min-height: 72px; + color: #fff4c2; + font-size: 1.8rem; + font-weight: 1000; + text-transform: uppercase; + background: linear-gradient(180deg, #2b8b69, #104d43); + border: 4px solid #a97522; + box-shadow: inset 0 0 0 2px rgba(255, 239, 185, 0.3), 0 7px 0 #2c1907; +} + +.setup-start-button:disabled { + cursor: wait; + filter: grayscale(0.7); + opacity: 0.72; +} + +.setup-overlay.ready .setup-start-button { + animation: setup-ready-pulse 1.8s ease-in-out infinite; +} + +.setup-settings-button { + justify-self: center; + color: #4c2b12; + font-weight: 900; + background: transparent; + border: 0; + text-decoration: underline; +} + +.setup-picker { + display: grid; + gap: 12px; + padding: 18px; + text-align: left; + background: rgba(255, 248, 222, 0.72); + border: 2px solid #a97522; + border-radius: 6px; +} + +.setup-picker[hidden] { display: none; } + +.setup-picker-intro { + margin: 0; + font-size: 0.98rem; + font-weight: 750; + color: #4c2b12; +} + +.setup-storage-hint { + padding: 8px 10px; + overflow-wrap: anywhere; + font-size: 0.82rem !important; + color: #4c2b12; + background: rgba(255, 255, 255, 0.42); + border-left: 4px solid #176e5e; +} + +.setup-picker-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 16px; +} + +.setup-picker-grid label { + display: grid; + gap: 4px; + font-weight: 850; + font-size: 0.86rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #4c2b12; +} + +.setup-picker-grid select { + padding: 8px 10px; + font-size: 0.95rem; + font-weight: 600; + color: #2c1907; + background: #fff8d8; + border: 2px solid #6d3c12; + border-radius: 4px; + text-transform: none; + letter-spacing: 0; +} + +.setup-picker-grid small { + font-weight: 500; + font-size: 0.78rem; + text-transform: none; + letter-spacing: 0; + color: #5a3e1f; +} + +@media (max-width: 700px) { + .setup-picker-grid { grid-template-columns: minmax(0, 1fr); } +} + +@keyframes setup-ready-pulse { + 50% { transform: scale(1.025); box-shadow: inset 0 0 0 2px rgba(255, 239, 185, 0.4), 0 10px 0 #2c1907, 0 0 30px rgba(255, 214, 101, 0.65); } +} + +@media (max-width: 1200px) { + .top-banner { + height: auto; + grid-template-columns: 190px minmax(0, 1fr) 132px 72px; + } + + .table-grid { + height: auto; + grid-template-columns: 220px minmax(0, 1fr); + } + + .right-rail { + grid-column: 1 / -1; + grid-template-columns: 1fr 1fr; + grid-template-rows: minmax(260px, 1fr); + } + + .tactic-tray { + grid-template-columns: repeat(3, minmax(132px, 1fr)); + } + + .center-stage { + grid-template-rows: auto minmax(620px, 68vh); + } +} + +@media (max-width: 1400px) { + .top-banner { + grid-template-columns: 250px minmax(0, 1fr) 150px 78px; + } + + .title-panel:before, + .title-panel:after { + display: none; + } + + .title-panel h1 { + font-size: clamp(3rem, 5.4vw, 4.35rem); + } + + .title-panel p { + font-size: 0.94rem; + } + + .turn-panel strong { + font-size: 1.72rem; + } + + .gear-button { + font-size: 2.5rem; + } + + .table-grid { + grid-template-columns: 250px minmax(0, 1fr) 300px; + } + + .wanted-card h2 { + font-size: 2.35rem; + } + + .suspect-image { + width: 172px; + height: 206px; + } + + .wanted-card { + gap: 5px; + padding: 10px; + } + + .wanted-card dl { + font-size: 0.94rem; + gap: 3px 7px; + } + + .wanted-card strong { + font-size: 1.25rem; + } + + .tactic-tray { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .tactic-card { + grid-template-columns: 48px minmax(0, 1fr); + gap: 0 5px; + padding: 7px; + } + + .tactic-card img { + width: 48px; + height: 48px; + } + + .tactic-card span { + font-size: 0.78rem; + } + + .tactic-card strong { + font-size: 0.95rem; + } + + .right-rail { + min-width: 0; + } +} + +@media (max-width: 760px) { + .top-banner, + .table-grid, + .right-rail { + grid-template-columns: 1fr; + } + + .bureau-crest { + min-height: 126px; + } + + .title-panel h1 { + font-size: 3rem; + white-space: normal; + text-align: center; + } + + .table-grid { + min-height: 0; + } + + .tactic-tray, + .legend-strip { + grid-template-columns: 1fr; + } + + .map-shell { + grid-template-rows: auto minmax(520px, 65vh) auto; + } +} + +body { + position: relative; +} + +.game-board { + position: fixed; + left: 50%; + top: 50%; + width: 1672px; + height: 940px; + min-height: 0; + transform: translate(-50%, -50%) scale(var(--app-scale, 1)); + transform-origin: center center; +} + +.top-banner { + height: 142px; + grid-template-columns: 286px minmax(0, 1fr) 172px 94px; +} + +.table-grid { + height: auto; + min-height: 0; + grid-template-columns: 286px minmax(0, 1fr) 334px; +} + +.left-rail { + grid-template-rows: minmax(0, 1fr) 92px 62px 46px 42px; +} + +.right-rail { + grid-column: auto; + min-width: 0; + grid-template-columns: 1fr; + grid-template-rows: minmax(0, 0.92fr) minmax(0, 1fr); +} + +.center-stage { + grid-template-rows: 130px minmax(0, 1fr); +} + +.tactic-tray { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.map-shell { + grid-template-rows: 38px minmax(0, 1fr) 62px; +} + +.legend-strip { + grid-template-columns: repeat(7, minmax(0, 1fr)); +} + +.case-control-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.case-control-row button { color: #ffe8a7; font-weight: 900; background: #63251c; border: 2px solid #c28a35; } +#notesText { min-height: 210px; resize: none; } +#notesStatus { margin: 6px 0 0; opacity: 0.76; } + +.notice-dialog, .witness-dialog, .story-dialog { padding: 0; color: var(--ink); background: transparent; border: 0; } +.notice-dialog::backdrop, .witness-dialog::backdrop, .story-dialog::backdrop { background: rgba(3, 14, 15, 0.9); backdrop-filter: blur(5px); } +.notice-panel { width: min(720px, 92vw); display: grid; gap: 16px; padding: 22px; background: #f3dda6; border: 5px solid #b47b25; box-shadow: 0 24px 80px #000; } +.notice-panel header, .witness-interview-shell > header, .story-reveal-shell > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.notice-panel h2, .witness-interview-shell h2, .story-reveal-shell h2 { margin: 0; color: #8b2217; text-transform: uppercase; } +.notice-panel textarea { min-height: 220px; padding: 16px; color: #251304; background: #fff0bc; border: 2px solid #9a6825; resize: vertical; } +.dialog-actions, .speech-controls, .text-chat-row { display: flex; gap: 10px; } +.dialog-actions button, .witness-interview-shell button, .story-reveal-shell button { min-height: 40px; padding: 8px 14px; color: #fff0bc; font-weight: 900; background: #0c5552; border: 2px solid #c18a34; } +.witness-dialog, .story-dialog { width: 100vw; max-width: none; height: 100vh; max-height: none; } +.witness-interview-shell, .story-reveal-shell { width: 100%; height: 100%; display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; gap: 14px; padding: clamp(18px, 3vw, 48px); color: #f8e7b3; background: radial-gradient(circle at 20% 10%, rgba(211, 159, 55, 0.16), transparent 34%), linear-gradient(145deg, #052d30, #171006 70%); border: 8px solid #9f6d22; } +.witness-interview-shell h2, .story-reveal-shell h2 { color: #ffd46f; font-size: clamp(2rem, 4vw, 4rem); } +.witness-interview-shell header p, .witness-interview-shell header small, .story-reveal-shell header small { margin: 4px 0; color: #e8c978; } +.witness-summary { padding: 14px 18px; color: #2a160b; background: #eed69b; border-left: 6px solid #9e2c20; } +.witness-transcript, .story-timeline { min-height: 0; overflow: auto; padding: 18px; background: rgba(0, 0, 0, 0.27); border: 2px solid #8c672d; } +.chat-message { width: min(76%, 820px); margin: 10px 0; padding: 12px 14px; border-radius: 8px; line-height: 1.45; } +.chat-message.user { margin-left: auto; color: #f9ebbd; background: #175e65; } +.chat-message.witness { color: #2b1709; background: #eed69b; } +.witness-interview-shell footer { display: grid; gap: 10px; } +.speech-controls { align-items: center; flex-wrap: wrap; } +#pushToTalkButton.recording, #autoSpeechButton.active { background: #9b2a20; box-shadow: 0 0 20px rgba(255, 85, 62, 0.6); } +#micLevel { width: 180px; } +.text-chat-row textarea { min-height: 58px; flex: 1; padding: 10px; color: #241307; background: #fff0bc; resize: none; } +.story-reveal-shell { grid-template-rows: auto minmax(0, 1fr) auto; } +.story-card { margin: 0 0 16px; padding: 18px; color: #28170a; background: #efd99f; border-left: 7px solid #9b2a20; } +.story-card h3 { margin: 0 0 8px; color: #7f2219; } +.story-facts { margin: 10px 0 0; color: #4c3318; } + +.case-intro-dialog { width: 100vw; max-width: none; height: 100vh; max-height: none; padding: 0; color: #f7e7b5; background: transparent; border: 0; } +.case-intro-dialog::backdrop { background: rgba(1, 8, 10, 0.94); backdrop-filter: blur(7px); } +.case-intro-shell { box-sizing: border-box; width: 100%; height: 100%; display: grid; grid-template-rows: auto 1fr auto; gap: clamp(18px, 3vh, 34px); padding: clamp(24px, 4vw, 64px); overflow: auto; background: radial-gradient(circle at 82% 12%, rgba(183, 44, 29, 0.2), transparent 30%), radial-gradient(circle at 12% 80%, rgba(199, 151, 51, 0.15), transparent 34%), #071f21; border: 10px solid #9a6926; box-shadow: inset 0 0 90px #000; } +.case-intro-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid rgba(232, 195, 104, 0.4); padding-bottom: 18px; } +.case-intro-heading small, .intro-card > small { color: #d9ae50; font-weight: 900; letter-spacing: 0.18em; text-transform: uppercase; } +.case-intro-heading h2 { margin: 5px 0 0; color: #ffe39a; font-size: clamp(2.4rem, 5vw, 5.2rem); line-height: 0.92; text-transform: uppercase; text-shadow: 4px 5px 0 #3f1710; } +.case-intro-heading p { max-width: 760px; margin: 12px 0 0; color: #d8c38f; font-size: 1.08rem; } +.case-stamp { padding: 12px 18px; color: #d4513e; font: 900 1.15rem Georgia, serif; letter-spacing: 0.14em; text-transform: uppercase; border: 4px double #b43a2b; transform: rotate(5deg); } +.case-intro-cards { display: grid; grid-template-columns: 1.25fr 0.8fr 1.15fr; gap: clamp(14px, 2vw, 28px); align-items: stretch; } +.intro-card { position: relative; min-height: 430px; padding: clamp(22px, 2.5vw, 38px); color: #281707; overflow: hidden; background: linear-gradient(145deg, #f5e3ac, #d8b96f); border: 1px solid #fff0be; box-shadow: 0 22px 50px rgba(0,0,0,0.5), inset 0 0 35px rgba(91,45,12,0.18); transform: rotate(-0.7deg); } +.intro-card:nth-child(2) { transform: translateY(10px) rotate(0.8deg); } +.intro-card:nth-child(3) { transform: rotate(-0.3deg); } +.intro-card:after { content: ""; position: absolute; inset: 10px; pointer-events: none; border: 1px solid rgba(107, 58, 17, 0.35); } +.intro-card-number { position: absolute; top: 10px; right: 20px; color: rgba(116, 57, 16, 0.16); font: 900 5rem Georgia, serif; } +.intro-card h3 { position: relative; margin: 14px 0; color: #7f2118; font: 900 clamp(1.55rem, 2.4vw, 2.7rem) Georgia, serif; line-height: 1; text-transform: uppercase; } +.intro-card p { position: relative; line-height: 1.55; } +.crime-card dl { position: relative; display: grid; grid-template-columns: 72px 1fr; gap: 8px 12px; margin-top: 24px; padding-top: 18px; border-top: 2px solid rgba(112, 62, 19, 0.35); } +.crime-card dt { color: #8d271b; font-weight: 900; text-transform: uppercase; } +.crime-card dd { margin: 0; font-weight: 700; } +.suspect-card { text-align: center; background: linear-gradient(160deg, #d9c17b, #ae8946); } +.suspect-card img { position: relative; width: min(90%, 230px); height: 245px; margin: 14px auto 4px; object-fit: cover; filter: sepia(0.8) contrast(1.2); border: 7px solid #f2dda0; box-shadow: 0 5px 15px rgba(48, 25, 5, 0.45); } +.suspect-card p { padding: 12px; color: #f8e8b7; background: #4e2615; } +.sightings-card ol { position: relative; display: grid; gap: 10px; margin: 18px 0 0; padding: 0; list-style: none; } +.sightings-card li { display: grid; grid-template-columns: 40px 1fr; gap: 12px; padding: 12px; background: rgba(255,247,211,0.45); border-left: 5px solid #856632; } +.sightings-card li.sighting-confirmed { background: rgba(153, 39, 26, 0.12); border-color: #9a2a1d; } +.sightings-card li > span { color: #9a2a1d; font: 900 1.35rem Georgia, serif; } +.sightings-card li small, .sightings-card li strong { display: block; } +.sightings-card li small { color: #75521e; font-weight: 900; text-transform: uppercase; } +.sightings-card li strong { margin: 2px 0 4px; font-size: 1.08rem; } +.sightings-card li p { margin: 0; font-size: 0.9rem; line-height: 1.35; } +.case-intro-shell footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; color: #d7bd79; } +.case-intro-shell footer button { min-width: 240px; padding: 15px 24px; color: #fff1bd; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; background: linear-gradient(#a73a29, #702116); border: 3px solid #d7a64d; box-shadow: 0 8px 0 #32100c; } +.case-intro-shell footer button:hover { transform: translateY(-2px); box-shadow: 0 10px 0 #32100c; } + +@media (max-width: 1000px) { + .case-intro-cards { grid-template-columns: 1fr; } + .intro-card { min-height: 0; transform: none !important; } + .case-intro-heading { align-items: flex-start; } +} + +/* How-to-play tutorial -------------------------------------------------- */ +.help-button { font: 900 1.5rem Georgia, serif; } +.tutorial-dialog { width: 100vw; max-width: none; height: 100vh; max-height: none; padding: 0; color: #f7e7b5; background: transparent; border: 0; } +.tutorial-dialog::backdrop { background: rgba(1, 8, 10, 0.95); backdrop-filter: blur(7px); } +.tutorial-shell { box-sizing: border-box; width: 100%; height: 100%; display: grid; grid-template-rows: auto 1fr auto; gap: clamp(16px, 2.6vh, 30px); padding: clamp(22px, 3.4vw, 56px); overflow: auto; background: radial-gradient(circle at 82% 12%, rgba(183, 44, 29, 0.18), transparent 30%), radial-gradient(circle at 12% 80%, rgba(199, 151, 51, 0.14), transparent 34%), #071f21; border: 10px solid #9a6926; box-shadow: inset 0 0 90px #000; } +.tutorial-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid rgba(232, 195, 104, 0.4); padding-bottom: 14px; } +.tutorial-head small { color: #d9ae50; font-weight: 900; letter-spacing: 0.18em; text-transform: uppercase; } +.tutorial-head h2 { margin: 5px 0 0; color: #ffe39a; font-size: clamp(1.9rem, 3.6vw, 3.4rem); line-height: 0.95; text-transform: uppercase; text-shadow: 3px 4px 0 #3f1710; } +.tutorial-skip { padding: 10px 18px; color: #e7cd8a; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; background: transparent; border: 2px solid rgba(215, 166, 77, 0.6); border-radius: 4px; cursor: pointer; } +.tutorial-skip:hover { color: #fff1bd; border-color: #d7a64d; } +.tutorial-body { display: grid; grid-template-columns: 1.55fr 1fr; gap: clamp(18px, 2.5vw, 40px); align-items: center; min-height: 0; } +.tutorial-figure { margin: 0; padding: 12px; background: linear-gradient(145deg, #16322f, #0a2422); border: 4px solid #9a6926; box-shadow: 0 22px 50px rgba(0,0,0,0.55); } +.tutorial-figure img { display: block; width: 100%; height: auto; max-height: 64vh; object-fit: contain; border: 1px solid rgba(0,0,0,0.5); } +.tutorial-copy { display: grid; gap: 14px; align-content: center; } +.tutorial-tag { justify-self: start; padding: 6px 14px; color: #1c1305; font: 900 0.82rem Georgia, serif; letter-spacing: 0.14em; text-transform: uppercase; background: linear-gradient(#f5e3ac, #d8b96f); border: 2px solid #fff0be; } +.tutorial-copy h3 { margin: 0; color: #ffe39a; font: 900 clamp(1.6rem, 2.6vw, 2.8rem) Georgia, serif; line-height: 1.02; text-transform: uppercase; } +.tutorial-text { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; } +.tutorial-text li { position: relative; padding-left: 26px; color: #e8d5a0; font-size: clamp(0.98rem, 1.2vw, 1.18rem); line-height: 1.45; } +.tutorial-text li::before { content: ""; position: absolute; left: 4px; top: 0.55em; width: 9px; height: 9px; background: #d4513e; transform: rotate(45deg); box-shadow: 0 0 0 2px rgba(215, 166, 77, 0.5); } +.tutorial-text li strong { color: #ffe39a; } +.tutorial-foot { display: flex; align-items: center; justify-content: space-between; gap: 20px; border-top: 1px solid rgba(232, 195, 104, 0.4); padding-top: 14px; } +.tutorial-dots { display: flex; gap: 10px; } +.tutorial-dots button { width: 13px; height: 13px; padding: 0; background: rgba(215, 166, 77, 0.3); border: 2px solid #8a6427; border-radius: 50%; cursor: pointer; } +.tutorial-dots button.active { background: #d4513e; border-color: #f0c267; } +.tutorial-nav { display: flex; align-items: center; gap: 14px; } +.tutorial-counter { color: #c9ad6c; font-weight: 800; letter-spacing: 0.08em; } +.tutorial-nav button { min-width: 130px; padding: 13px 22px; color: #fff1bd; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; background: linear-gradient(#23423d, #15302d); border: 3px solid #d7a64d; box-shadow: 0 6px 0 #0a1c1a; cursor: pointer; } +.tutorial-nav button.tutorial-primary { background: linear-gradient(#a73a29, #702116); box-shadow: 0 6px 0 #32100c; } +.tutorial-nav button:hover { transform: translateY(-2px); } +.tutorial-nav button:disabled { opacity: 0.4; cursor: default; transform: none; } + +@media (max-width: 1000px) { + .tutorial-body { grid-template-columns: 1fr; } + .tutorial-figure img { max-height: 40vh; } +} + +@media (max-height: 800px) and (min-width: 1001px) { + .case-intro-shell { gap: 14px; padding: 22px 42px; } + .case-intro-heading { padding-bottom: 10px; } + .case-intro-heading h2 { font-size: 3rem; } + .case-intro-heading p { margin-top: 7px; } + .intro-card { min-height: 360px; padding: 20px 25px; } + .intro-card h3 { font-size: 1.7rem; } + .suspect-card img { width: 180px; height: 195px; margin-top: 8px; } + .sightings-card ol { gap: 7px; margin-top: 10px; } + .sightings-card li { padding: 8px 10px; } + .sightings-card li p { font-size: 0.8rem; } + .case-intro-shell footer button { padding: 11px 20px; } +} diff --git a/ui/web/static/app.js b/ui/web/static/app.js new file mode 100644 index 0000000000000000000000000000000000000000..bbef1e51eea2c10292593c67ae21ae33c7c50f0f --- /dev/null +++ b/ui/web/static/app.js @@ -0,0 +1,2200 @@ +const DEFAULT_NOTICE = "Request high-confidence reports of a grey raincoat carrying a red folder at the selected junction."; +const DEFAULT_FOCUSED_JUNCTION = 100; + +const ASSET = "/static/assets/reference/"; +const ASSET_VERSION = "20260614-complete-icons-v3"; + +function assetUrl(filename) { + return `${ASSET}${filename}?v=${ASSET_VERSION}`; +} + +const TACTICS = { + roadblock: { + label: "Roadblock", + countLabel: "3 units", + icon: "icon_roadblock.png", + pin: "pin_roadblock.png", + preview: "Blocks one road route from this junction. Best for cutting off a known escape path.", + details: "Blocks one open route leaving this junction for two turns.", + }, + junction_lockdown: { + label: "Junction Lockdown", + countLabel: "3 units", + icon: "icon_junction_lockdown.png", + pin: "pin_junction_lockdown.png", + preview: "Locks down movement through this junction for a short time. Best at chokepoints.", + details: "Blocks movement through this junction for two turns.", + }, + patrol_unit: { + label: "Patrol Unit", + countLabel: "2 units", + icon: "icon_patrol_unit.png", + pin: "pin_patrol_unit.png", + preview: "Deters the suspect AND files a high-reliability sighting if they pass through or near this junction.", + details: "The culprit avoids this junction. If they still pass through or next to it, the patrol officer files a witness report.", + }, + search_team: { + label: "Search Team", + countLabel: "2 units", + icon: "icon_search_team.png", + pin: "pin_search_team.png", + preview: "Stakes out this junction. If the suspect passes through, the case ends instantly.", + details: "Wins the case if the culprit is at this junction at any point during the next turn.", + }, + lookout_board: { + label: "Lookout Board", + countLabel: "2 units", + icon: "icon_lookout_board.png", + pin: "pin_lookout_board.png", + preview: "Posts a public notice here. People nearby are more likely to report sightings after seeing it.", + details: "Increases nearby witness response when a lookout notice is raised.", + }, +}; + +const LAYER_LABELS = { + normal: "Normal", + taxi: "Taxi", + bus: "Bus", + subway: "Subway", +}; + +const LAYER_Y_OFFSET = { + normal: 0, + taxi: 86, + bus: 86, + subway: 86, +}; + +const LAYER_MODE = { + taxi: "taxi", + bus: "bus", + subway: "subway", +}; + +function currentLayerYOffset() { + return LAYER_Y_OFFSET[state.layer] || 0; +} + +const els = { + caseClock: document.querySelector("#caseClock"), + turnPhase: document.querySelector("#turnPhase"), + settingsButton: document.querySelector("#settingsButton"), + newCaseButton: document.querySelector("#newCaseButton"), + stopGameButton: document.querySelector("#stopGameButton"), + restartGameButton: document.querySelector("#restartGameButton"), + advanceButton: document.querySelector("#advanceButton"), + activeUnitsText: document.querySelector("#activeUnitsText"), + unitIcons: document.querySelector("#unitIcons"), + tacticTray: document.querySelector("#tacticTray"), + layerTabs: document.querySelector("#layerTabs"), + mapWrap: document.querySelector("#mapWrap"), + mapCanvas: document.querySelector("#mapCanvas"), + mapImage: document.querySelector("#mapImage"), + selectionLayer: document.querySelector("#selectionLayer"), + witnessLayer: document.querySelector("#witnessLayer"), + tacticLayer: document.querySelector("#tacticLayer"), + mapMessage: document.querySelector("#mapMessage"), + legendStrip: document.querySelector("#legendStrip"), + notesText: document.querySelector("#notesText"), + notesStatus: document.querySelector("#notesStatus"), + statementList: document.querySelector("#statementList"), + eventTicker: document.querySelector("#eventTicker"), + detailPopup: document.querySelector("#detailPopup"), + wantedDescription: document.querySelector("#wantedDescription"), + wantedLastSeen: document.querySelector("#wantedLastSeen"), + suspectImage: document.querySelector("#suspectImage"), + wantedAlias: document.querySelector("#wantedAlias"), + gameTitle: document.querySelector("#gameTitle"), + gameSubtitle: document.querySelector("#gameSubtitle"), + zoomOutButton: document.querySelector("#zoomOutButton"), + zoomInButton: document.querySelector("#zoomInButton"), + zoomResetButton: document.querySelector("#zoomResetButton"), + zoomValue: document.querySelector("#zoomValue"), + toggleWitnessesButton: document.querySelector("#toggleWitnessesButton"), + toggleTacticsButton: document.querySelector("#toggleTacticsButton"), + toggleFocusButton: document.querySelector("#toggleFocusButton"), + witnessModeButton: document.querySelector("#witnessModeButton"), + settingsDialog: document.querySelector("#settingsDialog"), + settingsCloseButton: document.querySelector("#settingsCloseButton"), + soundSetting: document.querySelector("#soundSetting"), + difficultySetting: document.querySelector("#difficultySetting"), + providerSetting: document.querySelector("#providerSetting"), + customModelSettings: document.querySelector("#customModelSettings"), + llamaConnectionSettings: document.querySelector("#llamaConnectionSettings"), + externalServerHint: document.querySelector("#externalServerHint"), + modelPathSetting: document.querySelector("#modelPathSetting"), + serverBinSetting: document.querySelector("#serverBinSetting"), + baseUrlSetting: document.querySelector("#baseUrlSetting"), + llmModelSetting: document.querySelector("#llmModelSetting"), + gatewayUrlSetting: document.querySelector("#gatewayUrlSetting"), + launcherPathSetting: document.querySelector("#launcherPathSetting"), + comniCheckoutSetting: document.querySelector("#comniCheckoutSetting"), + omniRootSetting: document.querySelector("#omniRootSetting"), + modelDirSetting: document.querySelector("#modelDirSetting"), + quantizationSetting: document.querySelector("#quantizationSetting"), + contextLengthSetting: document.querySelector("#contextLengthSetting"), + gpuLayersSetting: document.querySelector("#gpuLayersSetting"), + voiceDirSetting: document.querySelector("#voiceDirSetting"), + llamaStatusText: document.querySelector("#llamaStatusText"), + settingsSaveButton: document.querySelector("#settingsSaveButton"), + llamaStartButton: document.querySelector("#llamaStartButton"), + llamaRestartButton: document.querySelector("#llamaRestartButton"), + llamaStopButton: document.querySelector("#llamaStopButton"), + noticeDialog: document.querySelector("#noticeDialog"), + noticeCloseButton: document.querySelector("#noticeCloseButton"), + noticeCancelButton: document.querySelector("#noticeCancelButton"), + noticeJunctionLabel: document.querySelector("#noticeJunctionLabel"), + noticeText: document.querySelector("#noticeText"), + raiseLookoutButton: document.querySelector("#raiseLookoutButton"), + lookoutMeta: document.querySelector("#lookoutMeta"), + witnessDialog: document.querySelector("#witnessDialog"), + witnessCloseButton: document.querySelector("#witnessCloseButton"), + witnessName: document.querySelector("#witnessName"), + witnessProfile: document.querySelector("#witnessProfile"), + witnessSummary: document.querySelector("#witnessSummary"), + witnessTranscript: document.querySelector("#witnessTranscript"), + witnessConnection: document.querySelector("#witnessConnection"), + witnessMessage: document.querySelector("#witnessMessage"), + sendWitnessMessage: document.querySelector("#sendWitnessMessage"), + autoSpeechButton: document.querySelector("#autoSpeechButton"), + pushToTalkButton: document.querySelector("#pushToTalkButton"), + stopAudioButton: document.querySelector("#stopAudioButton"), + micLevel: document.querySelector("#micLevel"), + storyDialog: document.querySelector("#storyDialog"), + storyCloseButton: document.querySelector("#storyCloseButton"), + storyTimeline: document.querySelector("#storyTimeline"), + storyFooter: document.querySelector("#storyFooter"), + caseIntroDialog: document.querySelector("#caseIntroDialog"), + caseIntroTitle: document.querySelector("#caseIntroTitle"), + caseIntroKicker: document.querySelector("#caseIntroKicker"), + caseIntroCrime: document.querySelector("#caseIntroCrime"), + caseIntroNarrative: document.querySelector("#caseIntroNarrative"), + caseIntroStolen: document.querySelector("#caseIntroStolen"), + caseIntroVictim: document.querySelector("#caseIntroVictim"), + caseIntroAlias: document.querySelector("#caseIntroAlias"), + caseIntroDescription: document.querySelector("#caseIntroDescription"), + caseIntroImage: document.querySelector("#caseIntroImage"), + caseIntroSightings: document.querySelector("#caseIntroSightings"), + beginInvestigationButton: document.querySelector("#beginInvestigationButton"), + setupOverlay: document.querySelector("#setupOverlay"), + setupTitle: document.querySelector("#setupTitle"), + setupMessage: document.querySelector("#setupMessage"), + setupProgress: document.querySelector("#setupProgress"), + setupProgressText: document.querySelector("#setupProgressText"), + setupStartButton: document.querySelector("#setupStartButton"), + setupSettingsButton: document.querySelector("#setupSettingsButton"), + setupPicker: document.querySelector("#setupPicker"), + setupStorageHint: document.querySelector("#setupStorageHint"), + pickerQuantization: document.querySelector("#pickerQuantization"), + pickerDevice: document.querySelector("#pickerDevice"), + pickerGpuLayers: document.querySelector("#pickerGpuLayers"), + pickerContext: document.querySelector("#pickerContext"), + pickerDeviceHint: document.querySelector("#pickerDeviceHint"), + pickerQuantHint: document.querySelector("#pickerQuantHint"), + gpuDeviceSetting: document.querySelector("#gpuDeviceSetting"), + witnessChatTtsSetting: document.querySelector("#witnessChatTtsSetting"), + helpButton: document.querySelector("#helpButton"), + tutorialDialog: document.querySelector("#tutorialDialog"), + tutorialTag: document.querySelector("#tutorialTag"), + tutorialHeading: document.querySelector("#tutorialHeading"), + tutorialText: document.querySelector("#tutorialText"), + tutorialImage: document.querySelector("#tutorialImage"), + tutorialDots: document.querySelector("#tutorialDots"), + tutorialCounter: document.querySelector("#tutorialCounter"), + tutorialBack: document.querySelector("#tutorialBack"), + tutorialNext: document.querySelector("#tutorialNext"), + tutorialSkip: document.querySelector("#tutorialSkip"), +}; + +const state = { + gameId: null, + layer: "normal", + map: { layers: [], junctions: [] }, + selected: [], + focused: null, + witnesses: [], + witnessCards: [], + previousStatements: [], + placedTactics: [], + tacticCounts: emptyCounts(), + sound: true, + popup: null, + pointerDrag: null, + mapView: { zoom: 1.45, x: 0, y: 0, initialized: false }, + mapPan: null, + suppressMapClick: false, + settings: null, + appScale: 1, + game: null, + notesDirty: false, + notesTimer: null, + activeWitness: null, + witnessSocket: null, + mediaStream: null, + captureContext: null, + captureNode: null, + speechMode: null, + pushRecording: false, + pushDrainUntil: 0, + playbackContext: null, + playbackSources: [], + playbackTime: 0, + setup: null, + setupTimer: null, + runtimeOptions: null, + pickerHydrated: false, + activeIntroGameId: null, + mapVisibility: { witnesses: true, tactics: true, focus: true }, + tutorialIndex: 0, +}; + +function emptyCounts() { + const limits = Object.fromEntries(Object.keys(TACTICS).map((key) => [key, 0])); + return { + limits, + placed: { ...limits }, + remaining: { ...limits }, + total_limit: 12, + total_remaining: 12, + }; +} + +function api(path, payload = {}) { + return fetch(`/api/${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).then(async (response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: response.statusText })); + throw new Error(error.detail || response.statusText); + } + return response.json(); + }); +} + +function payload(extra = {}) { + return { + game_id: state.gameId, + selected_junctions: state.selected, + focused_junction: state.focused, + ...extra, + }; +} + +async function boot() { + adjustAppScale(); + restoreEditableTitle(); + bindEvents(); + const requestedGameId = new URLSearchParams(window.location.search).get("game_id"); + const snapshotUrl = requestedGameId + ? `/api/snapshot?game_id=${encodeURIComponent(requestedGameId)}` + : "/api/snapshot"; + const snapshot = await fetch(snapshotUrl).then((response) => response.json()); + applySnapshot(snapshot, false); + showOpeningForFreshCase(snapshot); + openTutorial(false); + if (!state.gameId) flash("Preparing the local AI runtime...", "map_select", false); + renderLegend(); + ensureLocalAI(); +} + +function adjustAppScale() { + const designWidth = 1672; + const designHeight = 940; + state.appScale = Math.min(window.innerWidth / designWidth, window.innerHeight / designHeight); + document.documentElement.style.setProperty("--app-scale", String(state.appScale)); +} + +function bindEvents() { + bindEditableTitle(els.gameTitle, "phantomGridTitle"); + bindEditableTitle(els.gameSubtitle, "phantomGridSubtitle"); + els.newCaseButton.addEventListener("click", () => openNewCase(true)); + els.stopGameButton.addEventListener("click", () => finishGame("stopped")); + els.restartGameButton.addEventListener("click", () => restartGame()); + els.settingsButton.addEventListener("click", openSettings); + els.advanceButton.addEventListener("click", async () => { + if (state.busy) return; + if (!state.gameId) return openNewCase(true); + if (!beginTurnProcessing()) return; + try { + const snapshot = await api("advance_turn", payload()); + playSound("turn_advance"); + if (snapshot?.sound && snapshot.sound !== "turn_advance") playSound(snapshot.sound); + applySnapshot(snapshot, false); + } finally { + endTurnProcessing(); + } + }); + els.raiseLookoutButton.addEventListener("click", publishNotice); + els.noticeCloseButton.addEventListener("click", () => els.noticeDialog.close()); + els.noticeCancelButton.addEventListener("click", () => els.noticeDialog.close()); + els.notesText.addEventListener("input", scheduleNotesSave); + + els.mapWrap.addEventListener("click", handleMapClick); + els.mapWrap.addEventListener("pointerdown", startMapPan); + els.mapWrap.addEventListener("dragover", (event) => event.preventDefault()); + els.mapWrap.addEventListener("drop", handleMapDrop); + els.mapWrap.addEventListener("wheel", handleMapWheel, { passive: false }); + els.zoomOutButton.addEventListener("click", () => zoomBy(0.86)); + els.zoomInButton.addEventListener("click", () => zoomBy(1.16)); + els.zoomResetButton.addEventListener("click", () => resetMapView(true)); + els.toggleWitnessesButton.addEventListener("click", () => toggleMapVisibility("witnesses")); + els.toggleTacticsButton.addEventListener("click", () => toggleMapVisibility("tactics")); + els.toggleFocusButton.addEventListener("click", () => toggleMapVisibility("focus")); + els.witnessModeButton.addEventListener("click", enableWitnessMode); + els.tacticTray.addEventListener("pointerdown", startTrayPointerDrag); + els.tacticLayer.addEventListener("pointerdown", startPlacedPointerDrag); + els.witnessLayer.addEventListener("click", handleWitnessClick); + els.tacticLayer.addEventListener("click", handleTacticClick); + els.detailPopup.addEventListener("click", handlePopupClick); + document.addEventListener("click", (event) => { + if (!event.target.closest(".detail-popup, .map-token, .witness-token")) { + closePopup(); + } + }); + document.addEventListener("dragover", (event) => event.preventDefault()); + document.addEventListener("drop", handleDocumentDrop); + window.addEventListener("pointermove", movePointerDrag); + window.addEventListener("pointerup", endPointerDrag); + window.addEventListener("pointercancel", cancelPointerDrag); + window.addEventListener("pointermove", moveMapPan); + window.addEventListener("pointerup", endMapPan); + window.addEventListener("pointercancel", cancelMapPan); + window.addEventListener("resize", () => { + adjustAppScale(); + clampMapView(); + renderMapView(); + renderMapOverlays(); + }); + els.mapImage.addEventListener("load", () => resetMapView(false)); + els.settingsCloseButton.addEventListener("click", () => els.settingsDialog.close()); + els.settingsSaveButton.addEventListener("click", saveSettings); + els.providerSetting.addEventListener("change", renderBackendFields); + els.llamaStartButton.addEventListener("click", () => runLlamaAction("start")); + els.llamaRestartButton.addEventListener("click", () => runLlamaAction("restart")); + els.llamaStopButton.addEventListener("click", () => runLlamaAction("stop")); + els.setupStartButton.addEventListener("click", handleSetupStart); + els.setupSettingsButton.addEventListener("click", openSettings); + els.witnessCloseButton.addEventListener("click", closeWitnessInterview); + els.sendWitnessMessage.addEventListener("click", sendWitnessText); + els.witnessMessage.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendWitnessText(); } + }); + els.autoSpeechButton.addEventListener("click", toggleAutoSpeech); + els.pushToTalkButton.addEventListener("pointerdown", startPushToTalk); + els.pushToTalkButton.addEventListener("pointerup", stopPushToTalk); + els.pushToTalkButton.addEventListener("pointercancel", stopPushToTalk); + window.addEventListener("pointerup", () => { if (state.pushRecording) stopPushToTalk(); }); + els.stopAudioButton.addEventListener("click", stopPlayback); + els.storyCloseButton.addEventListener("click", () => els.storyDialog.close()); + els.beginInvestigationButton.addEventListener("click", dismissCaseIntroduction); + els.helpButton.addEventListener("click", () => openTutorial(true)); + els.tutorialNext.addEventListener("click", () => advanceTutorial(1)); + els.tutorialBack.addEventListener("click", () => advanceTutorial(-1)); + els.tutorialSkip.addEventListener("click", closeTutorial); + els.tutorialDots.addEventListener("click", (event) => { + const dot = event.target.closest("[data-slide]"); + if (dot) gotoTutorialSlide(Number(dot.dataset.slide)); + }); + els.tutorialDialog.addEventListener("keydown", (event) => { + if (event.key === "ArrowRight") advanceTutorial(1); + else if (event.key === "ArrowLeft") advanceTutorial(-1); + }); + els.tutorialDialog.addEventListener("close", () => { + localStorage.setItem(TUTORIAL_SEEN_KEY, "1"); + }); +} + +async function ensureLocalAI() { + clearTimeout(state.setupTimer); + try { + const setup = await fetch("/api/setup/status").then((response) => response.json()); + if (!state.runtimeOptions) await loadRuntimeOptions(); + renderSetup(setup); + if (setup.service_ready) return; + // Files-ready but service not running: bring it up automatically with the + // settings the user already picked. We do NOT auto-start the heavy download + // — that waits for the user to confirm picker choices. + if (setup.files_ready && !setup.installing) { + const restarted = await fetch("/api/setup/start", { method: "POST" }).then((response) => response.json()); + renderSetup(restarted); + } + const next = setup.installing || setup.files_ready ? 2000 : 4000; + state.setupTimer = setTimeout(ensureLocalAI, next); + } catch (error) { + renderSetup({ state: "error", message: error.message || "Setup status could not be read.", progress: 0 }); + } +} + +async function loadRuntimeOptions() { + try { + const data = await fetch("/api/runtime/options").then((response) => response.json()); + state.runtimeOptions = data; + populatePicker(data); + } catch (error) { + els.pickerDeviceHint.textContent = error.message || "Could not detect runtime options; using defaults."; + } +} + +function populatePicker(options) { + if (!options || state.pickerHydrated) return; + const current = options.current || {}; + fillSelect(els.pickerQuantization, options.quantizations || [], (item) => ({ + value: item.id, label: item.label, selected: item.id === current.minicpm_quantization, + })); + fillSelect(els.pickerDevice, options.devices || [], (item) => ({ + value: item.id, label: item.label, selected: item.id === current.minicpm_gpu_device, + })); + fillSelect(els.pickerGpuLayers, options.gpu_layer_presets || [], (item) => ({ + value: item.id, label: item.label, selected: String(item.id) === String(current.llamacpp_gpu_layers), + })); + fillSelect(els.pickerContext, options.context_length_presets || [], (item) => ({ + value: String(item.id), label: item.label, selected: Number(item.id) === Number(current.llamacpp_context_length), + })); + // Mirror the GPU-device dropdown in the settings dialog using the same list. + fillSelect(els.gpuDeviceSetting, options.devices || [], (item) => ({ + value: item.id, label: item.label, selected: item.id === current.minicpm_gpu_device, + })); + if (els.setupStorageHint) { + els.setupStorageHint.textContent = `Managed files will be stored in ${options.runtime_root || "this project's runtime folder"}. ${options.free_disk_gb ?? "Unknown"} GB free.`; + } + state.pickerHydrated = true; +} + +function fillSelect(select, items, mapper) { + if (!select) return; + select.innerHTML = ""; + items.forEach((item) => { + const { value, label, selected } = mapper(item); + const option = document.createElement("option"); + option.value = String(value); + option.textContent = label; + if (selected) option.selected = true; + select.append(option); + }); +} + +function renderSetup(setup) { + state.setup = setup; + const ready = Boolean(setup.service_ready); + const installing = Boolean(setup.installing) || (setup.files_ready && !setup.service_ready) || setup.state === "running"; + const errored = setup.state === "error"; + // Show the picker only at the moment when nothing is downloading or running. + // Once setup is in flight, hide it so the progress UI takes over. + const showPicker = !ready && !installing && !errored && Boolean(state.runtimeOptions); + + els.setupOverlay.classList.toggle("ready", ready); + els.setupOverlay.hidden = Boolean(state.gameId); + els.setupPicker.hidden = !showPicker; + els.setupProgress.hidden = showPicker; + els.setupProgressText.hidden = showPicker; + els.setupProgress.value = Number(setup.progress || 0); + els.setupMessage.textContent = setup.message || "Preparing the local AI runtime..."; + els.setupProgressText.textContent = ready ? "Everything is ready" : `${Math.round(setup.progress || 0)}% - ${setup.stage || "setup"}`; + + if (ready) { + els.setupTitle.textContent = "The Investigation Desk Is Ready"; + els.setupStartButton.textContent = "Start Game"; + els.setupStartButton.disabled = false; + return; + } + if (errored) { + els.setupTitle.textContent = "Local AI Setup Needs Attention"; + els.setupStartButton.textContent = "Retry Setup"; + els.setupStartButton.disabled = false; + return; + } + if (showPicker) { + els.setupTitle.textContent = "Set Up Your Local AI"; + els.setupStartButton.textContent = "Download & Install With These Settings"; + els.setupStartButton.disabled = false; + return; + } + els.setupTitle.textContent = setup.files_ready ? "Starting Local AI" : "Preparing Your Investigation Desk"; + els.setupStartButton.textContent = setup.files_ready ? "Loading Model..." : "Downloading and Installing..."; + els.setupStartButton.disabled = true; +} + +function pickerPayload() { + return { + llm_provider: "minicpm_omni", + minicpm_quantization: els.pickerQuantization?.value || undefined, + minicpm_gpu_device: els.pickerDevice?.value || undefined, + llamacpp_gpu_layers: els.pickerGpuLayers?.value || undefined, + llamacpp_context_length: els.pickerContext?.value ? Number(els.pickerContext.value) : undefined, + }; +} + +async function handleSetupStart() { + if (state.setup?.service_ready) { + els.setupStartButton.disabled = true; + els.setupStartButton.textContent = "Opening Case..."; + await openNewCase(true); + if (state.gameId) els.setupOverlay.hidden = true; + return; + } + els.setupStartButton.disabled = true; + els.setupStartButton.textContent = "Starting..."; + // If the picker is visible, post the chosen options. Otherwise this is a + // retry or service-restart — backend uses whatever's already in .env. + const payload = state.runtimeOptions ? pickerPayload() : { llm_provider: "minicpm_omni" }; + try { + const setup = await api("setup/start", payload); + renderSetup(setup); + } catch (error) { + renderSetup({ + state: "missing", + message: error.message || "Could not start setup.", + progress: 0, + files_ready: false, + service_ready: false, + installing: false, + }); + return; + } + ensureLocalAI(); +} + +function restoreEditableTitle() { + const savedTitle = localStorage.getItem("phantomGridTitle"); + const savedSubtitle = localStorage.getItem("phantomGridSubtitle"); + if (savedTitle) els.gameTitle.textContent = savedTitle; + if (savedSubtitle) els.gameSubtitle.textContent = savedSubtitle; +} + +function bindEditableTitle(element, storageKey) { + element.addEventListener("input", () => { + localStorage.setItem(storageKey, element.textContent.trim()); + }); + element.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + element.blur(); + } + }); + element.addEventListener("blur", () => { + if (!element.textContent.trim()) { + element.textContent = storageKey === "phantomGridTitle" ? "Phantom Grid" : "Catch John Doe before he vanishes again!"; + } + localStorage.setItem(storageKey, element.textContent.trim()); + }); +} + +async function openSettings() { + try { + const data = await fetch("/api/settings").then((response) => response.json()); + state.settings = data.settings; + populateSettings(data); + if (!els.settingsDialog.open) { + els.settingsDialog.showModal(); + } + } catch (error) { + flash(error.message || "Could not load settings.", "map_select"); + } +} + +function populateSettings(data) { + const settings = data.settings || {}; + els.soundSetting.value = state.sound ? "on" : "off"; + els.difficultySetting.value = settings.difficulty || "normal"; + els.providerSetting.value = settings.llm_provider || "llama_cpp_server"; + els.modelPathSetting.value = settings.llamacpp_model_path || ""; + els.serverBinSetting.value = settings.llamacpp_server_bin || ""; + els.baseUrlSetting.value = settings.llamacpp_base_url || "http://127.0.0.1:8080/v1"; + els.llmModelSetting.value = settings.llm_model || ""; + renderBackendFields(); + els.gatewayUrlSetting.value = settings.omni_gateway_url || "http://127.0.0.1:8006"; + els.launcherPathSetting.value = settings.omni_launcher_path || ""; + els.comniCheckoutSetting.value = settings.comni_checkout_path || ""; + els.omniRootSetting.value = settings.llamacpp_omni_root || ""; + els.modelDirSetting.value = settings.minicpm_model_dir || ""; + els.contextLengthSetting.value = String(settings.llamacpp_context_length || 8192); + els.gpuLayersSetting.value = settings.llamacpp_gpu_layers || "auto"; + els.voiceDirSetting.value = settings.witness_voice_dir || ""; + if (state.runtimeOptions?.devices && !els.gpuDeviceSetting.options.length) { + fillSelect(els.gpuDeviceSetting, state.runtimeOptions.devices, (item) => ({ + value: item.id, label: item.label, selected: item.id === (settings.minicpm_gpu_device || "auto"), + })); + } else { + els.gpuDeviceSetting.value = settings.minicpm_gpu_device || "auto"; + } + els.witnessChatTtsSetting.value = settings.witness_chat_tts === false ? "0" : "1"; + const models = data.model_scan?.models || []; + els.quantizationSetting.innerHTML = models.length ? "" : ''; + models.forEach((model) => { + const option = document.createElement("option"); + option.value = model.filename; + option.textContent = `${model.quantization} (${formatBytes(model.size_bytes)})`; + option.selected = model.filename === settings.minicpm_quantization; + els.quantizationSetting.append(option); + }); + renderLlamaStatus(data.llama || data.omni, settings); +} + +function settingsPayload() { + return { + llm_provider: els.providerSetting.value, + llamacpp_model_path: els.modelPathSetting.value, + llamacpp_server_bin: els.serverBinSetting.value, + llamacpp_base_url: els.baseUrlSetting.value, + llm_model: els.llmModelSetting.value, + difficulty: els.difficultySetting.value, + omni_gateway_url: els.gatewayUrlSetting.value, + omni_launcher_path: els.launcherPathSetting.value, + comni_checkout_path: els.comniCheckoutSetting.value, + llamacpp_omni_root: els.omniRootSetting.value, + minicpm_model_dir: els.modelDirSetting.value, + minicpm_quantization: els.quantizationSetting.value, + llamacpp_context_length: Number(els.contextLengthSetting.value), + llamacpp_gpu_layers: els.gpuLayersSetting.value, + minicpm_gpu_device: els.gpuDeviceSetting.value, + witness_chat_tts: els.witnessChatTtsSetting.value === "1", + witness_voice_dir: els.voiceDirSetting.value, + }; +} + +function renderBackendFields() { + const managed = els.providerSetting.value === "llama_cpp_server"; + const external = els.providerSetting.value === "external_llama_cpp_server"; + els.customModelSettings.hidden = !managed; + els.llamaConnectionSettings.hidden = !(managed || external); + els.externalServerHint.hidden = !external; + els.llmModelSetting.disabled = managed; + els.llamaStartButton.disabled = external; + els.llamaRestartButton.disabled = external; + els.llamaStopButton.disabled = external; +} + +async function saveSettings() { + try { + state.sound = els.soundSetting.value === "on"; + const data = await api("settings", settingsPayload()); + state.settings = data.settings; + populateSettings(data); + flash("Settings saved. Difficulty applies to new cases.", "map_select"); + } catch (error) { + flash(error.message || "Could not save settings.", "map_select"); + } +} + +async function runLlamaAction(action) { + try { + state.sound = els.soundSetting.value === "on"; + const response = await fetch(`/api/llama/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settingsPayload()), + }); + const data = await response.json(); + if (data.settings) { + state.settings = data.settings; + } + renderLlamaStatus(data.llama || data.omni, data.settings || state.settings || {}); + flash(data.event || "AI backend status updated.", data.ok ? "blockade_set" : "map_select"); + } catch (error) { + flash(error.message || "Could not control the AI backend.", "map_select"); + } +} + +function renderLlamaStatus(llama, settings) { + const custom = settings.llm_provider === "llama_cpp_server"; + const external = settings.llm_provider === "external_llama_cpp_server"; + const backend = custom || external ? (settings.llm_model || "llama.cpp") : "OpenBMB MiniCPM-o"; + const launcher = external + ? "user-managed server" + : custom + ? (settings.llamacpp_model_exists && settings.llamacpp_server_bin_exists ? "model and server paths ok" : "model or server path missing") + : (settings.omni_launcher_exists ? "launcher path ok" : "launcher path missing"); + const reach = llama?.ready ? "ready" : llama?.reachable ? "reachable, not ready" : "not reachable"; + const pid = llama?.pid ? ` PID ${llama.pid}` : ""; + let detail = typeof llama?.detail === "string" ? llama.detail : ""; + if (!detail && (custom || external) && Array.isArray(llama?.detail?.data)) { + detail = `${llama.detail.data.length} model${llama.detail.data.length === 1 ? "" : "s"} loaded`; + } else if (!detail && llama?.detail?.workers) { + const workers = llama.detail.workers; + detail = `${workers.idle_workers || 0} of ${workers.total_workers || 0} workers idle`; + } + els.llamaStatusText.textContent = `${backend}: ${reach}${pid}. ${launcher}. Context ${settings.llamacpp_context_length || 8192}; GPU layers ${settings.llamacpp_gpu_layers || "auto"}. ${detail}`; +} + +async function openNewCase(makeNoise) { + if (state.busy) return; + closePopup(); + if (!beginNewCaseProcessing()) return; + try { + const snapshot = await api("new_case", {}); + applySnapshot(snapshot, makeNoise); + const openingJunction = snapshot.game?.last_seen?.junction_id || DEFAULT_FOCUSED_JUNCTION; + state.selected = [openingJunction]; + state.focused = openingJunction; + applySnapshot(await api("select_junctions", payload()), makeNoise); + showCaseIntroduction(snapshot.case_introduction, snapshot.game?.initial_description, snapshot.game?.game_id, true); + } catch (error) { + flash(error.message || "MiniCPM-o must be ready before a case can start.", "map_select"); + await openSettings(); + } finally { + endNewCaseProcessing(); + } +} + +function showOpeningForFreshCase(snapshot) { + if (!snapshot?.game || snapshot.game.turn !== 1 || snapshot.game.result || snapshot.game.phase === "complete") return; + showCaseIntroduction( + snapshot.case_introduction, + snapshot.game.initial_description, + snapshot.game.game_id, + ); +} + +function introSeenKey(gameId) { + return `phantomGridIntroSeen:${gameId}`; +} + +function showCaseIntroduction(intro, description, gameId, force = false) { + if (!intro || !els.caseIntroDialog) return; + if (!force && gameId && sessionStorage.getItem(introSeenKey(gameId)) === "1") return; + state.activeIntroGameId = gameId || state.gameId; + els.caseIntroTitle.textContent = intro.case_title || "A New Case"; + els.caseIntroKicker.textContent = intro.kicker || "A thief has vanished into London."; + els.caseIntroCrime.textContent = titleCase(intro.crime || "A daring theft"); + els.caseIntroNarrative.textContent = intro.narrative || "The trail is already growing cold."; + els.caseIntroStolen.textContent = intro.stolen_item || "Unknown valuables"; + els.caseIntroVictim.textContent = intro.victim || "Name withheld"; + els.caseIntroAlias.textContent = intro.culprit_alias || "John Doe"; + els.caseIntroDescription.textContent = description || "Description unavailable."; + if (intro.suspect_image && els.caseIntroImage) { + els.caseIntroImage.src = versionedSuspectImage(intro.suspect_image, gameId); + } + els.caseIntroSightings.innerHTML = (intro.last_seen || []).map((sighting, index) => ` +
    1. + ${String(index + 1).padStart(2, "0")} +
      ${escapeHtml(sighting.label || "Report")}${escapeHtml(sighting.location || `Junction ${sighting.junction_id}`)}

      ${escapeHtml(sighting.detail || "")}

      +
    2. + `).join(""); + if (!els.caseIntroDialog.open) els.caseIntroDialog.showModal(); + const shell = els.caseIntroDialog.querySelector(".case-intro-shell"); + shell.scrollTop = 0; + shell.focus({ preventScroll: true }); +} + +function dismissCaseIntroduction() { + if (state.activeIntroGameId) sessionStorage.setItem(introSeenKey(state.activeIntroGameId), "1"); + state.activeIntroGameId = null; + els.caseIntroDialog.close(); +} + +const TUTORIAL_SEEN_KEY = "phantomGridTutorialSeen:v1"; +const TUTORIAL_IMG = "/static/assets/tutorial/"; +const TUTORIAL_SLIDES = [ + { + tag: "Your Mission", + heading: "Catch The Phantom", + image: "01_board_overview.png", + points: [ + "You are the Commissioner of the Lantern Watch Bureau.", + "A thief is slipping across London's transport grid — one hidden move each turn.", + "Corner them before the turn counter (top-right) runs out.", + "Everything is on one board: the suspect at left, the map in the centre, your notes at right.", + ], + }, + { + tag: "Step 1", + heading: "Read The Case Briefing", + image: "02_briefing.png", + points: [ + "Every case opens with a dossier: the crime, the suspect, and their last-known sightings.", + "The description — coat, hat, what they carry — is your key to spotting real witness reports.", + "Note the Last Seen junctions. That is where the trail begins.", + ], + }, + { + tag: "Step 2", + heading: "Work The Map & Transport", + image: "03_map_layers.png", + points: [ + "Click a junction to focus it and reveal its legal moves.", + "Switch layers — Normal, Taxi, Bus, Subway — to see which routes connect where.", + "The suspect can only travel these lines, so cutting the right mode matters.", + "Drag to pan; use +/- or the mouse wheel to zoom.", + ], + }, + { + tag: "Step 3", + heading: "Gather Witnesses", + image: "08_witnesses_map.png", + points: [ + "Pins mark people who saw someone. Toggle Witness Mode to focus on them.", + "A viewed report looks different from one you have not opened yet.", + "Reports can be true sightings or false alarms — weigh each against the description.", + ], + }, + { + tag: "Step 4", + heading: "Interview A Witness", + image: "07_witness_interview.png", + points: [ + "Click a witness pin to open the interview.", + "Read their statement, then ask about colour, direction, time, or what they carried.", + "You can type or use your voice. Memories fade over turns, so ask early.", + ], + }, + { + tag: "Step 5", + heading: "Issue Public Notices", + image: "06_notice.png", + points: [ + "Post a public appeal to bring more witnesses forward near a junction.", + "The wording matters — a precise description surfaces the right people.", + "A tight notice in the right area can flush out a fresh lead.", + ], + }, + { + tag: "Step 6", + heading: "Deploy Tactics & Take Your Turn", + image: "04_tactics_tray.png", + points: [ + "Drag tactics onto junctions: Roadblock and Junction Lockdown seal routes.", + "Patrol Units deter and file reports; a Search Team wins instantly if the suspect is there.", + "A Lookout Board boosts notices. Units and searches are limited each turn.", + "When you are set, press Advance Turn — the suspect moves, and the hunt goes on. Catch them to win.", + ], + }, +]; + +function renderTutorialSlide() { + const index = Math.max(0, Math.min(state.tutorialIndex, TUTORIAL_SLIDES.length - 1)); + state.tutorialIndex = index; + const slide = TUTORIAL_SLIDES[index]; + els.tutorialTag.textContent = slide.tag; + els.tutorialHeading.textContent = slide.heading; + els.tutorialImage.src = `${TUTORIAL_IMG}${slide.image}?v=1`; + els.tutorialImage.alt = slide.heading; + els.tutorialText.innerHTML = slide.points.map((point) => `
    3. ${point}
    4. `).join(""); + els.tutorialDots.innerHTML = TUTORIAL_SLIDES.map((_, i) => + `` + ).join(""); + els.tutorialCounter.textContent = `${index + 1} / ${TUTORIAL_SLIDES.length}`; + els.tutorialBack.disabled = index === 0; + const last = index === TUTORIAL_SLIDES.length - 1; + els.tutorialNext.textContent = last ? "Start Playing" : "Next"; + els.tutorialSkip.hidden = last; + els.tutorialDialog.querySelector(".tutorial-shell").scrollTop = 0; +} + +function openTutorial(force = false) { + if (!els.tutorialDialog) return; + if (!force && localStorage.getItem(TUTORIAL_SEEN_KEY) === "1") return; + state.tutorialIndex = 0; + renderTutorialSlide(); + if (!els.tutorialDialog.open) els.tutorialDialog.showModal(); + els.tutorialDialog.querySelector(".tutorial-shell").focus({ preventScroll: true }); +} + +function closeTutorial() { + localStorage.setItem(TUTORIAL_SEEN_KEY, "1"); + if (els.tutorialDialog.open) els.tutorialDialog.close(); +} + +function gotoTutorialSlide(index) { + state.tutorialIndex = index; + renderTutorialSlide(); +} + +function advanceTutorial(delta) { + const next = state.tutorialIndex + delta; + if (next >= TUTORIAL_SLIDES.length) { + closeTutorial(); + return; + } + gotoTutorialSlide(Math.max(0, next)); +} + +function titleCase(value) { + return String(value).replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function applySnapshot(snapshot, makeNoise = true) { + if (!snapshot || !snapshot.ok) return; + state.gameId = snapshot.game?.game_id || state.gameId; + if (state.gameId) { + const url = new URL(window.location.href); + if (url.searchParams.get("game_id") !== state.gameId) { + url.searchParams.set("game_id", state.gameId); + window.history.replaceState({}, "", url); + } + } + state.map = snapshot.map || state.map; + state.selected = snapshot.selection?.junctions || state.selected; + state.focused = snapshot.selection?.focused ?? state.focused; + state.witnesses = snapshot.witness_locations || []; + state.witnessCards = snapshot.witness_cards || []; + state.previousStatements = snapshot.previous_statements || []; + state.placedTactics = snapshot.placed_tactics || []; + state.tacticCounts = snapshot.tactic_counts || state.tacticCounts; + state.game = snapshot.game || state.game; + if (snapshot.case_introduction?.culprit_alias) els.wantedAlias.textContent = snapshot.case_introduction.culprit_alias; + if (!state.notesDirty && typeof snapshot.notes === "string") els.notesText.value = snapshot.notes; + + renderGame(snapshot.game); + renderTacticTray(); + renderLayers(); + renderMap(); + renderMapOverlays(); + renderLookout(snapshot.lookout); + renderStatements(); + renderActiveUnits(); + + if (snapshot.notice_prompt?.open) openNoticeDialog(snapshot.notice_prompt); + if (snapshot.game?.result && snapshot.story_available) loadStoryReveal(); + + if (snapshot.event) { + flash(snapshot.event, snapshot.sound, makeNoise); + } +} + +function renderGame(game) { + if (!game) { + els.caseClock.textContent = "-"; + els.turnPhase.textContent = "Evening"; + els.advanceButton.disabled = true; + els.stopGameButton.disabled = true; + return; + } + const complete = Boolean(game.result || game.phase === "complete"); + els.caseClock.textContent = `${game.turn} / ${game.max_turns}`; + els.turnPhase.textContent = turnPhase(game.turn); + els.wantedDescription.textContent = game.initial_description || els.wantedDescription.textContent; + els.wantedLastSeen.textContent = game.last_seen?.location || (game.last_seen?.junction_id ? `Junction ${game.last_seen.junction_id}` : "Awaiting confirmed location"); + if (game.suspect_image && els.suspectImage) { + els.suspectImage.src = versionedSuspectImage(game.suspect_image, game.game_id); + } + els.advanceButton.disabled = complete; + els.stopGameButton.disabled = complete; +} + +function versionedSuspectImage(url, gameId) { + const separator = String(url).includes("?") ? "&" : "?"; + return `${url}${separator}case=${encodeURIComponent(gameId || "current")}`; +} + +function renderTacticTray() { + els.tacticTray.innerHTML = ""; + const complete = Boolean(state.game?.result || state.game?.phase === "complete"); + for (const [type, tactic] of Object.entries(TACTICS)) { + const remaining = complete ? 0 : (state.tacticCounts.remaining?.[type] ?? 0); + const limit = state.tacticCounts.limits?.[type] ?? 0; + const card = document.createElement("button"); + card.type = "button"; + card.className = "tactic-card"; + card.draggable = remaining > 0; + card.disabled = remaining <= 0; + card.dataset.tacticType = type; + card.setAttribute("aria-label", `${tactic.label}, ${remaining} of ${limit} remaining. ${tactic.preview}`); + card.innerHTML = ` + + ${escapeHtml(tactic.label)} + ${remaining} / ${limit} + ${escapeHtml(tactic.preview)}
      ${remaining} left
      + `; + card.addEventListener("dragstart", (event) => { + if (remaining <= 0) { + event.preventDefault(); + return; + } + event.dataTransfer.setData("application/x-tactic-type", type); + event.dataTransfer.effectAllowed = "copy"; + }); + els.tacticTray.append(card); + } +} + +function renderLayers() { + const ordered = ["normal", "taxi", "bus", "subway"].filter((layer) => state.map.layers.includes(layer)); + const key = ordered.join("|"); + if (els.layerTabs.dataset.ready !== key) { + els.layerTabs.dataset.ready = key; + els.layerTabs.innerHTML = ""; + ordered.forEach((layer) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = LAYER_LABELS[layer] || layer; + button.dataset.layer = layer; + button.addEventListener("click", () => { + state.layer = layer; + state.mapView.initialized = false; + renderLayers(); + renderMap(); + playSound("map_select"); + }); + els.layerTabs.append(button); + }); + } + [...els.layerTabs.children].forEach((button) => { + button.classList.toggle("active", button.dataset.layer === state.layer); + }); +} + +function renderMap() { + const nextSrc = `/assets/maps/${state.layer}`; + if (!els.mapImage.src.endsWith(nextSrc)) { + els.mapImage.src = nextSrc; + } +} + +function renderMapView() { + els.mapCanvas.style.transform = `translate(${state.mapView.x}px, ${state.mapView.y}px) scale(${state.mapView.zoom})`; + els.zoomValue.textContent = `${Math.round(state.mapView.zoom * 100)}%`; +} + +function resetMapView(force) { + if (!els.mapImage.naturalWidth) return; + if (!force && state.mapView.initialized) { + renderMapView(); + renderMapOverlays(); + return; + } + state.mapView.zoom = 1.45; + const wrap = { width: els.mapWrap.clientWidth, height: els.mapWrap.clientHeight }; + const base = imageBaseRect(); + const offset = currentLayerYOffset(); + const focus = junctionById(state.focused || DEFAULT_FOCUSED_JUNCTION); + const targetX = base.left + ((focus?.x || els.mapImage.naturalWidth / 2) / els.mapImage.naturalWidth) * base.width; + const focusY = focus?.y != null ? focus.y + offset : els.mapImage.naturalHeight / 2; + const targetY = base.top + (focusY / els.mapImage.naturalHeight) * base.height; + state.mapView.x = wrap.width / 2 - targetX * state.mapView.zoom; + state.mapView.y = wrap.height / 2 - targetY * state.mapView.zoom; + state.mapView.initialized = true; + clampMapView(); + renderMapView(); + renderMapOverlays(); +} + +function zoomBy(factor, clientX = null, clientY = null) { + const wrap = els.mapWrap.getBoundingClientRect(); + const anchorX = clientX == null ? els.mapWrap.clientWidth / 2 : (clientX - wrap.left) / state.appScale; + const anchorY = clientY == null ? els.mapWrap.clientHeight / 2 : (clientY - wrap.top) / state.appScale; + const oldZoom = state.mapView.zoom; + const nextZoom = Math.min(Math.max(oldZoom * factor, 0.85), 6); + const worldX = (anchorX - state.mapView.x) / oldZoom; + const worldY = (anchorY - state.mapView.y) / oldZoom; + state.mapView.zoom = nextZoom; + state.mapView.x = anchorX - worldX * nextZoom; + state.mapView.y = anchorY - worldY * nextZoom; + clampMapView(); + renderMapView(); +} + +function clampMapView() { + const wrap = { width: els.mapWrap.clientWidth, height: els.mapWrap.clientHeight }; + if (!wrap.width || !wrap.height) return; + const zoom = state.mapView.zoom; + const scaledWidth = wrap.width * zoom; + const scaledHeight = wrap.height * zoom; + const minX = Math.min(0, wrap.width - scaledWidth); + const minY = Math.min(0, wrap.height - scaledHeight); + state.mapView.x = Math.min(Math.max(state.mapView.x, minX), 0); + state.mapView.y = Math.min(Math.max(state.mapView.y, minY), 0); +} + +function renderMapOverlays() { + els.selectionLayer.innerHTML = ""; + els.witnessLayer.innerHTML = ""; + els.tacticLayer.innerHTML = ""; + const tacticCountsByJunction = new Map(); + state.placedTactics.forEach((placed) => { + tacticCountsByJunction.set(placed.junction_id, (tacticCountsByJunction.get(placed.junction_id) || 0) + 1); + }); + const witnessJunctions = new Set( + state.mapVisibility.witnesses ? state.witnesses.map((witness) => witness.junction_id) : [], + ); + + const focused = junctionById(state.focused); + if (focused && state.mapVisibility.focus) { + const marker = document.createElement("div"); + marker.className = "focus-marker"; + placeAtMapPoint(marker, focused.x, focused.y); + els.selectionLayer.append(marker); + } + + if (state.mapVisibility.witnesses) state.witnesses.forEach((witness) => { + const junction = junctionById(witness.junction_id); + if (!junction) return; + const reports = witness.reports?.length ? witness.reports : [{ + id: witness.sample_witness_id, + viewed: witness.viewed, + summary: witness.sample_summary, + }]; + if (reports.length > 1) { + const cluster = document.createElement("button"); + cluster.type = "button"; + cluster.className = "witness-token witness-cluster-token"; + cluster.dataset.witnessClusterJunction = String(witness.junction_id); + cluster.setAttribute("aria-label", `${reports.length} separate witness reports at Junction ${witness.junction_id}. Open report list.`); + cluster.innerHTML = ` + + ${reports.length} + `; + placeAtMapPoint(cluster, junction.x, junction.y); + els.witnessLayer.append(cluster); + } + reports.forEach((report, reportIndex) => { + const token = document.createElement("button"); + const offset = witnessReportOffset(reportIndex, reports.length, tacticCountsByJunction.has(witness.junction_id)); + token.type = "button"; + token.className = `witness-token witness-cluster-member ${report.viewed ? "viewed" : "unviewed"}`; + token.dataset.junctionId = String(witness.junction_id); + token.dataset.witnessId = report.id || ""; + token.style.setProperty("--token-offset-x", `${offset.x}px`); + token.style.setProperty("--token-offset-y", `${offset.y}px`); + if (reports.length > 1 || tacticCountsByJunction.has(witness.junction_id)) token.classList.add("co-located"); + token.setAttribute("aria-label", `${report.viewed ? "Viewed" : "Unviewed"} witness ${reportIndex + 1} of ${reports.length} at Junction ${witness.junction_id}`); + token.innerHTML = ` + + ${reports.length > 1 ? `${reportIndex + 1}` : ""} + `; + placeAtMapPoint(token, junction.x, junction.y); + els.witnessLayer.append(token); + }); + }); + + const renderedTacticsByJunction = new Map(); + if (state.mapVisibility.tactics) state.placedTactics.forEach((placed) => { + const tactic = TACTICS[placed.tactic_type]; + if (!tactic) return; + const token = document.createElement("button"); + token.type = "button"; + token.className = `map-token ${placed.tactic_type}`; + token.draggable = true; + token.dataset.tacticId = placed.tactic_id; + const tacticIndex = renderedTacticsByJunction.get(placed.junction_id) || 0; + renderedTacticsByJunction.set(placed.junction_id, tacticIndex + 1); + const colocatedWithWitness = witnessJunctions.has(placed.junction_id); + const tacticCount = tacticCountsByJunction.get(placed.junction_id) || 1; + if (colocatedWithWitness || tacticCount > 1) { + token.classList.add("co-located"); + const offset = tacticStackOffset(tacticIndex, tacticCount); + token.style.setProperty("--token-offset-x", `${offset.x}px`); + token.style.setProperty("--token-offset-y", `${offset.y}px`); + } + token.innerHTML = `${escapeHtml(tactic.label)}`; + token.addEventListener("dragstart", (event) => { + event.dataTransfer.setData("application/x-placed-tactic-id", placed.tactic_id); + event.dataTransfer.effectAllowed = "move"; + }); + placeAtMapPoint(token, placed.x, placed.y); + els.tacticLayer.append(token); + }); +} + +function tacticStackOffset(index, total) { + if (total <= 1) return { x: 0, y: 0 }; + if (total === 2) { + const spread = 44; + return { x: index === 0 ? -spread : spread, y: 0 }; + } + const radius = 46; + const angle = (-Math.PI / 2) + (index * Math.PI * 2) / total; + return { x: Math.round(Math.cos(angle) * radius), y: Math.round(Math.sin(angle) * radius) }; +} + +function witnessReportOffset(index, total, colocatedWithTactic) { + if (total === 1) return { x: colocatedWithTactic ? -28 : 0, y: 0 }; + const ringIndex = Math.floor(index / 8); + const position = index % 8; + const itemsInRing = Math.min(8, total - ringIndex * 8); + const radius = 26 + ringIndex * 18 + (colocatedWithTactic ? 8 : 0); + const angle = (-Math.PI / 2) + (position * Math.PI * 2) / itemsInRing; + return { x: Math.round(Math.cos(angle) * radius), y: Math.round(Math.sin(angle) * radius) }; +} + +function toggleMapVisibility(category) { + state.mapVisibility[category] = !state.mapVisibility[category]; + renderMapVisibilityControls(); + renderMapOverlays(); +} + +function enableWitnessMode() { + state.mapVisibility.witnesses = true; + state.mapVisibility.tactics = false; + state.mapVisibility.focus = false; + renderMapVisibilityControls(); + renderMapOverlays(); +} + +function renderMapVisibilityControls() { + const controls = [ + [els.toggleWitnessesButton, "witnesses"], + [els.toggleTacticsButton, "tactics"], + [els.toggleFocusButton, "focus"], + ]; + controls.forEach(([button, category]) => { + const visible = state.mapVisibility[category]; + button.classList.toggle("active", visible); + button.setAttribute("aria-pressed", String(visible)); + }); + els.witnessModeButton.classList.toggle( + "active", + state.mapVisibility.witnesses && !state.mapVisibility.tactics && !state.mapVisibility.focus, + ); +} + +function renderLookout(lookout) { + if (!lookout || !lookout.raised) { + els.lookoutMeta.textContent = "No witness pins yet."; + return; + } + const review = lookout.review_allowed ? "statements available" : "crowd reports only"; + els.lookoutMeta.textContent = `${lookout.witness_count} potential witnesses, ${review}.`; +} + +function renderStatements() { + els.statementList.innerHTML = ""; + if (!state.previousStatements.length) { + const empty = document.createElement("article"); + empty.className = "statement-card empty"; + empty.innerHTML = "No statements yet

      Ask a witness statement to pin it here.

      "; + els.statementList.append(empty); + return; + } + state.previousStatements.slice().reverse().forEach((statement) => { + const card = document.createElement("article"); + card.className = "statement-card"; + const observedTurn = statement.observed_turn ?? statement.turn; + card.innerHTML = ` +
      + ${String(statement.junction_id).padStart(2, "0")} Junction ${statement.junction_id} + Saw on Turn ${observedTurn} - ${escapeHtml(statement.time_label || "")} +
      +

      ${escapeHtml(shortSummary(statement.answer || statement.summary, 118))}

      + OK + `; + els.statementList.append(card); + }); +} + +function renderActiveUnits() { + const total = state.tacticCounts.total_limit ?? 12; + const remaining = state.tacticCounts.total_remaining ?? total; + els.activeUnitsText.textContent = `${remaining} / ${total} left`; + els.unitIcons.innerHTML = ""; + for (let index = 0; index < total; index += 1) { + const dot = document.createElement("span"); + dot.className = index < remaining ? "unit-dot ready" : "unit-dot used"; + els.unitIcons.append(dot); + } +} + +function renderLegend() { + const items = [ + ["pin_unviewed_witness.png", "Unviewed Witness", "Lead"], + ["pin_viewed_witness.png", "Viewed Witness", "Cleared"], + ["pin_roadblock.png", "Roadblock", "Blocks Road"], + ["pin_junction_lockdown.png", "Junction Lockdown", "Blocks Area"], + ["pin_patrol_unit.png", "Patrol Unit", "Patrolling"], + ["pin_search_team.png", "Search Team", "Investigating"], + ["pin_lookout_board.png", "Lookout Board", "Alerts"], + ]; + els.legendStrip.innerHTML = ""; + items.forEach(([icon, label, detail]) => { + const item = document.createElement("div"); + item.className = "legend-item"; + item.innerHTML = `${label}${detail}`; + els.legendStrip.append(item); + }); +} + +async function handleMapClick(event) { + if (state.suppressMapClick) { + state.suppressMapClick = false; + return; + } + if (event.target.closest(".map-token, .witness-token")) return; + const point = naturalPointFromEvent(event); + if (!point) return; + const junctionId = nearestJunction(point); + if (!junctionId) return; + state.focused = junctionId; + state.selected = [junctionId]; + renderMapOverlays(); + applySnapshot(await api("select_junctions", payload())); +} + +function startMapPan(event) { + if (event.button !== 0) return; + if (event.target.closest(".map-token, .witness-token, .map-controls, .detail-popup")) return; + state.mapPan = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: state.mapView.x, + originY: state.mapView.y, + moved: false, + }; +} + +function moveMapPan(event) { + const pan = state.mapPan; + if (!pan || pan.pointerId !== event.pointerId || state.pointerDrag) return; + const dx = (event.clientX - pan.startX) / state.appScale; + const dy = (event.clientY - pan.startY) / state.appScale; + if (Math.hypot(dx, dy) > 6) { + pan.moved = true; + } + state.mapView.x = pan.originX + dx; + state.mapView.y = pan.originY + dy; + clampMapView(); + renderMapView(); +} + +function endMapPan(event) { + const pan = state.mapPan; + if (!pan || pan.pointerId !== event.pointerId) return; + if (pan.moved) { + state.suppressMapClick = true; + } + state.mapPan = null; +} + +function cancelMapPan() { + state.mapPan = null; +} + +function handleMapWheel(event) { + if (!event.target.closest("#mapWrap")) return; + event.preventDefault(); + zoomBy(event.deltaY < 0 ? 1.12 : 0.89, event.clientX, event.clientY); +} + +async function handleMapDrop(event) { + event.preventDefault(); + event.stopPropagation(); + const tacticType = event.dataTransfer.getData("application/x-tactic-type"); + const movedTactic = event.dataTransfer.getData("application/x-placed-tactic-id"); + if (movedTactic) return; + if (!tacticType) return; + const point = naturalPointFromEvent(event); + const junctionId = point ? nearestJunction(point) : null; + if (!junctionId) { + flash("Drop the tactic closer to a junction.", "map_select"); + return; + } + if ((state.tacticCounts.remaining?.[tacticType] ?? 0) <= 0) { + flash(`No ${TACTICS[tacticType].label} units remain.`, "map_select"); + return; + } + await placeTacticAt(tacticType, junctionId); +} + +async function handleDocumentDrop(event) { + const tacticId = event.dataTransfer.getData("application/x-placed-tactic-id"); + if (!tacticId || event.target.closest("#mapWrap")) return; + event.preventDefault(); + closePopup(); + applySnapshot(await api("remove_tactic", payload({ tactic_id: tacticId }))); +} + +function startTrayPointerDrag(event) { + const card = event.target.closest(".tactic-card"); + if (!card || card.disabled) return; + const tacticType = card.dataset.tacticType; + if (!tacticType || (state.tacticCounts.remaining?.[tacticType] ?? 0) <= 0) return; + event.preventDefault(); + beginPointerDrag(event, { + kind: "new", + tacticType, + label: TACTICS[tacticType].label, + icon: TACTICS[tacticType].icon, + }); +} + +function startPlacedPointerDrag(event) { + const token = event.target.closest(".map-token"); + if (!token) return; + const placed = state.placedTactics.find((item) => item.tactic_id === token.dataset.tacticId); + if (!placed) return; + const tactic = TACTICS[placed.tactic_type]; + if (!tactic) return; + event.preventDefault(); + beginPointerDrag(event, { + kind: "placed", + tacticId: placed.tactic_id, + label: tactic.label, + icon: tactic.pin, + }); +} + +function beginPointerDrag(event, detail) { + closePopup(); + const ghost = document.createElement("div"); + ghost.className = "drag-ghost"; + ghost.innerHTML = `${escapeHtml(detail.label)}`; + document.body.append(ghost); + state.pointerDrag = { + ...detail, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + moved: false, + ghost, + }; + positionDragGhost(event.clientX, event.clientY); +} + +function movePointerDrag(event) { + const drag = state.pointerDrag; + if (!drag || drag.pointerId !== event.pointerId) return; + if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 8) { + drag.moved = true; + } + positionDragGhost(event.clientX, event.clientY); +} + +async function endPointerDrag(event) { + const drag = state.pointerDrag; + if (!drag || drag.pointerId !== event.pointerId) return; + state.pointerDrag = null; + drag.ghost.remove(); + + const point = naturalPointFromClient(event.clientX, event.clientY); + if (drag.kind === "new") { + if (!drag.moved) return; + const junctionId = point ? nearestJunction(point) : null; + if (!junctionId) { + flash("Drop the tactic closer to a junction.", "map_select"); + return; + } + await placeTacticAt(drag.tacticType, junctionId); + return; + } + + if (drag.kind === "placed" && drag.moved && !point) { + applySnapshot(await api("remove_tactic", payload({ tactic_id: drag.tacticId }))); + } +} + +function cancelPointerDrag() { + if (state.pointerDrag?.ghost) { + state.pointerDrag.ghost.remove(); + } + state.pointerDrag = null; +} + +function positionDragGhost(x, y) { + const ghost = state.pointerDrag?.ghost; + if (!ghost) return; + ghost.style.left = `${x + 12}px`; + ghost.style.top = `${y + 12}px`; +} + +async function placeTacticAt(tacticType, junctionId) { + state.focused = junctionId; + state.selected = [junctionId]; + optimisticCount(tacticType, -1); + renderTacticTray(); + renderActiveUnits(); + try { + applySnapshot(await api("place_tactic", payload({ + tactic_type: tacticType, + junction_id: junctionId, + layer: state.layer, + }))); + } catch (error) { + optimisticCount(tacticType, 1); + renderTacticTray(); + renderActiveUnits(); + flash(error.message || "Could not place tactic.", "map_select"); + } +} + +function handleTacticClick(event) { + const token = event.target.closest("[data-tactic-id]"); + if (!token) return; + event.preventDefault(); + event.stopPropagation(); + const placed = state.placedTactics.find((item) => item.tactic_id === token.dataset.tacticId); + if (!placed) return; + showTacticPopup(placed, event.clientX, event.clientY); +} + +function handleWitnessClick(event) { + const cluster = event.target.closest("[data-witness-cluster-junction]"); + if (cluster) { + event.preventDefault(); + event.stopPropagation(); + const junctionId = Number(cluster.dataset.witnessClusterJunction); + const location = state.witnesses.find((item) => item.junction_id === junctionId); + showWitnessClusterPopup(location, event.clientX, event.clientY); + return; + } + const token = event.target.closest("[data-witness-id]"); + if (!token) return; + event.preventDefault(); + event.stopPropagation(); + const witnessId = token.dataset.witnessId; + openWitnessInterview(witnessId); +} + +function handlePopupClick(event) { + const close = event.target.closest("[data-action='close-popup']"); + if (close) { + closePopup(); + return; + } + const remove = event.target.closest("[data-action='remove-tactic']"); + if (remove) { + api("remove_tactic", payload({ tactic_id: remove.dataset.tacticId })) + .then((snapshot) => { + closePopup(); + applySnapshot(snapshot); + }) + .catch((error) => flash(error.message || "Could not remove tactic.", "map_select")); + return; + } + const ask = event.target.closest("[data-action='ask-witness']"); + if (ask) { + askWitness(ask.dataset.witnessId); + return; + } + const openWitness = event.target.closest("[data-action='open-witness']"); + if (openWitness) { + openWitnessInterview(openWitness.dataset.witnessId); + } +} + +function showTacticPopup(placed, x, y) { + const tactic = TACTICS[placed.tactic_type]; + if (!tactic) return; + state.focused = placed.junction_id; + state.selected = [placed.junction_id]; + renderMapOverlays(); + els.detailPopup.innerHTML = ` + + +

      ${escapeHtml(tactic.label)}

      +

      ${escapeHtml(tactic.details)}

      +
      +
      Junction
      ${placed.junction_id}
      +
      Turn Placed
      ${placed.turn_created}
      +
      + + `; + placePopup(x, y); +} + +function showWitnessPopup(location, card, x, y) { + if (!location) return; + const witnessId = card?.id || location.sample_witness_id; + const canAsk = Boolean(witnessId); + state.focused = location.junction_id; + state.selected = [location.junction_id]; + renderMapOverlays(); + const observedTurn = card?.observed_turn ?? location.reports?.[0]?.observed_turn ?? null; + els.detailPopup.innerHTML = ` + + +

      ${location.viewed ? "Viewed Witness" : "Unviewed Witness"}

      +

      ${escapeHtml(shortSummary(card?.summary || location.sample_summary || "Potential witness report.", 160))}

      +
      +
      Junction
      ${location.junction_id}
      +
      Reports
      ${location.count}
      + ${observedTurn != null ? `
      Saw on Turn
      ${observedTurn}
      ` : ""} +
      + ${canAsk ? `` : ""} + `; + placePopup(x, y); +} + +function showWitnessClusterPopup(location, x, y) { + if (!location?.reports?.length) return; + state.focused = location.junction_id; + state.selected = [location.junction_id]; + renderMapOverlays(); + const reportButtons = location.reports.map((report, index) => ` + + `).join(""); + els.detailPopup.innerHTML = ` + +

      ${location.reports.length} Witness Reports

      +

      Junction ${location.junction_id}. Select a report to interview that witness.

      +
      ${reportButtons}
      + `; + placePopup(x, y); +} + +async function askWitness(witnessId) { + if (!witnessId) return; + try { + applySnapshot(await api("ask_witness", payload({ witness_id: witnessId, question: "Which direction were they moving?" }))); + closePopup(); + } catch (error) { + flash(error.message || "Could not ask witness.", "map_select"); + } +} + +function placePopup(x, y) { + const margin = 18; + els.detailPopup.hidden = false; + const width = 280; + const left = Math.min(Math.max(x + 14, margin), window.innerWidth - width - margin); + const top = Math.min(Math.max(y + 14, margin), window.innerHeight - 320); + els.detailPopup.style.left = `${left}px`; + els.detailPopup.style.top = `${Math.max(top, margin)}px`; +} + +function closePopup() { + els.detailPopup.hidden = true; + els.detailPopup.innerHTML = ""; +} + +function optimisticCount(tacticType, delta) { + if (!state.tacticCounts.remaining || !(tacticType in state.tacticCounts.remaining)) return; + state.tacticCounts.remaining[tacticType] = Math.max(0, state.tacticCounts.remaining[tacticType] + delta); + state.tacticCounts.total_remaining = Math.max(0, state.tacticCounts.total_remaining + delta); +} + +function nearestJunction(point) { + let best = null; + let bestDistance = 64; + for (const junction of state.map.junctions) { + const distance = Math.hypot(point.x - junction.x, point.y - junction.y); + if (distance <= bestDistance) { + best = junction.id; + bestDistance = distance; + } + } + return best; +} + +function naturalPointFromEvent(event) { + return naturalPointFromClient(event.clientX, event.clientY); +} + +function naturalPointFromClient(clientX, clientY) { + const wrap = els.mapWrap.getBoundingClientRect(); + const base = imageBaseRect(); + if (!wrap.width || !base) { + return null; + } + const localX = (clientX - wrap.left) / state.appScale; + const localY = (clientY - wrap.top) / state.appScale; + const canvasX = (localX - state.mapView.x) / state.mapView.zoom; + const canvasY = (localY - state.mapView.y) / state.mapView.zoom; + if (canvasX < base.left || canvasX > base.right || canvasY < base.top || canvasY > base.bottom) return null; + return { + x: ((canvasX - base.left) / base.width) * els.mapImage.naturalWidth, + y: ((canvasY - base.top) / base.height) * els.mapImage.naturalHeight - currentLayerYOffset(), + }; +} + +function placeAtMapPoint(node, x, y) { + const rect = imageBaseRect(); + if (!rect) return; + const offset = currentLayerYOffset(); + const left = rect.left + (x / els.mapImage.naturalWidth) * rect.width; + const top = rect.top + ((y + offset) / els.mapImage.naturalHeight) * rect.height; + node.style.left = `${left}px`; + node.style.top = `${top}px`; +} + +function imageBaseRect() { + const widthBox = els.mapWrap.clientWidth; + const heightBox = els.mapWrap.clientHeight; + if (!els.mapImage.naturalWidth || !els.mapImage.naturalHeight || !widthBox || !heightBox) return null; + const imageRatio = els.mapImage.naturalWidth / els.mapImage.naturalHeight; + const boxRatio = widthBox / heightBox; + let width = widthBox; + let height = heightBox; + let left = 0; + let top = 0; + if (boxRatio > imageRatio) { + width = height * imageRatio; + left += (widthBox - width) / 2; + } else { + height = width / imageRatio; + top += (heightBox - height) / 2; + } + return { left, top, width, height, right: left + width, bottom: top + height }; +} + +function junctionById(junctionId) { + return state.map.junctions.find((junction) => junction.id === junctionId); +} + +function turnPhase(turn) { + return ["Morning", "Midday", "Afternoon", "Evening", "Night"][(Number(turn || 1) - 1) % 5]; +} + +function flash(message, sound, makeNoise = true) { + els.eventTicker.textContent = message; + els.mapMessage.textContent = message; + if (makeNoise && sound) playSound(sound); +} + +function beginBusy(kind, message) { + if (state.busy) return false; + state.busy = kind; + const targets = [ + { button: els.advanceButton, busyLabel: "Processing Turn..." }, + { button: els.newCaseButton, busyLabel: "Opening Case..." }, + { button: els.stopGameButton, busyLabel: "" }, + { button: els.restartGameButton, busyLabel: "" }, + ]; + state.busyTargets = targets.map(({ button, busyLabel }) => ({ + button, + label: button.textContent, + disabledBefore: button.disabled, + busyLabel, + })); + state.busyTargets.forEach(({ button, busyLabel }) => { + button.disabled = true; + if (button === (kind === "new_case" ? els.newCaseButton : els.advanceButton)) { + button.classList.add("processing"); + if (busyLabel) button.textContent = busyLabel; + } + }); + els.eventTicker.textContent = message; + els.mapMessage.textContent = message; + playSound("blockade_set"); + return true; +} + +function endBusy() { + if (!state.busyTargets) { + state.busy = null; + return; + } + state.busyTargets.forEach(({ button, label, disabledBefore }) => { + button.classList.remove("processing"); + button.textContent = label; + button.disabled = disabledBefore; + }); + state.busy = null; + state.busyTargets = null; + const complete = Boolean(state.game?.result || state.game?.phase === "complete"); + els.advanceButton.disabled = complete; + els.stopGameButton.disabled = complete; +} + +function beginTurnProcessing() { + return beginBusy("advance_turn", "Generating the next turn... this can take a while. Please wait."); +} + +function endTurnProcessing() { + endBusy(); +} + +function beginNewCaseProcessing() { + return beginBusy("new_case", "Opening a new case... please wait."); +} + +function endNewCaseProcessing() { + endBusy(); +} + +let audioContext = null; + +function playSound(name) { + if (!state.sound) return; + audioContext ||= new AudioContext(); + const now = audioContext.currentTime; + if (name === "turn_advance") { + playChime([523.25, 783.99, 1046.5], 0.7, "sine", 0.32); + return; + } + const gain = audioContext.createGain(); + gain.connect(audioContext.destination); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(0.07, now + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2); + const tones = { + map_select: [220, 0.12, "triangle"], + blockade_set: [110, 0.2, "square"], + lookout_raise: [330, 0.24, "sawtooth"], + witness_popup: [520, 0.18, "sine"], + turn_advance: [160, 0.3, "triangle"], + }; + const [frequency, duration, type] = tones[name] || tones.map_select; + const oscillator = audioContext.createOscillator(); + oscillator.type = type; + oscillator.frequency.setValueAtTime(frequency, now); + oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.28, now + duration); + oscillator.connect(gain); + oscillator.start(now); + oscillator.stop(now + duration); +} + +function playChime(frequencies, duration, type, peak) { + if (!audioContext) return; + const now = audioContext.currentTime; + frequencies.forEach((frequency, index) => { + const start = now + index * 0.12; + const gain = audioContext.createGain(); + gain.connect(audioContext.destination); + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(peak, start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); + const oscillator = audioContext.createOscillator(); + oscillator.type = type; + oscillator.frequency.setValueAtTime(frequency, start); + oscillator.connect(gain); + oscillator.start(start); + oscillator.stop(start + duration); + }); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function shortSummary(value, limit = 126) { + const clean = String(value || "Report received.").replace(/\s+/g, " ").trim(); + if (clean.length <= limit) return clean; + return `${clean.slice(0, limit - 3)}...`; +} + +function openNoticeDialog(prompt) { + els.noticeJunctionLabel.textContent = `Junction ${prompt.junction_id}`; + els.noticeText.value = prompt.prefill || DEFAULT_NOTICE; + els.lookoutMeta.textContent = "The wording controls which existing witnesses recognize the appeal."; + if (!els.noticeDialog.open) els.noticeDialog.showModal(); + els.noticeText.focus(); +} + +async function publishNotice() { + if (!state.gameId) return; + try { + const snapshot = await api("issue_notice", payload({ notice_text: els.noticeText.value || DEFAULT_NOTICE })); + els.noticeDialog.close(); + applySnapshot(snapshot); + } catch (error) { + els.lookoutMeta.textContent = error.message || "Could not publish this notice."; + } +} + +function scheduleNotesSave() { + state.notesDirty = true; + els.notesStatus.textContent = "Saving..."; + clearTimeout(state.notesTimer); + state.notesTimer = setTimeout(saveNotes, 500); +} + +async function saveNotes() { + if (!state.gameId) return; + try { + const response = await fetch(`/api/game/${encodeURIComponent(state.gameId)}/notes`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ notes: els.notesText.value }), + }); + if (!response.ok) throw new Error("Could not save notes"); + state.notesDirty = false; + els.notesStatus.textContent = "Saved with this case."; + } catch (error) { + els.notesStatus.textContent = error.message || "Notes not saved."; + } +} + +async function openWitnessInterview(witnessId) { + if (!witnessId || !state.gameId) return; + closePopup(); + try { + const response = await fetch(`/api/witness/${encodeURIComponent(state.gameId)}/${encodeURIComponent(witnessId)}`); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || "Could not open witness."); + state.activeWitness = data.witness; + els.witnessName.textContent = data.witness.name; + const observedTurn = data.witness.observed_turn != null ? ` | Saw on Turn ${data.witness.observed_turn}` : ""; + els.witnessProfile.textContent = `${data.witness.occupation} | Junction ${data.witness.junction_id} | ${data.witness.personality.style || "measured"}${observedTurn}`; + els.witnessSummary.textContent = data.witness.summary; + els.witnessConnection.textContent = "Text + voice output ready | microphone idle"; + els.witnessTranscript.innerHTML = ""; + data.witness.transcript.forEach((turn) => { + appendChatMessage("user", turn.question); + appendChatMessage("witness", turn.answer); + }); + if (!els.witnessDialog.open) els.witnessDialog.showModal(); + els.witnessMessage.focus(); + } catch (error) { + flash(error.message || "Could not open witness.", "map_select"); + } +} + +async function sendWitnessText() { + const witness = state.activeWitness; + const message = els.witnessMessage.value.trim(); + if (!witness || !message) return; + els.witnessMessage.value = ""; + appendChatMessage("user", message); + prepareVoicePlayback(); + if (state.witnessSocket || state.speechMode) { + stopSpeechSession(); + await new Promise((resolve) => setTimeout(resolve, 350)); + } + els.sendWitnessMessage.disabled = true; + els.witnessConnection.textContent = "Witness is answering..."; + try { + const response = await fetch(`/api/witness/${encodeURIComponent(state.gameId)}/${encodeURIComponent(witness.id)}/message`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message }), + }); + const raw = await response.text(); + let data; + try { + data = raw ? JSON.parse(raw) : {}; + } catch (_) { + throw new Error(`Server returned an invalid response (${response.status}).`); + } + if (!response.ok) throw new Error(data.detail || "Witness response failed."); + appendChatMessage("witness", data.answer); + if (data.audio_data) { + playFloat32Audio(data.audio_data, data.audio_sample_rate || 24000); + } else { + appendChatMessage("witness", "[Voice output was not returned by MiniCPM-o. Check Voice in Text Chat in Settings.]"); + } + if (data.snapshot) applySnapshot(data.snapshot, false); + els.witnessConnection.textContent = data.audio_data ? "Text + voice output ready | microphone idle" : "Text ready | voice output unavailable"; + } catch (error) { + appendChatMessage("witness", `[Connection error: ${error.message}]`); + els.witnessConnection.textContent = "MiniCPM-o unavailable"; + } finally { + els.sendWitnessMessage.disabled = false; + } +} + +function appendChatMessage(role, text) { + const bubble = document.createElement("article"); + bubble.className = `chat-message ${role}`; + bubble.textContent = text; + els.witnessTranscript.append(bubble); + els.witnessTranscript.scrollTop = els.witnessTranscript.scrollHeight; + return bubble; +} + +async function finishGame(reason) { + if (!state.gameId) return; + try { + const response = await fetch(`/api/game/${encodeURIComponent(state.gameId)}/stop`, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.detail || "Could not finish case."); + if (data.snapshot) applySnapshot(data.snapshot, false); + if (els.noticeDialog.open) els.noticeDialog.close(); + if (els.witnessDialog.open) closeWitnessInterview(); + showStoryReveal(data.story, reason === "restarted"); + } catch (error) { + flash(error.message || "Could not finish case.", "map_select"); + } +} + +async function restartGame() { + if (!state.gameId) return openNewCase(true); + await finishGame("restarted"); +} + +async function loadStoryReveal() { + if (!state.gameId || els.storyDialog.open) return; + const response = await fetch(`/api/game/${encodeURIComponent(state.gameId)}/story`); + if (!response.ok) return; + const data = await response.json(); + showStoryReveal(data.story, false); +} + +function showStoryReveal(story, offerRestart) { + els.storyTimeline.innerHTML = ""; + (story.segments || []).forEach((segment) => { + const card = document.createElement("article"); + card.className = "story-card"; + const facts = (segment.observable_facts || []).map((fact) => `
    5. ${escapeHtml(fact.text)}
    6. `).join(""); + card.innerHTML = `

      Turn ${segment.turn_number}: Junction ${segment.from_junction} to ${segment.to_junction}

      ${escapeHtml(segment.mode)} | ${segment.changed_disguise ? "disguise changed" : "same disguise"}

      ${escapeHtml(segment.narrative)}

      ${facts ? `
        ${facts}
      ` : ""}`; + els.storyTimeline.append(card); + }); + els.storyFooter.innerHTML = offerRestart ? '' : `

      Case result: ${escapeHtml(story.result || story.finalized_reason || "complete")}

      `; + if (offerRestart) document.querySelector("#confirmRestartButton").addEventListener("click", async () => { + els.storyDialog.close(); state.gameId = null; state.game = null; await openNewCase(true); + }); + if (!els.storyDialog.open) els.storyDialog.showModal(); +} + +function formatBytes(value) { + if (!value) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); + return `${(value / (1024 ** index)).toFixed(index > 1 ? 1 : 0)} ${units[index]}`; +} + +async function toggleAutoSpeech() { + if (state.speechMode === "auto") return stopSpeechSession(); + state.speechMode = "auto"; + els.autoSpeechButton.classList.add("active"); + els.autoSpeechButton.textContent = "Stop Auto Speech"; + await startSpeechSession(); +} + +async function startPushToTalk(event) { + event.preventDefault(); + state.speechMode = "push"; + state.pushRecording = true; + state.pushDrainUntil = 0; + els.pushToTalkButton.classList.add("recording"); + els.pushToTalkButton.textContent = "Listening..."; + if (!state.witnessSocket || state.witnessSocket.readyState > 1) await startSpeechSession(); +} + +function stopPushToTalk() { + state.pushRecording = false; + state.pushDrainUntil = Date.now() + 1000; + els.pushToTalkButton.classList.remove("recording"); + els.pushToTalkButton.textContent = "Hold to Talk"; +} + +async function startSpeechSession() { + if (!state.activeWitness || !state.gameId) return; + try { + if (!state.mediaStream) { + state.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } }); + } + const proto = location.protocol === "https:" ? "wss" : "ws"; + const url = `${proto}://${location.host}/ws/witness/${encodeURIComponent(state.gameId)}/${encodeURIComponent(state.activeWitness.id)}`; + const socket = new WebSocket(url); + state.witnessSocket = socket; + els.witnessConnection.textContent = "Connecting speech..."; + socket.onopen = () => socket.send(JSON.stringify({ type: "prepare", config: {} })); + socket.onmessage = (event) => handleSpeechMessage(JSON.parse(event.data)); + socket.onclose = () => { + els.witnessConnection.textContent = "Text + voice output ready | microphone idle"; + stopCapture(); + state.witnessSocket = null; + state.speechMode = null; + els.autoSpeechButton.classList.remove("active"); + els.autoSpeechButton.textContent = "Start Auto Speech"; + }; + socket.onerror = () => { els.witnessConnection.textContent = "Speech connection failed"; }; + } catch (error) { + els.witnessConnection.textContent = error.message || "Microphone permission failed"; + state.speechMode = null; + } +} + +async function handleSpeechMessage(message) { + if (message.type === "queued" || message.type === "queue_update") { + els.witnessConnection.textContent = `Speech queued #${message.position}`; + } else if (message.type === "prepared") { + els.witnessConnection.textContent = state.speechMode === "auto" ? "Listening automatically" : "Push to talk ready"; + await startCapture(); + } else if (message.type === "vad_state") { + els.witnessConnection.textContent = message.speaking ? "Listening..." : "Waiting for speech"; + } else if (message.type === "generating") { + appendChatMessage("user", "[Spoken question]"); + state.currentAssistantBubble = appendChatMessage("witness", ""); + els.witnessConnection.textContent = "Witness is answering..."; + } else if (message.type === "chunk") { + if (message.text_delta) { + if (!state.currentAssistantBubble) state.currentAssistantBubble = appendChatMessage("witness", ""); + state.currentAssistantBubble.textContent += message.text_delta; + els.witnessTranscript.scrollTop = els.witnessTranscript.scrollHeight; + } + if (message.audio_data) playFloat32Audio(message.audio_data, message.audio_sample_rate || 24000); + } else if (message.type === "turn_done") { + state.currentAssistantBubble = null; + els.witnessConnection.textContent = state.speechMode === "auto" ? "Listening automatically" : "Push to talk ready"; + } else if (message.type === "error") { + els.witnessConnection.textContent = message.error || "Speech error"; + } +} + +async function startCapture() { + if (state.captureContext || !state.mediaStream) return; + const context = new AudioContext(); + const source = context.createMediaStreamSource(state.mediaStream); + const processor = context.createScriptProcessor(4096, 1, 1); + const silent = context.createGain(); + silent.gain.value = 0; + processor.onaudioprocess = (event) => { + const input = event.inputBuffer.getChannelData(0); + let sum = 0; + for (const value of input) sum += value * value; + els.micLevel.value = Math.min(Math.sqrt(sum / input.length) * 8, 1); + const shouldSend = state.speechMode === "auto" || ( + state.speechMode === "push" && (state.pushRecording || Date.now() < state.pushDrainUntil) + ); + if (!shouldSend || state.witnessSocket?.readyState !== WebSocket.OPEN) return; + const audio = resampleAudio(input, context.sampleRate, 16000); + state.witnessSocket.send(JSON.stringify({ type: "audio_chunk", audio_base64: float32ToBase64(audio) })); + }; + source.connect(processor); processor.connect(silent); silent.connect(context.destination); + state.captureContext = context; state.captureNode = processor; +} + +function resampleAudio(input, sourceRate, targetRate) { + if (sourceRate === targetRate) return new Float32Array(input); + const ratio = sourceRate / targetRate; + const output = new Float32Array(Math.floor(input.length / ratio)); + for (let i = 0; i < output.length; i += 1) { + const start = Math.floor(i * ratio); const end = Math.min(Math.floor((i + 1) * ratio), input.length); + let sum = 0; for (let j = start; j < end; j += 1) sum += input[j]; + output[i] = sum / Math.max(end - start, 1); + } + return output; +} + +function float32ToBase64(floatArray) { + const bytes = new Uint8Array(floatArray.buffer); let binary = ""; + for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +function prepareVoicePlayback() { + if (!state.playbackContext) state.playbackContext = new AudioContext(); + if (state.playbackContext.state === "suspended") state.playbackContext.resume().catch(() => {}); +} + +function playFloat32Audio(base64Data, sampleRate) { + const binary = atob(base64Data); const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + const usable = bytes.byteLength - (bytes.byteLength % 4); + const floats = new Float32Array(bytes.buffer.slice(0, usable)); + prepareVoicePlayback(); + const context = state.playbackContext; const buffer = context.createBuffer(1, floats.length, sampleRate); + buffer.copyToChannel(floats, 0); + const source = context.createBufferSource(); source.buffer = buffer; source.connect(context.destination); + const start = Math.max(context.currentTime + 0.03, state.playbackTime || 0); + source.start(start); state.playbackTime = start + buffer.duration; state.playbackSources.push(source); + source.onended = () => { state.playbackSources = state.playbackSources.filter((item) => item !== source); }; +} + +function stopPlayback() { + state.playbackSources.forEach((source) => { try { source.stop(); } catch (_) {} }); + state.playbackSources = []; state.playbackTime = 0; +} + +function stopCapture() { + if (state.captureNode) state.captureNode.disconnect(); + if (state.captureContext) state.captureContext.close(); + state.captureNode = null; state.captureContext = null; els.micLevel.value = 0; +} + +function stopSpeechSession() { + if (state.witnessSocket?.readyState === WebSocket.OPEN) state.witnessSocket.send(JSON.stringify({ type: "stop" })); + if (state.witnessSocket) state.witnessSocket.close(); + stopCapture(); state.speechMode = null; state.pushRecording = false; state.pushDrainUntil = 0; + els.autoSpeechButton.classList.remove("active"); els.autoSpeechButton.textContent = "Start Auto Speech"; + els.pushToTalkButton.classList.remove("recording"); els.pushToTalkButton.textContent = "Hold to Talk"; +} + +function closeWitnessInterview() { + stopSpeechSession(); stopPlayback(); + if (state.mediaStream) state.mediaStream.getTracks().forEach((track) => track.stop()); + state.mediaStream = null; state.activeWitness = null; els.witnessDialog.close(); +} + +boot().catch((error) => { + flash(error.message || "The board failed to open.", "map_select", false); +}); diff --git a/ui/web/static/asset_prompts.json b/ui/web/static/asset_prompts.json new file mode 100644 index 0000000000000000000000000000000000000000..2bb0c000305ed1a8cac33ef95a885f3a33cb7673 --- /dev/null +++ b/ui/web/static/asset_prompts.json @@ -0,0 +1,33 @@ +{ + "case_table_background": "top-down view of a moody London detective desk, paper map, pins, string, chalk dust, warm lamp light, stylized game UI background, no text", + "suspect_placeholder": "anonymous noir suspect silhouette in a grey raincoat holding a red folder, graphic novel style, transparent background, no text", + "witness_card_set": "four small portrait cards of London street witnesses, varied ages and moods, 1930s detective board style, consistent illustration style, no text", + "lookout_board_texture": "green-black chalkboard with faint chalk smudges and taped paper edges, game UI texture, no readable text", + "map_select": "short tactile wooden token tap on a board, warm room tone, 0.3 seconds", + "blockade_set": "metal stamp clack with soft paper thud, detective office, 0.5 seconds", + "lookout_raise": "chalk scrape and corkboard paper rustle, subtle, 0.8 seconds", + "witness_popup": "quick paper card flick with faint bell, playful noir, 0.4 seconds", + "turn_advance": "old clock tick plus distant city ambience swell, 1 second", + "reference_asset_manifest": "/static/assets/reference/reference_assets.json", + "frame_header": "Teal and gold top banner frame used as a styling reference; title text remains editable HTML.", + "crest_frame": "Left agency crest panel reference; bureau text is recreated/editable in HTML where practical.", + "wanted_card_frame": "Parchment wanted poster frame and paper texture; suspect facts remain editable HTML.", + "suspect_portrait_placeholder_crop": "Temporary noir suspect portrait crop used until a replaceable generated portrait is available.", + "side_panel_frame": "Right teal/gold side panel frame reference for CSS panel styling.", + "paper_note_frame": "Parchment note frame for lookout and statement panels; text remains editable HTML.", + "tactic_tile_frame": "Parchment tactic card frame reference; labels and counts remain editable HTML.", + "icon_roadblock": "Barricade tactic icon for roadblock tiles and map tokens.", + "icon_junction_lockdown": "Junction barrier tactic icon for lockdown tiles and map tokens.", + "icon_patrol_unit": "Police helmet tactic icon for patrol unit tiles and map tokens.", + "icon_search_team": "Magnifier/team tactic icon for search team tiles and map tokens.", + "icon_lookout_board": "Notice-board tactic icon for lookout board tiles and map tokens.", + "pin_unviewed_witness": "Red witness lead pin shown before a witness statement is asked.", + "pin_viewed_witness": "Green checked witness pin shown after a witness statement is asked.", + "pin_roadblock": "Roadblock map token crop for placed route blocks.", + "pin_junction_lockdown": "Junction lockdown token crop for placed junction blocks.", + "pin_patrol_unit": "Patrol unit map token crop for placed patrols.", + "pin_search_team": "Search team map token crop for placed investigations.", + "pin_lookout_board": "Lookout board map token crop for placed public notice boards.", + "button_advance_frame": "Blue and gold advance button style reference; button text remains editable HTML.", + "legend_strip_frame": "Bottom parchment legend strip reference; legend labels remain editable HTML." +} diff --git a/ui/web/static/assets/reference/_asset_contact_sheet.png b/ui/web/static/assets/reference/_asset_contact_sheet.png new file mode 100644 index 0000000000000000000000000000000000000000..0373d31b2a7b48a409f3cd99b4c10bc6079b883a --- /dev/null +++ b/ui/web/static/assets/reference/_asset_contact_sheet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:859485e56f1bd4563b13b5303dc864114ed3369fd3b4921b23f45e0fdbb1d672 +size 165728 diff --git a/ui/web/static/assets/reference/button_advance_frame.png b/ui/web/static/assets/reference/button_advance_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..3e9e583067f375ffe7ba00cb3031e0a88424c74c Binary files /dev/null and b/ui/web/static/assets/reference/button_advance_frame.png differ diff --git a/ui/web/static/assets/reference/crest_frame.png b/ui/web/static/assets/reference/crest_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..43ca50aa5b14ad553662a7949539bd420ffdd2f5 Binary files /dev/null and b/ui/web/static/assets/reference/crest_frame.png differ diff --git a/ui/web/static/assets/reference/frame_header.png b/ui/web/static/assets/reference/frame_header.png new file mode 100644 index 0000000000000000000000000000000000000000..ad3feccb26a00de3634a81dea8061c5439b2b72f --- /dev/null +++ b/ui/web/static/assets/reference/frame_header.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c619698575458d0602fff76221fc6d86124ff4556f6c20986f2091d2bf37e42 +size 215591 diff --git a/ui/web/static/assets/reference/icon_junction_lockdown.png b/ui/web/static/assets/reference/icon_junction_lockdown.png new file mode 100644 index 0000000000000000000000000000000000000000..b9e1ebbb4431ca963995abf255e36e58568ffe77 Binary files /dev/null and b/ui/web/static/assets/reference/icon_junction_lockdown.png differ diff --git a/ui/web/static/assets/reference/icon_lookout_board.png b/ui/web/static/assets/reference/icon_lookout_board.png new file mode 100644 index 0000000000000000000000000000000000000000..5677501604875a1886b2558af2871661f7908617 Binary files /dev/null and b/ui/web/static/assets/reference/icon_lookout_board.png differ diff --git a/ui/web/static/assets/reference/icon_patrol_unit.png b/ui/web/static/assets/reference/icon_patrol_unit.png new file mode 100644 index 0000000000000000000000000000000000000000..52cec0a089ba2f924415a8b1625fdffbba37a799 Binary files /dev/null and b/ui/web/static/assets/reference/icon_patrol_unit.png differ diff --git a/ui/web/static/assets/reference/icon_roadblock.png b/ui/web/static/assets/reference/icon_roadblock.png new file mode 100644 index 0000000000000000000000000000000000000000..ae28dc1edd484b1177d0e149afddcac7e0af18b3 Binary files /dev/null and b/ui/web/static/assets/reference/icon_roadblock.png differ diff --git a/ui/web/static/assets/reference/icon_search_team.png b/ui/web/static/assets/reference/icon_search_team.png new file mode 100644 index 0000000000000000000000000000000000000000..bcc76778f0de49d6d803d8d9c688698f89ed39ed Binary files /dev/null and b/ui/web/static/assets/reference/icon_search_team.png differ diff --git a/ui/web/static/assets/reference/legend_strip_frame.png b/ui/web/static/assets/reference/legend_strip_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..8a5f6642733dbe29675bcc5bf8bbddc712da044c --- /dev/null +++ b/ui/web/static/assets/reference/legend_strip_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f1d0c910043879b926937ac3c08cad0ec42f8ad16e36b4a42735620f6cbe47c +size 146036 diff --git a/ui/web/static/assets/reference/paper_note_frame.png b/ui/web/static/assets/reference/paper_note_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..98556e5ff39b654c6452a25fe40f8e84784848e8 --- /dev/null +++ b/ui/web/static/assets/reference/paper_note_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22b30ac044004c3bb6c78c2ddf410283cecaaa7f0f9e08f2bd7d1b0da88e913e +size 165380 diff --git a/ui/web/static/assets/reference/pin_junction_lockdown.png b/ui/web/static/assets/reference/pin_junction_lockdown.png new file mode 100644 index 0000000000000000000000000000000000000000..b2f5372f49b222b0bb1b828865122492c4fc9573 Binary files /dev/null and b/ui/web/static/assets/reference/pin_junction_lockdown.png differ diff --git a/ui/web/static/assets/reference/pin_lookout_board.png b/ui/web/static/assets/reference/pin_lookout_board.png new file mode 100644 index 0000000000000000000000000000000000000000..6ba58ffb75c32897b06d8861937ed3d57b653f94 Binary files /dev/null and b/ui/web/static/assets/reference/pin_lookout_board.png differ diff --git a/ui/web/static/assets/reference/pin_patrol_unit.png b/ui/web/static/assets/reference/pin_patrol_unit.png new file mode 100644 index 0000000000000000000000000000000000000000..484d79bfaaec2f7a30ee01a0b65e8195a1b0dcc7 Binary files /dev/null and b/ui/web/static/assets/reference/pin_patrol_unit.png differ diff --git a/ui/web/static/assets/reference/pin_roadblock.png b/ui/web/static/assets/reference/pin_roadblock.png new file mode 100644 index 0000000000000000000000000000000000000000..ffa647f75d7566f8b1740a8dd38a4de5367347d4 Binary files /dev/null and b/ui/web/static/assets/reference/pin_roadblock.png differ diff --git a/ui/web/static/assets/reference/pin_search_team.png b/ui/web/static/assets/reference/pin_search_team.png new file mode 100644 index 0000000000000000000000000000000000000000..5682bbd300cca225dba3b0f444274529e4b214ed Binary files /dev/null and b/ui/web/static/assets/reference/pin_search_team.png differ diff --git a/ui/web/static/assets/reference/pin_unviewed_witness.png b/ui/web/static/assets/reference/pin_unviewed_witness.png new file mode 100644 index 0000000000000000000000000000000000000000..bdb37d4c0dd71508664ea85fdd21f5cbf498b42b Binary files /dev/null and b/ui/web/static/assets/reference/pin_unviewed_witness.png differ diff --git a/ui/web/static/assets/reference/pin_viewed_witness.png b/ui/web/static/assets/reference/pin_viewed_witness.png new file mode 100644 index 0000000000000000000000000000000000000000..8e2f76546989644cb0c5bbeeb3e212692c3476c5 Binary files /dev/null and b/ui/web/static/assets/reference/pin_viewed_witness.png differ diff --git a/ui/web/static/assets/reference/reference_assets.json b/ui/web/static/assets/reference/reference_assets.json new file mode 100644 index 0000000000000000000000000000000000000000..d020317ace38caa3a28cf82810b81f7552ca1783 --- /dev/null +++ b/ui/web/static/assets/reference/reference_assets.json @@ -0,0 +1,277 @@ +{ + "assets": { + "frame_header": { + "file": "/static/assets/reference/frame_header.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 300, + 7, + 1328, + 140 + ], + "description": "Teal and gold top banner frame used as a styling reference; title text remains editable HTML.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "crest_frame": { + "file": "/static/assets/reference/crest_frame.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 7, + 7, + 284, + 168 + ], + "description": "Left agency crest panel reference; bureau text is recreated/editable in HTML where practical.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "wanted_card_frame": { + "file": "/static/assets/reference/wanted_card_frame.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 14, + 178, + 273, + 621 + ], + "description": "Parchment wanted poster frame and paper texture; suspect facts remain editable HTML.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "suspect_portrait_placeholder": { + "file": "/static/assets/reference/suspect_portrait_placeholder.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 43, + 247, + 241, + 470 + ], + "description": "Temporary noir suspect portrait crop used until a replaceable generated portrait is available.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "side_panel_frame": { + "file": "/static/assets/reference/side_panel_frame.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 1354, + 141, + 1672, + 932 + ], + "description": "Right teal/gold side panel frame reference for CSS panel styling.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "paper_note_frame": { + "file": "/static/assets/reference/paper_note_frame.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 1372, + 203, + 1657, + 501 + ], + "description": "Parchment note frame for lookout and statement panels; text remains editable HTML.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "tactic_tile_frame": { + "file": "/static/assets/reference/tactic_tile_frame.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 303, + 183, + 507, + 288 + ], + "description": "Parchment tactic tile frame reference; labels and counts remain editable HTML.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "icon_roadblock": { + "file": "/static/assets/reference/icon_roadblock.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 329, + 198, + 391, + 266 + ], + "description": "Barricade tactic icon for roadblock tiles and map tokens.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "icon_junction_lockdown": { + "file": "/static/assets/reference/icon_junction_lockdown.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 544, + 198, + 604, + 266 + ], + "description": "Junction barrier tactic icon for lockdown tiles and map tokens.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "icon_patrol_unit": { + "file": "/static/assets/reference/icon_patrol_unit.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 743, + 196, + 817, + 269 + ], + "description": "Police helmet tactic icon for patrol unit tiles and map tokens.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "icon_search_team": { + "file": "/static/assets/reference/icon_search_team.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 961, + 205, + 1024, + 263 + ], + "description": "Magnifier/team tactic icon for search team tiles and map tokens.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "icon_lookout_board": { + "file": "/static/assets/reference/icon_lookout_board.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 1182, + 205, + 1236, + 264 + ], + "description": "Notice-board tactic icon for lookout board tiles and map tokens.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "pin_unviewed_witness": { + "file": "/static/assets/reference/pin_unviewed_witness.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 638, + 345, + 687, + 421 + ], + "description": "Red witness lead pin shown before a witness statement is asked.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "pin_viewed_witness": { + "file": "/static/assets/reference/pin_viewed_witness.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 1013, + 345, + 1064, + 421 + ], + "description": "Green checked witness pin shown after a witness statement is asked.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "pin_roadblock": { + "file": "/static/assets/reference/pin_roadblock.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 786, + 351, + 837, + 405 + ], + "description": "Roadblock map token crop for placed route blocks.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "pin_junction_lockdown": { + "file": "/static/assets/reference/pin_junction_lockdown.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 540, + 871, + 599, + 928 + ], + "description": "Junction lockdown token crop for placed junction blocks.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "pin_patrol_unit": { + "file": "/static/assets/reference/pin_patrol_unit.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 579, + 519, + 630, + 577 + ], + "description": "Patrol unit map token crop for placed patrols.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "pin_search_team": { + "file": "/static/assets/reference/pin_search_team.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 989, + 450, + 1044, + 506 + ], + "description": "Search team map token crop for placed investigations.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "pin_lookout_board": { + "file": "/static/assets/reference/pin_lookout_board.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 735, + 633, + 792, + 706 + ], + "description": "Lookout board map token crop for placed public notice boards.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "button_advance_frame": { + "file": "/static/assets/reference/button_advance_frame.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 10, + 775, + 277, + 862 + ], + "description": "Blue and gold advance button style reference; button text remains editable HTML.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + }, + "legend_strip_frame": { + "file": "/static/assets/reference/legend_strip_frame.png", + "source": "D:\\UserData\\Downloads\\ChatGPT Image Jun 12, 2026, 11_21_30 PM.png", + "crop_box": [ + 290, + 859, + 1350, + 931 + ], + "description": "Bottom parchment legend strip reference; legend labels remain editable HTML.", + "temporary": true, + "editable_text_policy": "Do not bake dynamic text into this asset; recreate labels, counts, and copy in HTML/CSS." + } + } +} \ No newline at end of file diff --git a/ui/web/static/assets/reference/side_panel_frame.png b/ui/web/static/assets/reference/side_panel_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..65805ff50868827c0741cdb57576ead177001a41 --- /dev/null +++ b/ui/web/static/assets/reference/side_panel_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6c3b09f785eb2b9d996cb8bc3cd74db81cdab87f1411f76446e7993c6bf7569 +size 459546 diff --git a/ui/web/static/assets/reference/suspect_portrait_placeholder.png b/ui/web/static/assets/reference/suspect_portrait_placeholder.png new file mode 100644 index 0000000000000000000000000000000000000000..858e7bd8b033fba454850f8fae17bbaaf0e6cbac Binary files /dev/null and b/ui/web/static/assets/reference/suspect_portrait_placeholder.png differ diff --git a/ui/web/static/assets/reference/tactic_tile_frame.png b/ui/web/static/assets/reference/tactic_tile_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..701ded7948f4525bc09acb017d9f52b3f6ca012e Binary files /dev/null and b/ui/web/static/assets/reference/tactic_tile_frame.png differ diff --git a/ui/web/static/assets/reference/wanted_card_frame.png b/ui/web/static/assets/reference/wanted_card_frame.png new file mode 100644 index 0000000000000000000000000000000000000000..928c94d1beb81c6448b6ee717ca44cc6385a5f19 --- /dev/null +++ b/ui/web/static/assets/reference/wanted_card_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37996d1be553a1dfb4fd720769480b0ecdb96f0d42e816d6cb377a8f06e0020c +size 219842 diff --git a/ui/web/static/assets/suspects/amber_quill.png b/ui/web/static/assets/suspects/amber_quill.png new file mode 100644 index 0000000000000000000000000000000000000000..f1534208df936261c348051cef2f1b991296642b --- /dev/null +++ b/ui/web/static/assets/suspects/amber_quill.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46545b896228b81b76cc297902708374e18fad289b69ea54d84ccb6c96482229 +size 654392 diff --git a/ui/web/static/assets/suspects/blue_hour.png b/ui/web/static/assets/suspects/blue_hour.png new file mode 100644 index 0000000000000000000000000000000000000000..671b091a6cb50da992011cb5049a5df19e4e5cad --- /dev/null +++ b/ui/web/static/assets/suspects/blue_hour.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f70d2b92e80da510dc97eefdcf41a476406a3c0f91c699964697f576d9bcfef3 +size 627867 diff --git a/ui/web/static/assets/suspects/brass_moth.png b/ui/web/static/assets/suspects/brass_moth.png new file mode 100644 index 0000000000000000000000000000000000000000..f85284ab9fa49256d318187aec0c50dd8061cd37 --- /dev/null +++ b/ui/web/static/assets/suspects/brass_moth.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9961be1e487e11d2787c9ba2eeceb4a42b015c86190fe030d7abdb34d366760 +size 617392 diff --git a/ui/web/static/assets/suspects/copper_saint.png b/ui/web/static/assets/suspects/copper_saint.png new file mode 100644 index 0000000000000000000000000000000000000000..37f7a60dbd27ec793f7a8fc0e2a3bb39c1241ca8 --- /dev/null +++ b/ui/web/static/assets/suspects/copper_saint.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72cba34c03f1d1b387b5f18ec3aa81de639fa05e08eed191b93a4403b50b53cc +size 618490 diff --git a/ui/web/static/assets/suspects/glass_finch.png b/ui/web/static/assets/suspects/glass_finch.png new file mode 100644 index 0000000000000000000000000000000000000000..0833bee070bfe2b810f7d48c853d719830626397 --- /dev/null +++ b/ui/web/static/assets/suspects/glass_finch.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a525b0a0d30e5d60bab429cd8eb8a785bfe9ffcfa7c955f87a5a8785005fdac +size 616365 diff --git a/ui/web/static/assets/suspects/green_signal.png b/ui/web/static/assets/suspects/green_signal.png new file mode 100644 index 0000000000000000000000000000000000000000..6341d73d8de3a216f0bada27035c6d01ec03b050 --- /dev/null +++ b/ui/web/static/assets/suspects/green_signal.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:206fb3428bb8c873acd2f4e82a37c211ee6bb2ee841a7f1e6c9149b7896807bf +size 611314 diff --git a/ui/web/static/assets/suspects/ivory_rook.png b/ui/web/static/assets/suspects/ivory_rook.png new file mode 100644 index 0000000000000000000000000000000000000000..52e0f7f83711146664cd1d04fd555f012af967ce --- /dev/null +++ b/ui/web/static/assets/suspects/ivory_rook.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:deef8a8b4fe508c4a1990dd76366993e26c1b0efc456bd794e81969222a194c8 +size 621764 diff --git a/ui/web/static/assets/suspects/madame_mercury.png b/ui/web/static/assets/suspects/madame_mercury.png new file mode 100644 index 0000000000000000000000000000000000000000..d397c26d193056c2c36d92be3e484b3232307e89 --- /dev/null +++ b/ui/web/static/assets/suspects/madame_mercury.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5b5e0bce2ed7515c629acb8d7ac69a6c4fa69d2a37e3c5ed3a62f542dec09f1 +size 669988 diff --git a/ui/web/static/assets/suspects/rose_diamond.png b/ui/web/static/assets/suspects/rose_diamond.png new file mode 100644 index 0000000000000000000000000000000000000000..faf2849509931208515ec226b960a87041b26e5e --- /dev/null +++ b/ui/web/static/assets/suspects/rose_diamond.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64c9e960763ed81835bce4783f7dd155771a9773428120ddb01116102af04ab9 +size 645615 diff --git a/ui/web/static/assets/suspects/saffron_pen.png b/ui/web/static/assets/suspects/saffron_pen.png new file mode 100644 index 0000000000000000000000000000000000000000..7a979b80a00ef201c0ecf6de362fcce21df4f273 --- /dev/null +++ b/ui/web/static/assets/suspects/saffron_pen.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8492ce6a2e31d4a91905b289a69c5b44cba16f00ccdfd2234cdd4d5187073e7d +size 655638 diff --git a/ui/web/static/assets/suspects/scarlet_lark.png b/ui/web/static/assets/suspects/scarlet_lark.png new file mode 100644 index 0000000000000000000000000000000000000000..a5506296137b0b3995601895f91c333cb099fc83 --- /dev/null +++ b/ui/web/static/assets/suspects/scarlet_lark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:189851ecae664ca469e2f4bde92deda8fee139c44013c5389173bc565e2d23a2 +size 651046 diff --git a/ui/web/static/assets/suspects/thames_ghost.png b/ui/web/static/assets/suspects/thames_ghost.png new file mode 100644 index 0000000000000000000000000000000000000000..2151dc34eaa63660bc55f3b0552ea7ea974aa87c --- /dev/null +++ b/ui/web/static/assets/suspects/thames_ghost.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d213cff26bf3d99398aa18a2a241cce8bdfb8abcded991448ee14b8e29afbc27 +size 634646 diff --git a/ui/web/static/assets/suspects/velvet_mask.png b/ui/web/static/assets/suspects/velvet_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..03d2ff0482a608021577d6051490a62f42709956 --- /dev/null +++ b/ui/web/static/assets/suspects/velvet_mask.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dac8decaa5e59f6cd7ed962c54cd4f0f8e910db8f7d47cb3a7db2855f03625c0 +size 620360 diff --git a/ui/web/static/assets/suspects/violet_cipher.png b/ui/web/static/assets/suspects/violet_cipher.png new file mode 100644 index 0000000000000000000000000000000000000000..76e527a90126daed4ef2c7b78dcd52042c42e9da --- /dev/null +++ b/ui/web/static/assets/suspects/violet_cipher.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:722a72606aded0c23bd47143a7076a4736e52f63dbdd0007778cb0f21e3e1153 +size 551246 diff --git a/ui/web/static/assets/suspects/white_raven.png b/ui/web/static/assets/suspects/white_raven.png new file mode 100644 index 0000000000000000000000000000000000000000..50c7e3529526c46e9f046dce9273a36dd123d319 --- /dev/null +++ b/ui/web/static/assets/suspects/white_raven.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa975cb67350bc5119c88e4c9b18dddba84ce1510133fec6fb6539407a69c1ea +size 563886 diff --git a/ui/web/static/assets/tutorial/01_board_overview.png b/ui/web/static/assets/tutorial/01_board_overview.png new file mode 100644 index 0000000000000000000000000000000000000000..4802862daec7aa0148c09c681afb27422043be24 --- /dev/null +++ b/ui/web/static/assets/tutorial/01_board_overview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c8e7de571ef49b3a3ddc5201b9a93a6758babe010380414eada2a5fb317b4e12 +size 4245968 diff --git a/ui/web/static/assets/tutorial/02_briefing.png b/ui/web/static/assets/tutorial/02_briefing.png new file mode 100644 index 0000000000000000000000000000000000000000..812d131f3528932399a0e3a0c74c86712d07bbba --- /dev/null +++ b/ui/web/static/assets/tutorial/02_briefing.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14784757a764947790c2b735744b6320535482ec1a1157c596b185f004bbe7e9 +size 2803867 diff --git a/ui/web/static/assets/tutorial/03_map_layers.png b/ui/web/static/assets/tutorial/03_map_layers.png new file mode 100644 index 0000000000000000000000000000000000000000..801207dc1545824abfcf9b5d8457e7125f3db0d9 --- /dev/null +++ b/ui/web/static/assets/tutorial/03_map_layers.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4227c6c80492b9d2ce145ed9f151fd2dc1069886ffea7fd34e0525765f9e031 +size 2745957 diff --git a/ui/web/static/assets/tutorial/04_tactics_tray.png b/ui/web/static/assets/tutorial/04_tactics_tray.png new file mode 100644 index 0000000000000000000000000000000000000000..07fdc45d645709fd1c94d854395947b2c4a4f445 --- /dev/null +++ b/ui/web/static/assets/tutorial/04_tactics_tray.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfdc49f2c683003266257666e48aade54c8e0b829e09562fb5cb70c9747b0529 +size 138694 diff --git a/ui/web/static/assets/tutorial/06_notice.png b/ui/web/static/assets/tutorial/06_notice.png new file mode 100644 index 0000000000000000000000000000000000000000..20c034cebb7672822b67340a5531bdb00c35a847 --- /dev/null +++ b/ui/web/static/assets/tutorial/06_notice.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b602702aa8651372f06fb274aa4dc09a600ab72bb033b59515dd067357e4f2dd +size 707146 diff --git a/ui/web/static/assets/tutorial/07_witness_interview.png b/ui/web/static/assets/tutorial/07_witness_interview.png new file mode 100644 index 0000000000000000000000000000000000000000..9ba78327380faff2e7e3674edc081b2ca3a77f49 --- /dev/null +++ b/ui/web/static/assets/tutorial/07_witness_interview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e030a363f782f4219c196b1a4a60a1ad22b3861f61b8913d4a62ac5cfb116a65 +size 1581881 diff --git a/ui/web/static/assets/tutorial/08_witnesses_map.png b/ui/web/static/assets/tutorial/08_witnesses_map.png new file mode 100644 index 0000000000000000000000000000000000000000..118a8a3e725fe7eaf9de17c975823183f4a4963e --- /dev/null +++ b/ui/web/static/assets/tutorial/08_witnesses_map.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb575a4361a246d6d455bf2da0a6bd7989753a4d73b8d76fdc75752012a33afc +size 2911885 diff --git a/ui/web/static/default-suspect.svg b/ui/web/static/default-suspect.svg new file mode 100644 index 0000000000000000000000000000000000000000..a0e0866328962f0c4c04688b766f2b93cb885829 --- /dev/null +++ b/ui/web/static/default-suspect.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/witness_panel.py b/ui/witness_panel.py new file mode 100644 index 0000000000000000000000000000000000000000..715094cbbd21ccee43cef47970194be0fb90b593 --- /dev/null +++ b/ui/witness_panel.py @@ -0,0 +1,2 @@ +"""Witness response and questioning panels will live here.""" +