Compare commits

...

10 Commits

Author SHA1 Message Date
dc53b79d84 Enhance map update handling and connection stability
- Introduced mechanisms to detect stale connections in the map updates, allowing for automatic reconnection if no messages are received within a specified timeframe.
- Updated the `refresh` method in `SmartTileLayer` to return a boolean indicating whether the tile was refreshed, improving the handling of tile updates.
- Enhanced the `Send` method in the `Topic` struct to drop messages for full subscribers while keeping them subscribed, ensuring continuous delivery of future updates.
- Added a keepalive mechanism in the `WatchGridUpdates` handler to maintain the connection and prevent timeouts.
2026-03-04 21:29:53 +03:00
179357bc93 Refactor Dockerignore and enhance Leaflet styles for improved map functionality
- Updated .dockerignore to streamline build context by ensuring unnecessary files are excluded.
- Refined CSS styles in leaflet-overrides.css to enhance visual consistency and user experience for map tooltips and popups.
- Improved map initialization and update handling in useMapApi and useMapUpdates composables for better performance and reliability.
2026-03-04 18:16:41 +03:00
3968bdc76f Enhance API documentation and improve marker functionality
- Updated API documentation for clarity and consistency, including detailed descriptions of authentication and user account endpoints.
- Added a new cave marker image to enhance visual representation in the frontend.
- Implemented normalization for cave marker images during upload to ensure consistent storage format.
- Expanded test coverage for client services, including new tests for marker uploads and image normalization.
2026-03-04 16:57:43 +03:00
40945c818b Enhance character marker functionality and tooltip integration
- Updated character icon properties for improved visual representation.
- Introduced tooltip binding to character markers, displaying coordinates and character names.
- Implemented smooth position animation for character markers during updates.
- Added tests to verify tooltip content updates and animation cancellation on removal.
2026-03-04 16:02:54 +03:00
7fcdde3657 Remove coordinates display from MapSearch component for cleaner UI 2026-03-04 14:19:07 +03:00
1a0db9baf0 Update project conventions and introduce linting skill documentation
- Revised project-conventions.mdc to include a new section for running lint before tests, enhancing the development workflow.
- Added run-lint skill documentation in a new SKILL.md file, detailing usage for backend and frontend linting processes in the hnh-map monorepo.
2026-03-04 14:15:51 +03:00
337386caa8 Enhance Vitest configuration and improve Vue integration
- Added Vue plugin to vitest.config.ts for better component testing.
- Introduced vitest.setup.ts to expose Vue reactivity and lifecycle methods globally, ensuring compatibility with .vue components.
- Updated mock implementations in nuxt-imports.ts to include readonly for improved reactivity handling.
- Refactored useMapBookmarks and useToast composables to utilize readonly from Vue for better state management.
2026-03-04 14:12:48 +03:00
fd624c2357 Refactor frontend components for improved functionality and accessibility
- Consolidated global error handling in app.vue to redirect users to the login page on API authentication failure.
- Enhanced MapView component by reintroducing event listeners for selected map and marker updates, improving interactivity.
- Updated PasswordInput and various modal components to ensure proper input handling and accessibility compliance.
- Refactored MapControls and MapControlsContent to streamline prop management and enhance user experience.
- Improved error handling in local storage operations within useMapBookmarks and useRecentLocations composables.
- Standardized input elements across forms for consistency in user interaction.
2026-03-04 14:06:27 +03:00
761fbaed55 Refactor Docker and Makefile configurations for improved build processes
- Updated docker-compose.tools.yml to mount source code at /src and set working directory for backend tools, ensuring proper Go module caching.
- Modified Dockerfile.tools to install the latest golangci-lint version compatible with Go 1.24 and adjusted working directory for build-time operations.
- Enhanced Makefile to build backend tools before running tests and linting, ensuring dependencies are up-to-date and improving overall workflow efficiency.
- Refactored test and handler files to include error handling for database operations, enhancing reliability and debugging capabilities.
2026-03-04 13:59:00 +03:00
fc42d86ca0 Enhance map components and improve build processes
- Updated Makefile to include `--build` flag for `docker-compose.dev.yml` and `--no-cache` for `docker-compose.prod.yml` to ensure fresh builds.
- Added new CSS styles for Leaflet tooltips and popups to utilize theme colors, enhancing visual consistency.
- Enhanced MapView component with new props for markers and current zoom level, improving marker management and zoom functionality.
- Introduced new icons for copy and info actions to improve user interface clarity.
- Updated MapBookmarks and MapControls components to support new features and improve user experience with bookmarks and zoom controls.
- Refactored MapSearch to display coordinates and improve marker search functionality.
2026-03-04 12:49:31 +03:00
72 changed files with 5354 additions and 4621 deletions

View File

@@ -11,4 +11,5 @@ alwaysApply: true
- **Local run / build:** [docs/development.md](docs/development.md), [CONTRIBUTING.md](CONTRIBUTING.md). Dev ports: frontend 3000, backend 3080; prod: 8080. Build, test, lint, and format run via Docker (Makefile + docker-compose.tools.yml). - **Local run / build:** [docs/development.md](docs/development.md), [CONTRIBUTING.md](CONTRIBUTING.md). Dev ports: frontend 3000, backend 3080; prod: 8080. Build, test, lint, and format run via Docker (Makefile + docker-compose.tools.yml).
- **Docs:** [docs/](docs/) (architecture, API, configuration, development, deployment). Some docs are in Russian. - **Docs:** [docs/](docs/) (architecture, API, configuration, development, deployment). Some docs are in Russian.
- **Coding:** Write tests first before implementing any functionality. - **Coding:** Write tests first before implementing any functionality.
- **Running tests:** When the user asks to run tests or to verify changes, use the run-tests skill: [.cursor/skills/run-tests/SKILL.md](.cursor/skills/run-tests/SKILL.md). - **Running lint:** When the user asks to run the linter or to verify changes, use the run-lint skill: [.cursor/skills/run-lint/SKILL.md](.cursor/skills/run-lint/SKILL.md).
- **Running tests:** When the user asks to run tests or to verify changes, run lint first (run-lint skill), then use the run-tests skill: [.cursor/skills/run-tests/SKILL.md](.cursor/skills/run-tests/SKILL.md).

View File

@@ -0,0 +1,29 @@
---
name: run-lint
description: Runs backend (golangci-lint) and frontend (ESLint) linters for the hnh-map monorepo. Use when the user asks to run the linter, lint, or to check code style and lint errors.
---
# Run linter
## When to run
- User asks to run the linter, run lint, or check lint/style.
- After making code changes that should be validated by the project linters.
## What to run
Lint runs **in Docker** via the Makefile; no local Go or Node is required.
From the repo root:
- **Both backend and frontend:** `make lint` (runs golangci-lint then frontend ESLint in Docker).
Uses `docker-compose.tools.yml`; the first run may build the backend-tools and frontend-tools images.
## Scope
- **Backend-only changes** (e.g. `internal/`, `cmd/`): `make lint` still runs both; backend lint runs first.
- **Frontend-only changes** (e.g. `frontend-nuxt/`): `make lint` runs both; frontend lint runs second.
- **Both or unclear**: run `make lint`.
Report pass/fail and any linter errors or file/line references so the user can fix them.

View File

@@ -1,7 +1,10 @@
# Backend tools image: Go + golangci-lint for test, fmt, lint. # Backend tools image: Go + golangci-lint for test, fmt, lint.
# Source is mounted at /hnh-map at run time via docker-compose.tools.yml. # Source is mounted at /src at run time; this WORKDIR is only for build-time go mod download.
FROM golang:1.24-alpine FROM golang:1.24-alpine
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0 # v1.64+ required for Go 1.24 (export data format); see https://github.com/golangci/golangci-lint/issues/5225
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.0
WORKDIR /hnh-map WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download

View File

@@ -3,25 +3,31 @@
TOOLS_COMPOSE = docker compose -f docker-compose.tools.yml TOOLS_COMPOSE = docker compose -f docker-compose.tools.yml
dev: dev:
docker compose -f docker-compose.dev.yml up docker compose -f docker-compose.dev.yml up --build
build: build:
docker compose -f docker-compose.prod.yml build docker compose -f docker-compose.prod.yml build --no-cache
test: test-backend test-frontend test: test-backend test-frontend
test-backend: test-backend:
$(TOOLS_COMPOSE) run --rm backend-tools go test ./... $(TOOLS_COMPOSE) build backend-tools
$(TOOLS_COMPOSE) run --rm backend-tools sh -c "go mod download && go test ./..."
test-frontend: test-frontend:
$(TOOLS_COMPOSE) build frontend-tools
$(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm test" $(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm test"
lint: lint:
$(TOOLS_COMPOSE) run --rm backend-tools golangci-lint run $(TOOLS_COMPOSE) build backend-tools
$(TOOLS_COMPOSE) build frontend-tools
$(TOOLS_COMPOSE) run --rm backend-tools sh -c "go mod download && golangci-lint run"
$(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm run lint" $(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm run lint"
fmt: fmt:
$(TOOLS_COMPOSE) run --rm backend-tools go fmt ./... $(TOOLS_COMPOSE) build backend-tools
$(TOOLS_COMPOSE) build frontend-tools
$(TOOLS_COMPOSE) run --rm backend-tools sh -c "go mod download && go fmt ./..."
$(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm run format" $(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm run format"
generate-frontend: generate-frontend:

View File

@@ -1,13 +1,18 @@
# One-off tools: test, lint, fmt. Use with: docker compose -f docker-compose.tools.yml run --rm <service> <cmd> # One-off tools: test, lint, fmt. Use with: docker compose -f docker-compose.tools.yml run --rm <service> <cmd>
# Source is mounted so commands run against current code. # Source is mounted so commands run against current code.
# Backend: mount at /src so the image's /go module cache (from build) is not overwritten.
services: services:
backend-tools: backend-tools:
build: build:
context: . context: .
dockerfile: Dockerfile.tools dockerfile: Dockerfile.tools
working_dir: /src
environment:
GOPATH: /go
GOMODCACHE: /go/pkg/mod
volumes: volumes:
- .:/hnh-map - .:/src
# Default command; override when running (e.g. go test ./..., golangci-lint run). # Default command; override when running (e.g. go test ./..., golangci-lint run).
command: ["go", "test", "./..."] command: ["go", "test", "./..."]

View File

@@ -1,6 +1,26 @@
import { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick } from 'vue' import {
ref,
reactive,
computed,
watch,
watchEffect,
onMounted,
onUnmounted,
nextTick,
readonly,
} from 'vue'
export { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick } export {
ref,
reactive,
computed,
watch,
watchEffect,
onMounted,
onUnmounted,
nextTick,
readonly,
}
export function useRuntimeConfig() { export function useRuntimeConfig() {
return { return {

View File

@@ -4,6 +4,16 @@
</NuxtLayout> </NuxtLayout>
</template> </template>
<script setup lang="ts">
// Global error handling: on API auth failure, redirect to login
const { onApiError } = useMapApi()
const { fullUrl } = useAppPaths()
const unsubscribe = onApiError(() => {
if (import.meta.client) window.location.href = fullUrl('/login')
})
onUnmounted(() => unsubscribe())
</script>
<style> <style>
.page-enter-active, .page-enter-active,
.page-leave-active { .page-leave-active {
@@ -14,13 +24,3 @@
opacity: 0; opacity: 0;
} }
</style> </style>
<script setup lang="ts">
// Global error handling: on API auth failure, redirect to login
const { onApiError } = useMapApi()
const { fullUrl } = useAppPaths()
const unsubscribe = onApiError(() => {
if (import.meta.client) window.location.href = fullUrl('/login')
})
onUnmounted(() => unsubscribe())
</script>

View File

@@ -21,3 +21,39 @@
.leaflet-tile.tile-fresh { .leaflet-tile.tile-fresh {
animation: tile-fresh-glow 0.4s ease-out; animation: tile-fresh-glow 0.4s ease-out;
} }
/* Leaflet tooltip: use theme colors (dark/light) */
.leaflet-tooltip {
background-color: var(--color-base-100);
color: var(--color-base-content);
border-color: var(--color-base-300);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.leaflet-tooltip-top:before {
border-top-color: var(--color-base-100);
}
.leaflet-tooltip-bottom:before {
border-bottom-color: var(--color-base-100);
}
.leaflet-tooltip-left:before {
border-left-color: var(--color-base-100);
}
.leaflet-tooltip-right:before {
border-right-color: var(--color-base-100);
}
/* Leaflet popup: use theme colors (dark/light) */
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: var(--color-base-100);
color: var(--color-base-content);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.35);
}
.leaflet-container a.leaflet-popup-close-button {
color: var(--color-base-content);
opacity: 0.7;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
opacity: 1;
}

View File

@@ -88,24 +88,27 @@
</div> </div>
<MapControls <MapControls
:hide-markers="mapLogic.state.hideMarkers.value" :hide-markers="mapLogic.state.hideMarkers.value"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
:selected-map-id="mapLogic.state.selectedMapId.value" :selected-map-id="mapLogic.state.selectedMapId.value"
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
:overlay-map-id="mapLogic.state.overlayMapId.value" :overlay-map-id="mapLogic.state.overlayMapId.value"
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
:selected-marker-id="mapLogic.state.selectedMarkerId.value" :selected-marker-id="mapLogic.state.selectedMarkerId.value"
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
:selected-player-id="mapLogic.state.selectedPlayerId.value" :selected-player-id="mapLogic.state.selectedPlayerId.value"
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
:markers="allMarkers"
:current-zoom="currentZoom"
:current-map-id="mapLogic.state.mapid.value" :current-map-id="mapLogic.state.mapid.value"
:current-coords="mapLogic.state.displayCoords.value" :current-coords="mapLogic.state.displayCoords.value"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
@zoom-in="mapLogic.zoomIn(leafletMap)" @zoom-in="mapLogic.zoomIn(leafletMap)"
@zoom-out="mapLogic.zoomOutControl(leafletMap)" @zoom-out="mapLogic.zoomOutControl(leafletMap)"
@reset-view="mapLogic.resetView(leafletMap)" @reset-view="mapLogic.resetView(leafletMap)"
@set-zoom="onSetZoom"
@jump-to-marker="mapLogic.state.selectedMarkerId.value = $event" @jump-to-marker="mapLogic.state.selectedMarkerId.value = $event"
/> />
<MapContextMenu <MapContextMenu
@@ -147,7 +150,7 @@ import { useMapNavigate } from '~/composables/useMapNavigate'
import { useFullscreen } from '~/composables/useFullscreen' import { useFullscreen } from '~/composables/useFullscreen'
import { startMapUpdates, type UseMapUpdatesReturn, type SseConnectionState } from '~/composables/useMapUpdates' import { startMapUpdates, type UseMapUpdatesReturn, type SseConnectionState } from '~/composables/useMapUpdates'
import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers' import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers'
import type { MapInfo, ConfigResponse, MeResponse } from '~/types/api' import type { MapInfo, ConfigResponse, MeResponse, Marker as ApiMarker } from '~/types/api'
import type L from 'leaflet' import type L from 'leaflet'
const props = withDefaults( const props = withDefaults(
@@ -252,6 +255,10 @@ const maps = ref<MapInfo[]>([])
const mapsLoaded = ref(false) const mapsLoaded = ref(false)
const questGivers = ref<Array<{ id: number; name: string }>>([]) const questGivers = ref<Array<{ id: number; name: string }>>([])
const players = ref<Array<{ id: number; name: string }>>([]) const players = ref<Array<{ id: number; name: string }>>([])
/** All markers from API for search suggestions (updated when markers load or on merge). */
const allMarkers = ref<ApiMarker[]>([])
/** Current map zoom level (16) for zoom slider. Updated on zoomend. */
const currentZoom = ref(HnHDefaultZoom)
/** Single source of truth: layout updates me, we derive auths for context menu. */ /** Single source of truth: layout updates me, we derive auths for context menu. */
const me = useState<MeResponse | null>('me', () => null) const me = useState<MeResponse | null>('me', () => null)
const auths = computed(() => me.value?.auths ?? []) const auths = computed(() => me.value?.auths ?? [])
@@ -347,6 +354,10 @@ function reloadPage() {
if (import.meta.client) window.location.reload() if (import.meta.client) window.location.reload()
} }
function onSetZoom(z: number) {
if (leafletMap) leafletMap.setZoom(z)
}
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement const target = e.target as HTMLElement
const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '') const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')
@@ -462,6 +473,16 @@ onMounted(async () => {
getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value, getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value,
setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id }, setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id },
onMarkerContextMenu: mapLogic.openMarkerContextMenu, onMarkerContextMenu: mapLogic.openMarkerContextMenu,
onAddMarkerToBookmark: (markerId, getMarkerById) => {
const m = getMarkerById(markerId)
if (!m) return
openBookmarkModal(m.name, 'Add bookmark', {
kind: 'add',
mapId: m.map,
x: Math.floor(m.position.x / TileSize),
y: Math.floor(m.position.y / TileSize),
})
},
resolveIconUrl: (path) => resolvePath(path), resolveIconUrl: (path) => resolvePath(path),
fallbackIconUrl: FALLBACK_MARKER_ICON, fallbackIconUrl: FALLBACK_MARKER_ICON,
}) })
@@ -479,7 +500,9 @@ onMounted(async () => {
layersManager!.changeMap(mapTo) layersManager!.changeMap(mapTo)
api.getMarkers().then((body) => { api.getMarkers().then((body) => {
if (!mounted) return if (!mounted) return
layersManager!.updateMarkers(Array.isArray(body) ? body : []) const list = Array.isArray(body) ? body : []
allMarkers.value = list
layersManager!.updateMarkers(list)
questGivers.value = layersManager!.getQuestGivers() questGivers.value = layersManager!.getQuestGivers()
}) })
leafletMap!.setView(latLng, leafletMap!.getZoom()) leafletMap!.setView(latLng, leafletMap!.getZoom())
@@ -530,7 +553,9 @@ onMounted(async () => {
// Markers load asynchronously after map is visible. // Markers load asynchronously after map is visible.
api.getMarkers().then((body) => { api.getMarkers().then((body) => {
if (!mounted) return if (!mounted) return
layersManager!.updateMarkers(Array.isArray(body) ? body : []) const list = Array.isArray(body) ? body : []
allMarkers.value = list
layersManager!.updateMarkers(list)
questGivers.value = layersManager!.getQuestGivers() questGivers.value = layersManager!.getQuestGivers()
updateSelectedMarkerForBookmark() updateSelectedMarkerForBookmark()
}) })
@@ -650,6 +675,10 @@ onMounted(async () => {
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap)) leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
mapLogic.updateDisplayCoords(leafletMap) mapLogic.updateDisplayCoords(leafletMap)
currentZoom.value = leafletMap.getZoom()
leafletMap.on('zoomend', () => {
if (leafletMap) currentZoom.value = leafletMap.getZoom()
})
leafletMap.on('drag', () => { leafletMap.on('drag', () => {
mapLogic.state.trackingCharacterId.value = -1 mapLogic.state.trackingCharacterId.value = -1
}) })

View File

@@ -15,7 +15,7 @@
:readonly="readonly" :readonly="readonly"
:aria-describedby="ariaDescribedby" :aria-describedby="ariaDescribedby"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/> >
<button <button
type="button" type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-9 min-w-9 touch-manipulation" class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-9 min-w-9 touch-manipulation"
@@ -41,7 +41,14 @@ const props = withDefaults(
inputId?: string inputId?: string
ariaDescribedby?: string ariaDescribedby?: string
}>(), }>(),
{ required: false, autocomplete: 'off', inputId: undefined, ariaDescribedby: undefined } {
required: false,
autocomplete: 'off',
inputId: undefined,
ariaDescribedby: undefined,
label: undefined,
placeholder: undefined,
}
) )
const emit = defineEmits<{ 'update:modelValue': [value: string] }>() const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

View File

@@ -6,5 +6,5 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ inheritAttrs: false }) defineOptions({ name: 'AppSkeleton', inheritAttrs: false })
</script> </script>

View File

@@ -30,7 +30,7 @@ const props = withDefaults(
email?: string email?: string
size?: number size?: number
}>(), }>(),
{ size: 32 } { size: 32, email: undefined }
) )
const gravatarError = ref(false) const gravatarError = ref(false)

