From 3968bdc76f844872dab425bf08b2f048db16787d Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Wed, 4 Mar 2026 16:57:43 +0300 Subject: [PATCH] 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. --- docs/api.md | 158 +-- frontend-nuxt/lib/Marker.ts | 324 +++--- frontend-nuxt/public/gfx/terobjs/mm/cave.png | Bin 0 -> 2453 bytes internal/app/services/client.go | 1033 +++++++++--------- internal/app/services/client_test.go | 214 ++-- 5 files changed, 883 insertions(+), 846 deletions(-) create mode 100644 frontend-nuxt/public/gfx/terobjs/mm/cave.png diff --git a/docs/api.md b/docs/api.md index 645d4c4..601e789 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,79 +1,79 @@ -# HTTP API - -The API is available under the `/map/api/` prefix. Requests requiring authentication use a `session` cookie (set on login). - -## Authentication - -- **POST /map/api/login** — sign in. Body: `{"user":"...","pass":"..."}`. On success returns JSON with user data and sets a session cookie. On first run, bootstrap is available: logging in as `admin` with the password from `HNHMAP_BOOTSTRAP_PASSWORD` creates the first admin user. For users created via OAuth (no password), returns 401 with `{"error":"Use OAuth to sign in"}`. -- **GET /map/api/me** — current user (by session). Response: `username`, `auths`, and optionally `tokens`, `prefix`, `email` (string, optional — for Gravatar and display). -- **POST /map/api/logout** — sign out (invalidates the session). -- **GET /map/api/setup** — check if initial setup is needed. Response: `{"setupRequired": true|false}`. - -### OAuth - -- **GET /map/api/oauth/providers** — list of configured OAuth providers. Response: `["google", ...]`. -- **GET /map/api/oauth/{provider}/login** — redirect to the provider's authorization page. Query: `redirect` — path to redirect to after successful login (e.g. `/profile`). -- **GET /map/api/oauth/{provider}/callback** — callback from the provider (called automatically). Exchanges the `code` for tokens, creates or finds the user, creates a session, and redirects to `/profile` or the `redirect` from state. - -## User account - -- **PATCH /map/api/me** — update current user. Body: `{"email": "..."}`. Used to set or change the user's email (for Gravatar and profile display). Requires a valid session. -- **POST /map/api/me/tokens** — generate a new upload token (requires `upload` permission). Response: `{"tokens": ["...", ...]}`. -- **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`. - -## Map data - -- **GET /map/api/config** — client configuration (title, auths). Requires a session. -- **GET /map/api/v1/characters** — list of characters on the map (requires `map` permission; `markers` permission needed to see data). Each character object includes `ownedByMe` (boolean), which is true when the character was last updated by one of the current user's upload tokens. -- **GET /map/api/v1/markers** — markers (requires `map` permission; `markers` permission needed to see data). -- **GET /map/api/maps** — list of maps (filtered by permissions and hidden status). For non-admin users hidden maps are excluded; for admin, the response may include hidden maps (client should hide them in map selector if needed). - -## Admin (all endpoints below require `admin` permission) - -- **GET /map/api/admin/users** — list of usernames. -- **POST /map/api/admin/users** — create or update a user. Body: `{"user":"...","pass":"...","auths":["admin","map",...]}`. -- **GET /map/api/admin/users/:name** — user data. -- **DELETE /map/api/admin/users/:name** — delete a user. -- **GET /map/api/admin/settings** — settings (prefix, defaultHide, title). -- **POST /map/api/admin/settings** — save settings. Body: `{"prefix":"...","defaultHide":true|false,"title":"..."}` (all fields optional). -- **GET /map/api/admin/maps** — list of maps for the admin panel. -- **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority). -- **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility. -- **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database. -- **POST /map/api/admin/rebuildZooms** — start rebuilding tile zoom levels from base tiles in the background. Returns **202 Accepted** immediately; the operation can take minutes when there are many grids. The client may poll **GET /map/api/admin/rebuildZooms/status** until `{"running": false}` and then refresh the map. -- **GET /map/api/admin/rebuildZooms/status** — returns `{"running": true|false}` indicating whether a rebuild started via POST rebuildZooms is still in progress. -- **GET /map/api/admin/export** — download data export (ZIP). -- **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers). -- **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`. -- **GET /map/api/admin/setCoords** — shift grid coordinates. Query: `map`, `fx`, `fy`, `tx`, `ty`. -- **GET /map/api/admin/hideMarker** — hide a marker. Query: `id`. - -## Game client - -The game client (e.g. Purus Pasta) communicates via `/client/{token}/...` endpoints using token-based authentication. - -- **GET /client/{token}/checkVersion** — check client protocol version. Query: `version`. Returns 200 if matching, 400 otherwise. -- **GET /client/{token}/locate** — get grid coordinates. Query: `gridID`. Response: `mapid;x;y`. -- **POST /client/{token}/gridUpdate** — report visible grids and receive upload requests. -- **POST /client/{token}/gridUpload** — upload a tile image (multipart). -- **POST /client/{token}/positionUpdate** — update character positions. -- **POST /client/{token}/markerUpdate** — upload markers. - -## SSE (Server-Sent Events) - -- **GET /map/updates** — real-time tile and merge updates. Requires a session with `map` permission. Sends an initial `data:` message with an empty tile cache array `[]`, then incremental `data:` messages with tile cache updates and `event: merge` messages for map merges. The client requests tiles with `cache=0` when not yet in cache. - -## Tile images - -- **GET /map/grids/{mapid}/{zoom}/{x}_{y}.png** — tile image. Requires a session with `map` permission. Returns the tile image or a transparent 1×1 PNG if the tile does not exist. - -## Response codes - -- **200** — success. -- **400** — bad request (wrong method, body, or parameters). -- **401** — unauthorized (missing or invalid session). -- **403** — forbidden (insufficient permissions). -- **404** — not found. -- **500** — internal error. - -Error format: JSON body `{"error": "message", "code": "CODE"}`. +# HTTP API + +The API is available under the `/map/api/` prefix. Requests requiring authentication use a `session` cookie (set on login). + +## Authentication + +- **POST /map/api/login** — sign in. Body: `{"user":"...","pass":"..."}`. On success returns JSON with user data and sets a session cookie. On first run, bootstrap is available: logging in as `admin` with the password from `HNHMAP_BOOTSTRAP_PASSWORD` creates the first admin user. For users created via OAuth (no password), returns 401 with `{"error":"Use OAuth to sign in"}`. +- **GET /map/api/me** — current user (by session). Response: `username`, `auths`, and optionally `tokens`, `prefix`, `email` (string, optional — for Gravatar and display). +- **POST /map/api/logout** — sign out (invalidates the session). +- **GET /map/api/setup** — check if initial setup is needed. Response: `{"setupRequired": true|false}`. + +### OAuth + +- **GET /map/api/oauth/providers** — list of configured OAuth providers. Response: `["google", ...]`. +- **GET /map/api/oauth/{provider}/login** — redirect to the provider's authorization page. Query: `redirect` — path to redirect to after successful login (e.g. `/profile`). +- **GET /map/api/oauth/{provider}/callback** — callback from the provider (called automatically). Exchanges the `code` for tokens, creates or finds the user, creates a session, and redirects to `/profile` or the `redirect` from state. + +## User account + +- **PATCH /map/api/me** — update current user. Body: `{"email": "..."}`. Used to set or change the user's email (for Gravatar and profile display). Requires a valid session. +- **POST /map/api/me/tokens** — generate a new upload token (requires `upload` permission). Response: `{"tokens": ["...", ...]}`. +- **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`. + +## Map data + +- **GET /map/api/config** — client configuration (title, auths). Requires a session. +- **GET /map/api/v1/characters** — list of characters on the map (requires `map` permission; `markers` permission needed to see data). Each character object includes `ownedByMe` (boolean), which is true when the character was last updated by one of the current user's upload tokens. +- **GET /map/api/v1/markers** — markers (requires `map` permission; `markers` permission needed to see data). +- **GET /map/api/maps** — list of maps (filtered by permissions and hidden status). For non-admin users hidden maps are excluded; for admin, the response may include hidden maps (client should hide them in map selector if needed). + +## Admin (all endpoints below require `admin` permission) + +- **GET /map/api/admin/users** — list of usernames. +- **POST /map/api/admin/users** — create or update a user. Body: `{"user":"...","pass":"...","auths":["admin","map",...]}`. +- **GET /map/api/admin/users/:name** — user data. +- **DELETE /map/api/admin/users/:name** — delete a user. +- **GET /map/api/admin/settings** — settings (prefix, defaultHide, title). +- **POST /map/api/admin/settings** — save settings. Body: `{"prefix":"...","defaultHide":true|false,"title":"..."}` (all fields optional). +- **GET /map/api/admin/maps** — list of maps for the admin panel. +- **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority). +- **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility. +- **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database. +- **POST /map/api/admin/rebuildZooms** — start rebuilding tile zoom levels from base tiles in the background. Returns **202 Accepted** immediately; the operation can take minutes when there are many grids. The client may poll **GET /map/api/admin/rebuildZooms/status** until `{"running": false}` and then refresh the map. +- **GET /map/api/admin/rebuildZooms/status** — returns `{"running": true|false}` indicating whether a rebuild started via POST rebuildZooms is still in progress. +- **GET /map/api/admin/export** — download data export (ZIP). +- **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers). +- **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`. +- **GET /map/api/admin/setCoords** — shift grid coordinates. Query: `map`, `fx`, `fy`, `tx`, `ty`. +- **GET /map/api/admin/hideMarker** — hide a marker. Query: `id`. + +## Game client + +The game client (e.g. Purus Pasta) communicates via `/client/{token}/...` endpoints using token-based authentication. + +- **GET /client/{token}/checkVersion** — check client protocol version. Query: `version`. Returns 200 if matching, 400 otherwise. +- **GET /client/{token}/locate** — get grid coordinates. Query: `gridID`. Response: `mapid;x;y`. +- **POST /client/{token}/gridUpdate** — report visible grids and receive upload requests. +- **POST /client/{token}/gridUpload** — upload a tile image (multipart). +- **POST /client/{token}/positionUpdate** — update character positions. +- **POST /client/{token}/markerUpdate** — upload markers. + +## SSE (Server-Sent Events) + +- **GET /map/updates** — real-time tile and merge updates. Requires a session with `map` permission. Sends an initial `data:` message with an empty tile cache array `[]`, then incremental `data:` messages with tile cache updates and `event: merge` messages for map merges. The client requests tiles with `cache=0` when not yet in cache. + +## Tile images + +- **GET /map/grids/{mapid}/{zoom}/{x}_{y}.png** — tile image. Requires a session with `map` permission. Returns the tile image or a transparent 1×1 PNG if the tile does not exist. + +## Response codes + +- **200** — success. +- **400** — bad request (wrong method, body, or parameters). +- **401** — unauthorized (missing or invalid session). +- **403** — forbidden (insufficient permissions). +- **404** — not found. +- **500** — internal error. + +Error format: JSON body `{"error": "message", "code": "CODE"}`. diff --git a/frontend-nuxt/lib/Marker.ts b/frontend-nuxt/lib/Marker.ts index d00960c..e12a3a1 100644 --- a/frontend-nuxt/lib/Marker.ts +++ b/frontend-nuxt/lib/Marker.ts @@ -1,159 +1,165 @@ -import type L from 'leaflet' -import { HnHMaxZoom, ImageIcon, TileSize } from '~/lib/LeafletCustomTypes' - -export interface MarkerData { - id: number - position: { x: number; y: number } - name: string - image: string - hidden: boolean - map: number -} - -export interface MapViewRef { - map: L.Map - mapid: number - markerLayer: L.LayerGroup -} - -export interface MapMarker { - id: number - position: { x: number; y: number } - name: string - image: string - type: string - text: string - value: number - hidden: boolean - map: number - leafletMarker: L.Marker | null - remove: (mapview: MapViewRef) => void - add: (mapview: MapViewRef) => void - update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void - jumpTo: (map: L.Map) => void - setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void - setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void -} - -function detectType(name: string): string { - if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest' - if (name === 'custom') return 'custom' - return name.substring('gfx/terobjs/mm/'.length) -} - -export interface MarkerIconOptions { - /** Resolves relative icon path to absolute URL (e.g. with app base path). */ - resolveIconUrl: (path: string) => string - /** Optional fallback URL when the icon image fails to load. */ - fallbackIconUrl?: string -} - -export type LeafletApi = L - -export function createMarker( - data: MarkerData, - iconOptions: MarkerIconOptions | undefined, - L: LeafletApi -): MapMarker { - let leafletMarker: L.Marker | null = null - let onClick: ((e: L.LeafletMouseEvent) => void) | null = null - let onContext: ((e: L.LeafletMouseEvent) => void) | null = null - - const marker: MapMarker = { - id: data.id, - position: { ...data.position }, - name: data.name, - image: data.image, - type: detectType(data.image), - text: data.name, - value: data.id, - hidden: data.hidden, - map: data.map, - - get leafletMarker() { - return leafletMarker - }, - - remove(_mapview: MapViewRef): void { - if (leafletMarker) { - leafletMarker.remove() - leafletMarker = null - } - }, - - add(mapview: MapViewRef): void { - if (!marker.hidden) { - const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path) - const fallback = iconOptions?.fallbackIconUrl - let icon: L.Icon - if (marker.image === 'gfx/terobjs/mm/custom') { - icon = new ImageIcon({ - iconUrl: resolve('gfx/terobjs/mm/custom.png'), - iconSize: [21, 23], - iconAnchor: [11, 21], - popupAnchor: [1, 3], - tooltipAnchor: [1, 3], - fallbackIconUrl: fallback, - }) - } else { - icon = new ImageIcon({ - iconUrl: resolve(`${marker.image}.png`), - iconSize: [32, 32], - fallbackIconUrl: fallback, - }) - } - - const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom) - 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) - const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.() - if (markerEl) markerEl.setAttribute('aria-label', marker.name) - leafletMarker.on('click', (e: L.LeafletMouseEvent) => { - if (onClick) onClick(e) - }) - leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => { - if (onContext) onContext(e) - }) - } - }, - - update(mapview: MapViewRef, updated: MarkerData | MapMarker): void { - marker.position = { ...updated.position } - marker.name = updated.name - marker.hidden = updated.hidden - marker.map = updated.map - if (leafletMarker) { - const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) - 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}`) - } - }, - - jumpTo(map: L.Map): void { - if (leafletMarker) { - const position = map.unproject([marker.position.x, marker.position.y], HnHMaxZoom) - leafletMarker.setLatLng(position) - } - }, - - setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void { - onClick = callback - }, - - setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void { - onContext = callback - }, - } - - return marker -} +import type L from 'leaflet' +import { HnHMaxZoom, ImageIcon, TileSize } from '~/lib/LeafletCustomTypes' + +export interface MarkerData { + id: number + position: { x: number; y: number } + name: string + image: string + hidden: boolean + map: number +} + +export interface MapViewRef { + map: L.Map + mapid: number + markerLayer: L.LayerGroup +} + +export interface MapMarker { + id: number + position: { x: number; y: number } + name: string + image: string + type: string + text: string + value: number + hidden: boolean + map: number + leafletMarker: L.Marker | null + remove: (mapview: MapViewRef) => void + add: (mapview: MapViewRef) => void + update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void + jumpTo: (map: L.Map) => void + setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void + setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void +} + +function detectType(name: string): string { + if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest' + if (name === 'custom') return 'custom' + return name.substring('gfx/terobjs/mm/'.length) +} + +export interface MarkerIconOptions { + /** Resolves relative icon path to absolute URL (e.g. with app base path). */ + resolveIconUrl: (path: string) => string + /** Optional fallback URL when the icon image fails to load. */ + fallbackIconUrl?: string +} + +export type LeafletApi = L + +export function createMarker( + data: MarkerData, + iconOptions: MarkerIconOptions | undefined, + L: LeafletApi +): MapMarker { + let leafletMarker: L.Marker | null = null + let onClick: ((e: L.LeafletMouseEvent) => void) | null = null + let onContext: ((e: L.LeafletMouseEvent) => void) | null = null + + const marker: MapMarker = { + id: data.id, + position: { ...data.position }, + name: data.name, + image: data.image, + type: detectType(data.image), + text: data.name, + value: data.id, + hidden: data.hidden, + map: data.map, + + get leafletMarker() { + return leafletMarker + }, + + remove(_mapview: MapViewRef): void { + if (leafletMarker) { + leafletMarker.remove() + leafletMarker = null + } + }, + + add(mapview: MapViewRef): void { + if (!marker.hidden) { + const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path) + 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 + if (marker.image === 'gfx/terobjs/mm/custom' && marker.name !== 'Cave') { + icon = new ImageIcon({ + iconUrl, + iconSize: [21, 23], + iconAnchor: [11, 21], + popupAnchor: [1, 3], + tooltipAnchor: [1, 3], + fallbackIconUrl: fallback, + }) + } else { + icon = new ImageIcon({ + iconUrl, + iconSize: [32, 32], + fallbackIconUrl: fallback, + }) + } + + const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom) + 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) + const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.() + if (markerEl) markerEl.setAttribute('aria-label', marker.name) + leafletMarker.on('click', (e: L.LeafletMouseEvent) => { + if (onClick) onClick(e) + }) + leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => { + if (onContext) onContext(e) + }) + } + }, + + update(mapview: MapViewRef, updated: MarkerData | MapMarker): void { + marker.position = { ...updated.position } + marker.name = updated.name + marker.hidden = updated.hidden + marker.map = updated.map + if (leafletMarker) { + const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) + 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}`) + } + }, + + jumpTo(map: L.Map): void { + if (leafletMarker) { + const position = map.unproject([marker.position.x, marker.position.y], HnHMaxZoom) + leafletMarker.setLatLng(position) + } + }, + + setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void { + onClick = callback + }, + + setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void { + onContext = callback + }, + } + + return marker +} diff --git a/frontend-nuxt/public/gfx/terobjs/mm/cave.png b/frontend-nuxt/public/gfx/terobjs/mm/cave.png new file mode 100644 index 0000000000000000000000000000000000000000..eb507e7fc25dc792834d35029ae2d433b960229a GIT binary patch literal 2453 zcmV;G32OFPx#1ZP1_K>z@;j|==^1poj532;bRa{vGy!TDZyZ*CwmHXvbjXCN{#F*P7DG&(UhIx;XID=;xIFjoTPPXGV_8FWQhbW?9; zba!ELWdKiZWgt*!Z**@UR%vB-Wpd5fyrTdB01R|RSad{Xb7OL8aCB*JZU7SrAtL|) z2)0Q?K~z{r#g|!ZRM#2Df9KqL=gz)(!^U`n8Uks603`&nwM|lmDoUjgwMr_bs>o9t zRaGBTDXNrKeaJ$kv{BSbX_|+0O&*X+mNu$F%aTBV0Jfnl1{-_q@t7IU%$>R0Ieh@b z;DAE=(*Mgn_vn1z|98)R;r}eLu;K>F0c(Ly;31#~r~@w0e)(AfYQSHC-!DvXBi#%@ zJMapy4u~zzqPK0dGaCjBqa}ecJx35Rgjw}9AD#At_m8PytQgJ$JAmH+m4ykL^?3kY z!1HY>vGw7#X7bi(xvoL#r3q?{fJP`OIwp%! zh$rt&7y^+#Gp!zSTs;jOS!lrL06Yua_1w02>h^w9X1Z77%}rpU@O{ndEn6|;ZA_dV zqBd71CSBq&3qJraluVU0%exHL_C;d5j=1*#6TopmH=D@jmnL-q%eUWaCzf?cX(c;o zUp#=+iq6(3w+ytBO-OLdNHc;TXhPGb;kejgm8=~Qi%6#C1d(V=tY2nlfUjSk#MgZE zjQ}14dbbRkW;7y6i2_oI@`0vqc-g=S=zHUEtv>yxg>U?16L#ns#6TK`LrhC>8+V4(0fyJz!2!O1+7yF z+w2>>J(lTRO>)_T#Cx|Ol3fIm95Lx&Fa0uBwueL_LC@k0Jxf|y)Y*<{+f0m&QmbAh z8W$|e2-;IZ04<;KT37*)iivN3<%`K%zOnUQqix9wVOb_l%^{v{MPneUr!dQVF#IAC zg%z_gZHq`ELukaAxR7UJdWMLpDR~x-tBIHrTl%r#3Dpkln)kY14+uk&)iNSd=@wxK zO|5W}nNuI&)~lccjJcDPBAaMrHWTF55!gM1(SAJHPSp_;VB`tbN& zahZ7t~Ug$EGzP(hS_E{KnHbC zd|bO7OE^5T2B=T4>DAXbboK~~tvuZc2P>K(QvL|9bdpd9)O|_GF{#joG!*4(h|&V3 zHNLOJyPrBnxu)LZ1_1lluV3HZ($XsC>V|mytIK)!(+ZW+BnLm)!`TaCoZliClZvBR z#n|yWnN$~1UBylhQm^Fk>=lSuoJ1@H5inZ{2t!ax(-K$u!xKTP9dFO_{a@x;@?ZiPmwd9b!uySjy!!VKsG40^i43Xk zO<1N&)TkjOMeVUQ664g?k-TypH zdA4@7p%6^$tI@wCM&)eC8Nb3CpB$t*eUM7=IDR8f;8*d(fIumP1f@VLAtKV0bx#+s zcwWuQp%-6xA=cBgL^m1@oLYt9Q{z1K@(I*6TL<_xMWCVW?l`w);w({7`bKtvZaTZVio;9^x%u0p9CQY?i8p&-gm`F3pB610S2ZG7!R~ zS_ikGX-Qi&eDFg}sK5z?l#u!i0N{C^uZqPYg+c*o7;M|Njr$*XfJ`cl5LYix^@5^& z1TGE*boAXpt(GJihE%HoXamm^L`*>@Ws!^<=*t%`t)b>>sbTeet}6hQO2yo_cMs>z zjWRkq%F2~1$)u9Jv-3|Vt>&8n@b5o3=m7HjJi<`1Ki^M1j8mz3s78gBlp>cBpcRva zfIwY3f}|7-jr(W_=5t*EP)dFL`tRPT?ArAX(}ig^Zrq5{8c!Ltwzgf<1A(LQOW+n2 z`}gl>ZF-owsz<~$FewwUeS{R$8=6u@fdWGcqISr!^K~D1{xbl;!((G(zZx1Ea#yWf zrFwgNaXp_<3+!kFt*tBK+XBs^F+)oICfP8+6v!iE8$vS(F z7V2}Za)24&7uUo$zcgt?DK(SN=L?O7JE@ckfFckD?B<4>1}&zYD5u6NJiP5m9LM4K z@ga^KJIeHQfkI*C;?%|JIPf~~NOLSV@n_VmyMP}6D=&k@Hf&he_O;cK$jNiVj0v~C zDDhw<9#6T7bYhs-vnL*h8HH}zXWL3J-`q9`uf%_8(i8gXZN4jecTa= zL`$}#qa#|c*X7jI)Lf&{cmsH5VQ{nlSAcol2Yd<0Tz;CD$5{b?0sg!&V4?m44B_cX TS_^8y00000NkvXXu0mjf9-nO% literal 0 HcmV?d00001 diff --git a/internal/app/services/client.go b/internal/app/services/client.go index 01fc3c5..d4a04a3 100644 --- a/internal/app/services/client.go +++ b/internal/app/services/client.go @@ -1,515 +1,518 @@ -package services - -import ( - "context" - "encoding/json" - "fmt" - "io" - "log/slog" - "os" - "strconv" - "strings" - "time" - - "github.com/andyleap/hnh-map/internal/app" - "github.com/andyleap/hnh-map/internal/app/apperr" - "github.com/andyleap/hnh-map/internal/app/store" - "go.etcd.io/bbolt" -) - -// GridUpdate is the client grid update request body. -type GridUpdate struct { - Grids [][]string `json:"grids"` -} - -// GridRequest is the grid update response. -type GridRequest struct { - GridRequests []string `json:"gridRequests"` - Map int `json:"map"` - Coords app.Coord `json:"coords"` -} - -// ExtraData carries season info from the client. -type ExtraData struct { - Season int -} - -// ClientService handles game client operations. -type ClientService struct { - st *store.Store - mapSvc *MapService - // withChars provides locked mutable access to the character map. - withChars func(fn func(chars map[string]app.Character)) -} - -// ClientServiceDeps holds dependencies for ClientService. -type ClientServiceDeps struct { - Store *store.Store - MapSvc *MapService - WithChars func(fn func(chars map[string]app.Character)) -} - -// NewClientService creates a ClientService with the given dependencies. -func NewClientService(d ClientServiceDeps) *ClientService { - return &ClientService{ - st: d.Store, - mapSvc: d.MapSvc, - withChars: d.WithChars, - } -} - -// Locate returns "mapid;x;y" for a grid, or error if not found. -func (s *ClientService) Locate(ctx context.Context, gridID string) (string, error) { - var result string - err := s.st.View(ctx, func(tx *bbolt.Tx) error { - raw := s.st.GetGrid(tx, gridID) - if raw == nil { - return apperr.ErrNotFound - } - cur := app.GridData{} - if err := json.Unmarshal(raw, &cur); err != nil { - return err - } - result = fmt.Sprintf("%d;%d;%d", cur.Map, cur.Coord.X, cur.Coord.Y) - return nil - }) - return result, err -} - -// GridUpdateResult contains the response and any tile operations to process. -type GridUpdateResult struct { - Response GridRequest - Ops []TileOp -} - -// ProcessGridUpdate handles a client grid update and returns the response. -func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate) (*GridUpdateResult, error) { - result := &GridUpdateResult{} - greq := &result.Response - - err := s.st.Update(ctx, func(tx *bbolt.Tx) error { - grids, err := tx.CreateBucketIfNotExists(store.BucketGrids) - if err != nil { - return err - } - tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles) - if err != nil { - return err - } - mapB, err := tx.CreateBucketIfNotExists(store.BucketMaps) - if err != nil { - return err - } - configb, err := tx.CreateBucketIfNotExists(store.BucketConfig) - if err != nil { - return err - } - - maps := map[int]struct{ X, Y int }{} - for x, row := range grup.Grids { - for y, grid := range row { - gridRaw := grids.Get([]byte(grid)) - if gridRaw != nil { - gd := app.GridData{} - if err := json.Unmarshal(gridRaw, &gd); err != nil { - return err - } - maps[gd.Map] = struct{ X, Y int }{gd.Coord.X - x, gd.Coord.Y - y} - } - } - } - - if len(maps) == 0 { - seq, err := mapB.NextSequence() - if err != nil { - return err - } - mi := app.MapInfo{ - ID: int(seq), - Name: strconv.Itoa(int(seq)), - Hidden: configb.Get([]byte("defaultHide")) != nil, - } - raw, _ := json.Marshal(mi) - if err = mapB.Put([]byte(strconv.Itoa(int(seq))), raw); err != nil { - return err - } - slog.Info("client created new map", "map_id", seq) - for x, row := range grup.Grids { - for y, grid := range row { - cur := app.GridData{ID: grid, Map: int(seq), Coord: app.Coord{X: x - 1, Y: y - 1}} - raw, err := json.Marshal(cur) - if err != nil { - return err - } - if err := grids.Put([]byte(grid), raw); err != nil { - return err - } - greq.GridRequests = append(greq.GridRequests, grid) - } - } - greq.Coords = app.Coord{X: 0, Y: 0} - return nil - } - - mapid := -1 - offset := struct{ X, Y int }{} - for id, off := range maps { - mi := app.MapInfo{} - mraw := mapB.Get([]byte(strconv.Itoa(id))) - if mraw != nil { - if err := json.Unmarshal(mraw, &mi); err != nil { - return err - } - } - if mi.Priority { - mapid = id - offset = off - break - } - if id < mapid || mapid == -1 { - mapid = id - offset = off - } - } - - slog.Debug("client in map", "map_id", mapid) - - for x, row := range grup.Grids { - for y, grid := range row { - cur := app.GridData{} - if curRaw := grids.Get([]byte(grid)); curRaw != nil { - if err := json.Unmarshal(curRaw, &cur); err != nil { - return err - } - if time.Now().After(cur.NextUpdate) { - greq.GridRequests = append(greq.GridRequests, grid) - } - continue - } - cur.ID = grid - cur.Map = mapid - cur.Coord.X = x + offset.X - cur.Coord.Y = y + offset.Y - raw, err := json.Marshal(cur) - if err != nil { - return err - } - if err := grids.Put([]byte(grid), raw); err != nil { - return err - } - greq.GridRequests = append(greq.GridRequests, grid) - } - } - if len(grup.Grids) >= 2 && len(grup.Grids[1]) >= 2 { - if curRaw := grids.Get([]byte(grup.Grids[1][1])); curRaw != nil { - cur := app.GridData{} - if err := json.Unmarshal(curRaw, &cur); err != nil { - return err - } - greq.Map = cur.Map - greq.Coords = cur.Coord - } - } - if len(maps) > 1 { - if err := grids.ForEach(func(k, v []byte) error { - gd := app.GridData{} - if err := json.Unmarshal(v, &gd); err != nil { - return err - } - if gd.Map == mapid { - return nil - } - if merge, ok := maps[gd.Map]; ok { - var td *app.TileData - mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(gd.Map))) - if err != nil { - return err - } - zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(0))) - if err != nil { - return err - } - tileraw := zoom.Get([]byte(gd.Coord.Name())) - if tileraw != nil { - if err := json.Unmarshal(tileraw, &td); err != nil { - return err - } - } - - gd.Map = mapid - gd.Coord.X += offset.X - merge.X - gd.Coord.Y += offset.Y - merge.Y - raw, _ := json.Marshal(gd) - if td != nil { - result.Ops = append(result.Ops, TileOp{ - MapID: mapid, - X: gd.Coord.X, - Y: gd.Coord.Y, - File: td.File, - }) - } - if err := grids.Put(k, raw); err != nil { - return err - } - } - return nil - }); err != nil { - return err - } - } - for mergeid, merge := range maps { - if mapid == mergeid { - continue - } - if err := mapB.Delete([]byte(strconv.Itoa(mergeid))); err != nil { - return err - } - 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}) - } - return nil - }) - if err != nil { - return nil, err - } - - s.mapSvc.ProcessZoomLevels(ctx, result.Ops) - return result, nil -} - -// ProcessGridUpload handles a tile image upload from the client. -func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraData string, fileReader io.Reader) error { - if extraData != "" { - ed := ExtraData{} - _ = json.Unmarshal([]byte(extraData), &ed) - if ed.Season == 3 { - needTile := false - if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { - raw := s.st.GetGrid(tx, id) - if raw == nil { - return fmt.Errorf("unknown grid id: %s", id) - } - cur := app.GridData{} - if err := json.Unmarshal(raw, &cur); err != nil { - return err - } - tdRaw := s.st.GetTile(tx, cur.Map, 0, cur.Coord.Name()) - if tdRaw == nil { - needTile = true - return nil - } - td := app.TileData{} - if err := json.Unmarshal(tdRaw, &td); err != nil { - return err - } - if td.File == "" { - needTile = true - return nil - } - if time.Now().After(cur.NextUpdate) { - cur.NextUpdate = time.Now().Add(app.TileUpdateInterval) - } - raw, _ = json.Marshal(cur) - return s.st.PutGrid(tx, id, raw) - }); err != nil { - return err - } - if !needTile { - slog.Debug("ignoring tile upload: winter") - return nil - } - slog.Debug("missing tile, using winter version") - } - } - - slog.Debug("processing tile upload", "grid_id", id) - - updateTile := false - cur := app.GridData{} - mapid := 0 - - if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { - raw := s.st.GetGrid(tx, id) - if raw == nil { - return fmt.Errorf("unknown grid id: %s", id) - } - if err := json.Unmarshal(raw, &cur); err != nil { - return err - } - updateTile = time.Now().After(cur.NextUpdate) - mapid = cur.Map - if updateTile { - cur.NextUpdate = time.Now().Add(app.TileUpdateInterval) - } - raw, _ = json.Marshal(cur) - return s.st.PutGrid(tx, id, raw) - }); err != nil { - return err - } - - if updateTile { - gridDir := fmt.Sprintf("%s/grids", s.mapSvc.GridStorage()) - if err := os.MkdirAll(gridDir, 0755); err != nil { - slog.Error("failed to create grids dir", "error", err) - return err - } - f, err := os.Create(fmt.Sprintf("%s/grids/%s.png", s.mapSvc.GridStorage(), cur.ID)) - if err != nil { - return err - } - if _, err = io.Copy(f, fileReader); err != nil { - f.Close() - return err - } - f.Close() - - s.mapSvc.SaveTile(ctx, mapid, cur.Coord, 0, fmt.Sprintf("grids/%s.png", cur.ID), time.Now().UnixNano()) - - c := cur.Coord - for z := 1; z <= app.MaxZoomLevel; z++ { - c = c.Parent() - s.mapSvc.UpdateZoomLevel(ctx, mapid, c, z) - } - } - return nil -} - -// UpdatePositions updates character positions from client data. -func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error { - craws := map[string]struct { - Name string - GridID string - Coords struct{ X, Y int } - Type string - }{} - if err := json.Unmarshal(data, &craws); err != nil { - slog.Error("failed to decode position update", "error", err) - return err - } - - gridDataByID := make(map[string]app.GridData) - if err := s.st.View(ctx, func(tx *bbolt.Tx) error { - for _, craw := range craws { - raw := s.st.GetGrid(tx, craw.GridID) - if raw != nil { - var gd app.GridData - if json.Unmarshal(raw, &gd) == nil { - gridDataByID[craw.GridID] = gd - } - } - } - return nil - }); err != nil { - return err - } - - username, _ := ctx.Value(app.ClientUsernameKey).(string) - - s.withChars(func(chars map[string]app.Character) { - for id, craw := range craws { - gd, ok := gridDataByID[craw.GridID] - if !ok { - continue - } - idnum, _ := strconv.Atoi(id) - c := app.Character{ - Name: craw.Name, - ID: idnum, - Map: gd.Map, - Position: app.Position{ - X: craw.Coords.X + (gd.Coord.X * app.GridSize), - Y: craw.Coords.Y + (gd.Coord.Y * app.GridSize), - }, - Type: craw.Type, - Updated: time.Now(), - Username: username, - } - old, ok := chars[id] - if !ok { - chars[id] = c - } else { - if old.Type == "player" { - if c.Type == "player" { - chars[id] = c - } else { - old.Position = c.Position - old.Username = username - chars[id] = old - } - } else if old.Type != "unknown" { - if c.Type != "unknown" { - chars[id] = c - } else { - old.Position = c.Position - old.Username = username - chars[id] = old - } - } else { - chars[id] = c - } - } - } - }) - return nil -} - -// UploadMarkers stores markers uploaded by the client. -func (s *ClientService) UploadMarkers(ctx context.Context, data []byte) error { - markers := []struct { - Name string - GridID string - X, Y int - Image string - Type string - Color string - }{} - if err := json.Unmarshal(data, &markers); err != nil { - slog.Error("failed to decode marker upload", "error", err) - return err - } - return s.st.Update(ctx, func(tx *bbolt.Tx) error { - grid, idB, err := s.st.CreateMarkersBuckets(tx) - if err != nil { - return err - } - for _, mraw := range markers { - key := []byte(fmt.Sprintf("%s_%d_%d", mraw.GridID, mraw.X, mraw.Y)) - if grid.Get(key) != nil { - continue - } - img := mraw.Image - if img == "" { - img = "gfx/terobjs/mm/custom" - } - id, err := idB.NextSequence() - if err != nil { - return err - } - idKey := []byte(strconv.Itoa(int(id))) - m := app.Marker{ - Name: mraw.Name, - ID: int(id), - GridID: mraw.GridID, - Position: app.Position{X: mraw.X, Y: mraw.Y}, - Image: img, - } - raw, _ := json.Marshal(m) - if err := grid.Put(key, raw); err != nil { - return err - } - if err := idB.Put(idKey, key); err != nil { - return err - } - } - return nil - }) -} - -// FixMultipartContentType fixes broken multipart Content-Type headers from some game clients. -func FixMultipartContentType(ct string) string { - if strings.Count(ct, "=") >= 2 && strings.Count(ct, "\"") == 0 { - parts := strings.SplitN(ct, "=", 2) - return parts[0] + "=\"" + parts[1] + "\"" - } - return ct -} +package services + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "strconv" + "strings" + "time" + + "github.com/andyleap/hnh-map/internal/app" + "github.com/andyleap/hnh-map/internal/app/apperr" + "github.com/andyleap/hnh-map/internal/app/store" + "go.etcd.io/bbolt" +) + +// GridUpdate is the client grid update request body. +type GridUpdate struct { + Grids [][]string `json:"grids"` +} + +// GridRequest is the grid update response. +type GridRequest struct { + GridRequests []string `json:"gridRequests"` + Map int `json:"map"` + Coords app.Coord `json:"coords"` +} + +// ExtraData carries season info from the client. +type ExtraData struct { + Season int +} + +// ClientService handles game client operations. +type ClientService struct { + st *store.Store + mapSvc *MapService + // withChars provides locked mutable access to the character map. + withChars func(fn func(chars map[string]app.Character)) +} + +// ClientServiceDeps holds dependencies for ClientService. +type ClientServiceDeps struct { + Store *store.Store + MapSvc *MapService + WithChars func(fn func(chars map[string]app.Character)) +} + +// NewClientService creates a ClientService with the given dependencies. +func NewClientService(d ClientServiceDeps) *ClientService { + return &ClientService{ + st: d.Store, + mapSvc: d.MapSvc, + withChars: d.WithChars, + } +} + +// Locate returns "mapid;x;y" for a grid, or error if not found. +func (s *ClientService) Locate(ctx context.Context, gridID string) (string, error) { + var result string + err := s.st.View(ctx, func(tx *bbolt.Tx) error { + raw := s.st.GetGrid(tx, gridID) + if raw == nil { + return apperr.ErrNotFound + } + cur := app.GridData{} + if err := json.Unmarshal(raw, &cur); err != nil { + return err + } + result = fmt.Sprintf("%d;%d;%d", cur.Map, cur.Coord.X, cur.Coord.Y) + return nil + }) + return result, err +} + +// GridUpdateResult contains the response and any tile operations to process. +type GridUpdateResult struct { + Response GridRequest + Ops []TileOp +} + +// ProcessGridUpdate handles a client grid update and returns the response. +func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate) (*GridUpdateResult, error) { + result := &GridUpdateResult{} + greq := &result.Response + + err := s.st.Update(ctx, func(tx *bbolt.Tx) error { + grids, err := tx.CreateBucketIfNotExists(store.BucketGrids) + if err != nil { + return err + } + tiles, err := tx.CreateBucketIfNotExists(store.BucketTiles) + if err != nil { + return err + } + mapB, err := tx.CreateBucketIfNotExists(store.BucketMaps) + if err != nil { + return err + } + configb, err := tx.CreateBucketIfNotExists(store.BucketConfig) + if err != nil { + return err + } + + maps := map[int]struct{ X, Y int }{} + for x, row := range grup.Grids { + for y, grid := range row { + gridRaw := grids.Get([]byte(grid)) + if gridRaw != nil { + gd := app.GridData{} + if err := json.Unmarshal(gridRaw, &gd); err != nil { + return err + } + maps[gd.Map] = struct{ X, Y int }{gd.Coord.X - x, gd.Coord.Y - y} + } + } + } + + if len(maps) == 0 { + seq, err := mapB.NextSequence() + if err != nil { + return err + } + mi := app.MapInfo{ + ID: int(seq), + Name: strconv.Itoa(int(seq)), + Hidden: configb.Get([]byte("defaultHide")) != nil, + } + raw, _ := json.Marshal(mi) + if err = mapB.Put([]byte(strconv.Itoa(int(seq))), raw); err != nil { + return err + } + slog.Info("client created new map", "map_id", seq) + for x, row := range grup.Grids { + for y, grid := range row { + cur := app.GridData{ID: grid, Map: int(seq), Coord: app.Coord{X: x - 1, Y: y - 1}} + raw, err := json.Marshal(cur) + if err != nil { + return err + } + if err := grids.Put([]byte(grid), raw); err != nil { + return err + } + greq.GridRequests = append(greq.GridRequests, grid) + } + } + greq.Coords = app.Coord{X: 0, Y: 0} + return nil + } + + mapid := -1 + offset := struct{ X, Y int }{} + for id, off := range maps { + mi := app.MapInfo{} + mraw := mapB.Get([]byte(strconv.Itoa(id))) + if mraw != nil { + if err := json.Unmarshal(mraw, &mi); err != nil { + return err + } + } + if mi.Priority { + mapid = id + offset = off + break + } + if id < mapid || mapid == -1 { + mapid = id + offset = off + } + } + + slog.Debug("client in map", "map_id", mapid) + + for x, row := range grup.Grids { + for y, grid := range row { + cur := app.GridData{} + if curRaw := grids.Get([]byte(grid)); curRaw != nil { + if err := json.Unmarshal(curRaw, &cur); err != nil { + return err + } + if time.Now().After(cur.NextUpdate) { + greq.GridRequests = append(greq.GridRequests, grid) + } + continue + } + cur.ID = grid + cur.Map = mapid + cur.Coord.X = x + offset.X + cur.Coord.Y = y + offset.Y + raw, err := json.Marshal(cur) + if err != nil { + return err + } + if err := grids.Put([]byte(grid), raw); err != nil { + return err + } + greq.GridRequests = append(greq.GridRequests, grid) + } + } + if len(grup.Grids) >= 2 && len(grup.Grids[1]) >= 2 { + if curRaw := grids.Get([]byte(grup.Grids[1][1])); curRaw != nil { + cur := app.GridData{} + if err := json.Unmarshal(curRaw, &cur); err != nil { + return err + } + greq.Map = cur.Map + greq.Coords = cur.Coord + } + } + if len(maps) > 1 { + if err := grids.ForEach(func(k, v []byte) error { + gd := app.GridData{} + if err := json.Unmarshal(v, &gd); err != nil { + return err + } + if gd.Map == mapid { + return nil + } + if merge, ok := maps[gd.Map]; ok { + var td *app.TileData + mapb, err := tiles.CreateBucketIfNotExists([]byte(strconv.Itoa(gd.Map))) + if err != nil { + return err + } + zoom, err := mapb.CreateBucketIfNotExists([]byte(strconv.Itoa(0))) + if err != nil { + return err + } + tileraw := zoom.Get([]byte(gd.Coord.Name())) + if tileraw != nil { + if err := json.Unmarshal(tileraw, &td); err != nil { + return err + } + } + + gd.Map = mapid + gd.Coord.X += offset.X - merge.X + gd.Coord.Y += offset.Y - merge.Y + raw, _ := json.Marshal(gd) + if td != nil { + result.Ops = append(result.Ops, TileOp{ + MapID: mapid, + X: gd.Coord.X, + Y: gd.Coord.Y, + File: td.File, + }) + } + if err := grids.Put(k, raw); err != nil { + return err + } + } + return nil + }); err != nil { + return err + } + } + for mergeid, merge := range maps { + if mapid == mergeid { + continue + } + if err := mapB.Delete([]byte(strconv.Itoa(mergeid))); err != nil { + return err + } + 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}) + } + return nil + }) + if err != nil { + return nil, err + } + + s.mapSvc.ProcessZoomLevels(ctx, result.Ops) + return result, nil +} + +// ProcessGridUpload handles a tile image upload from the client. +func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraData string, fileReader io.Reader) error { + if extraData != "" { + ed := ExtraData{} + _ = json.Unmarshal([]byte(extraData), &ed) + if ed.Season == 3 { + needTile := false + if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { + raw := s.st.GetGrid(tx, id) + if raw == nil { + return fmt.Errorf("unknown grid id: %s", id) + } + cur := app.GridData{} + if err := json.Unmarshal(raw, &cur); err != nil { + return err + } + tdRaw := s.st.GetTile(tx, cur.Map, 0, cur.Coord.Name()) + if tdRaw == nil { + needTile = true + return nil + } + td := app.TileData{} + if err := json.Unmarshal(tdRaw, &td); err != nil { + return err + } + if td.File == "" { + needTile = true + return nil + } + if time.Now().After(cur.NextUpdate) { + cur.NextUpdate = time.Now().Add(app.TileUpdateInterval) + } + raw, _ = json.Marshal(cur) + return s.st.PutGrid(tx, id, raw) + }); err != nil { + return err + } + if !needTile { + slog.Debug("ignoring tile upload: winter") + return nil + } + slog.Debug("missing tile, using winter version") + } + } + + slog.Debug("processing tile upload", "grid_id", id) + + updateTile := false + cur := app.GridData{} + mapid := 0 + + if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { + raw := s.st.GetGrid(tx, id) + if raw == nil { + return fmt.Errorf("unknown grid id: %s", id) + } + if err := json.Unmarshal(raw, &cur); err != nil { + return err + } + updateTile = time.Now().After(cur.NextUpdate) + mapid = cur.Map + if updateTile { + cur.NextUpdate = time.Now().Add(app.TileUpdateInterval) + } + raw, _ = json.Marshal(cur) + return s.st.PutGrid(tx, id, raw) + }); err != nil { + return err + } + + if updateTile { + gridDir := fmt.Sprintf("%s/grids", s.mapSvc.GridStorage()) + if err := os.MkdirAll(gridDir, 0755); err != nil { + slog.Error("failed to create grids dir", "error", err) + return err + } + f, err := os.Create(fmt.Sprintf("%s/grids/%s.png", s.mapSvc.GridStorage(), cur.ID)) + if err != nil { + return err + } + if _, err = io.Copy(f, fileReader); err != nil { + f.Close() + return err + } + f.Close() + + s.mapSvc.SaveTile(ctx, mapid, cur.Coord, 0, fmt.Sprintf("grids/%s.png", cur.ID), time.Now().UnixNano()) + + c := cur.Coord + for z := 1; z <= app.MaxZoomLevel; z++ { + c = c.Parent() + s.mapSvc.UpdateZoomLevel(ctx, mapid, c, z) + } + } + return nil +} + +// UpdatePositions updates character positions from client data. +func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error { + craws := map[string]struct { + Name string + GridID string + Coords struct{ X, Y int } + Type string + }{} + if err := json.Unmarshal(data, &craws); err != nil { + slog.Error("failed to decode position update", "error", err) + return err + } + + gridDataByID := make(map[string]app.GridData) + if err := s.st.View(ctx, func(tx *bbolt.Tx) error { + for _, craw := range craws { + raw := s.st.GetGrid(tx, craw.GridID) + if raw != nil { + var gd app.GridData + if json.Unmarshal(raw, &gd) == nil { + gridDataByID[craw.GridID] = gd + } + } + } + return nil + }); err != nil { + return err + } + + username, _ := ctx.Value(app.ClientUsernameKey).(string) + + s.withChars(func(chars map[string]app.Character) { + for id, craw := range craws { + gd, ok := gridDataByID[craw.GridID] + if !ok { + continue + } + idnum, _ := strconv.Atoi(id) + c := app.Character{ + Name: craw.Name, + ID: idnum, + Map: gd.Map, + Position: app.Position{ + X: craw.Coords.X + (gd.Coord.X * app.GridSize), + Y: craw.Coords.Y + (gd.Coord.Y * app.GridSize), + }, + Type: craw.Type, + Updated: time.Now(), + Username: username, + } + old, ok := chars[id] + if !ok { + chars[id] = c + } else { + if old.Type == "player" { + if c.Type == "player" { + chars[id] = c + } else { + old.Position = c.Position + old.Username = username + chars[id] = old + } + } else if old.Type != "unknown" { + if c.Type != "unknown" { + chars[id] = c + } else { + old.Position = c.Position + old.Username = username + chars[id] = old + } + } else { + chars[id] = c + } + } + } + }) + return nil +} + +// UploadMarkers stores markers uploaded by the client. +func (s *ClientService) UploadMarkers(ctx context.Context, data []byte) error { + markers := []struct { + Name string + GridID string + X, Y int + Image string + Type string + Color string + }{} + if err := json.Unmarshal(data, &markers); err != nil { + slog.Error("failed to decode marker upload", "error", err) + return err + } + return s.st.Update(ctx, func(tx *bbolt.Tx) error { + grid, idB, err := s.st.CreateMarkersBuckets(tx) + if err != nil { + return err + } + for _, mraw := range markers { + key := []byte(fmt.Sprintf("%s_%d_%d", mraw.GridID, mraw.X, mraw.Y)) + if grid.Get(key) != nil { + continue + } + img := mraw.Image + if img == "" { + img = "gfx/terobjs/mm/custom" + } + if mraw.Name == "Cave" { + img = "gfx/terobjs/mm/cave" + } + id, err := idB.NextSequence() + if err != nil { + return err + } + idKey := []byte(strconv.Itoa(int(id))) + m := app.Marker{ + Name: mraw.Name, + ID: int(id), + GridID: mraw.GridID, + Position: app.Position{X: mraw.X, Y: mraw.Y}, + Image: img, + } + raw, _ := json.Marshal(m) + if err := grid.Put(key, raw); err != nil { + return err + } + if err := idB.Put(idKey, key); err != nil { + return err + } + } + return nil + }) +} + +// FixMultipartContentType fixes broken multipart Content-Type headers from some game clients. +func FixMultipartContentType(ct string) string { + if strings.Count(ct, "=") >= 2 && strings.Count(ct, "\"") == 0 { + parts := strings.SplitN(ct, "=", 2) + return parts[0] + "=\"" + parts[1] + "\"" + } + return ct +} diff --git a/internal/app/services/client_test.go b/internal/app/services/client_test.go index e19fd5a..4bea069 100644 --- a/internal/app/services/client_test.go +++ b/internal/app/services/client_test.go @@ -1,93 +1,121 @@ -package services_test - -import ( - "context" - "encoding/json" - "testing" - - "github.com/andyleap/hnh-map/internal/app" - "github.com/andyleap/hnh-map/internal/app/services" - "github.com/andyleap/hnh-map/internal/app/store" - "go.etcd.io/bbolt" -) - -func TestFixMultipartContentType_NeedsQuoting(t *testing.T) { - ct := "multipart/form-data; boundary=----WebKitFormBoundary=abc123" - got := services.FixMultipartContentType(ct) - want := `multipart/form-data; boundary="----WebKitFormBoundary=abc123"` - if got != want { - t.Fatalf("expected %q, got %q", want, got) - } -} - -func TestFixMultipartContentType_AlreadyQuoted(t *testing.T) { - ct := `multipart/form-data; boundary="----WebKitFormBoundary"` - got := services.FixMultipartContentType(ct) - if got != ct { - t.Fatalf("expected unchanged, got %q", got) - } -} - -func TestFixMultipartContentType_Normal(t *testing.T) { - ct := "multipart/form-data; boundary=----WebKitFormBoundary" - got := services.FixMultipartContentType(ct) - if got != ct { - t.Fatalf("expected unchanged, got %q", got) - } -} - -func newTestClientService(t *testing.T) (*services.ClientService, *store.Store) { - t.Helper() - db := newTestDB(t) - st := store.New(db) - mapSvc := services.NewMapService(services.MapServiceDeps{ - Store: st, - GridStorage: t.TempDir(), - GridUpdates: &app.Topic[app.TileData]{}, - }) - client := services.NewClientService(services.ClientServiceDeps{ - Store: st, - MapSvc: mapSvc, - WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) }, - }) - return client, st -} - -func TestClientLocate_Found(t *testing.T) { - client, st := newTestClientService(t) - ctx := context.Background() - gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}} - raw, _ := json.Marshal(gd) - if err := st.Update(ctx, func(tx *bbolt.Tx) error { - return st.PutGrid(tx, "g1", raw) - }); err != nil { - t.Fatal(err) - } - result, err := client.Locate(ctx, "g1") - if err != nil { - t.Fatal(err) - } - if result != "1;2;3" { - t.Fatalf("expected 1;2;3, got %q", result) - } -} - -func TestClientLocate_NotFound(t *testing.T) { - client, _ := newTestClientService(t) - _, err := client.Locate(context.Background(), "ghost") - if err == nil { - t.Fatal("expected error for unknown grid") - } -} - -func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) { - client, _ := newTestClientService(t) - ctx := context.Background() - result, err := client.ProcessGridUpdate(ctx, services.GridUpdate{Grids: [][]string{}}) - if err != nil { - t.Fatal(err) - } - if result == nil { - t.Fatal("expected non-nil result") - } -} +package services_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/andyleap/hnh-map/internal/app" + "github.com/andyleap/hnh-map/internal/app/services" + "github.com/andyleap/hnh-map/internal/app/store" + "go.etcd.io/bbolt" +) + +func TestFixMultipartContentType_NeedsQuoting(t *testing.T) { + ct := "multipart/form-data; boundary=----WebKitFormBoundary=abc123" + got := services.FixMultipartContentType(ct) + want := `multipart/form-data; boundary="----WebKitFormBoundary=abc123"` + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestFixMultipartContentType_AlreadyQuoted(t *testing.T) { + ct := `multipart/form-data; boundary="----WebKitFormBoundary"` + got := services.FixMultipartContentType(ct) + if got != ct { + t.Fatalf("expected unchanged, got %q", got) + } +} + +func TestFixMultipartContentType_Normal(t *testing.T) { + ct := "multipart/form-data; boundary=----WebKitFormBoundary" + got := services.FixMultipartContentType(ct) + if got != ct { + t.Fatalf("expected unchanged, got %q", got) + } +} + +func newTestClientService(t *testing.T) (*services.ClientService, *store.Store) { + t.Helper() + db := newTestDB(t) + st := store.New(db) + mapSvc := services.NewMapService(services.MapServiceDeps{ + Store: st, + GridStorage: t.TempDir(), + GridUpdates: &app.Topic[app.TileData]{}, + }) + client := services.NewClientService(services.ClientServiceDeps{ + Store: st, + MapSvc: mapSvc, + WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) }, + }) + return client, st +} + +func TestClientLocate_Found(t *testing.T) { + client, st := newTestClientService(t) + ctx := context.Background() + gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}} + raw, _ := json.Marshal(gd) + if err := st.Update(ctx, func(tx *bbolt.Tx) error { + return st.PutGrid(tx, "g1", raw) + }); err != nil { + t.Fatal(err) + } + result, err := client.Locate(ctx, "g1") + if err != nil { + t.Fatal(err) + } + if result != "1;2;3" { + t.Fatalf("expected 1;2;3, got %q", result) + } +} + +func TestClientLocate_NotFound(t *testing.T) { + client, _ := newTestClientService(t) + _, err := client.Locate(context.Background(), "ghost") + if err == nil { + t.Fatal("expected error for unknown grid") + } +} + +func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) { + client, _ := newTestClientService(t) + ctx := context.Background() + result, err := client.ProcessGridUpdate(ctx, services.GridUpdate{Grids: [][]string{}}) + if err != nil { + t.Fatal(err) + } + if result == nil { + 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) + } +}