Skip to content

Commit ccd0fcd

Browse files
authored
One map in all three places, with heads and the world on (#129)
* One map in all three places, with heads and the world on The desktop app, the web panel and the public site showed three different maps, because the desktop one was a second implementation. The panel and the site share `MAP_JS`; the desktop had its own React canvas written for #26 and never gained what #104 and #119 added — so it had no pan, no zoom, no coordinate readout, no heads, no world and no reset, while offering a heatmap the others did not surface the same way. The desktop now uses the same view transform from `@shared/livemap` — the same `fitView`, `panBy`, `zoomAt` and `screenToWorld` — and the same controls: drag to pan, wheel to zoom anchored on the cursor, the world under the markers, skin heads, the coordinate readout, reset view, and the heatmap it already had. **The tile queue moved into `core/worldTiles`.** All three surfaces now share one queue and one parse budget rather than the web owning it and the desktop having none: three callers each parsing regions on their own would be three times the work on the same main thread, and three maps that disagree about what has loaded. The desktop reaches it over a new `map:tiles` IPC, capped the same way the HTTP route is — the renderer is trusted, but a bug there should not be able to queue a whole world either. **Heads and the world default to on.** They are what make this a map of people and terrain rather than dots on a grid, and an operator should not have to find two toggles to get the obvious thing. The public site is unchanged in substance: its feed still refuses heads unless the operator agreed to send names to an avatar service, and publishing the terrain is still its own decision, off by default. A head is decoration, so `mapHead` now returns the dot rather than throwing when the environment has no `Image` at all — a missing avatar must not take the grid, the markers and the terrain down with it. * Self-review: bound the tile cache, and a cursor that never changed The desktop held every chunk it had ever looked at. Each tile is small and "small times unbounded" is still unbounded, in a process that runs for as long as the app is open — panning a big world would grow it without limit. Tiles outside the view are dropped past 2048, which costs a re-fetch the main process already has cached. The grab/grabbing cursor was bound to a React ref. A ref does not re-render, so the style never updated and the canvas showed `grab` while dragging. It comes from `:active` in CSS now, which is what the web map already does. And `headFor` marked a name as failed AFTER starting the load rather than before, so a handler that resolved first would have been overwritten by the mark. Browsers never fire `onload` synchronously, so this was a latent ordering bug rather than a live one — but it is the kind that only shows up under a cache hit on somebody else's machine.
1 parent e683c82 commit ccd0fcd

11 files changed

Lines changed: 389 additions & 109 deletions

File tree

src/main/core/worldTiles.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,78 @@ export function peekChunkTile(
302302
return hit.tiles.get(chunkSlot(localChunk(chunkX), localChunk(chunkZ))) ?? null
303303
}
304304

305+
/**
306+
* Serve what is parsed, queue what is not.
307+
*
308+
* Lives here rather than in the web server so the desktop app and the two web
309+
* surfaces share one queue and one parse budget — three callers each with their
310+
* own would be three times the work and three different maps, which is the
311+
* complaint this consolidates.
312+
*/
313+
const queue: { serverId: string; dim: string; cx: number; cz: number }[] = []
314+
let working = false
315+
316+
export function requestTiles(
317+
serverId: string,
318+
dim: string,
319+
want: { cx: number; cz: number }[]
320+
): { tiles: Record<string, { c: number[]; h: number[] }>; pending: number } {
321+
const tiles: Record<string, { c: number[]; h: number[] }> = {}
322+
const missing: { cx: number; cz: number }[] = []
323+
for (const w of want) {
324+
const t = peekChunkTile(serverId, dim, w.cx, w.cz)
325+
if (t === undefined) missing.push(w)
326+
else if (t) tiles[w.cx + ',' + w.cz] = { c: t.colour, h: t.height }
327+
}
328+
for (const m of missing) {
329+
if (queue.length > 4096) break
330+
if (!queue.some((q) => q.serverId === serverId && q.dim === dim && q.cx === m.cx && q.cz === m.cz)) {
331+
queue.push({ serverId, dim, ...m })
332+
}
333+
}
334+
if (missing.length && !working) void drain()
335+
return { tiles, pending: missing.length }
336+
}
337+
338+
async function drain(): Promise<void> {
339+
working = true
340+
try {
341+
while (queue.length) {
342+
// Yielding between chunks is not enough on its own: the first chunk of an
343+
// unseen region parses the whole file. Wait for the parse budget.
344+
if (!parseBudgetReady()) {
345+
await new Promise((r) => setTimeout(r, 60))
346+
continue
347+
}
348+
const job = queue.shift()
349+
if (!job) break
350+
try {
351+
chunkTile(job.serverId, job.dim, job.cx, job.cz)
352+
} catch {
353+
/* one bad region must not stop the queue */
354+
}
355+
await new Promise((r) => setImmediate(r))
356+
}
357+
} finally {
358+
working = false
359+
}
360+
}
361+
362+
/** `cx,cz;cx,cz…`, capped so one call cannot ask for a whole world. */
363+
export const MAX_TILES_PER_REQUEST = 64
364+
365+
export function parseWantedTiles(raw: string | null | undefined): { cx: number; cz: number }[] {
366+
const out: { cx: number; cz: number }[] = []
367+
for (const pair of (raw ?? '').split(';')) {
368+
const [a, b] = pair.split(',')
369+
const cx = Number(a)
370+
const cz = Number(b)
371+
if (Number.isSafeInteger(cx) && Number.isSafeInteger(cz)) out.push({ cx, cz })
372+
if (out.length >= MAX_TILES_PER_REQUEST) break
373+
}
374+
return out
375+
}
376+
305377
/** One chunk's tile, parsing the region if needed. Never call from a request. */
306378
export function chunkTile(
307379
serverId: string,

src/main/ipc/register.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import * as players from '../core/players'
1919
import * as rcon from '../core/rcon'
2020
import * as mods from '../core/mods'
2121
import * as bridgeInstall from '../core/bridgeInstall'
22+
import * as worldTiles from '../core/worldTiles'
2223
import * as backups from '../core/backups'
2324
import * as worlds from '../core/worlds'
2425
import * as scheduler from '../core/scheduler'
@@ -301,6 +302,11 @@ export function registerIpc(): void {
301302
H(IPC.bridgeInstall, (_e, id: string) =>
302303
bridgeInstall.installBridge(id, { by: 'desktop', source: 'panel' })
303304
)
305+
H(IPC.mapTiles, (_e, id: string, dim: string, chunks: { cx: number; cz: number }[]) =>
306+
// Capped here too: the renderer is trusted, but a bug there should not be
307+
// able to queue a whole world any more than a web caller can.
308+
worldTiles.requestTiles(id, dim, (chunks ?? []).slice(0, worldTiles.MAX_TILES_PER_REQUEST))
309+
)
304310

305311
// --- java installs ---
306312
H(IPC.javaList, (_e, refresh?: boolean) => listJavaInstalls(!!refresh))

src/main/smoke.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3932,6 +3932,16 @@ function runPageScript(html: string, seed: Record<string, unknown> = {}): PageRu
39323932
clearTimeout: () => {},
39333933
requestAnimationFrame: () => 0,
39343934
cancelAnimationFrame: () => {},
3935+
// Pages load avatars and bake tiles with these. Present but inert: the
3936+
// assertions are about the wiring and the text, never the pixels.
3937+
Image: class {
3938+
crossOrigin = ''
3939+
onload: (() => void) | null = null
3940+
onerror: (() => void) | null = null
3941+
set src(_v: string) {
3942+
/* never resolves, so a head stays the dot — which is the fallback path */
3943+
}
3944+
},
39353945
alert: (msg: string) => calls.push(['alert', msg]),
39363946
confirm: () => true,
39373947
encodeURIComponent,

src/main/web/server.ts

Lines changed: 4 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -78,90 +78,10 @@ export function _resetRosterCache(): void {
7878
lastFlush.clear()
7979
}
8080

81-
// ---- world tiles (#119) ----
82-
//
83-
// A request NEVER parses a region. It asks for the chunks a viewport covers and
84-
// gets back the ones already parsed; anything missing is queued and appears on a
85-
// later poll. Parsing a region is megabytes of NBT, and letting a caller trigger
86-
// it synchronously is the amplification closed in #107 with a far bigger
87-
// multiplier — one pan across an explored world would be hundreds of regions.
88-
const tileQueue: { serverId: string; dim: string; cx: number; cz: number }[] = []
89-
let tileWorking = false
90-
91-
function queueTiles(serverId: string, dim: string, want: { cx: number; cz: number }[]): void {
92-
for (const w of want) {
93-
if (tileQueue.length > 4096) break
94-
if (!tileQueue.some((q) => q.serverId === serverId && q.dim === dim && q.cx === w.cx && q.cz === w.cz)) {
95-
tileQueue.push({ serverId, dim, ...w })
96-
}
97-
}
98-
if (!tileWorking) void drainTiles()
99-
}
100-
101-
async function drainTiles(): Promise<void> {
102-
tileWorking = true
103-
try {
104-
while (tileQueue.length) {
105-
// Yielding between chunks is not enough on its own: the first chunk of an
106-
// unseen region parses the whole file in one go, so back-to-back regions
107-
// would hold the main thread for seconds. Wait for the parse budget
108-
// instead of spinning through the queue.
109-
if (!worldTiles.parseBudgetReady()) {
110-
await new Promise((r) => setTimeout(r, 60))
111-
continue
112-
}
113-
const job = tileQueue.shift()
114-
if (!job) break
115-
try {
116-
worldTiles.chunkTile(job.serverId, job.dim, job.cx, job.cz)
117-
} catch {
118-
/* one bad region must not stop the queue */
119-
}
120-
// ...and still yield between chunks, for the ones that hit a region the
121-
// previous job already parsed.
122-
await new Promise((r) => setImmediate(r))
123-
}
124-
} finally {
125-
tileWorking = false
126-
}
127-
}
128-
129-
/**
130-
* Serve what is parsed, queue what is not.
131-
*
132-
* `pending` is how the client knows to ask again rather than concluding the
133-
* world is empty there.
134-
*/
135-
function tilesFor(
136-
serverId: string,
137-
dim: string,
138-
want: { cx: number; cz: number }[]
139-
): { tiles: Record<string, { c: number[]; h: number[] }>; pending: number } {
140-
const tiles: Record<string, { c: number[]; h: number[] }> = {}
141-
const missing: { cx: number; cz: number }[] = []
142-
for (const w of want) {
143-
const t = worldTiles.peekChunkTile(serverId, dim, w.cx, w.cz)
144-
if (t === undefined) missing.push(w)
145-
else if (t) tiles[w.cx + ',' + w.cz] = { c: t.colour, h: t.height }
146-
}
147-
if (missing.length) queueTiles(serverId, dim, missing)
148-
return { tiles, pending: missing.length }
149-
}
150-
151-
/** `cx,cz cx,cz …`, capped so one request cannot ask for a whole world. */
152-
const MAX_TILES_PER_REQUEST = 64
153-
154-
function parseWanted(raw: string | null): { cx: number; cz: number }[] {
155-
const out: { cx: number; cz: number }[] = []
156-
for (const pair of (raw ?? '').split(';')) {
157-
const [a, b] = pair.split(',')
158-
const cx = Number(a)
159-
const cz = Number(b)
160-
if (Number.isSafeInteger(cx) && Number.isSafeInteger(cz)) out.push({ cx, cz })
161-
if (out.length >= MAX_TILES_PER_REQUEST) break
162-
}
163-
return out
164-
}
81+
// World tiles (#119) live in `core/worldTiles`, queue and all, so the desktop
82+
// app and the two web surfaces share one parse budget rather than three.
83+
const tilesFor = worldTiles.requestTiles
84+
const parseWanted = worldTiles.parseWantedTiles
16585

16686
// ---- asking the server to write the inventory down (#117) ----
16787
//

src/preload/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const api: MsmsApi = {
8080

8181
bridgeStatus: (id) => ipcRenderer.invoke(IPC.bridgeStatus, id),
8282
installBridge: (id) => ipcRenderer.invoke(IPC.bridgeInstall, id),
83+
mapTiles: (id, dim, chunks) => ipcRenderer.invoke(IPC.mapTiles, id, dim, chunks),
8384

8485
listJava: (refresh) => ipcRenderer.invoke(IPC.javaList, refresh),
8586
resolveJava: (override) => ipcRenderer.invoke(IPC.javaResolve, override),

0 commit comments

Comments
 (0)