View File

@@ -0,0 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
</template>

View File

@@ -18,7 +18,7 @@
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder="Bookmark name" placeholder="Bookmark name"
@keydown.enter.prevent="onSubmit" @keydown.enter.prevent="onSubmit"
/> >
</div> </div>
<div class="modal-action"> <div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit"> <form method="dialog" @submit.prevent="onSubmit">

View File

@@ -7,7 +7,7 @@
<div class="flex flex-col gap-1 max-h-40 overflow-y-auto"> <div class="flex flex-col gap-1 max-h-40 overflow-y-auto">
<template v-if="bookmarks.length === 0"> <template v-if="bookmarks.length === 0">
<p class="text-xs text-base-content/60 py-1">No saved locations.</p> <p class="text-xs text-base-content/60 py-1">No saved locations.</p>
<p class="text-xs text-base-content/50 py-0">Add current location or a selected quest giver below.</p> <p class="text-xs text-base-content/50 py-0">Add your first location below.</p>
</template> </template>
<template v-else> <template v-else>
<div <div
@@ -49,7 +49,7 @@
class="btn btn-primary btn-sm w-full" class="btn btn-primary btn-sm w-full"
:class="touchFriendly ? 'min-h-11' : ''" :class="touchFriendly ? 'min-h-11' : ''"
:disabled="!selectedMarkerForBookmark" :disabled="!selectedMarkerForBookmark"
title="Add selected quest giver as bookmark" :title="selectedMarkerForBookmark ? 'Add selected quest giver as bookmark' : 'Select a quest giver from the list above to add it as a bookmark.'"
@click="onAddSelectedMarker" @click="onAddSelectedMarker"
> >
<icons-icon-plus class="size-4" /> <icons-icon-plus class="size-4" />

View File

