Skip to content

One map in all three places, with heads and the world on - #129

Merged
CaYatur merged 2 commits into
mainfrom
feat/one-map-everywhere
Jul 29, 2026
Merged

One map in all three places, with heads and the world on#129
CaYatur merged 2 commits into
mainfrom
feat/one-map-everywhere

Conversation

@CaYatur

@CaYatur CaYatur commented Jul 29, 2026

Copy link
Copy Markdown
Owner

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.

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.
Copilot AI review requested due to automatic review settings July 29, 2026 01:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
@CaYatur

CaYatur commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

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.

@CaYatur
CaYatur merged commit ccd0fcd into main Jul 29, 2026
1 check passed
@CaYatur
CaYatur deleted the feat/one-map-everywhere branch July 29, 2026 01:29
CaYatur added a commit that referenced this pull request Jul 29, 2026
The under-roof rule skips solid blocks until it has seen an air gap. A column
that is solid from the ceiling all the way down — a netherrack pillar joining
floor to roof — never shows one, so every block in it was skipped and the column
came out transparent: holes punched through the nether map exactly where the
terrain is thickest. The highest solid block seen while skipping is kept and used
if the column ends up empty.

And the web map's tile cache was never trimmed. The desktop got that in #129 and
the shared engine did not, so panning a big world grew it for as long as the page
stayed open. It matters more now than it did: since drawing iterates what is
HELD rather than what is visible — which is the fix for the map vanishing when
zoomed out — an unbounded cache is a per-frame cost as well as a memory one.
CaYatur added a commit that referenced this pull request Jul 29, 2026
…#135) (#138)

