Spaces:
Sleeping
Sleeping
| 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 ? "" : '<option value="">No compatible models found</option>'; | |
| 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) => ` | |
| <li class="sighting-${escapeHtml(sighting.confidence || "unconfirmed")}"> | |
| <span>${String(index + 1).padStart(2, "0")}</span> | |
| <div><small>${escapeHtml(sighting.label || "Report")}</small><strong>${escapeHtml(sighting.location || `Junction ${sighting.junction_id}`)}</strong><p>${escapeHtml(sighting.detail || "")}</p></div> | |
| </li> | |
| `).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 <strong>Commissioner</strong> of the Lantern Watch Bureau.", | |
| "A thief is slipping across London's transport grid — one <strong>hidden move</strong> each turn.", | |
| "Corner them before the <strong>turn counter</strong> (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 <strong>dossier</strong>: the crime, the suspect, and their last-known sightings.", | |
| "The description — coat, hat, what they carry — is your key to spotting <strong>real</strong> witness reports.", | |
| "Note the <strong>Last Seen</strong> junctions. That is where the trail begins.", | |
| ], | |
| }, | |
| { | |
| tag: "Step 2", | |
| heading: "Work The Map & Transport", | |
| image: "03_map_layers.png", | |
| points: [ | |
| "Click a <strong>junction</strong> to focus it and reveal its legal moves.", | |
| "Switch layers — <strong>Normal, Taxi, Bus, Subway</strong> — 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 <strong>Witness Mode</strong> to focus on them.", | |
| "A <strong>viewed</strong> 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 <strong>interview</strong>.", | |
| "Read their statement, then ask about <strong>colour, direction, time, or what they carried</strong>.", | |
| "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 <strong>public appeal</strong> to bring more witnesses forward near a junction.", | |
| "The <strong>wording matters</strong> — 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: <strong>Roadblock</strong> and <strong>Junction Lockdown</strong> seal routes.", | |
| "<strong>Patrol Units</strong> deter and file reports; a <strong>Search Team</strong> wins instantly if the suspect is there.", | |
| "A <strong>Lookout Board</strong> boosts notices. Units and searches are limited each turn.", | |
| "When you are set, press <strong>Advance Turn</strong> — 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) => `<li>${point}</li>`).join(""); | |
| els.tutorialDots.innerHTML = TUTORIAL_SLIDES.map((_, i) => | |
| `<button type="button" class="${i === index ? "active" : ""}" data-slide="${i}" aria-label="Go to slide ${i + 1}"></button>` | |
| ).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 = ` | |
| <img src="${assetUrl(tactic.icon)}" alt="" /> | |
| <span>${escapeHtml(tactic.label)}</span> | |
| <strong>${remaining} / ${limit}</strong> | |
| <em class="tactic-preview">${escapeHtml(tactic.preview)}<br><b>${remaining} left</b></em> | |
| `; | |
| 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 = ` | |
| <img src="${assetUrl(reports.some((report) => !report.viewed) ? "pin_unviewed_witness.png" : "pin_viewed_witness.png")}" alt="" /> | |
| <strong>${reports.length}</strong> | |
| `; | |
| 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 = ` | |
| <img src="${assetUrl(report.viewed ? "pin_viewed_witness.png" : "pin_unviewed_witness.png")}" alt="" /> | |
| ${reports.length > 1 ? `<strong>${reportIndex + 1}</strong>` : ""} | |
| `; | |
| 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 = `<img src="${assetUrl(tactic.pin)}" alt="${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 = "<strong>No statements yet</strong><p>Ask a witness statement to pin it here.</p>"; | |
| 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 = ` | |
| <div> | |
| <strong>${String(statement.junction_id).padStart(2, "0")} Junction ${statement.junction_id}</strong> | |
| <span>Saw on Turn ${observedTurn} - ${escapeHtml(statement.time_label || "")}</span> | |
| </div> | |
| <p>${escapeHtml(shortSummary(statement.answer || statement.summary, 118))}</p> | |
| <mark aria-label="Viewed">OK</mark> | |
| `; | |
| 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 = `<img src="${assetUrl(icon)}" alt="" /><strong>${label}</strong><span>${detail}</span>`; | |
| 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 = `<img src="${assetUrl(detail.icon)}" alt="" /><span>${escapeHtml(detail.label)}</span>`; | |
| 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 = ` | |
| <button class="popup-close" type="button" data-action="close-popup" aria-label="Close">X</button> | |
| <img src="${assetUrl(tactic.pin)}" alt="" /> | |
| <h3>${escapeHtml(tactic.label)}</h3> | |
| <p>${escapeHtml(tactic.details)}</p> | |
| <dl> | |
| <dt>Junction</dt><dd>${placed.junction_id}</dd> | |
| <dt>Turn Placed</dt><dd>${placed.turn_created}</dd> | |
| </dl> | |
| <button class="remove-button" type="button" data-action="remove-tactic" data-tactic-id="${escapeHtml(placed.tactic_id)}">Remove</button> | |
| `; | |
| 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 = ` | |
| <button class="popup-close" type="button" data-action="close-popup" aria-label="Close">X</button> | |
| <img src="${assetUrl(location.viewed ? "pin_viewed_witness.png" : "pin_unviewed_witness.png")}" alt="" /> | |
| <h3>${location.viewed ? "Viewed Witness" : "Unviewed Witness"}</h3> | |
| <p>${escapeHtml(shortSummary(card?.summary || location.sample_summary || "Potential witness report.", 160))}</p> | |
| <dl> | |
| <dt>Junction</dt><dd>${location.junction_id}</dd> | |
| <dt>Reports</dt><dd>${location.count}</dd> | |
| ${observedTurn != null ? `<dt>Saw on Turn</dt><dd>${observedTurn}</dd>` : ""} | |
| </dl> | |
| ${canAsk ? `<button class="ask-button" type="button" data-action="ask-witness" data-witness-id="${escapeHtml(witnessId)}">Ask Statement</button>` : ""} | |
| `; | |
| 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) => ` | |
| <button class="cluster-report-button ${report.viewed ? "viewed" : "unviewed"}" type="button" data-action="open-witness" data-witness-id="${escapeHtml(report.id)}"> | |
| <strong>Report ${index + 1}: ${escapeHtml(report.name || report.style || "Witness")}</strong> | |
| <em>Saw on Turn ${report.observed_turn ?? "?"}</em> | |
| <span>${escapeHtml(shortSummary(report.summary || "Potential witness report.", 92))}</span> | |
| </button> | |
| `).join(""); | |
| els.detailPopup.innerHTML = ` | |
| <button class="popup-close" type="button" data-action="close-popup" aria-label="Close">X</button> | |
| <h3>${location.reports.length} Witness Reports</h3> | |
| <p>Junction ${location.junction_id}. Select a report to interview that witness.</p> | |
| <div class="cluster-report-list">${reportButtons}</div> | |
| `; | |
| 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); | |
| } | |
| if (data.snapshot) applySnapshot(data.snapshot, false); | |
| els.witnessConnection.textContent = data.audio_data ? "Text + voice output ready | microphone idle" : "Text ready"; | |
| } catch (error) { | |
| appendChatMessage("witness", `[Connection error: ${error.message}]`); | |
| els.witnessConnection.textContent = "Backend 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) => `<li>${escapeHtml(fact.text)}</li>`).join(""); | |
| card.innerHTML = `<h3>Turn ${segment.turn_number}: Junction ${segment.from_junction} to ${segment.to_junction}</h3><p><strong>${escapeHtml(segment.mode)}</strong> | ${segment.changed_disguise ? "disguise changed" : "same disguise"}</p><p>${escapeHtml(segment.narrative)}</p>${facts ? `<ul class="story-facts">${facts}</ul>` : ""}`; | |
| els.storyTimeline.append(card); | |
| }); | |
| els.storyFooter.innerHTML = offerRestart ? '<button id="confirmRestartButton" type="button">Confirm New Case</button>' : `<p>Case result: ${escapeHtml(story.result || story.finalized_reason || "complete")}</p>`; | |
| 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); | |
| }); | |