@@ -39,22 +39,25 @@
<MapControlsContent <MapControlsContent
v-model:hide-markers="hideMarkers" v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect" :selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
:overlay-map-id="overlayMapId" :overlay-map-id="overlayMapId"
@update:overlay-map-id="(v) => (overlayMapId = v)"
:selected-marker-id-select="selectedMarkerIdSelect" :selected-marker-id-select="selectedMarkerIdSelect"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
:selected-player-id-select="selectedPlayerIdSelect" :selected-player-id-select="selectedPlayerIdSelect"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
:markers="markers"
:current-zoom="currentZoom"
:current-map-id="currentMapId ?? undefined" :current-map-id="currentMapId ?? undefined"
:current-coords="currentCoords" :current-coords="currentCoords"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
@update:overlay-map-id="(v) => (overlayMapId = v)"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
@zoom-in="$emit('zoomIn')" @zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')" @zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')" @reset-view="$emit('resetView')"
@set-zoom="$emit('setZoom', $event)"
@jump-to-marker="$emit('jumpToMarker', $event)" @jump-to-marker="$emit('jumpToMarker', $event)"
/> />
</div> </div>
@@ -130,23 +133,26 @@
<MapControlsContent <MapControlsContent
v-model:hide-markers="hideMarkers" v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect" :selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
:overlay-map-id="overlayMapId" :overlay-map-id="overlayMapId"
@update:overlay-map-id="(v) => (overlayMapId = v)"
:selected-marker-id-select="selectedMarkerIdSelect" :selected-marker-id-select="selectedMarkerIdSelect"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
:selected-player-id-select="selectedPlayerIdSelect" :selected-player-id-select="selectedPlayerIdSelect"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
:markers="markers"
:current-zoom="currentZoom"
:current-map-id="currentMapId ?? undefined" :current-map-id="currentMapId ?? undefined"
:current-coords="currentCoords" :current-coords="currentCoords"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
:touch-friendly="true" :touch-friendly="true"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
@update:overlay-map-id="(v) => (overlayMapId = v)"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
@zoom-in="$emit('zoomIn')" @zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')" @zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')" @reset-view="$emit('resetView')"
@set-zoom="$emit('setZoom', $event)"
@jump-to-marker="$emit('jumpToMarker', $event)" @jump-to-marker="$emit('jumpToMarker', $event)"
/> />
</div> </div>
@@ -169,7 +175,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfo } from '~/types/api' import type { MapInfo, Marker as ApiMarker } from '~/types/api'
import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue' import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue'
import MapControlsContent from '~/components/map/MapControlsContent.vue' import MapControlsContent from '~/components/map/MapControlsContent.vue'
@@ -185,9 +191,11 @@ interface Player {
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
maps: MapInfo[] maps?: MapInfo[]
questGivers: QuestGiver[] questGivers?: QuestGiver[]
players: Player[] players?: Player[]
markers?: ApiMarker[]
currentZoom?: number
currentMapId?: number | null currentMapId?: number | null
currentCoords?: { x: number; y: number; z: number } | null currentCoords?: { x: number; y: number; z: number } | null
selectedMarkerForBookmark?: SelectedMarkerForBookmark selectedMarkerForBookmark?: SelectedMarkerForBookmark
@@ -196,6 +204,8 @@ const props = withDefaults(
maps: () => [], maps: () => [],
questGivers: () => [], questGivers: () => [],
players: () => [], players: () => [],
markers: () => [],
currentZoom: 1,
currentMapId: null, currentMapId: null,
currentCoords: null, currentCoords: null,
selectedMarkerForBookmark: null, selectedMarkerForBookmark: null,
@@ -206,6 +216,7 @@ defineEmits<{
zoomIn: [] zoomIn: []
zoomOut: [] zoomOut: []
resetView: [] resetView: []
setZoom: [level: number]
jumpToMarker: [id: number] jumpToMarker: [id: number]
}>() }>()

View File

@@ -5,6 +5,8 @@
v-if="currentMapId != null && currentCoords != null" v-if="currentMapId != null && currentCoords != null"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:markers="markers"
:overlay-map-id="props.overlayMapId"
:current-map-id="currentMapId" :current-map-id="currentMapId"
:current-coords="currentCoords" :current-coords="currentCoords"
:touch-friendly="touchFriendly" :touch-friendly="touchFriendly"
@@ -48,6 +50,19 @@
<icons-icon-home /> <icons-icon-home />
</button> </button>
</div> </div>
<div class="flex items-center gap-2">
<input
type="range"
:min="zoomMin"
:max="zoomMax"
:value="currentZoom"
class="range range-primary range-sm flex-1"
:class="touchFriendly ? 'range-lg' : ''"
aria-label="Zoom level"
@input="onZoomSliderInput($event)"
>
<span class="text-xs font-mono w-6 text-right" aria-hidden="true">{{ currentZoom }}</span>
</div>
</section> </section>
<!-- Display --> <!-- Display -->
<section class="flex flex-col gap-2"> <section class="flex flex-col gap-2">
@@ -56,7 +71,7 @@
Display Display
</h3> </h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''"> <label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" /> <input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" >
<span>Hide markers</span> <span>Hide markers</span>
</label> </label>
</section> </section>
@@ -78,7 +93,16 @@
</select> </select>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label py-0"><span>Overlay Map</span></label> <label class="label py-0 flex items-center gap-1.5">
<span>Overlay Map</span>
<span
class="inline-flex text-base-content/60 cursor-help"
title="Overlay shows markers from another map on top of the current one."
aria-label="Overlay shows markers from another map on top of the current one."
>
<icons-icon-info class="size-3.5" />
</span>
</label>
<select <select
v-model="overlayMapId" v-model="overlayMapId"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation" class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
@@ -89,22 +113,43 @@
</select> </select>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label py-0"><span>Jump to Quest Giver</span></label> <label class="label py-0"><span>Jump to</span></label>
<div class="join w-full flex">
<button
type="button"
class="btn btn-sm join-item flex-1 touch-manipulation"
:class="[jumpToTab === 'quest' ? 'btn-active' : 'btn-ghost', touchFriendly ? 'min-h-11 text-base' : '']"
aria-pressed="jumpToTab === 'quest'"
@click="jumpToTab = 'quest'"
>
Quest giver
</button>
<button
type="button"
class="btn btn-sm join-item flex-1 touch-manipulation"
:class="[jumpToTab === 'player' ? 'btn-active' : 'btn-ghost', touchFriendly ? 'min-h-11 text-base' : '']"
aria-pressed="jumpToTab === 'player'"
@click="jumpToTab = 'player'"
>
Player
</button>
</div>
<select <select
v-if="jumpToTab === 'quest'"
v-model="selectedMarkerIdSelect" v-model="selectedMarkerIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation" class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation mt-1"
:class="touchFriendly ? 'min-h-11 text-base' : ''" :class="touchFriendly ? 'min-h-11 text-base' : ''"
aria-label="Select quest giver"
> >
<option value="">Select quest giver</option> <option value="">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="String(q.id)">{{ q.name }}</option> <option v-for="q in questGivers" :key="q.id" :value="String(q.id)">{{ q.name }}</option>
</select> </select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Player</span></label>
<select <select
v-else
v-model="selectedPlayerIdSelect" v-model="selectedPlayerIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation" class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation mt-1"
:class="touchFriendly ? 'min-h-11 text-base' : ''" :class="touchFriendly ? 'min-h-11 text-base' : ''"
aria-label="Select player"
> >
<option value="">Select player</option> <option value="">Select player</option>
<option v-for="p in players" :key="p.id" :value="String(p.id)">{{ p.name }}</option> <option v-for="p in players" :key="p.id" :value="String(p.id)">{{ p.name }}</option>
@@ -126,9 +171,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfo } from '~/types/api' import type { MapInfo, Marker as ApiMarker } from '~/types/api'
import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue' import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue'
import MapBookmarks from '~/components/map/MapBookmarks.vue' import MapBookmarks from '~/components/map/MapBookmarks.vue'
import { HnHMinZoom, HnHMaxZoom } from '~/lib/LeafletCustomTypes'
interface QuestGiver { interface QuestGiver {
id: number id: number
@@ -140,27 +186,33 @@ interface Player {
name: string name: string
} }
const zoomMin = HnHMinZoom
const zoomMax = HnHMaxZoom
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
maps: MapInfo[] maps: MapInfo[]
questGivers: QuestGiver[] questGivers: QuestGiver[]
players: Player[] players: Player[]
markers?: ApiMarker[]
touchFriendly?: boolean touchFriendly?: boolean
selectedMapIdSelect: string selectedMapIdSelect: string
overlayMapId: number overlayMapId: number
selectedMarkerIdSelect: string selectedMarkerIdSelect: string
selectedPlayerIdSelect: string selectedPlayerIdSelect: string
currentZoom?: number
currentMapId?: number currentMapId?: number
currentCoords?: { x: number; y: number; z: number } | null currentCoords?: { x: number; y: number; z: number } | null
selectedMarkerForBookmark?: SelectedMarkerForBookmark selectedMarkerForBookmark?: SelectedMarkerForBookmark
}>(), }>(),
{ touchFriendly: false, currentMapId: 0, currentCoords: null, selectedMarkerForBookmark: null } { touchFriendly: false, markers: () => [], currentZoom: 1, currentMapId: 0, currentCoords: null, selectedMarkerForBookmark: null }
) )
const emit = defineEmits<{ const emit = defineEmits<{
zoomIn: [] zoomIn: []
zoomOut: [] zoomOut: []
resetView: [] resetView: []
setZoom: [level: number]
jumpToMarker: [id: number] jumpToMarker: [id: number]
'update:hideMarkers': [v: boolean] 'update:hideMarkers': [v: boolean]
'update:selectedMapIdSelect': [v: string] 'update:selectedMapIdSelect': [v: string]
@@ -169,8 +221,16 @@ const emit = defineEmits<{
'update:selectedPlayerIdSelect': [v: string] 'update:selectedPlayerIdSelect': [v: string]
}>() }>()
function onZoomSliderInput(event: Event) {
const value = (event.target as HTMLInputElement).value
const level = Number(value)
if (!Number.isNaN(level)) emit('setZoom', level)
}
const hideMarkers = defineModel<boolean>('hideMarkers', { required: true }) const hideMarkers = defineModel<boolean>('hideMarkers', { required: true })
const jumpToTab = ref<'quest' | 'player'>('quest')
const selectedMapIdSelect = computed({ const selectedMapIdSelect = computed({
get: () => props.selectedMapIdSelect, get: () => props.selectedMapIdSelect,
set: (v) => emit('update:selectedMapIdSelect', v), set: (v) => emit('update:selectedMapIdSelect', v),

View File

@@ -11,8 +11,8 @@
<h3 id="coord-set-modal-title" class="font-bold text-lg">Rewrite tile coords</h3> <h3 id="coord-set-modal-title" class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p> <p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2"> <div class="flex gap-2">
<input ref="firstInputRef" v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" /> <input ref="firstInputRef" v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" >
<input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" /> <input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" >
</div> </div>
<div class="modal-action"> <div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit"> <form method="dialog" @submit.prevent="onSubmit">

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="displayCoords" v-if="displayCoords"
class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow cursor-pointer select-none transition-all hover:border-primary/50 hover:bg-base-100" class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-base bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow cursor-pointer select-none transition-all hover:border-primary/50 hover:bg-base-100 flex items-center gap-2"
aria-label="Current grid position and zoom click to copy share link" aria-label="Current grid position and zoom click to copy share link"
:title="copied ? 'Copied!' : 'Click to copy share link'" :title="copied ? 'Copied!' : 'Click to copy share link'"
role="button" role="button"
@@ -19,6 +19,7 @@
Copied! Copied!
</span> </span>
</span> </span>
<icons-icon-copy class="size-4 shrink-0 opacity-70" aria-hidden="true" />
</div> </div>
</template> </template>

View File

@@ -22,7 +22,7 @@
@keydown.enter="onEnter" @keydown.enter="onEnter"
@keydown.down.prevent="moveHighlight(1)" @keydown.down.prevent="moveHighlight(1)"
@keydown.up.prevent="moveHighlight(-1)" @keydown.up.prevent="moveHighlight(-1)"
/> >
<button <button
v-if="query" v-if="query"
type="button" type="button"
@@ -78,7 +78,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfo } from '~/types/api' import type { MapInfo, Marker as ApiMarker } from '~/types/api'
import { TileSize } from '~/lib/LeafletCustomTypes'
import { useMapNavigate } from '~/composables/useMapNavigate' import { useMapNavigate } from '~/composables/useMapNavigate'
import { useRecentLocations } from '~/composables/useRecentLocations' import { useRecentLocations } from '~/composables/useRecentLocations'
@@ -86,11 +87,13 @@ const props = withDefaults(
defineProps<{ defineProps<{
maps: MapInfo[] maps: MapInfo[]
questGivers: Array<{ id: number; name: string }> questGivers: Array<{ id: number; name: string }>
markers?: ApiMarker[]
overlayMapId?: number
currentMapId: number currentMapId: number
currentCoords: { x: number; y: number; z: number } | null currentCoords: { x: number; y: number; z: number } | null
touchFriendly?: boolean touchFriendly?: boolean
}>(), }>(),
{ touchFriendly: false } { touchFriendly: false, markers: () => [], overlayMapId: -1 }
) )
const { goToCoords } = useMapNavigate() const { goToCoords } = useMapNavigate()
@@ -147,22 +150,37 @@ const suggestions = computed<Suggestion[]>(() => {
} }
const list: Suggestion[] = [] const list: Suggestion[] = []
for (const qg of props.questGivers) { const overlayId = props.overlayMapId ?? -1
if (qg.name.toLowerCase().includes(q)) { const visibleMarkers = (props.markers ?? []).filter(
(m) =>
!m.hidden &&
(m.map === props.currentMapId || (overlayId >= 0 && m.map === overlayId))
)
const qLower = q.toLowerCase()
for (const m of visibleMarkers) {
if (m.name.toLowerCase().includes(qLower)) {
const gridX = Math.floor(m.position.x / TileSize)
const gridY = Math.floor(m.position.y / TileSize)
list.push({ list.push({
key: `qg-${qg.id}`, key: `marker-${m.id}`,
label: qg.name, label: `${m.name} · ${gridX}, ${gridY}`,
mapId: props.currentMapId, mapId: m.map,
x: 0, x: gridX,
y: 0, y: gridY,
zoom: undefined, zoom: undefined,
markerId: qg.id, markerId: m.id,
}) })
} }
} }
if (list.length > 0) return list.slice(0, 8) // Prefer quest givers (match by id in questGivers) so they appear first when query matches both
const qgIds = new Set(props.questGivers.map((qg) => qg.id))
return [] list.sort((a, b) => {
const aQg = a.markerId != null && qgIds.has(a.markerId) ? 1 : 0
const bQg = b.markerId != null && qgIds.has(b.markerId) ? 1 : 0
if (bQg !== aQg) return bQg - aQg
return 0
})
return list.slice(0, 8)
}) })
function scheduleCloseDropdown() { function scheduleCloseDropdown() {

View File

@@ -1,10 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAppPaths } from '../useAppPaths'
const useRuntimeConfigMock = vi.fn() const useRuntimeConfigMock = vi.fn()
vi.stubGlobal('useRuntimeConfig', useRuntimeConfigMock) vi.stubGlobal('useRuntimeConfig', useRuntimeConfigMock)
import { useAppPaths } from '../useAppPaths'
describe('useAppPaths with default base /', () => { describe('useAppPaths with default base /', () => {
beforeEach(() => { beforeEach(() => {
useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/' } }) useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/' } })

View File

@@ -1,12 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useMapApi } from '../useMapApi'
vi.stubGlobal('useRuntimeConfig', () => ({ vi.stubGlobal('useRuntimeConfig', () => ({
app: { baseURL: '/' }, app: { baseURL: '/' },
public: { apiBase: '/map/api' }, public: { apiBase: '/map/api' },
})) }))
import { useMapApi } from '../useMapApi'
function mockFetch(status: number, body: unknown, contentType = 'application/json') { function mockFetch(status: number, body: unknown, contentType = 'application/json') {
return vi.fn().mockResolvedValue({ return vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue' import { ref } from 'vue'
import { useMapBookmarks } from '../useMapBookmarks'
const stateByKey: Record<string, ReturnType<typeof ref>> = {} const stateByKey: Record<string, ReturnType<typeof ref>> = {}
const useStateMock = vi.fn((key: string, init: () => unknown) => { const useStateMock = vi.fn((key: string, init: () => unknown) => {
if (!stateByKey[key]) { if (!stateByKey[key]) {
@@ -18,15 +20,13 @@ const localStorageMock = {
storage[key] = value storage[key] = value
}), }),
clear: vi.fn(() => { clear: vi.fn(() => {
for (const k of Object.keys(storage)) delete storage[k] delete storage['hnh-map-bookmarks']
}), }),
} }
vi.stubGlobal('localStorage', localStorageMock) vi.stubGlobal('localStorage', localStorageMock)
vi.stubGlobal('import.meta.server', false) vi.stubGlobal('import.meta.server', false)
vi.stubGlobal('import.meta.client', true) vi.stubGlobal('import.meta.client', true)
import { useMapBookmarks } from '../useMapBookmarks'
describe('useMapBookmarks', () => { describe('useMapBookmarks', () => {
beforeEach(() => { beforeEach(() => {
storage['hnh-map-bookmarks'] = '[]' storage['hnh-map-bookmarks'] = '[]'

View File

@@ -1,11 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import type { Map } from 'leaflet'
import { useMapLogic } from '../useMapLogic'
vi.stubGlobal('ref', ref) vi.stubGlobal('ref', ref)
vi.stubGlobal('reactive', reactive) vi.stubGlobal('reactive', reactive)
import { useMapLogic } from '../useMapLogic'
describe('useMapLogic', () => { describe('useMapLogic', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -27,7 +28,7 @@ describe('useMapLogic', () => {
it('zoomIn calls map.zoomIn', () => { it('zoomIn calls map.zoomIn', () => {
const { zoomIn } = useMapLogic() const { zoomIn } = useMapLogic()
const mockMap = { zoomIn: vi.fn() } const mockMap = { zoomIn: vi.fn() }
zoomIn(mockMap as unknown as import('leaflet').Map) zoomIn(mockMap as unknown as Map)
expect(mockMap.zoomIn).toHaveBeenCalled() expect(mockMap.zoomIn).toHaveBeenCalled()
}) })
@@ -39,7 +40,7 @@ describe('useMapLogic', () => {
it('zoomOutControl calls map.zoomOut', () => { it('zoomOutControl calls map.zoomOut', () => {
const { zoomOutControl } = useMapLogic() const { zoomOutControl } = useMapLogic()
const mockMap = { zoomOut: vi.fn() } const mockMap = { zoomOut: vi.fn() }
zoomOutControl(mockMap as unknown as import('leaflet').Map) zoomOutControl(mockMap as unknown as Map)
expect(mockMap.zoomOut).toHaveBeenCalled() expect(mockMap.zoomOut).toHaveBeenCalled()
}) })
@@ -47,7 +48,7 @@ describe('useMapLogic', () => {
const { state, resetView } = useMapLogic() const { state, resetView } = useMapLogic()
state.trackingCharacterId.value = 42 state.trackingCharacterId.value = 42
const mockMap = { setView: vi.fn() } const mockMap = { setView: vi.fn() }
resetView(mockMap as unknown as import('leaflet').Map) resetView(mockMap as unknown as Map)
expect(state.trackingCharacterId.value).toBe(-1) expect(state.trackingCharacterId.value).toBe(-1)
expect(mockMap.setView).toHaveBeenCalledWith([0, 0], 1, { animate: false }) expect(mockMap.setView).toHaveBeenCalledWith([0, 0], 1, { animate: false })
}) })
@@ -59,7 +60,7 @@ describe('useMapLogic', () => {
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })), getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
getZoom: vi.fn(() => 3), getZoom: vi.fn(() => 3),
} }
updateDisplayCoords(mockMap as unknown as import('leaflet').Map) updateDisplayCoords(mockMap as unknown as Map)
expect(state.displayCoords.value).toEqual({ x: 5, y: 3, z: 3 }) expect(state.displayCoords.value).toEqual({ x: 5, y: 3, z: 3 })
}) })
@@ -72,7 +73,7 @@ describe('useMapLogic', () => {
it('toLatLng calls map.unproject', () => { it('toLatLng calls map.unproject', () => {
const { toLatLng } = useMapLogic() const { toLatLng } = useMapLogic()
const mockMap = { unproject: vi.fn(() => ({ lat: 1, lng: 2 })) } const mockMap = { unproject: vi.fn(() => ({ lat: 1, lng: 2 })) }
const result = toLatLng(mockMap as unknown as import('leaflet').Map, 100, 200) const result = toLatLng(mockMap as unknown as Map, 100, 200)
expect(mockMap.unproject).toHaveBeenCalledWith([100, 200], 6) expect(mockMap.unproject).toHaveBeenCalledWith([100, 200], 6)
expect(result).toEqual({ lat: 1, lng: 2 }) expect(result).toEqual({ lat: 1, lng: 2 })
}) })

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from '../useToast'
const stateByKey: Record<string, ReturnType<typeof ref>> = {} const stateByKey: Record<string, ReturnType<typeof ref>> = {}
const useStateMock = vi.fn((key: string, init: () => unknown) => { const useStateMock = vi.fn((key: string, init: () => unknown) => {
if (!stateByKey[key]) { if (!stateByKey[key]) {
@@ -10,8 +12,6 @@ const useStateMock = vi.fn((key: string, init: () => unknown) => {
}) })
vi.stubGlobal('useState', useStateMock) vi.stubGlobal('useState', useStateMock)
import { useToast } from '../useToast'
describe('useToast', () => { describe('useToast', () => {
beforeEach(() => { beforeEach(() => {
stateByKey['hnh-map-toasts'] = ref([]) stateByKey['hnh-map-toasts'] = ref([])

View File

@@ -1,3 +1,5 @@
import { readonly } from 'vue'
export interface MapBookmark { export interface MapBookmark {
id: string id: string
name: string name: string
@@ -29,7 +31,7 @@ function saveBookmarks(bookmarks: MapBookmark[]) {
if (import.meta.server) return if (import.meta.server) return
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks.slice(0, MAX_BOOKMARKS))) localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks.slice(0, MAX_BOOKMARKS)))
} catch (_) {} } catch { /* ignore */ }
} }
export function useMapBookmarks() { export function useMapBookmarks() {

View File

@@ -1,5 +1,5 @@
import type L from 'leaflet' import type L from 'leaflet'
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { createMarker, type MapMarker, type MarkerData, type MapViewRef } from '~/lib/Marker' import { createMarker, type MapMarker, type MarkerData, type MapViewRef } from '~/lib/Marker'
import { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character' import { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character'
import { import {
@@ -12,11 +12,21 @@ import {
import type { SmartTileLayer } from '~/lib/SmartTileLayer' import type { SmartTileLayer } from '~/lib/SmartTileLayer'
import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api' import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api'
type LeafletModule = L
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer> type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export interface MapLayersOptions { export interface MapLayersOptions {
/** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */ /** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */
L: typeof import('leaflet') L: LeafletModule
map: L.Map map: L.Map
markerLayer: L.LayerGroup markerLayer: L.LayerGroup
layer: SmartTileLayerInstance layer: SmartTileLayerInstance
@@ -24,10 +34,12 @@ export interface MapLayersOptions {
getCurrentMapId: () => number getCurrentMapId: () => number
setCurrentMapId: (id: number) => void setCurrentMapId: (id: number) => void
setSelectedMapId: (id: number) => void setSelectedMapId: (id: number) => void
getAuths: () => string[] getAuths?: () => string[]
getTrackingCharacterId: () => number getTrackingCharacterId: () => number
setTrackingCharacterId: (id: number) => void setTrackingCharacterId: (id: number) => void
onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void
/** Called when user clicks "Add to saved locations" in marker popup. Receives marker id and getter to resolve marker. */
onAddMarkerToBookmark?: (markerId: number, getMarkerById: (id: number) => MapMarker | undefined) => void
/** Resolves relative marker icon path to absolute URL. If omitted, relative paths are used. */ /** Resolves relative marker icon path to absolute URL. If omitted, relative paths are used. */
resolveIconUrl?: (path: string) => string resolveIconUrl?: (path: string) => string
/** Fallback icon URL when a marker image fails to load. */ /** Fallback icon URL when a marker image fails to load. */
@@ -58,10 +70,11 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
getCurrentMapId, getCurrentMapId,
setCurrentMapId, setCurrentMapId,
setSelectedMapId, setSelectedMapId,
getAuths, getAuths: _getAuths,
getTrackingCharacterId, getTrackingCharacterId,
setTrackingCharacterId, setTrackingCharacterId,
onMarkerContextMenu, onMarkerContextMenu,
onAddMarkerToBookmark,
resolveIconUrl, resolveIconUrl,
fallbackIconUrl, fallbackIconUrl,
} = options } = options
@@ -112,7 +125,30 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
(marker: MapMarker) => { (marker: MapMarker) => {
if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx) if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx)
marker.setClickCallback(() => { marker.setClickCallback(() => {
if (marker.leafletMarker) map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom) if (marker.leafletMarker) {
if (onAddMarkerToBookmark) {
const gridX = Math.floor(marker.position.x / TileSize)
const gridY = Math.floor(marker.position.y / TileSize)
const div = document.createElement('div')
div.className = 'map-marker-popup text-sm'
div.innerHTML = `
<p class="font-medium mb-1">${escapeHtml(marker.name)}</p>
<p class="text-base-content/70 text-xs mb-2 font-mono">${gridX}, ${gridY}</p>
<button type="button" class="btn btn-primary btn-xs w-full">Add to saved locations</button>
`
const btn = div.querySelector('button')
if (btn) {
btn.addEventListener('click', () => {
onAddMarkerToBookmark(marker.id, findMarkerById)
marker.leafletMarker?.closePopup()
})
}
marker.leafletMarker.unbindPopup()
marker.leafletMarker.bindPopup(div, { minWidth: 140, autoPan: true }).openPopup()
} else {
map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom)
}
}
}) })
marker.setContextMenu((mev: L.LeafletMouseEvent) => { marker.setContextMenu((mev: L.LeafletMouseEvent) => {
mev.originalEvent.preventDefault() mev.originalEvent.preventDefault()

View File

@@ -38,6 +38,9 @@ export interface UseMapUpdatesReturn {
const RECONNECT_INITIAL_MS = 1000 const RECONNECT_INITIAL_MS = 1000
const RECONNECT_MAX_MS = 30000 const RECONNECT_MAX_MS = 30000
/** If no SSE message received for this long, treat connection as stale and reconnect. */
const STALE_CONNECTION_MS = 65000
const STALE_CHECK_INTERVAL_MS = 30000
export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn { export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn {
const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options
@@ -50,6 +53,8 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet
let batchScheduled = false let batchScheduled = false
let source: EventSource | null = null let source: EventSource | null = null
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null
let staleCheckIntervalId: ReturnType<typeof setInterval> | null = null
let lastMessageTime = 0
let reconnectDelayMs = RECONNECT_INITIAL_MS let reconnectDelayMs = RECONNECT_INITIAL_MS
let destroyed = false let destroyed = false
@@ -79,15 +84,22 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet
overlayLayer.cache[key] = u.T overlayLayer.cache[key] = u.T
} }
const visible = getVisibleTileBounds() const visible = getVisibleTileBounds()
// u.Z is backend storage zoom (0..5); visible.zoom is map zoom (1..6). With zoomReverse, current backend Z = maxZoom - mapZoom.
const currentBackendZ = visible ? layer.options.maxZoom - visible.zoom : null
let needRedraw = false
for (const u of updates) { for (const u of updates) {
if (visible && u.Z !== visible.zoom) continue if (visible && currentBackendZ != null && u.Z !== currentBackendZ) continue
if ( if (
visible && visible &&
(u.X < visible.minX || u.X > visible.maxX || u.Y < visible.minY || u.Y > visible.maxY) (u.X < visible.minX || u.X > visible.maxX || u.Y < visible.minY || u.Y > visible.maxY)
) )
continue continue
if (layer.map === u.M) layer.refresh(u.X, u.Y, u.Z) if (layer.map === u.M && !layer.refresh(u.X, u.Y, u.Z)) needRedraw = true
if (overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z) if (overlayLayer.map === u.M && !overlayLayer.refresh(u.X, u.Y, u.Z)) needRedraw = true
}
if (needRedraw) {
layer.redraw()
overlayLayer.redraw()
} }
} }
@@ -99,12 +111,30 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet
function connect() { function connect() {
if (destroyed || !import.meta.client) return if (destroyed || !import.meta.client) return
if (staleCheckIntervalId != null) {
clearInterval(staleCheckIntervalId)
staleCheckIntervalId = null
}
source = new EventSource(updatesUrl) source = new EventSource(updatesUrl)
if (connectionStateRef) connectionStateRef.value = 'connecting' if (connectionStateRef) connectionStateRef.value = 'connecting'
source.onopen = () => { source.onopen = () => {
if (connectionStateRef) connectionStateRef.value = 'open' if (connectionStateRef) connectionStateRef.value = 'open'
lastMessageTime = Date.now()
reconnectDelayMs = RECONNECT_INITIAL_MS reconnectDelayMs = RECONNECT_INITIAL_MS
staleCheckIntervalId = setInterval(() => {
if (destroyed || !source) return
if (Date.now() - lastMessageTime > STALE_CONNECTION_MS) {
if (staleCheckIntervalId != null) {
clearInterval(staleCheckIntervalId)
staleCheckIntervalId = null
}
source.close()
source = null
if (connectionStateRef) connectionStateRef.value = 'error'
connect()
}
}, STALE_CHECK_INTERVAL_MS)
} }
source.onerror = () => { source.onerror = () => {
@@ -121,6 +151,7 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet
} }
source.onmessage = (event: MessageEvent) => { source.onmessage = (event: MessageEvent) => {
lastMessageTime = Date.now()
if (connectionStateRef) connectionStateRef.value = 'open' if (connectionStateRef) connectionStateRef.value = 'open'
try { try {
const raw: unknown = event?.data const raw: unknown = event?.data
@@ -157,6 +188,10 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet
function cleanup() { function cleanup() {
destroyed = true destroyed = true
if (staleCheckIntervalId != null) {
clearInterval(staleCheckIntervalId)
staleCheckIntervalId = null
}
if (reconnectTimeoutId != null) { if (reconnectTimeoutId != null) {
clearTimeout(reconnectTimeoutId) clearTimeout(reconnectTimeoutId)
reconnectTimeoutId = null reconnectTimeoutId = null

View File

@@ -26,7 +26,7 @@ function saveRecent(list: RecentLocation[]) {
if (import.meta.server) return if (import.meta.server) return
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list.slice(0, MAX_RECENT))) localStorage.setItem(STORAGE_KEY, JSON.stringify(list.slice(0, MAX_RECENT)))
} catch (_) {} } catch { /* ignore */ }
} }
export function useRecentLocations() { export function useRecentLocations() {

View File

@@ -1,3 +1,5 @@
import { readonly } from 'vue'
export type ToastType = 'success' | 'error' | 'info' export type ToastType = 'success' | 'error' | 'info'
export interface Toast { export interface Toast {

View File

@@ -1,11 +1,14 @@
// @ts-check // @ts-check
import withNuxt from './.nuxt/eslint.config.mjs' import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({ export default withNuxt(
{ ignores: ['eslint.config.mjs'] },
{
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn', '@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }], '@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }],
'@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/no-require-imports': 'off',
}, },
}) },
)

View File

@@ -12,7 +12,7 @@
type="checkbox" type="checkbox"
class="drawer-toggle" class="drawer-toggle"
@change="onDrawerChange" @change="onDrawerChange"
/> >
<div class="drawer-content flex flex-col h-screen overflow-hidden"> <div class="drawer-content flex flex-col h-screen overflow-hidden">
<header class="navbar relative z-[1100] bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0"> <header class="navbar relative z-[1100] bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0">
<NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200"> <NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200">
@@ -87,7 +87,7 @@
class="toggle toggle-sm toggle-primary shrink-0" class="toggle toggle-sm toggle-primary shrink-0"
:checked="dark" :checked="dark"
@change="onThemeToggle" @change="onThemeToggle"
/> >
</label> </label>
</li> </li>
<li> <li>
@@ -177,7 +177,7 @@
class="toggle toggle-sm toggle-primary shrink-0" class="toggle toggle-sm toggle-primary shrink-0"
:checked="dark" :checked="dark"
@change="onThemeToggle" @change="onThemeToggle"
/> >
</label> </label>
</li> </li>
<li> <li>
@@ -296,7 +296,7 @@ async function loadConfig(loadToken: number) {
const config = await useMapApi().getConfig() const config = await useMapApi().getConfig()
if (loadToken !== loadId) return if (loadToken !== loadId) return
if (config?.title) title.value = config.title if (config?.title) title.value = config.title
} catch (_) {} } catch { /* ignore */ }
} }
onMounted(() => { onMounted(() => {

View File

@@ -1,8 +1,8 @@
import type L from 'leaflet' import type L from 'leaflet'
import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors' import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors'
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
export type LeafletApi = typeof import('leaflet') export type LeafletApi = L
function buildCharacterIconUrl(colors: CharacterColors): string { function buildCharacterIconUrl(colors: CharacterColors): string {
const svg = const svg =
@@ -16,9 +16,10 @@ function buildCharacterIconUrl(colors: CharacterColors): string {
export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon { export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon {
return new L.Icon({ return new L.Icon({
iconUrl: buildCharacterIconUrl(colors), iconUrl: buildCharacterIconUrl(colors),
iconSize: [24, 32], iconSize: [25, 32],
iconAnchor: [12, 32], iconAnchor: [12, 17],
popupAnchor: [0, -32], popupAnchor: [0, -32],
tooltipAnchor: [12, 0],
}) })
} }
@@ -54,10 +55,17 @@ export interface MapCharacter {
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
} }
const CHARACTER_MOVE_DURATION_MS = 280
function easeOutQuad(t: number): number {
return t * (2 - t)
}
export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter { export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter {
let leafletMarker: L.Marker | null = null let leafletMarker: L.Marker | null = null
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
let ownedByMe = data.ownedByMe ?? false let ownedByMe = data.ownedByMe ?? false
let animationFrameId: number | null = null
const colors = getColorForCharacterId(data.id, { ownedByMe }) const colors = getColorForCharacterId(data.id, { ownedByMe })
let characterIcon = createCharacterIcon(L, colors) let characterIcon = createCharacterIcon(L, colors)
@@ -81,6 +89,10 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
}, },
remove(mapview: CharacterMapViewRef): void { remove(mapview: CharacterMapViewRef): void {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
if (leafletMarker) { if (leafletMarker) {
const layer = mapview.markerLayer ?? mapview.map const layer = mapview.markerLayer ?? mapview.map
layer.removeLayer(leafletMarker) layer.removeLayer(leafletMarker)
@@ -91,12 +103,22 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
add(mapview: CharacterMapViewRef): void { add(mapview: CharacterMapViewRef): void {
if (character.map === mapview.mapid) { if (character.map === mapview.mapid) {
const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom) const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
leafletMarker = L.marker(position, { icon: characterIcon, title: character.name }) leafletMarker = L.marker(position, { icon: characterIcon })
const gridX = Math.floor(character.position.x / TileSize)
const gridY = Math.floor(character.position.y / TileSize)
const tooltipContent = `${character.name} · ${gridX}, ${gridY}`
leafletMarker.bindTooltip(tooltipContent, {
direction: 'top',
permanent: false,
offset: L.point(-10.5, -18),
})
leafletMarker.on('click', (e: L.LeafletMouseEvent) => { leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
if (onClick) onClick(e) if (onClick) onClick(e)
}) })
const targetLayer = mapview.markerLayer ?? mapview.map const targetLayer = mapview.markerLayer ?? mapview.map
leafletMarker.addTo(targetLayer) leafletMarker.addTo(targetLayer)
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
if (markerEl) markerEl.setAttribute('aria-label', character.name)
} }
}, },
@@ -114,11 +136,51 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
character.position = { ...updated.position } character.position = { ...updated.position }
if (!leafletMarker && character.map === mapview.mapid) { if (!leafletMarker && character.map === mapview.mapid) {
character.add(mapview) character.add(mapview)
return
} }
if (leafletMarker) { if (!leafletMarker) return
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
leafletMarker.setLatLng(position) const newLatLng = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
const updateTooltip = (): void => {
const gridX = Math.floor(character.position.x / TileSize)
const gridY = Math.floor(character.position.y / TileSize)
leafletMarker?.setTooltipContent(`${character.name} · ${gridX}, ${gridY}`)
} }
const from = leafletMarker.getLatLng()
const latDelta = newLatLng.lat - from.lat
const lngDelta = newLatLng.lng - from.lng
const distSq = latDelta * latDelta + lngDelta * lngDelta
if (distSq < 1e-12) {
updateTooltip()
return
}
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
const start = typeof performance !== 'undefined' ? performance.now() : Date.now()
const duration = CHARACTER_MOVE_DURATION_MS
const tick = (): void => {
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - start
const t = Math.min(1, elapsed / duration)
const eased = easeOutQuad(t)
leafletMarker?.setLatLng({
lat: from.lat + latDelta * eased,
lng: from.lng + lngDelta * eased,
})
if (t >= 1) {
animationFrameId = null
leafletMarker?.setLatLng(newLatLng)
updateTooltip()
return
}
animationFrameId = requestAnimationFrame(tick)
}
animationFrameId = requestAnimationFrame(tick)
}, },
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void { setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {

View File

@@ -1,5 +1,5 @@
import type L from 'leaflet' import type L from 'leaflet'
import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom, ImageIcon, TileSize } from '~/lib/LeafletCustomTypes'
export interface MarkerData { export interface MarkerData {
id: number id: number
@@ -48,7 +48,7 @@ export interface MarkerIconOptions {
fallbackIconUrl?: string fallbackIconUrl?: string
} }
export type LeafletApi = typeof import('leaflet') export type LeafletApi = L
export function createMarker( export function createMarker(
data: MarkerData, data: MarkerData,
@@ -85,10 +85,16 @@ export function createMarker(
if (!marker.hidden) { if (!marker.hidden) {
const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path) const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path)
const fallback = iconOptions?.fallbackIconUrl const fallback = iconOptions?.fallbackIconUrl
const iconUrl =
marker.name === 'Cave' && marker.image === 'gfx/terobjs/mm/custom'
? resolve('gfx/terobjs/mm/cave.png')
: marker.image === 'gfx/terobjs/mm/custom'
? resolve('gfx/terobjs/mm/custom.png')
: resolve(`${marker.image}.png`)
let icon: L.Icon let icon: L.Icon
if (marker.image === 'gfx/terobjs/mm/custom') { if (marker.image === 'gfx/terobjs/mm/custom' && marker.name !== 'Cave') {
icon = new ImageIcon({ icon = new ImageIcon({
iconUrl: resolve('gfx/terobjs/mm/custom.png'), iconUrl,
iconSize: [21, 23], iconSize: [21, 23],
iconAnchor: [11, 21], iconAnchor: [11, 21],
popupAnchor: [1, 3], popupAnchor: [1, 3],
@@ -97,14 +103,22 @@ export function createMarker(
}) })
} else { } else {
icon = new ImageIcon({ icon = new ImageIcon({
iconUrl: resolve(`${marker.image}.png`), iconUrl,
iconSize: [32, 32], iconSize: [32, 32],
fallbackIconUrl: fallback, fallbackIconUrl: fallback,
}) })
} }
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom) const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
leafletMarker = L.marker(position, { icon, title: marker.name }) leafletMarker = L.marker(position, { icon })
const gridX = Math.floor(marker.position.x / TileSize)
const gridY = Math.floor(marker.position.y / TileSize)
const tooltipContent = `${marker.name} · ${gridX}, ${gridY}`
leafletMarker.bindTooltip(tooltipContent, {
direction: 'top',
permanent: false,
offset: L.point(0, -14),
})
leafletMarker.addTo(mapview.markerLayer) leafletMarker.addTo(mapview.markerLayer)
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.() const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
if (markerEl) markerEl.setAttribute('aria-label', marker.name) if (markerEl) markerEl.setAttribute('aria-label', marker.name)
@@ -125,6 +139,9 @@ export function createMarker(
if (leafletMarker) { if (leafletMarker) {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
leafletMarker.setLatLng(position) leafletMarker.setLatLng(position)
const gridX = Math.floor(updated.position.x / TileSize)
const gridY = Math.floor(updated.position.y / TileSize)
leafletMarker.setTooltipContent(`${marker.name} · ${gridX}, ${gridY}`)
} }
}, },

View File

@@ -58,7 +58,7 @@ export const SmartTileLayer = L.TileLayer.extend({
return Util.template(this._url, Util.extend(data, this.options)) return Util.template(this._url, Util.extend(data, this.options))
}, },
refresh(x: number, y: number, z: number) { refresh(x: number, y: number, z: number): boolean {
let zoom = z let zoom = z
const maxZoom = this.options.maxZoom const maxZoom = this.options.maxZoom
const zoomReverse = this.options.zoomReverse const zoomReverse = this.options.zoomReverse
@@ -71,19 +71,20 @@ export const SmartTileLayer = L.TileLayer.extend({
const key = `${x}:${y}:${zoom}` const key = `${x}:${y}:${zoom}`
const tile = this._tiles[key] const tile = this._tiles[key]
if (!tile?.el) return if (!tile?.el) return false
const newUrl = this.getTrueTileUrl({ x, y }, z) const newUrl = this.getTrueTileUrl({ x, y }, z)
if (tile.el.dataset.tileUrl === newUrl) return if (tile.el.dataset.tileUrl === newUrl) return true
tile.el.dataset.tileUrl = newUrl tile.el.dataset.tileUrl = newUrl
tile.el.src = newUrl tile.el.src = newUrl
tile.el.classList.add('tile-fresh') tile.el.classList.add('tile-fresh')
const el = tile.el const el = tile.el
setTimeout(() => el.classList.remove('tile-fresh'), 400) setTimeout(() => el.classList.remove('tile-fresh'), 400)
return true
}, },
}) as unknown as new (urlTemplate: string, options?: L.TileLayerOptions) => L.TileLayer & { }) as unknown as new (urlTemplate: string, options?: L.TileLayerOptions) => L.TileLayer & {
cache: SmartTileLayerCache cache: SmartTileLayerCache
invalidTile: string invalidTile: string
map: number map: number
getTrueTileUrl: (coords: { x: number; y: number }, zoom: number) => string getTrueTileUrl: (coords: { x: number; y: number }, zoom: number) => string
refresh: (x: number, y: number, z: number) => void refresh: (x: number, y: number, z: number) => boolean
} }

View File

@@ -39,7 +39,10 @@ export function uniqueListUpdate<T extends Identifiable>(
if (addCallback) { if (addCallback) {
elementsToAdd.forEach((it) => addCallback(it)) elementsToAdd.forEach((it) => addCallback(it))
} }
elementsToRemove.forEach((it) => delete list.elements[String(it.id)]) const toRemove = new Set(elementsToRemove.map((it) => String(it.id)))
list.elements = Object.fromEntries(
Object.entries(list.elements).filter(([id]) => !toRemove.has(id))
) as Record<string, T>
elementsToAdd.forEach((it) => (list.elements[String(it.id)] = it)) elementsToAdd.forEach((it) => (list.elements[String(it.id)] = it))
} }

View File

@@ -1,31 +1,44 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('leaflet', () => { import type L from 'leaflet'
import type { Map, LayerGroup } from 'leaflet'
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
const { leafletMock } = vi.hoisted(() => {
const markerMock = { const markerMock = {
on: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(), addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(), setLatLng: vi.fn().mockReturnThis(),
setIcon: vi.fn().mockReturnThis(), setIcon: vi.fn().mockReturnThis(),
bindTooltip: vi.fn().mockReturnThis(),
setTooltipContent: vi.fn().mockReturnThis(),
getLatLng: vi.fn().mockReturnValue({ lat: 0, lng: 0 }),
} }
return { const Icon = vi.fn().mockImplementation(function (this: unknown) {
default: { return {}
marker: vi.fn(() => markerMock),
Icon: vi.fn().mockImplementation(() => ({})),
},
marker: vi.fn(() => markerMock),
Icon: vi.fn().mockImplementation(() => ({})),
}
}) })
const L = {
marker: vi.fn(() => markerMock),
Icon,
point: vi.fn((x: number, y: number) => ({ x, y })),
}
return { leafletMock: L }
})
vi.mock('leaflet', () => ({
__esModule: true,
default: leafletMock,
marker: leafletMock.marker,
Icon: leafletMock.Icon,
}))
vi.mock('~/lib/LeafletCustomTypes', () => ({ vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6, HnHMaxZoom: 6,
TileSize: 100,
})) }))
import type L from 'leaflet'
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
function getL(): L { function getL(): L {
return require('leaflet').default return leafletMock as unknown as L
} }
function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData { function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData {
@@ -44,12 +57,12 @@ function makeMapViewRef(mapid = 1): CharacterMapViewRef {
map: { map: {
unproject: vi.fn(() => ({ lat: 0, lng: 0 })), unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
removeLayer: vi.fn(), removeLayer: vi.fn(),
} as unknown as import('leaflet').Map, } as unknown as Map,
mapid, mapid,
markerLayer: { markerLayer: {
removeLayer: vi.fn(), removeLayer: vi.fn(),
addLayer: vi.fn(), addLayer: vi.fn(),
} as unknown as import('leaflet').LayerGroup, } as unknown as LayerGroup,
} }
} }
@@ -81,6 +94,21 @@ describe('createCharacter', () => {
expect(mapview.map.unproject).toHaveBeenCalled() expect(mapview.map.unproject).toHaveBeenCalled()
}) })
it('add creates marker without title and binds Leaflet tooltip', () => {
const char = createCharacter(makeCharData({ position: { x: 100, y: 200 } }), getL())
const mapview = makeMapViewRef(1)
char.add(mapview)
expect(leafletMock.marker).toHaveBeenCalledWith(
expect.anything(),
expect.not.objectContaining({ title: expect.anything() })
)
const marker = char.leafletMarker as { bindTooltip: ReturnType<typeof vi.fn> }
expect(marker.bindTooltip).toHaveBeenCalledWith(
'Hero · 1, 2',
expect.objectContaining({ direction: 'top', permanent: false })
)
})
it('add does not create marker for different map', () => { it('add does not create marker for different map', () => {
const char = createCharacter(makeCharData({ map: 2 }), getL()) const char = createCharacter(makeCharData({ map: 2 }), getL())
const mapview = makeMapViewRef(1) const mapview = makeMapViewRef(1)
@@ -124,4 +152,36 @@ describe('createCharacter', () => {
char.update(mapview, makeCharData({ ownedByMe: true })) char.update(mapview, makeCharData({ ownedByMe: true }))
expect(marker.setIcon).toHaveBeenCalledTimes(1) expect(marker.setIcon).toHaveBeenCalledTimes(1)
}) })
it('update with position change updates tooltip content when marker exists', () => {
const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1)
char.add(mapview)
const marker = char.leafletMarker as { setTooltipContent: ReturnType<typeof vi.fn> }
marker.setTooltipContent.mockClear()
char.update(mapview, makeCharData({ position: { x: 350, y: 450 } }))
expect(marker.setTooltipContent).toHaveBeenCalledWith('Hero · 3, 4')
})
it('remove cancels active position animation', () => {
const cancelSpy = vi.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
let rafCallback: (() => void) | null = null
vi.spyOn(global, 'requestAnimationFrame').mockImplementation((cb: (() => void) | (FrameRequestCallback)) => {
rafCallback = typeof cb === 'function' ? cb : () => {}
return 1
})
const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1)
mapview.map.unproject = vi.fn(() => ({ lat: 1, lng: 1 }))
char.add(mapview)
const marker = char.leafletMarker as { getLatLng: ReturnType<typeof vi.fn> }
marker.getLatLng.mockReturnValue({ lat: 0, lng: 0 })
char.update(mapview, makeCharData({ position: { x: 200, y: 200 } }))
expect(rafCallback).not.toBeNull()
cancelSpy.mockClear()
char.remove(mapview)
expect(cancelSpy).toHaveBeenCalledWith(1)
cancelSpy.mockRestore()
vi.restoreAllMocks()
})
}) })

View File

@@ -1,31 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import L from 'leaflet'
import type { Map, LayerGroup } from 'leaflet'
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
vi.mock('leaflet', () => { vi.mock('leaflet', () => {
const markerMock = { const markerMock = {
on: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(), addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(), setLatLng: vi.fn().mockReturnThis(),
remove: vi.fn().mockReturnThis(), remove: vi.fn().mockReturnThis(),
bindTooltip: vi.fn().mockReturnThis(),
setTooltipContent: vi.fn().mockReturnThis(),
openPopup: vi.fn().mockReturnThis(),
closePopup: vi.fn().mockReturnThis(),
} }
return { const point = (x: number, y: number) => ({ x, y })
default: { const L = {
marker: vi.fn(() => markerMock), marker: vi.fn(() => markerMock),
Icon: class {}, Icon: vi.fn(),
}, point,
marker: vi.fn(() => markerMock),
Icon: class {},
} }
return { __esModule: true, default: L, ...L }
}) })
vi.mock('~/lib/LeafletCustomTypes', () => ({ vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6, HnHMaxZoom: 6,
ImageIcon: class { TileSize: 100,
constructor(_opts: Record<string, unknown>) {} ImageIcon: vi.fn(),
},
})) }))
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData { function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData {
return { return {
id: 1, id: 1,
@@ -42,12 +46,12 @@ function makeMapViewRef(): MapViewRef {
return { return {
map: { map: {
unproject: vi.fn(() => ({ lat: 0, lng: 0 })), unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
} as unknown as import('leaflet').Map, } as unknown as Map,
mapid: 1, mapid: 1,
markerLayer: { markerLayer: {
removeLayer: vi.fn(), removeLayer: vi.fn(),
addLayer: vi.fn(), addLayer: vi.fn(),
} as unknown as import('leaflet').LayerGroup, } as unknown as LayerGroup,
} }
} }
@@ -57,7 +61,7 @@ describe('createMarker', () => {
}) })
it('creates a marker with correct properties', () => { it('creates a marker with correct properties', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
expect(marker.id).toBe(1) expect(marker.id).toBe(1)
expect(marker.name).toBe('Tower') expect(marker.name).toBe('Tower')
expect(marker.position).toEqual({ x: 100, y: 200 }) expect(marker.position).toEqual({ x: 100, y: 200 })
@@ -69,46 +73,46 @@ describe('createMarker', () => {
}) })
it('detects quest type', () => { it('detects quest type', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' })) const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' }), undefined, L)
expect(marker.type).toBe('quest') expect(marker.type).toBe('quest')
}) })
it('detects quest type for bumling', () => { it('detects quest type for bumling', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' })) const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' }), undefined, L)
expect(marker.type).toBe('quest') expect(marker.type).toBe('quest')
}) })
it('detects custom type', () => { it('detects custom type', () => {
const marker = createMarker(makeMarkerData({ image: 'custom' })) const marker = createMarker(makeMarkerData({ image: 'custom' }), undefined, L)
expect(marker.type).toBe('custom') expect(marker.type).toBe('custom')
}) })
it('extracts type from gfx path', () => { it('extracts type from gfx path', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' })) const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' }), undefined, L)
expect(marker.type).toBe('village') expect(marker.type).toBe('village')
}) })
it('starts with null leaflet marker', () => { it('starts with null leaflet marker', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
expect(marker.leafletMarker).toBeNull() expect(marker.leafletMarker).toBeNull()
}) })
it('add creates a leaflet marker for non-hidden markers', () => { it('add creates a leaflet marker for non-hidden markers', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.add(mapview) marker.add(mapview)
expect(mapview.map.unproject).toHaveBeenCalled() expect(mapview.map.unproject).toHaveBeenCalled()
}) })
it('add does nothing for hidden markers', () => { it('add does nothing for hidden markers', () => {
const marker = createMarker(makeMarkerData({ hidden: true })) const marker = createMarker(makeMarkerData({ hidden: true }), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.add(mapview) marker.add(mapview)
expect(mapview.map.unproject).not.toHaveBeenCalled() expect(mapview.map.unproject).not.toHaveBeenCalled()
}) })
it('update changes position and name', () => { it('update changes position and name', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.update(mapview, { marker.update(mapview, {
@@ -122,7 +126,7 @@ describe('createMarker', () => {
}) })
it('setClickCallback and setContextMenu work', () => { it('setClickCallback and setContextMenu work', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const clickCb = vi.fn() const clickCb = vi.fn()
const contextCb = vi.fn() const contextCb = vi.fn()
@@ -131,7 +135,7 @@ describe('createMarker', () => {
}) })
it('remove on a marker without leaflet marker does nothing', () => { it('remove on a marker without leaflet marker does nothing', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.remove(mapview) // should not throw marker.remove(mapview) // should not throw
expect(marker.leafletMarker).toBeNull() expect(marker.leafletMarker).toBeNull()

View File

@@ -58,7 +58,7 @@
placeholder="Search users…" placeholder="Search users…"
class="input input-sm input-bordered w-full min-h-11 touch-manipulation" class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
aria-label="Search users" aria-label="Search users"
/> >
</div> </div>
<div class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto"> <div class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto">
<div <div
@@ -101,7 +101,7 @@
placeholder="Search maps…" placeholder="Search maps…"
class="input input-sm input-bordered w-full min-h-11 touch-manipulation" class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
aria-label="Search maps" aria-label="Search maps"
/> >
</div> </div>
<div class="overflow-x-auto max-h-[60vh] overflow-y-auto"> <div class="overflow-x-auto max-h-[60vh] overflow-y-auto">
<table class="table table-sm table-zebra min-w-[32rem]"> <table class="table table-sm table-zebra min-w-[32rem]">
@@ -121,7 +121,7 @@
</th> </th>
<th scope="col">Hidden</th> <th scope="col">Hidden</th>
<th scope="col">Priority</th> <th scope="col">Priority</th>
<th scope="col" class="text-right"></th> <th scope="col" class="text-right"/>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -159,7 +159,7 @@
v-model="settings.prefix" v-model="settings.prefix"
type="text" type="text"
class="input input-sm w-full min-h-11 touch-manipulation" class="input input-sm w-full min-h-11 touch-manipulation"
/> >
</fieldset> </fieldset>
<fieldset class="fieldset w-full max-w-xs"> <fieldset class="fieldset w-full max-w-xs">
<label class="label" for="admin-settings-title">Title</label> <label class="label" for="admin-settings-title">Title</label>
@@ -168,7 +168,7 @@
v-model="settings.title" v-model="settings.title"
type="text" type="text"
class="input input-sm w-full min-h-11 touch-manipulation" class="input input-sm w-full min-h-11 touch-manipulation"
/> >
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label gap-2 cursor-pointer justify-start min-h-11 touch-manipulation" for="admin-settings-default-hide"> <label class="label gap-2 cursor-pointer justify-start min-h-11 touch-manipulation" for="admin-settings-default-hide">
@@ -177,7 +177,7 @@
v-model="settings.defaultHide" v-model="settings.defaultHide"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="checkbox checkbox-sm"
/> >
Default hide new maps Default hide new maps
</label> </label>
</fieldset> </fieldset>
@@ -211,7 +211,7 @@
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" /> <input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" >
<button type="button" class="btn btn-sm min-h-11 touch-manipulation" @click="mergeFileRef?.click()"> <button type="button" class="btn btn-sm min-h-11 touch-manipulation" @click="mergeFileRef?.click()">
Choose merge file Choose merge file
</button> </button>

View File

@@ -2,20 +2,20 @@
<div class="container mx-auto p-4 max-w-2xl min-w-0"> <div class="container mx-auto p-4 max-w-2xl min-w-0">
<h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1> <h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1>
<form v-if="map" @submit.prevent="submit" class="flex flex-col gap-4"> <form v-if="map" class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="name">Name</label> <label class="label" for="name">Name</label>
<input id="name" v-model="form.name" type="text" class="input min-h-11 touch-manipulation" required /> <input id="name" v-model="form.name" type="text" class="input min-h-11 touch-manipulation" required >
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label cursor-pointer gap-2"> <label class="label cursor-pointer gap-2">
<input v-model="form.hidden" type="checkbox" class="checkbox" /> <input v-model="form.hidden" type="checkbox" class="checkbox" >
<span>Hidden</span> <span>Hidden</span>
</label> </label>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label cursor-pointer gap-2"> <label class="label cursor-pointer gap-2">
<input v-model="form.priority" type="checkbox" class="checkbox" /> <input v-model="form.priority" type="checkbox" class="checkbox" >
<span>Priority</span> <span>Priority</span>
</label> </label>
</fieldset> </fieldset>

View File

@@ -2,7 +2,7 @@
<div class="container mx-auto p-4 max-w-2xl min-w-0"> <div class="container mx-auto p-4 max-w-2xl min-w-0">
<h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1> <h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1>
<form @submit.prevent="submit" class="flex flex-col gap-4"> <form class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="user">Username</label> <label class="label" for="user">Username</label>
<input <input
@@ -12,7 +12,7 @@
class="input min-h-11 touch-manipulation" class="input min-h-11 touch-manipulation"
required required
:readonly="!isNew" :readonly="!isNew"
/> >
</fieldset> </fieldset>
<p id="admin-user-password-hint" class="text-sm text-base-content/60 mb-1">Leave blank to keep current password.</p> <p id="admin-user-password-hint" class="text-sm text-base-content/60 mb-1">Leave blank to keep current password.</p>
<PasswordInput <PasswordInput
@@ -25,7 +25,7 @@
<label class="label">Auths</label> <label class="label">Auths</label>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<label v-for="a of authOptions" :key="a" class="label cursor-pointer gap-2" :for="`auth-${a}`"> <label v-for="a of authOptions" :key="a" class="label cursor-pointer gap-2" :for="`auth-${a}`">
<input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" /> <input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" >
<span>{{ a }}</span> <span>{{ a }}</span>
</label> </label>
</div> </div>

View File

@@ -14,18 +14,18 @@
</a> </a>
<div class="divider text-sm">or</div> <div class="divider text-sm">or</div>
</div> </div>
<form @submit.prevent="submit" class="flex flex-col gap-4"> <form class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="user">User</label> <label class="label" for="user">User</label>
<input <input
ref="userInputRef"
id="user" id="user"
ref="userInputRef"
v-model="user" v-model="user"
type="text" type="text"
class="input min-h-11 touch-manipulation" class="input min-h-11 touch-manipulation"
required required
autocomplete="username" autocomplete="username"
/> >
</fieldset> </fieldset>
<PasswordInput <PasswordInput
v-model="pass" v-model="pass"

View File

@@ -115,7 +115,7 @@
<icons-icon-settings /> <icons-icon-settings />
Change password Change password
</h2> </h2>
<form @submit.prevent="changePass" class="flex flex-col gap-2"> <form class="flex flex-col gap-2" @submit.prevent="changePass">
<PasswordInput <PasswordInput
v-model="newPass" v-model="newPass"
placeholder="New password" placeholder="New password"

View File

@@ -6,7 +6,7 @@
This is the first run. Create the administrator account using the bootstrap password This is the first run. Create the administrator account using the bootstrap password
from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>). from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>).
</p> </p>
<form @submit.prevent="submit" class="flex flex-col gap-4"> <form class="flex flex-col gap-4" @submit.prevent="submit">
<PasswordInput <PasswordInput
v-model="pass" v-model="pass"
label="Bootstrap password" label="Bootstrap password"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,11 +1,14 @@
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
import Vue from '@vitejs/plugin-vue'
import { resolve } from 'path' import { resolve } from 'path'
export default defineConfig({ export default defineConfig({
plugins: [Vue()],
test: { test: {
environment: 'happy-dom', environment: 'happy-dom',
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'], include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
globals: true, globals: true,
setupFiles: ['./vitest.setup.ts'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'html'], reporter: ['text', 'html'],

View File

@@ -0,0 +1,27 @@
/**
* Expose Vue reactivity and lifecycle on globalThis so that .vue components
* that rely on Nuxt auto-imports (ref, computed, etc.) work in Vitest.
*/
import {
ref,
computed,
reactive,
watch,
watchEffect,
onMounted,
onUnmounted,
nextTick,
readonly,
} from 'vue'
Object.assign(globalThis, {
ref,
computed,
reactive,
watch,
watchEffect,
onMounted,
onUnmounted,
nextTick,
readonly,
})

View File

@@ -26,8 +26,9 @@ const (
MultipartMaxMemory = 100 << 20 // 100 MB MultipartMaxMemory = 100 << 20 // 100 MB
MergeMaxMemory = 500 << 20 // 500 MB MergeMaxMemory = 500 << 20 // 500 MB
ClientVersion = "4" ClientVersion = "4"
SSETickInterval = 5 * time.Second SSETickInterval = 1 * time.Second
SSETileChannelSize = 1000 SSEKeepaliveInterval = 30 * time.Second
SSETileChannelSize = 2000
SSEMergeChannelSize = 5 SSEMergeChannelSize = 5
) )

View File

@@ -93,16 +93,41 @@ func TestTopicClose(t *testing.T) {
} }
} }
func TestTopicDropsSlowSubscriber(t *testing.T) { func TestTopicSkipsFullChannel(t *testing.T) {
topic := &app.Topic[int]{} topic := &app.Topic[int]{}
slow := make(chan *int) // unbuffered, will block slow := make(chan *int) // unbuffered, so Send will skip this subscriber
fast := make(chan *int, 10)
topic.Watch(slow) topic.Watch(slow)
topic.Watch(fast)
val := 42 val := 42
topic.Send(&val) // should drop the slow subscriber topic.Send(&val) // slow is full (unbuffered), message dropped for slow only; fast receives
topic.Send(&val)
// Fast subscriber got both messages
for i := 0; i < 2; i++ {
select {
case got := <-fast:
if *got != 42 {
t.Fatalf("fast got %d", *got)
}
default:
t.Fatalf("expected fast to have message %d", i+1)
}
}
// Slow subscriber was skipped (channel full), not closed - channel still open and empty
select {
case _, ok := <-slow:
if !ok {
t.Fatal("slow channel should not be closed when subscriber is skipped")
}
t.Fatal("slow should have received no message")
default:
// slow is open and empty, which is correct
}
topic.Close()
_, ok := <-slow _, ok := <-slow
if ok { if ok {
t.Fatal("expected slow subscriber channel to be closed") t.Fatal("expected slow channel closed after topic.Close()")
} }
} }

View File

@@ -66,7 +66,7 @@ func (h *Handlers) clientLocate(rw http.ResponseWriter, req *http.Request) {
} }
rw.Header().Set("Content-Type", "text/plain") rw.Header().Set("Content-Type", "text/plain")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
rw.Write([]byte(result)) _, _ = rw.Write([]byte(result))
} }
func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) {
@@ -85,7 +85,7 @@ func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) {
} }
rw.Header().Set("Content-Type", "application/json") rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
json.NewEncoder(rw).Encode(result.Response) _ = json.NewEncoder(rw).Encode(result.Response)
} }
func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) {

View File

@@ -64,9 +64,11 @@ func (env *testEnv) createUser(t *testing.T, username, password string, auths ap
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
u := app.User{Pass: hash, Auths: auths} u := app.User{Pass: hash, Auths: auths}
raw, _ := json.Marshal(u) raw, _ := json.Marshal(u)
env.st.Update(context.Background(), func(tx *bbolt.Tx) error { if err := env.st.Update(context.Background(), func(tx *bbolt.Tx) error {
return env.st.PutUser(tx, username, raw) return env.st.PutUser(tx, username, raw)
}) }); err != nil {
t.Fatal(err)
}
} }
func (env *testEnv) loginSession(t *testing.T, username string) string { func (env *testEnv) loginSession(t *testing.T, username string) string {
@@ -90,7 +92,7 @@ func TestAPISetup_NoUsers(t *testing.T) {
} }
var resp struct{ SetupRequired bool } var resp struct{ SetupRequired bool }
json.NewDecoder(rr.Body).Decode(&resp) _ = json.NewDecoder(rr.Body).Decode(&resp)
if !resp.SetupRequired { if !resp.SetupRequired {
t.Fatal("expected setupRequired=true") t.Fatal("expected setupRequired=true")
} }
@@ -105,7 +107,7 @@ func TestAPISetup_WithUsers(t *testing.T) {
env.h.APISetup(rr, req) env.h.APISetup(rr, req)
var resp struct{ SetupRequired bool } var resp struct{ SetupRequired bool }
json.NewDecoder(rr.Body).Decode(&resp) _ = json.NewDecoder(rr.Body).Decode(&resp)
if resp.SetupRequired { if resp.SetupRequired {
t.Fatal("expected setupRequired=false with users") t.Fatal("expected setupRequired=false with users")
} }
@@ -196,7 +198,7 @@ func TestAPIMe_Authenticated(t *testing.T) {
Username string `json:"username"` Username string `json:"username"`
Auths []string `json:"auths"` Auths []string `json:"auths"`
} }
json.NewDecoder(rr.Body).Decode(&resp) _ = json.NewDecoder(rr.Body).Decode(&resp)
if resp.Username != "alice" { if resp.Username != "alice" {
t.Fatalf("expected alice, got %s", resp.Username) t.Fatalf("expected alice, got %s", resp.Username)
} }
@@ -245,7 +247,7 @@ func TestAPIMeTokens_Authenticated(t *testing.T) {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
} }
var resp struct{ Tokens []string } var resp struct{ Tokens []string }
json.NewDecoder(rr.Body).Decode(&resp) _ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Tokens) != 1 { if len(resp.Tokens) != 1 {
t.Fatalf("expected 1 token, got %d", len(resp.Tokens)) t.Fatalf("expected 1 token, got %d", len(resp.Tokens))
} }
@@ -396,7 +398,7 @@ func TestAdminSettings(t *testing.T) {
DefaultHide bool `json:"defaultHide"` DefaultHide bool `json:"defaultHide"`
Title string `json:"title"` Title string `json:"title"`
} }
json.NewDecoder(rr3.Body).Decode(&resp) _ = json.NewDecoder(rr3.Body).Decode(&resp)
if resp.Prefix != "pfx" || !resp.DefaultHide || resp.Title != "New Title" { if resp.Prefix != "pfx" || !resp.DefaultHide || resp.Title != "New Title" {
t.Fatalf("unexpected settings: %+v", resp) t.Fatalf("unexpected settings: %+v", resp)
} }
@@ -493,7 +495,7 @@ func TestAPIGetChars_NoMarkersAuth(t *testing.T) {
t.Fatalf("expected 200, got %d", rr.Code) t.Fatalf("expected 200, got %d", rr.Code)
} }
var chars []interface{} var chars []interface{}
json.NewDecoder(rr.Body).Decode(&chars) _ = json.NewDecoder(rr.Body).Decode(&chars)
if len(chars) != 0 { if len(chars) != 0 {
t.Fatal("expected empty array without markers auth") t.Fatal("expected empty array without markers auth")
} }
@@ -511,7 +513,7 @@ func TestAPIGetMarkers_NoMarkersAuth(t *testing.T) {
t.Fatalf("expected 200, got %d", rr.Code) t.Fatalf("expected 200, got %d", rr.Code)
} }
var markers []interface{} var markers []interface{}
json.NewDecoder(rr.Body).Decode(&markers) _ = json.NewDecoder(rr.Body).Decode(&markers)
if len(markers) != 0 { if len(markers) != 0 {
t.Fatal("expected empty array without markers auth") t.Fatal("expected empty array without markers auth")
} }
@@ -583,7 +585,7 @@ func TestAdminUserByName(t *testing.T) {
Username string `json:"username"` Username string `json:"username"`
Auths []string `json:"auths"` Auths []string `json:"auths"`
} }
json.NewDecoder(rr.Body).Decode(&resp) _ = json.NewDecoder(rr.Body).Decode(&resp)
if resp.Username != "bob" { if resp.Username != "bob" {
t.Fatalf("expected bob, got %s", resp.Username) t.Fatalf("expected bob, got %s", resp.Username)
} }
@@ -613,9 +615,11 @@ func TestClientRouter_Locate(t *testing.T) {
} }
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}} gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
raw, _ := json.Marshal(gd) raw, _ := json.Marshal(gd)
env.st.Update(ctx, func(tx *bbolt.Tx) error { if err := env.st.Update(ctx, func(tx *bbolt.Tx) error {
return env.st.PutGrid(tx, "g1", raw) return env.st.PutGrid(tx, "g1", raw)
}) }); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/locate?gridID=g1", nil) req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/locate?gridID=g1", nil)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()

View File

@@ -55,14 +55,23 @@ func (h *Handlers) WatchGridUpdates(rw http.ResponseWriter, req *http.Request) {
tileCache := []services.TileCache{} tileCache := []services.TileCache{}
raw, _ := json.Marshal(tileCache) raw, _ := json.Marshal(tileCache)
fmt.Fprint(rw, "data: ") fmt.Fprint(rw, "data: ")
rw.Write(raw) _, _ = rw.Write(raw)
fmt.Fprint(rw, "\n\n") fmt.Fprint(rw, "\n\n")
flusher.Flush() flusher.Flush()
ticker := time.NewTicker(app.SSETickInterval) ticker := time.NewTicker(app.SSETickInterval)
defer ticker.Stop() defer ticker.Stop()
keepaliveTicker := time.NewTicker(app.SSEKeepaliveInterval)
defer keepaliveTicker.Stop()
for { for {
select { select {
case <-ctx.Done():
return
case <-keepaliveTicker.C:
if _, err := fmt.Fprint(rw, ": keepalive\n\n"); err != nil {
return
}
flusher.Flush()
case e, ok := <-c: case e, ok := <-c:
if !ok { if !ok {
return return
@@ -93,13 +102,15 @@ func (h *Handlers) WatchGridUpdates(rw http.ResponseWriter, req *http.Request) {
} }
fmt.Fprint(rw, "event: merge\n") fmt.Fprint(rw, "event: merge\n")
fmt.Fprint(rw, "data: ") fmt.Fprint(rw, "data: ")
rw.Write(raw) _, _ = rw.Write(raw)
fmt.Fprint(rw, "\n\n") fmt.Fprint(rw, "\n\n")
flusher.Flush() flusher.Flush()
case <-ticker.C: case <-ticker.C:
raw, _ := json.Marshal(tileCache) raw, _ := json.Marshal(tileCache)
fmt.Fprint(rw, "data: ") fmt.Fprint(rw, "data: ")
rw.Write(raw) if _, err := rw.Write(raw); err != nil {
return
}
fmt.Fprint(rw, "\n\n") fmt.Fprint(rw, "\n\n")
tileCache = tileCache[:0] tileCache = tileCache[:0]
flusher.Flush() flusher.Flush()
@@ -152,7 +163,7 @@ func (h *Handlers) GridTile(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Content-Type", "image/png") rw.Header().Set("Content-Type", "image/png")
rw.Header().Set("Cache-Control", "private, max-age=3600") rw.Header().Set("Cache-Control", "private, max-age=3600")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
rw.Write(transparentPNG) _, _ = rw.Write(transparentPNG)
return return
} }

View File

@@ -172,7 +172,9 @@ var migrations = []func(tx *bbolt.Tx) error{
if err != nil { if err != nil {
return err return err
} }
users.Put(k, raw) if err := users.Put(k, raw); err != nil {
return err
}
} }
return nil return nil
}) })

View File

@@ -26,7 +26,7 @@ func TestRunMigrations_FreshDB(t *testing.T) {
t.Fatalf("migrations failed on fresh DB: %v", err) t.Fatalf("migrations failed on fresh DB: %v", err)
} }
db.View(func(tx *bbolt.Tx) error { if err := db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketConfig) b := tx.Bucket(store.BucketConfig)
if b == nil { if b == nil {
t.Fatal("expected config bucket after migrations") t.Fatal("expected config bucket after migrations")
@@ -40,13 +40,15 @@ func TestRunMigrations_FreshDB(t *testing.T) {
t.Fatalf("expected default title, got %s", title) t.Fatalf("expected default title, got %s", title)
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
if tx, _ := db.Begin(false); tx != nil { if tx, _ := db.Begin(false); tx != nil {
if tx.Bucket(store.BucketOAuthStates) == nil { if tx.Bucket(store.BucketOAuthStates) == nil {
t.Fatal("expected oauth_states bucket after migrations") t.Fatal("expected oauth_states bucket after migrations")
} }
tx.Rollback() _ = tx.Rollback()
} }
} }
@@ -61,7 +63,7 @@ func TestRunMigrations_Idempotent(t *testing.T) {
t.Fatalf("second run failed: %v", err) t.Fatalf("second run failed: %v", err)
} }
db.View(func(tx *bbolt.Tx) error { if err := db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketConfig) b := tx.Bucket(store.BucketConfig)
if b == nil { if b == nil {
t.Fatal("expected config bucket") t.Fatal("expected config bucket")
@@ -71,7 +73,9 @@ func TestRunMigrations_Idempotent(t *testing.T) {
t.Fatal("expected version key") t.Fatal("expected version key")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestRunMigrations_SetsVersion(t *testing.T) { func TestRunMigrations_SetsVersion(t *testing.T) {
@@ -81,11 +85,13 @@ func TestRunMigrations_SetsVersion(t *testing.T) {
} }
var version string var version string
db.View(func(tx *bbolt.Tx) error { if err := db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketConfig) b := tx.Bucket(store.BucketConfig)
version = string(b.Get([]byte("version"))) version = string(b.Get([]byte("version")))
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
if version == "" || version == "0" { if version == "" || version == "0" {
t.Fatalf("expected non-zero version, got %q", version) t.Fatalf("expected non-zero version, got %q", version)

View File

@@ -101,7 +101,9 @@ func (s *AdminService) DeleteUser(ctx context.Context, username string) error {
return err return err
} }
for _, tok := range u.Tokens { for _, tok := range u.Tokens {
s.st.DeleteToken(tx, tok) if err := s.st.DeleteToken(tx, tok); err != nil {
return err
}
} }
} }
return s.st.DeleteUser(tx, username) return s.st.DeleteUser(tx, username)
@@ -129,17 +131,25 @@ func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultH
func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error { func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error { return s.st.Update(ctx, func(tx *bbolt.Tx) error {
if prefix != nil { if prefix != nil {
s.st.PutConfig(tx, "prefix", []byte(*prefix)) if err := s.st.PutConfig(tx, "prefix", []byte(*prefix)); err != nil {
return err
}
} }
if defaultHide != nil { if defaultHide != nil {
if *defaultHide { if *defaultHide {
s.st.PutConfig(tx, "defaultHide", []byte("1")) if err := s.st.PutConfig(tx, "defaultHide", []byte("1")); err != nil {
return err
}
} else { } else {
s.st.DeleteConfig(tx, "defaultHide") if err := s.st.DeleteConfig(tx, "defaultHide"); err != nil {
return err
}
} }
} }
if title != nil { if title != nil {
s.st.PutConfig(tx, "title", []byte(*title)) if err := s.st.PutConfig(tx, "title", []byte(*title)); err != nil {
return err
}
} }
return nil return nil
}) })
@@ -264,7 +274,9 @@ func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error {
return err return err
} }
for _, id := range ids { for _, id := range ids {
grids.Delete(id) if err := grids.Delete(id); err != nil {
return err
}
} }
return nil return nil
}); err != nil { }); err != nil {
@@ -310,7 +322,9 @@ func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int
g.Coord.X += diff.X g.Coord.X += diff.X
g.Coord.Y += diff.Y g.Coord.Y += diff.Y
raw, _ := json.Marshal(g) raw, _ := json.Marshal(g)
grids.Put(k, raw) if err := grids.Put(k, raw); err != nil {
return err
}
} }
return nil return nil
}); err != nil { }); err != nil {
@@ -367,7 +381,9 @@ func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
} }
m.Hidden = true m.Hidden = true
raw, _ = json.Marshal(m) raw, _ = json.Marshal(m)
grid.Put(key, raw) if err := grid.Put(key, raw); err != nil {
return err
}
return nil return nil
}) })
} }