* Give the map back its mouse, and render the dimensions it never could (#135)

**An invisible box was eating every mouse event.** `.mp-empty` is
`position:absolute; inset:0` — it covers the whole canvas — and #103 removed its
`pointer-events:none` so the bridge install button inside it could be clicked.
That handed `mousedown`, `mousemove` and the `wheel` listener to the overlay, so
neither web map could be dragged or zoomed and the wheel fell through to the
page. It is transparent to the mouse again, with only its children clickable,
which is what the button needed in the first place.

The desktop scrolled the page for a different reason: React registers wheel
listeners as **passive**, so `preventDefault` inside `onWheel` does nothing. It
binds its own listener with `passive: false`.

**The terrain vanished when zoomed out** because one function answered two
questions. `mapVisibleChunks` is capped at 4096 so a zoomed-out view does not
ask for a million chunks — and it was also the list of what to *draw*, so the
cap blanked the map. Drawing now iterates what is HELD and clips to the
viewport, which costs the size of the cache rather than the size of the view and
stays cheap however far out you go.

**The nether and the end have never rendered.** Paper splits dimensions into
sibling world folders — `world_nether/DIM-1/region`, `world_the_end/DIM1/region`
— and MSMS only ever built vanilla's `world/DIM-1/region`, which does not exist
there. Both layouts are tried now, plus a custom world's own folder, and the
resolved directory is remembered because `peekChunkTile` runs once per requested
chunk.

**And the nether needed a different technique.** It has a bedrock roof at y=127,
so a top-down scan finds bedrock in every column and paints the dimension one
flat grey slab. The scan starts below the roof and skips solid blocks until it
has seen an air gap — the floor a player stands on rather than the ceiling above
them. Verified by rendering the real world to an image and looking at it:
netherrack, lava and warped forest where there was a grey slab, and the end's
island in end stone with its obsidian pillars where there was nothing at all.

Asserted with a synthetic nether-shaped chunk — bedrock roof, air gap,
netherrack floor — read under both rules: the overworld rule stops at the roof
(which is the bug), the nether rule reaches the floor below it.

The terrain toggle now defaults on. A grid with dots on it was not a map.

* Self-review: holes in the nether, and a web tile cache that never shrank

The under-roof rule skips solid blocks until it has seen an air gap. A column
that is solid from the ceiling all the way down — a netherrack pillar joining
floor to roof — never shows one, so every block in it was skipped and the column
came out transparent: holes punched through the nether map exactly where the
terrain is thickest. The highest solid block seen while skipping is kept and used
if the column ends up empty.

And the web map's tile cache was never trimmed. The desktop got that in #129 and
the shared engine did not, so panning a big world grew it for as long as the page
stayed open. It matters more now than it did: since drawing iterates what is
HELD rather than what is visible — which is the fix for the map vanishing when
zoomed out — an unbounded cache is a per-frame cost as well as a memory one.
CaYatur added a commit that referenced this pull request Jul 29, 2026
* Named chunk areas, drawn on every map surface (#144)

An operator can mark chunks as a named, coloured region with a note, and
everyone looking at the map reads it.

Built pure-first, in `@shared/chunkAreas`, because four surfaces draw
this — desktop app, admin panel, public site, and the map page still to
come. #129 unified three maps that had drifted apart; adding a feature
to one of them and backfilling the rest is how they drifted in the first
place.

The decisions that had to be made once rather than four times:

  Dimension. An area belongs to exactly one, or an overworld claim
  paints the same rectangle over the nether, where it means nothing.

  Overlap: SMALLEST WINS. A plot inside a town inside a claimed
  continent should read as the plot — the specific label is the
  informative one, and the big region is still visible everywhere the
  small one is not. Ties break on the later edit, then on id, so the
  order is total and every surface resolves a chunk identically. An
  explicit z-order would be one more thing for four UIs to get right.

  Shape is a list of RECTANGLES, not of chunks. A region 100 chunks
  square is one rect or ten thousand pairs, and this is served to a
  public page on every map load. Clicking chunks and typing coordinates
  both produce the same structure, and the tidy-up merges neighbours
  that share a full edge — so the stored shape covers exactly the chunks
  that were sent, written down smaller.

  What a visitor may read is its own type, like `PublicMapPlayer`: name,
  colour, note, rectangles. Not the timestamps, and not the hidden flag,
  which would tell a stranger that hidden areas exist.

Areas default to ON, unlike structure markers. A structure marker is
information the operator may not want published; an area is a label they
wrote on purpose for people to read.

The two web pages carry their own copy of the lookup, the convention
this codebase already uses for the view transform — a page pasted
together as a string cannot import from @shared. The smoke runs both
over every chunk in a range against a battery with three nested areas
and a tie pair, and fails on the first disagreement.

Verified: 12/12 gates.

Both new checks proved failable. Deleting the page's smallest-wins rule
first left the gate GREEN — the battery stepped 3 and 7 and walked
straight over the 3x3 plot and the 2x2 tie pair, so it only ever
compared chunks where nothing overlaps. Testing every chunk instead, and
counting how many are actually contested, turns the same break into "the
page disagrees about -3,-3 in overworld: app says plot, page says town".

* The web panel can create areas too, not just draw them (#144)

The first commit gave the panel a map that draws areas and no way to make
one. "Yönetim panellerinden" is plural, and an operator who can only
create areas from the desktop app has half a feature.

Same two ways in as the desktop editor, for the same reason: clicking
chunks is how you draw a town you can see, typing coordinates is how you
enter the four hundred somebody sent you in a message. They edit one
selection, so switching mid-edit loses nothing.

Picking is a MODE, not a held modifier. This panel is used on a phone,
where there is no shift key, and the map already pans on drag - a
distance check tells a click from the end of a pan.

Taking a chunk back out expands the selection and re-derives it without
that chunk, rather than dropping the rectangle containing it. Rects are
merged on the way in, so the chunk under the pointer is usually inside
one covering forty others; dropping it would take all forty. The smoke
removes the middle chunk of a merged row of four and checks that three
survive.

The shared map engine gained three optional host hooks - the picker
state, the selection, and a redraw signal. The public site defines none
of them and gets a read-only map, which is the point: one engine, and
what a surface may do is what it declares, not what it is trusted to
avoid calling.

Verified: 12/12 gates. Two test defects fixed on the way: the stub canvas
had no setLineDash, so a dashed selection failed the run with a TypeError
that read like a page bug; and the tidy comparison went through
JSON.stringify, which called a difference in key order a difference in
the answer.

* Self-review: removing one chunk from a big selection deleted 143 others (#144)

`normalizeRects` sliced its INPUT at 256 rectangles, and the removal path
expanded the selection to one rectangle per chunk before filtering. So a
20x20 region — one rectangle, 400 chunks — became 400 rectangles, then
399, then the first 256. A hundred and forty-three chunks disappeared
with no error, no warning, and nothing to say which ones.

The first test used a row of four chunks, which is exactly why it stayed
green. Restoring the slice now fails with "removing one chunk from 400
left 256".

Removal no longer expands anything. It splits the rectangle AROUND the
chunk into the at most four pieces beside it, which touches only the
rectangle involved and cannot grow the list by more than three, whatever
the selection's size. The test now removes an interior chunk, a corner,
an edge, the only chunk, and one that was never selected — and checks
every one of the other 399 is covered exactly once, so a split that
overlapped would fail too.

The same slice had two more consequences, both fixed by refusing instead
of trimming:

  An API caller who POSTed a hundred scattered chunks got a 200 with
  sixty-four of them stored. `checkArea` counted the chunks and the
  emptiness but never noticed rectangles had been dropped. There is now
  a named `too-many-rects`, documented beside `too-many-chunks`, and the
  cap is checked before normalising as well as after — a shape too
  complex to store is refused, never silently trimmed. Three hundred
  chunks in a ROW still pass, because they merge to one rectangle.

  The panel's own tidy had no cap at all, so above 256 it and the app
  disagreed about what had been selected. The panel now splits the same
  way, and the smoke checks its answer against `subtractChunk`'s on the
  400-chunk case rather than only on four.

Also: the desktop editor was permanently open, against its own comment
saying it should not be — drawing areas and editing them are two
decisions, which is how the web panel already had it. And
`forgetServerAreas` was written, documented as "called when a server is
forgotten", and called by nothing; `removeServer` calls it now, so a
later server issued the same id cannot inherit someone else's
annotations.

Verified: 12/12 gates.
CaYatur added a commit that referenced this pull request Jul 29, 2026
* A third listener that serves one fullscreen map (#146)

A LISTENER, not a path on the public site. `WebConfig` already carries a
port and an enabled flag per surface, and following that shape here is
not about symmetry: a separate port is what lets an operator hand the map
to people who must not reach the shop or the panel, with a firewall rule
rather than with trust. "Yönetici kısıtlayabilmeli" has to mean something
at the network layer, not only in a template.

Not a fourth map. The page pastes `MAP_CSS`, `MAP_HTML` and `MAP_JS` from
`@shared/mapUi` and restyles the wrapper - the canvas fills the window
instead of sitting in a card, and the controls float over it. #129 was
about three maps that had drifted apart; writing another one here would
undo it. The one thing the engine needed was a hook for the areas URL,
because this page has neither an admin id nor the public site's routes
and would have 404ed on both branches of the guess.

Three access modes: open, a shared passphrase, or a signed-in player.
The gate refuses the DATA, not just the HTML - a page that gates only its
markup is a page whose feed anyone can fetch directly. `mapPageAllows` is
the single answer, called by the state route and every feed, so the door
and the data cannot disagree.

The passphrase is stored in the clear on purpose. It is a shared doorcode
an operator has to be able to read back and tell people, not a credential
belonging to a person; hashing it would only stop them looking it up,
while anyone who can read the config can already change it. The COOKIE is
a hash of it and an in-memory salt, so restarting MSMS signs everyone out
- a cheap way to shut a leaked link without changing the code and telling
everybody the new one.

What the page may show is eleven separate operator decisions, and every
one of them is enforced server-side: `world: false` makes the tile route
a 404, not a hidden layer. Heads are refused with names off, because a
face identifies a player exactly as well as a name does (#116).

Verified: 12/12 gates. The gate proved failable - disabling it answers
"protected /api/map answered 200".

One test defect fixed: the route-coverage check isolated `handlePanel` by
slicing to `startWebServer`, which held only while `handlePanel` was the
last thing before it. `handleMapPage` landed in between and its routes -
a different listener, deliberately outside the /api/v1 surface - were
read as undocumented panel routes. It now slices to the next top-level
function, and asserts the slice is big enough to be the real router,
because a slice that is too short reads exactly like success.

* Self-review: the players-only mode was a door that never opened (#146)

Three access modes shipped; two had tests. The untested one did not work.

`players` read the public site's session, which lives in `localStorage`
under `msms_ptoken`. localStorage is per ORIGIN, and a different port IS
a different origin - so the map page on 8724 could never read what the
site on 8723 wrote, whatever the two agreed to call it. The mode was
permanently shut, and nothing said so: the state route answered
`allowed: false` exactly as it would for a visitor who simply had not
signed in yet.

The page signs players in itself now, against the same `playerAuth`
accounts, and holds the session in a cookie on its own origin. Cookies
are not isolated by port, which is a weakness elsewhere and the mechanism
here. Session-length rather than the passphrase cookie's thirty days:
this one stands for a person.

The test signs a real account in and reads the map with the cookie a
browser would send. Restoring the old cookie name fails it with "a
signed-in player still could not read the map". It also asserts the
NEGATIVE - that the public site's cookie name is not accepted - because
reading that was the bug.

Each mode's login route now 404s in the other's mode. An unused door left
open is a door.

Also: the page title is operator text interpolated inside a script block,
and `JSON.stringify` escapes quotes while leaving `<` alone - a title
containing a closing script tag ended the block and the rest of the page
became markup. Escaped, and the smoke serves a page titled
`evil</script><img src=x>` and checks it did not break out. The two HTML
positions were already escaped; this one had been missed.

And a port collision with the panel or the site is now said on screen.
It used to be an EADDRINUSE in the log and a card that said "stopped".

Verified: 12/12 gates.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants