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) => `
${escapeHtml(sighting.detail || "")}
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 = `${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 = `${escapeHtml(tactic.details)}
${escapeHtml(shortSummary(card?.summary || location.sample_summary || "Potential witness report.", 160))}
Junction ${location.junction_id}. Select a report to interview that witness.
${escapeHtml(segment.mode)} | ${segment.changed_disguise ? "disguise changed" : "same disguise"}
${escapeHtml(segment.narrative)}
${facts ? `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); });