View File

@@ -234,7 +234,7 @@ func TestToggleMapHidden(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
admin.UpdateMap(ctx, 1, "world", false, false) _ = admin.UpdateMap(ctx, 1, "world", false, false)
mi, err := admin.ToggleMapHidden(ctx, 1) mi, err := admin.ToggleMapHidden(ctx, 1)
if err != nil { if err != nil {
@@ -257,19 +257,27 @@ func TestWipe(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutGrid(tx, "g1", []byte("data")) if err := st.PutGrid(tx, "g1", []byte("data")); err != nil {
st.PutMap(tx, 1, []byte("data")) return err
st.PutTile(tx, 1, 0, "0_0", []byte("data")) }
st.CreateMarkersBuckets(tx) if err := st.PutMap(tx, 1, []byte("data")); err != nil {
return nil return err
}) }
if err := st.PutTile(tx, 1, 0, "0_0", []byte("data")); err != nil {
return err
}
_, _, err := st.CreateMarkersBuckets(tx)
return err
}); err != nil {
t.Fatal(err)
}
if err := admin.Wipe(ctx); err != nil { if err := admin.Wipe(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
} }
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if st.GetGrid(tx, "g1") != nil { if st.GetGrid(tx, "g1") != nil {
t.Fatal("expected grids wiped") t.Fatal("expected grids wiped")
} }
@@ -283,7 +291,9 @@ func TestWipe(t *testing.T) {
t.Fatal("expected markers wiped") t.Fatal("expected markers wiped")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestGetMap_NotFound(t *testing.T) { func TestGetMap_NotFound(t *testing.T) {

View File

@@ -53,7 +53,7 @@ func (s *AuthService) GetSession(ctx context.Context, req *http.Request) *app.Se
return nil return nil
} }
var sess *app.Session var sess *app.Session
s.st.View(ctx, func(tx *bbolt.Tx) error { if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetSession(tx, c.Value) raw := s.st.GetSession(tx, c.Value)
if raw == nil { if raw == nil {
return nil return nil
@@ -77,7 +77,9 @@ func (s *AuthService) GetSession(ctx context.Context, req *http.Request) *app.Se
} }
sess.Auths = u.Auths sess.Auths = u.Auths
return nil return nil
}) }); err != nil {
return nil
}
return sess return sess
} }
@@ -165,7 +167,7 @@ func (s *AuthService) GetUserByUsername(ctx context.Context, username string) *a
// SetupRequired returns true if no users exist (first run). // SetupRequired returns true if no users exist (first run).
func (s *AuthService) SetupRequired(ctx context.Context) bool { func (s *AuthService) SetupRequired(ctx context.Context) bool {
var required bool var required bool
s.st.View(ctx, func(tx *bbolt.Tx) error { _ = s.st.View(ctx, func(tx *bbolt.Tx) error {
if s.st.UserCount(tx) == 0 { if s.st.UserCount(tx) == 0 {
required = true required = true
} }
@@ -181,7 +183,7 @@ func (s *AuthService) BootstrapAdmin(ctx context.Context, username, pass, bootst
} }
var created bool var created bool
var u *app.User var u *app.User
s.st.Update(ctx, func(tx *bbolt.Tx) error { if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
if s.st.GetUser(tx, "admin") != nil { if s.st.GetUser(tx, "admin") != nil {
return nil return nil
} }
@@ -200,7 +202,9 @@ func (s *AuthService) BootstrapAdmin(ctx context.Context, username, pass, bootst
created = true created = true
u = &user u = &user
return nil return nil
}) }); err != nil {
return nil
}
if created { if created {
return u return u
} }
@@ -239,7 +243,7 @@ func (s *AuthService) GenerateTokenForUser(ctx context.Context, username string)
} }
token := hex.EncodeToString(tokenRaw) token := hex.EncodeToString(tokenRaw)
var tokens []string var tokens []string
s.st.Update(ctx, func(tx *bbolt.Tx) error { _ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username) uRaw := s.st.GetUser(tx, username)
u := app.User{} u := app.User{}
if uRaw != nil { if uRaw != nil {
@@ -250,7 +254,9 @@ func (s *AuthService) GenerateTokenForUser(ctx context.Context, username string)
u.Tokens = append(u.Tokens, token) u.Tokens = append(u.Tokens, token)
tokens = u.Tokens tokens = u.Tokens
buf, _ := json.Marshal(u) buf, _ := json.Marshal(u)
s.st.PutUser(tx, username, buf) if err := s.st.PutUser(tx, username, buf); err != nil {
return err
}
return s.st.PutToken(tx, token, username) return s.st.PutToken(tx, token, username)
}) })
return tokens return tokens
@@ -522,7 +528,9 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
user.Email = email user.Email = email
} }
raw, _ = json.Marshal(user) raw, _ = json.Marshal(user)
s.st.PutUser(tx, username, raw) if err := s.st.PutUser(tx, username, raw); err != nil {
return err
}
} }
return nil return nil
} }

View File

@@ -41,9 +41,11 @@ func createUser(t *testing.T, st *store.Store, username, password string, auths
} }
u := app.User{Pass: hash, Auths: auths} u := app.User{Pass: hash, Auths: auths}
raw, _ := json.Marshal(u) raw, _ := json.Marshal(u)
st.Update(context.Background(), func(tx *bbolt.Tx) error { if err := st.Update(context.Background(), func(tx *bbolt.Tx) error {
return st.PutUser(tx, username, raw) return st.PutUser(tx, username, raw)
}) }); err != nil {
t.Fatal(err)
}
} }
func TestSetupRequired_EmptyDB(t *testing.T) { func TestSetupRequired_EmptyDB(t *testing.T) {
@@ -246,9 +248,11 @@ func TestGetUserTokensAndPrefix(t *testing.T) {
ctx := context.Background() ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutConfig(tx, "prefix", []byte("myprefix")) return st.PutConfig(tx, "prefix", []byte("myprefix"))
}) }); err != nil {
t.Fatal(err)
}
auth.GenerateTokenForUser(ctx, "alice") auth.GenerateTokenForUser(ctx, "alice")
tokens, prefix := auth.GetUserTokensAndPrefix(ctx, "alice") tokens, prefix := auth.GetUserTokensAndPrefix(ctx, "alice")
@@ -288,9 +292,11 @@ func TestValidateClientToken_NoUploadPerm(t *testing.T) {
ctx := context.Background() ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP}) createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP})
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutToken(tx, "tok123", "alice") return st.PutToken(tx, "tok123", "alice")
}) }); err != nil {
t.Fatal(err)
}
_, err := auth.ValidateClientToken(ctx, "tok123") _, err := auth.ValidateClientToken(ctx, "tok123")
if err == nil { if err == nil {

View File

@@ -141,7 +141,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
if err != nil { if err != nil {
return err return err
} }
grids.Put([]byte(grid), raw) if err := grids.Put([]byte(grid), raw); err != nil {
return err
}
greq.GridRequests = append(greq.GridRequests, grid) greq.GridRequests = append(greq.GridRequests, grid)
} }
} }
@@ -192,7 +194,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
if err != nil { if err != nil {
return err return err
} }
grids.Put([]byte(grid), raw) if err := grids.Put([]byte(grid), raw); err != nil {
return err
}
greq.GridRequests = append(greq.GridRequests, grid) greq.GridRequests = append(greq.GridRequests, grid)
} }
} }
@@ -207,7 +211,7 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
} }
} }
if len(maps) > 1 { if len(maps) > 1 {
grids.ForEach(func(k, v []byte) error { if err := grids.ForEach(func(k, v []byte) error {
gd := app.GridData{} gd := app.GridData{}
if err := json.Unmarshal(v, &gd); err != nil { if err := json.Unmarshal(v, &gd); err != nil {
return err return err
@@ -244,16 +248,22 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
File: td.File, File: td.File,
}) })
} }
grids.Put(k, raw) if err := grids.Put(k, raw); err != nil {
return err
}
} }
return nil return nil
}) }); err != nil {
return err
}
} }
for mergeid, merge := range maps { for mergeid, merge := range maps {
if mapid == mergeid { if mapid == mergeid {
continue continue
} }
mapB.Delete([]byte(strconv.Itoa(mergeid))) if err := mapB.Delete([]byte(strconv.Itoa(mergeid))); err != nil {
return err
}
slog.Info("reporting merge", "from", mergeid, "to", mapid) slog.Info("reporting merge", "from", mergeid, "to", mapid)
s.mapSvc.ReportMerge(mergeid, mapid, app.Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y}) s.mapSvc.ReportMerge(mergeid, mapid, app.Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y})
} }
@@ -271,10 +281,10 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraData string, fileReader io.Reader) error { func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraData string, fileReader io.Reader) error {
if extraData != "" { if extraData != "" {
ed := ExtraData{} ed := ExtraData{}
json.Unmarshal([]byte(extraData), &ed) _ = json.Unmarshal([]byte(extraData), &ed)
if ed.Season == 3 { if ed.Season == 3 {
needTile := false needTile := false
s.st.Update(ctx, func(tx *bbolt.Tx) error { if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, id) raw := s.st.GetGrid(tx, id)
if raw == nil { if raw == nil {
return fmt.Errorf("unknown grid id: %s", id) return fmt.Errorf("unknown grid id: %s", id)
@@ -301,7 +311,9 @@ func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraD
} }
raw, _ = json.Marshal(cur) raw, _ = json.Marshal(cur)
return s.st.PutGrid(tx, id, raw) return s.st.PutGrid(tx, id, raw)
}) }); err != nil {
return err
}
if !needTile { if !needTile {
slog.Debug("ignoring tile upload: winter") slog.Debug("ignoring tile upload: winter")
return nil return nil
@@ -316,7 +328,7 @@ func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraD
cur := app.GridData{} cur := app.GridData{}
mapid := 0 mapid := 0
s.st.Update(ctx, func(tx *bbolt.Tx) error { if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, id) raw := s.st.GetGrid(tx, id)
if raw == nil { if raw == nil {
return fmt.Errorf("unknown grid id: %s", id) return fmt.Errorf("unknown grid id: %s", id)
@@ -331,7 +343,9 @@ func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraD
} }
raw, _ = json.Marshal(cur) raw, _ = json.Marshal(cur)
return s.st.PutGrid(tx, id, raw) return s.st.PutGrid(tx, id, raw)
}) }); err != nil {
return err
}
if updateTile { if updateTile {
gridDir := fmt.Sprintf("%s/grids", s.mapSvc.GridStorage()) gridDir := fmt.Sprintf("%s/grids", s.mapSvc.GridStorage())
@@ -374,7 +388,7 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
} }
gridDataByID := make(map[string]app.GridData) gridDataByID := make(map[string]app.GridData)
s.st.View(ctx, func(tx *bbolt.Tx) error { if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
for _, craw := range craws { for _, craw := range craws {
raw := s.st.GetGrid(tx, craw.GridID) raw := s.st.GetGrid(tx, craw.GridID)
if raw != nil { if raw != nil {
@@ -385,7 +399,9 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
} }
} }
return nil return nil
}) }); err != nil {
return err
}
username, _ := ctx.Value(app.ClientUsernameKey).(string) username, _ := ctx.Value(app.ClientUsernameKey).(string)
@@ -465,6 +481,9 @@ func (s *ClientService) UploadMarkers(ctx context.Context, data []byte) error {
if img == "" { if img == "" {
img = "gfx/terobjs/mm/custom" img = "gfx/terobjs/mm/custom"
} }
if mraw.Name == "Cave" {
img = "gfx/terobjs/mm/cave"
}
id, err := idB.NextSequence() id, err := idB.NextSequence()
if err != nil { if err != nil {
return err return err
@@ -478,8 +497,12 @@ func (s *ClientService) UploadMarkers(ctx context.Context, data []byte) error {
Image: img, Image: img,
} }
raw, _ := json.Marshal(m) raw, _ := json.Marshal(m)
grid.Put(key, raw) if err := grid.Put(key, raw); err != nil {
idB.Put(idKey, key) return err
}
if err := idB.Put(idKey, key); err != nil {
return err
}
} }
return nil return nil
}) })

View File

@@ -58,9 +58,11 @@ func TestClientLocate_Found(t *testing.T) {
ctx := context.Background() ctx := context.Background()
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}} gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
raw, _ := json.Marshal(gd) raw, _ := json.Marshal(gd)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutGrid(tx, "g1", raw) return st.PutGrid(tx, "g1", raw)
}) }); err != nil {
t.Fatal(err)
}
result, err := client.Locate(ctx, "g1") result, err := client.Locate(ctx, "g1")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -89,3 +91,31 @@ func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) {
t.Fatal("expected non-nil result") t.Fatal("expected non-nil result")
} }
} }
func TestUploadMarkers_NormalizesCaveImage(t *testing.T) {
client, st := newTestClientService(t)
ctx := context.Background()
body := []byte(`[{"Name":"Cave","GridID":"g1","X":10,"Y":20,"Image":"gfx/terobjs/mm/custom"}]`)
if err := client.UploadMarkers(ctx, body); err != nil {
t.Fatal(err)
}
var stored app.Marker
if err := st.View(ctx, func(tx *bbolt.Tx) error {
grid := st.GetMarkersGridBucket(tx)
if grid == nil {
t.Fatal("markers grid bucket not found")
return nil
}
v := grid.Get([]byte("g1_10_20"))
if v == nil {
t.Fatal("marker g1_10_20 not found")
return nil
}
return json.Unmarshal(v, &stored)
}); err != nil {
t.Fatal(err)
}
if stored.Image != "gfx/terobjs/mm/cave" {
t.Fatalf("expected stored marker Image gfx/terobjs/mm/cave, got %q", stored.Image)
}
}

View File

@@ -111,7 +111,7 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
if markersb != nil { if markersb != nil {
markersgrid := markersb.Bucket(store.BucketMarkersGrid) markersgrid := markersb.Bucket(store.BucketMarkersGrid)
if markersgrid != nil { if markersgrid != nil {
markersgrid.ForEach(func(k, v []byte) error { if err := markersgrid.ForEach(func(k, v []byte) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
@@ -125,7 +125,9 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
maps[gridMap[marker.GridID]].Markers[marker.GridID] = append(maps[gridMap[marker.GridID]].Markers[marker.GridID], marker) maps[gridMap[marker.GridID]].Markers[marker.GridID] = append(maps[gridMap[marker.GridID]].Markers[marker.GridID], marker)
} }
return nil return nil
}) }); err != nil {
return err
}
} }
} }
return nil return nil
@@ -218,7 +220,11 @@ func (s *ExportService) Merge(ctx context.Context, zr *zip.Reader) error {
f.Close() f.Close()
return err return err
} }
io.Copy(f, r) if _, err := io.Copy(f, r); err != nil {
r.Close()
f.Close()
return err
}
r.Close() r.Close()
f.Close() f.Close()
newTiles[strings.TrimSuffix(filepath.Base(fhdr.Name), ".png")] = struct{}{} newTiles[strings.TrimSuffix(filepath.Base(fhdr.Name), ".png")] = struct{}{}
@@ -290,8 +296,12 @@ func (s *ExportService) processMergeJSON(
Image: img, Image: img,
} }
raw, _ := json.Marshal(m) raw, _ := json.Marshal(m)
mgrid.Put(key, raw) if err := mgrid.Put(key, raw); err != nil {
idB.Put(idKey, key) return err
}
if err := idB.Put(idKey, key); err != nil {
return err
}
} }
} }
@@ -333,7 +343,9 @@ func (s *ExportService) processMergeJSON(
if err != nil { if err != nil {
return err return err
} }
grids.Put([]byte(grid), raw) if err := grids.Put([]byte(grid), raw); err != nil {
return err
}
} }
return nil return nil
} }
@@ -372,11 +384,13 @@ func (s *ExportService) processMergeJSON(
if err != nil { if err != nil {
return err return err
} }
grids.Put([]byte(grid), raw) if err := grids.Put([]byte(grid), raw); err != nil {
return err
}
} }
if len(existingMaps) > 1 { if len(existingMaps) > 1 {
grids.ForEach(func(k, v []byte) error { if err := grids.ForEach(func(k, v []byte) error {
gd := app.GridData{} gd := app.GridData{}
if err := json.Unmarshal(v, &gd); err != nil { if err := json.Unmarshal(v, &gd); err != nil {
return err return err
@@ -413,16 +427,22 @@ func (s *ExportService) processMergeJSON(
File: td.File, File: td.File,
}) })
} }
grids.Put(k, raw) if err := grids.Put(k, raw); err != nil {
return err
}
} }
return nil return nil
}) }); err != nil {
return err
}
} }
for mergeid, merge := range existingMaps { for mergeid, merge := range existingMaps {
if mapid == mergeid { if mapid == mergeid {
continue continue
} }
mapB.Delete([]byte(strconv.Itoa(mergeid))) if err := mapB.Delete([]byte(strconv.Itoa(mergeid))); err != nil {
return err
}
slog.Info("reporting merge", "from", mergeid, "to", mapid) slog.Info("reporting merge", "from", mergeid, "to", mapid)
s.mapSvc.ReportMerge(mergeid, mapid, app.Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y}) s.mapSvc.ReportMerge(mergeid, mapid, app.Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y})
} }

View File

@@ -47,12 +47,14 @@ func TestExport_WithGrid(t *testing.T) {
gdRaw, _ := json.Marshal(gd) gdRaw, _ := json.Marshal(gd)
mi := app.MapInfo{ID: 1, Name: "test", Hidden: false} mi := app.MapInfo{ID: 1, Name: "test", Hidden: false}
miRaw, _ := json.Marshal(mi) miRaw, _ := json.Marshal(mi)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
if err := st.PutGrid(tx, "g1", gdRaw); err != nil { if err := st.PutGrid(tx, "g1", gdRaw); err != nil {
return err return err
} }
return st.PutMap(tx, 1, miRaw) return st.PutMap(tx, 1, miRaw)
}) }); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer var buf bytes.Buffer
err := export.Export(ctx, &buf) err := export.Export(ctx, &buf)
if err != nil { if err != nil {

View File

@@ -218,7 +218,7 @@ func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z
// SaveTile persists a tile and broadcasts the update. // SaveTile persists a tile and broadcasts the update.
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) { func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
s.st.Update(ctx, func(tx *bbolt.Tx) error { _ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
td := &app.TileData{ td := &app.TileData{
MapID: mapid, MapID: mapid,
Coord: c, Coord: c,
@@ -293,7 +293,7 @@ func (s *MapService) RebuildZooms(ctx context.Context) error {
if b == nil { if b == nil {
return nil return nil
} }
b.ForEach(func(k, v []byte) error { if err := b.ForEach(func(k, v []byte) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
@@ -306,8 +306,12 @@ func (s *MapService) RebuildZooms(ctx context.Context) error {
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{} needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
return nil return nil
}) }); err != nil {
tx.DeleteBucket(store.BucketTiles) return err
}
if err := tx.DeleteBucket(store.BucketTiles); err != nil {
return err
}
return nil return nil
}); err != nil { }); err != nil {
slog.Error("RebuildZooms: failed to update store", "error", err) slog.Error("RebuildZooms: failed to update store", "error", err)
@@ -364,7 +368,7 @@ func (s *MapService) WatchMerges() chan *app.Merge {
// GetAllTileCache returns all tiles for the initial SSE cache dump. // GetAllTileCache returns all tiles for the initial SSE cache dump.
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache { func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
var cache []TileCache var cache []TileCache
s.st.View(ctx, func(tx *bbolt.Tx) error { _ = s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error { return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():

View File

@@ -57,9 +57,11 @@ func TestGetConfig(t *testing.T) {
svc, st := newTestMapService(t) svc, st := newTestMapService(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutConfig(tx, "title", []byte("Test Map")) return st.PutConfig(tx, "title", []byte("Test Map"))
}) }); err != nil {
t.Fatal(err)
}
config, err := svc.GetConfig(ctx, app.Auths{app.AUTH_MAP}) config, err := svc.GetConfig(ctx, app.Auths{app.AUTH_MAP})
if err != nil { if err != nil {
@@ -93,9 +95,11 @@ func TestGetConfig_Empty(t *testing.T) {
func TestGetPage(t *testing.T) { func TestGetPage(t *testing.T) {
svc, st := newTestMapService(t) svc, st := newTestMapService(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutConfig(tx, "title", []byte("Map Page")) return st.PutConfig(tx, "title", []byte("Map Page"))
}) }); err != nil {
t.Fatal(err)
}
page, err := svc.GetPage(ctx) page, err := svc.GetPage(ctx)
if err != nil { if err != nil {
@@ -112,9 +116,11 @@ func TestGetGrid(t *testing.T) {
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 5, Y: 10}} gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 5, Y: 10}}
raw, _ := json.Marshal(gd) raw, _ := json.Marshal(gd)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutGrid(tx, "g1", raw) return st.PutGrid(tx, "g1", raw)
}) }); err != nil {
t.Fatal(err)
}
got, err := svc.GetGrid(ctx, "g1") got, err := svc.GetGrid(ctx, "g1")
if err != nil { if err != nil {
@@ -158,11 +164,17 @@ func TestGetMaps_HiddenFilter(t *testing.T) {
mi2 := app.MapInfo{ID: 2, Name: "hidden", Hidden: true} mi2 := app.MapInfo{ID: 2, Name: "hidden", Hidden: true}
raw1, _ := json.Marshal(mi1) raw1, _ := json.Marshal(mi1)
raw2, _ := json.Marshal(mi2) raw2, _ := json.Marshal(mi2)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutMap(tx, 1, raw1) if err := st.PutMap(tx, 1, raw1); err != nil {
st.PutMap(tx, 2, raw2) return err
}
if err := st.PutMap(tx, 2, raw2); err != nil {
return err
}
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
maps, err := svc.GetMaps(ctx, false) maps, err := svc.GetMaps(ctx, false)
if err != nil { if err != nil {
@@ -201,14 +213,18 @@ func TestGetMarkers_WithData(t *testing.T) {
m := app.Marker{Name: "Tower", ID: 1, GridID: "g1", Position: app.Position{X: 10, Y: 20}, Image: "gfx/terobjs/mm/tower"} m := app.Marker{Name: "Tower", ID: 1, GridID: "g1", Position: app.Position{X: 10, Y: 20}, Image: "gfx/terobjs/mm/tower"}
mRaw, _ := json.Marshal(m) mRaw, _ := json.Marshal(m)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutGrid(tx, "g1", gdRaw) if err := st.PutGrid(tx, "g1", gdRaw); err != nil {
return err
}
grid, _, err := st.CreateMarkersBuckets(tx) grid, _, err := st.CreateMarkersBuckets(tx)
if err != nil { if err != nil {
return err return err
} }
return grid.Put([]byte("g1_10_20"), mRaw) return grid.Put([]byte("g1_10_20"), mRaw)
}) }); err != nil {
t.Fatal(err)
}
markers, err := svc.GetMarkers(ctx) markers, err := svc.GetMarkers(ctx)
if err != nil { if err != nil {
@@ -233,9 +249,11 @@ func TestGetTile(t *testing.T) {
td := app.TileData{MapID: 1, Coord: app.Coord{X: 0, Y: 0}, Zoom: 0, File: "grids/g1.png", Cache: 12345} td := app.TileData{MapID: 1, Coord: app.Coord{X: 0, Y: 0}, Zoom: 0, File: "grids/g1.png", Cache: 12345}
raw, _ := json.Marshal(td) raw, _ := json.Marshal(td)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutTile(tx, 1, 0, "0_0", raw) return st.PutTile(tx, 1, 0, "0_0", raw)
}) }); err != nil {
t.Fatal(err)
}
got := svc.GetTile(ctx, 1, app.Coord{X: 0, Y: 0}, 0) got := svc.GetTile(ctx, 1, app.Coord{X: 0, Y: 0}, 0)
if got == nil { if got == nil {
@@ -287,9 +305,11 @@ func TestGetAllTileCache_WithData(t *testing.T) {
td := app.TileData{MapID: 1, Coord: app.Coord{X: 1, Y: 2}, Zoom: 0, Cache: 999} td := app.TileData{MapID: 1, Coord: app.Coord{X: 1, Y: 2}, Zoom: 0, Cache: 999}
raw, _ := json.Marshal(td) raw, _ := json.Marshal(td)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutTile(tx, 1, 0, "1_2", raw) return st.PutTile(tx, 1, 0, "1_2", raw)
}) }); err != nil {
t.Fatal(err)
}
cache := svc.GetAllTileCache(ctx) cache := svc.GetAllTileCache(ctx)
if len(cache) != 1 { if len(cache) != 1 {

View File

@@ -25,12 +25,14 @@ func TestUserCRUD(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// Verify user doesn't exist on empty DB. // Verify user doesn't exist on empty DB.
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if got := st.GetUser(tx, "alice"); got != nil { if got := st.GetUser(tx, "alice"); got != nil {
t.Fatal("expected nil user before creation") t.Fatal("expected nil user before creation")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
// Create user. // Create user.
if err := st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
@@ -40,7 +42,7 @@ func TestUserCRUD(t *testing.T) {
} }
// Verify user exists and count is correct (separate transaction for accurate Stats). // Verify user exists and count is correct (separate transaction for accurate Stats).
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
got := st.GetUser(tx, "alice") got := st.GetUser(tx, "alice")
if got == nil || string(got) != `{"pass":"hash"}` { if got == nil || string(got) != `{"pass":"hash"}` {
t.Fatalf("expected user data, got %s", got) t.Fatalf("expected user data, got %s", got)
@@ -49,7 +51,9 @@ func TestUserCRUD(t *testing.T) {
t.Fatalf("expected 1 user, got %d", c) t.Fatalf("expected 1 user, got %d", c)
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
// Delete user. // Delete user.
if err := st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
@@ -58,31 +62,41 @@ func TestUserCRUD(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if got := st.GetUser(tx, "alice"); got != nil { if got := st.GetUser(tx, "alice"); got != nil {
t.Fatal("expected nil user after deletion") t.Fatal("expected nil user after deletion")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestForEachUser(t *testing.T) { func TestForEachUser(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutUser(tx, "alice", []byte("1")) if err := st.PutUser(tx, "alice", []byte("1")); err != nil {
st.PutUser(tx, "bob", []byte("2")) return err
}
if err := st.PutUser(tx, "bob", []byte("2")); err != nil {
return err
}
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
var names []string var names []string
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
return st.ForEachUser(tx, func(k, _ []byte) error { return st.ForEachUser(tx, func(k, _ []byte) error {
names = append(names, string(k)) names = append(names, string(k))
return nil return nil
}) })
}) }); err != nil {
t.Fatal(err)
}
if len(names) != 2 { if len(names) != 2 {
t.Fatalf("expected 2 users, got %d", len(names)) t.Fatalf("expected 2 users, got %d", len(names))
} }
@@ -91,12 +105,14 @@ func TestForEachUser(t *testing.T) {
func TestUserCountEmptyBucket(t *testing.T) { func TestUserCountEmptyBucket(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if c := st.UserCount(tx); c != 0 { if c := st.UserCount(tx); c != 0 {
t.Fatalf("expected 0 users on empty db, got %d", c) t.Fatalf("expected 0 users on empty db, got %d", c)
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestSessionCRUD(t *testing.T) { func TestSessionCRUD(t *testing.T) {
@@ -211,19 +227,27 @@ func TestForEachMap(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutMap(tx, 1, []byte("a")) if err := st.PutMap(tx, 1, []byte("a")); err != nil {
st.PutMap(tx, 2, []byte("b")) return err
}
if err := st.PutMap(tx, 2, []byte("b")); err != nil {
return err
}
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
var count int var count int
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
return st.ForEachMap(tx, func(_, _ []byte) error { return st.ForEachMap(tx, func(_, _ []byte) error {
count++ count++
return nil return nil
}) })
}) }); err != nil {
t.Fatal(err)
}
if count != 2 { if count != 2 {
t.Fatalf("expected 2 maps, got %d", count) t.Fatalf("expected 2 maps, got %d", count)
} }
@@ -290,20 +314,30 @@ func TestForEachTile(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutTile(tx, 1, 0, "0_0", []byte("a")) if err := st.PutTile(tx, 1, 0, "0_0", []byte("a")); err != nil {
st.PutTile(tx, 1, 1, "0_0", []byte("b")) return err
st.PutTile(tx, 2, 0, "1_1", []byte("c")) }
if err := st.PutTile(tx, 1, 1, "0_0", []byte("b")); err != nil {
return err
}
if err := st.PutTile(tx, 2, 0, "1_1", []byte("c")); err != nil {
return err
}
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
var count int var count int
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
return st.ForEachTile(tx, func(_, _, _, _ []byte) error { return st.ForEachTile(tx, func(_, _, _, _ []byte) error {
count++ count++
return nil return nil
}) })
}) }); err != nil {
t.Fatal(err)
}
if count != 3 { if count != 3 {
t.Fatalf("expected 3 tiles, got %d", count) t.Fatalf("expected 3 tiles, got %d", count)
} }
@@ -313,7 +347,7 @@ func TestTilesMapBucket(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
if b := st.GetTilesMapBucket(tx, 1); b != nil { if b := st.GetTilesMapBucket(tx, 1); b != nil {
t.Fatal("expected nil bucket before creation") t.Fatal("expected nil bucket before creation")
} }
@@ -328,31 +362,39 @@ func TestTilesMapBucket(t *testing.T) {
t.Fatal("expected non-nil after create") t.Fatal("expected non-nil after create")
} }
return st.DeleteTilesMapBucket(tx, 1) return st.DeleteTilesMapBucket(tx, 1)
}) }); err != nil {
t.Fatal(err)
}
} }
func TestDeleteTilesBucket(t *testing.T) { func TestDeleteTilesBucket(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutTile(tx, 1, 0, "0_0", []byte("a")) if err := st.PutTile(tx, 1, 0, "0_0", []byte("a")); err != nil {
return err
}
return st.DeleteTilesBucket(tx) return st.DeleteTilesBucket(tx)
}) }); err != nil {
t.Fatal(err)
}
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if got := st.GetTile(tx, 1, 0, "0_0"); got != nil { if got := st.GetTile(tx, 1, 0, "0_0"); got != nil {
t.Fatal("expected nil after bucket deletion") t.Fatal("expected nil after bucket deletion")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestMarkerBuckets(t *testing.T) { func TestMarkerBuckets(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
if b := st.GetMarkersGridBucket(tx); b != nil { if b := st.GetMarkersGridBucket(tx); b != nil {
t.Fatal("expected nil grid bucket before creation") t.Fatal("expected nil grid bucket before creation")
} }
@@ -376,7 +418,9 @@ func TestMarkerBuckets(t *testing.T) {
t.Fatal("expected non-zero sequence") t.Fatal("expected non-zero sequence")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestOAuthStateCRUD(t *testing.T) { func TestOAuthStateCRUD(t *testing.T) {
@@ -408,23 +452,29 @@ func TestBucketExistsAndDelete(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
if st.BucketExists(tx, store.BucketUsers) { if st.BucketExists(tx, store.BucketUsers) {
t.Fatal("expected bucket to not exist") t.Fatal("expected bucket to not exist")
} }
st.PutUser(tx, "alice", []byte("x")) if err := st.PutUser(tx, "alice", []byte("x")); err != nil {
return err
}
if !st.BucketExists(tx, store.BucketUsers) { if !st.BucketExists(tx, store.BucketUsers) {
t.Fatal("expected bucket to exist") t.Fatal("expected bucket to exist")
} }
return st.DeleteBucket(tx, store.BucketUsers) return st.DeleteBucket(tx, store.BucketUsers)
}) }); err != nil {
t.Fatal(err)
}
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if st.BucketExists(tx, store.BucketUsers) { if st.BucketExists(tx, store.BucketUsers) {
t.Fatal("expected bucket to be deleted") t.Fatal("expected bucket to be deleted")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestDeleteBucketNonExistent(t *testing.T) { func TestDeleteBucketNonExistent(t *testing.T) {

View File

@@ -15,7 +15,9 @@ func (t *Topic[T]) Watch(c chan *T) {
t.c = append(t.c, c) t.c = append(t.c, c)
} }
// Send broadcasts to all subscribers. // Send broadcasts to all subscribers. If a subscriber's channel is full,
// the message is dropped for that subscriber only; the subscriber is not
// removed, so the connection stays alive and later updates are still delivered.
func (t *Topic[T]) Send(b *T) { func (t *Topic[T]) Send(b *T) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
@@ -23,9 +25,7 @@ func (t *Topic[T]) Send(b *T) {
select { select {
case t.c[i] <- b: case t.c[i] <- b:
default: default:
close(t.c[i]) // Channel full: drop this message for this subscriber, keep them subscribed
t.c[i] = t.c[len(t.c)-1]
t.c = t.c[:len(t.c)-1]
} }
} }
} }