Skip to content

Commit e34e397

Browse files
committed
Reach the tile route, and give the map what it was missing (#130, #131)
**The world rendered in the app and in neither web surface, because the panel's tile route was unreachable.** Server routes match /^\/api\/servers\/([^/]+)(?:\/(\w+))?$/ and `\w` does not match a slash, so `/api/servers/:id/map/tiles` matched nothing and fell through to the 404 at the bottom. Every tile fetch from the panel has 404'd since #119; the desktop was fine because it goes over IPC. One nested segment is allowed now, still `\w`-only per segment so a sub-route cannot express a traversal. The coverage test could not have caught it: it compares the documented surface to route literals found in the handler's own source, and `'map/tiles'` was in the source. So there is a second check now — every documented fixed-path GET route must answer something other than the router's own "not-found", called with an owner token so a scope refusal cannot be mistaken for a reachable route. It found the bug immediately when reinstated, and it also flags routes with a second placeholder as untestable rather than pretending to cover them. **Heatmap off by default**, on all three surfaces. It is an analysis overlay, and a red block over the one player online was the first thing anyone saw. **Click a player to go to them.** The list under the map was inert; with several people online, finding one meant panning and reading the coordinate readout. It centres the view and keeps the zoom — jumping to a fixed zoom would throw away the scale the operator had chosen. **Structures on the map**: villages, dungeons and ruins, temples, fortresses, mineshafts, with a filter. Read from `structures.starts` in the same chunk NBT the surface already comes from, so it costs a lookup rather than a second pass over the world, and only when asked for — a payload that carries them "in case" is one the public feed could leak. Off by default everywhere. An operator may switch them on for their own map without a setting, because they can already read the world folder. **The public map cannot**: the feed sends markers only when the operator published them and ignores what the caller asks for, so the site cannot be talked into a treasure map of a private world. The toggle is hidden on the public page for the same reason — a control the feed ignores is not a control. **How far ahead to load**, as an operator setting. Worth being exact about what it is not: MSMS never generates terrain. It reads region files the server has already written, and a map that could grow a world by being panned would be a map that can fill a disk. "Load ahead" reads a ring of chunks around the viewport so panning is already drawn — more parsing, same world. Pregenerating terrain deliberately is a separate feature.
1 parent ccd0fcd commit e34e397

14 files changed

Lines changed: 358 additions & 24 deletions

File tree

src/main/core/worldTiles.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,25 @@ import {
2929
regionOf,
3030
unpackIndices,
3131
seeThrough,
32+
structureKind,
3233
CHUNK_AXIS,
3334
INVISIBLE
3435
} from '@shared/regionFormat'
36+
import type { StructureMark } from '@shared/regionFormat'
3537

3638
/** A chunk's surface: 256 columns, row-major (x fastest). */
3739
export interface ChunkTile {
3840
/** Packed 0xRRGGBB per column. */
3941
colour: number[]
4042
/** World Y of the drawn block, for cross-chunk shading. */
4143
height: number[]
44+
/**
45+
* Structures starting in this chunk (#131).
46+
*
47+
* Read from the same NBT the surface came from, so it costs one more object
48+
* lookup rather than a second pass over the world. Absent on most chunks.
49+
*/
50+
marks?: StructureMark[]
4251
}
4352

4453
interface RegionEntry {
@@ -178,7 +187,37 @@ export function tileFromChunk(chunk: any): ChunkTile | null {
178187
}
179188
// Nothing at all: an ungenerated or empty chunk, which is not a tile.
180189
if (remaining === colour.length) return null
181-
return { colour, height }
190+
const marks = structuresOf(v)
191+
return { colour, height, ...(marks.length ? { marks } : {}) }
192+
}
193+
194+
/**
195+
* Structures whose start is in this chunk.
196+
*
197+
* `structures.starts` is keyed by structure id and each entry carries the chunk
198+
* it starts in — `ChunkX`/`ChunkZ` in chunk units, which is why they are
199+
* multiplied here rather than used raw. A chunk that merely CONTAINS part of a
200+
* structure lists it in `References`, not `starts`, so this yields one mark per
201+
* structure rather than one per chunk it sprawls across.
202+
*/
203+
function structuresOf(v: any): StructureMark[] {
204+
const starts = tag(tag(v.structures)?.starts) ?? tag(tag(tag(v.Level)?.Structures)?.Starts)
205+
if (!starts || typeof starts !== 'object') return []
206+
const out: StructureMark[] = []
207+
for (const [id, raw] of Object.entries(starts)) {
208+
const s = tag(raw)
209+
if (!s || typeof s !== 'object') continue
210+
const cx = Number(tag((s as any).ChunkX))
211+
const cz = Number(tag((s as any).ChunkZ))
212+
if (!Number.isFinite(cx) || !Number.isFinite(cz)) continue
213+
out.push({
214+
kind: structureKind(id),
215+
id: String(id).replace(/^minecraft:/, ''),
216+
x: cx * CHUNK_AXIS + CHUNK_AXIS / 2,
217+
z: cz * CHUNK_AXIS + CHUNK_AXIS / 2
218+
})
219+
}
220+
return out
182221
}
183222

184223
function decompress(buf: Buffer, kind: number): Buffer | null {
@@ -316,14 +355,23 @@ let working = false
316355
export function requestTiles(
317356
serverId: string,
318357
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[] }> = {}
358+
want: { cx: number; cz: number }[],
359+
opts: { marks?: boolean } = {}
360+
): { tiles: Record<string, { c: number[]; h: number[]; m?: StructureMark[] }>; pending: number } {
361+
const tiles: Record<string, { c: number[]; h: number[]; m?: StructureMark[] }> = {}
322362
const missing: { cx: number; cz: number }[] = []
323363
for (const w of want) {
324364
const t = peekChunkTile(serverId, dim, w.cx, w.cz)
325365
if (t === undefined) missing.push(w)
326-
else if (t) tiles[w.cx + ',' + w.cz] = { c: t.colour, h: t.height }
366+
// Structures are omitted unless asked for. They are a spoiler, and a
367+
// payload that carries them "in case" is one the public feed could leak.
368+
else if (t) {
369+
tiles[w.cx + ',' + w.cz] = {
370+
c: t.colour,
371+
h: t.height,
372+
...(opts.marks && t.marks ? { m: t.marks } : {})
373+
}
374+
}
327375
}
328376
for (const m of missing) {
329377
if (queue.length > 4096) break

src/main/smoke.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import { getPublicSiteHtml } from './web/publicSiteHtml'
6767
import { CRATE_CSS } from '@shared/crateUi'
6868
import { openApiDocument } from '@shared/openapi'
6969
import { clampGrace, deliveryDecision, queueReason, HOLD_REASONS } from '@shared/delivery'
70-
import { API_PREFIX } from '@shared/apiSurface'
70+
import { API_PREFIX, API_ROUTES } from '@shared/apiSurface'
7171
import { MODERATION_ACTIONS, WORLD_ACTIONS } from '@shared/ops'
7272
import { removeServer } from './core/serverRegistry'
7373
import * as sf from './core/serverFiles'
@@ -6990,6 +6990,44 @@ export async function runWebSmoke(): Promise<void> {
69906990
}
69916991
}
69926992

6993+
// ...and the router can actually REACH them (#130).
6994+
//
6995+
// Everything above compares documentation to source literals, which says
6996+
// nothing about routing. `/servers/{id}/map/tiles` was documented, present
6997+
// as a literal in the handler, and unreachable for weeks: the route
6998+
// matcher's `\w+` cannot match a slash, so every two-segment sub-route
6999+
// fell through to the 404 at the bottom. The desktop app hid it by going
7000+
// over IPC.
7001+
//
7002+
// A 404 with `not-found` is the fall-through; anything else — a real
7003+
// answer, a 400, a 403 — means the route was found.
7004+
{
7005+
const fixture = getConfig().servers.find((s) => s.id === id)
7006+
if (!fixture) return fail('the fixture server vanished before the reachability check')
7007+
let checked = 0
7008+
for (const route of API_ROUTES) {
7009+
if (route.method !== 'GET') continue
7010+
if (!route.path.startsWith('/servers/{id}')) continue
7011+
const url = '/api' + route.path.replace('{id}', encodeURIComponent(id))
7012+
// Only fixed paths. A route with another placeholder needs a value
7013+
// that exists, and a legitimate "no such player" is indistinguishable
7014+
// from "no such route" — which would make this assert nothing useful
7015+
// about routing while looking like it did.
7016+
if (url.includes('{')) continue
7017+
// Owner token: a refusal for want of scope would hide the real
7018+
// question, which is whether the router found the route at all.
7019+
const rr = await get(url, ot)
7020+
checked++
7021+
if (rr.status === 404) {
7022+
const body = (await rr.json().catch(() => ({}))) as { error?: string }
7023+
if (body.error === 'not-found') {
7024+
return fail('the router cannot reach a documented route: GET ' + url)
7025+
}
7026+
}
7027+
}
7028+
if (checked < 10) return fail('the reachability check only tried ' + checked + ' routes')
7029+
}
7030+
69937031
// Keep the checked-in copy current. It is a generated artefact, and a
69947032
// stale one in the repo is worse than none — an integrator reads the file
69957033
// in the repository, not the one this process would serve.

src/main/web/panelHtml.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -794,8 +794,9 @@ function mapFeedUrl(dim,cell){
794794
return '/api/servers/'+mapServerId()+'/map?dim='+encodeURIComponent(dim)+'&cell='+encodeURIComponent(cell)}
795795
/* The map engine does not know how this page wraps a response, and must not:
796796
the two pages disagree, and it used to assume this one (#115). */
797-
function mapTilesUrl(dim,list){
798-
return '/api/servers/'+mapServerId()+'/map/tiles?dim='+encodeURIComponent(dim)+'&c='+encodeURIComponent(list)}
797+
function mapTilesUrl(dim,list,marks){
798+
return '/api/servers/'+mapServerId()+'/map/tiles?dim='+encodeURIComponent(dim)+
799+
'&c='+encodeURIComponent(list)+(marks?'&marks=1':'')}
799800
function mapGet(u){return api(u).then(function(r){return r.ok?r.body:null}).catch(function(){return null})}
800801
function mapPost(u){return api(u,{method:'POST'}).then(function(r){
801802
/* A refusal still has a body worth showing — "no jar available" is the answer,

src/main/web/publicSiteHtml.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,12 @@ function pagePost(id){
483483
rounds coordinates, drops the height, and only sends a uuid when the operator
484484
turned heads on. */
485485
function pageMap(){
486+
/* The operator's settings, not the visitor's. Structures and read-ahead are
487+
published decisions; a visitor toggling them would only be asking for data
488+
the feed refuses anyway (#131). */
489+
MAP.marksOn=!!S.mapStructures;
490+
MAP.loadAhead=!!S.mapLoadAhead;
491+
MAP.world=!!S.mapWorld;
486492
setTimeout(function(){mapStart()},0);
487493
return '<section class="section"><div class="wrap"><div class="section-head"><h2>'+esc(T('map.title'))+'</h2>'+
488494
'<span class="muted" id="mapRoundNote"></span></div>'+${JSON.stringify(MAP_HTML)}+'</div></section>'}
@@ -491,6 +497,8 @@ function mapFeedUrl(dim,cell){
491497
/* This page's api() answers {ok,s,j}; the panel's answers {ok,status,body}. The
492498
map engine used to read .body unconditionally, so on this page every poll
493499
threw on undefined and the map never drew (#115). */
500+
/* No marks parameter: the public feed decides from the operator's setting and
501+
ignores what the caller asks for, so there is nothing to send (#131). */
494502
function mapTilesUrl(dim,list){
495503
return '/api/public/map/tiles?dim='+encodeURIComponent(dim)+'&c='+encodeURIComponent(list)}
496504
function mapGet(u){return api(u).then(function(r){return r.ok?r.j:null}).catch(function(){return null})}

src/main/web/server.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -738,7 +738,14 @@ async function handlePublic(
738738
if (!cfg || !cfg.world) return sendJson(res, 404, { error: 'not-found' })
739739
const q = new URL(req.url ?? '/', 'http://localhost').searchParams
740740
const dim = normalizeDimension(q.get('dim') ?? 'overworld')
741-
return sendJson(res, 200, tilesFor(cfg.serverId, dim, parseWanted(q.get('c'))))
741+
// Structures only when the operator published them, whatever the caller
742+
// asks for: where every village and dungeon is turns a public site into a
743+
// treasure map of a private world.
744+
return sendJson(
745+
res,
746+
200,
747+
tilesFor(cfg.serverId, dim, parseWanted(q.get('c')), { marks: cfg.structures })
748+
)
742749
}
743750

744751
const sid = site.siteServerId()
@@ -972,7 +979,15 @@ async function handlePanel(req: IncomingMessage, res: ServerResponse): Promise<v
972979
}
973980

974981
// ---- /api/servers/:id/... ----
975-
const m = path.match(/^\/api\/servers\/([^/]+)(?:\/(\w+))?$/)
982+
//
983+
// One optional NESTED segment. `\w` does not match a slash, so the previous
984+
// shape silently refused every two-part sub-route: `/map/tiles` matched
985+
// nothing and fell through to the 404 at the bottom of this function, which
986+
// is why the world rendered in the desktop app (IPC) and nowhere on the web.
987+
//
988+
// Still `\w`-only per segment, so a sub-route cannot express `..` or anything
989+
// else that would mean something to a path.
990+
const m = path.match(/^\/api\/servers\/([^/]+)(?:\/(\w+(?:\/\w+)?))?$/)
976991
if (m) {
977992
const id = decodeURIComponent(m[1])
978993
const sub = m[2]
@@ -1228,7 +1243,15 @@ async function handlePanel(req: IncomingMessage, res: ServerResponse): Promise<v
12281243
if (sub === 'map/tiles' && method === 'GET') {
12291244
if (!gate('view')) return
12301245
const dim = normalizeDimension(url.searchParams.get('dim') ?? 'overworld')
1231-
return sendJson(res, 200, tilesFor(id, dim, parseWanted(url.searchParams.get('c'))))
1246+
// An operator may ask for structures on their own map without a setting —
1247+
// they can already read the world folder. The public feed cannot.
1248+
return sendJson(
1249+
res,
1250+
200,
1251+
tilesFor(id, dim, parseWanted(url.searchParams.get('c')), {
1252+
marks: url.searchParams.get('marks') === '1'
1253+
})
1254+
)
12321255
}
12331256
if (sub === 'map' && method === 'GET') {
12341257
if (!gate('view')) return

src/main/web/site.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ export function setSiteConfig(patch: Partial<SiteConfig>): SiteConfig {
144144
if (m.heads !== undefined) s.map.heads = !!m.heads
145145
if (m.names !== undefined) s.map.names = !!m.names
146146
if (m.world !== undefined) s.map.world = !!m.world
147+
if (m.structures !== undefined) s.map.structures = !!m.structures
148+
if (m.loadAhead !== undefined) s.map.loadAhead = !!m.loadAhead
147149
}
148150
if (patch.profile) {
149151
// Field by field and coerced, for the reason above: "false" is truthy, and
@@ -316,6 +318,8 @@ export function publicSite(): PublicSite {
316318
showMap: s.map.enabled && !!getServer(s.map.serverId),
317319
mapHeads: s.map.enabled && s.map.heads,
318320
mapWorld: s.map.enabled && s.map.world,
321+
mapStructures: s.map.enabled && s.map.structures,
322+
mapLoadAhead: s.map.enabled && s.map.loadAhead,
319323
// A profile needs a server to read a roster from. Without one the page
320324
// would render a name and a head and nothing else.
321325
showProfiles: !!getServer(s.storeServerId),

src/renderer/src/components/LiveMap.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,9 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
100100
const [bridge, setBridge] = useState(false)
101101
const [dim, setDim] = useState('overworld')
102102
const [cell, setCell] = useState(16)
103-
const [showHeat, setShowHeat] = useState(true)
103+
// An analysis overlay, not what a map is for: a red block over the one player
104+
// online was the first thing anyone saw (#131).
105+
const [showHeat, setShowHeat] = useState(false)
104106
const canvasRef = useRef<HTMLCanvasElement>(null)
105107
// Only to explain an empty canvas. The install itself is `BridgeNotice`,
106108
// which renders above the map whether or not there is anyone to draw — this
@@ -494,13 +496,22 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
494496
</div>
495497
{shown.length > 0 && (
496498
<div className="row wrap" style={{ gap: 6, marginTop: 8 }}>
499+
{/* Click to centre on them, keeping the zoom — with several people
500+
online, finding one meant panning around reading the coordinate
501+
readout (#131). */}
497502
{shown.map((p) => (
498-
<span key={p.name} className="badge">
503+
<button
504+
key={p.name}
505+
className="badge"
506+
style={{ cursor: 'pointer', font: 'inherit', color: 'inherit' }}
507+
title={t('map.goTo')}
508+
onClick={() => view && setView({ cx: p.x, cz: p.z, scale: view.scale })}
509+
>
499510
{p.name}{' '}
500511
<span className="dim">
501512
{p.x}, {p.y}, {p.z}
502513
</span>
503-
</span>
514+
</button>
504515
))}
505516
</div>
506517
)}

src/renderer/src/locales/en.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export default {
125125
needsBridge:
126126
'No live positions yet — this server has no MSMS-Bridge plugin. Positions arrive over the server console, so installing it opens no extra port.',
127127
heads: 'Heads',
128+
goTo: 'Centre the map here',
128129
world: 'World',
129130
resetView: 'Reset view',
130131
installBridge: 'Install MSMS-Bridge {{version}}',
@@ -715,6 +716,11 @@ export default {
715716
mapRound: 'Round to (blocks)',
716717
mapNames: 'Show names',
717718
mapHeads: 'Draw skin heads',
719+
mapWorld: 'Show the terrain',
720+
mapStructures: 'Show villages and dungeons',
721+
mapLoadAhead: 'Load ahead of the view',
722+
mapLoadHint:
723+
'MSMS never generates terrain — it reads what the server has already written. “Load ahead” only reads a ring of chunks around the view so panning is already drawn; it costs more parsing and changes nothing about the world.',
718724
profile_inventory: 'Publish inventories',
719725
profile_enderChest: 'Publish ender chests',
720726
profile_stats: 'Publish health and XP',

src/renderer/src/locales/tr.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ const tr: typeof en = {
127127
needsBridge:
128128
'Henüz canlı konum yok — bu sunucuda MSMS-Bridge eklentisi yok. Konumlar sunucu konsolu üzerinden geldiği için kurmak ek bir port açmaz.',
129129
heads: 'Kafalar',
130+
goTo: 'Haritayı buraya ortala',
130131
world: 'Dünya',
131132
resetView: 'Görünümü sıfırla',
132133
installBridge: 'MSMS-Bridge {{version}} kur',
@@ -719,6 +720,11 @@ const tr: typeof en = {
719720
mapRound: 'Yuvarlama (blok)',
720721
mapNames: 'İsimleri göster',
721722
mapHeads: 'Oyuncu kafalarını çiz',
723+
mapWorld: 'Araziyi göster',
724+
mapStructures: 'Köyleri ve zindanları göster',
725+
mapLoadAhead: 'Görünümün ötesini önceden yükle',
726+
mapLoadHint:
727+
'MSMS arazi üretmez — sunucunun zaten yazdığını okur. “Önceden yükle” yalnızca görünümün etrafındaki chunk halkasını okur, böylece kaydırınca çizilmiş olur; daha fazla ayrıştırma demektir, dünyada hiçbir şeyi değiştirmez.',
722728
profile_inventory: 'Envanterleri yayınla',
723729
profile_enderChest: 'Ender sandıklarını yayınla',
724730
profile_stats: 'Can ve XP’yi yayınla',

src/renderer/src/views/SiteView.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,29 @@ export function SiteView(): JSX.Element {
197197
<input type="checkbox" checked={cfg.map.heads} onChange={(e) => patchMap({ heads: e.target.checked })} />
198198
{t('site.mapHeads')}
199199
</label>
200+
<label className="switch" style={{ paddingBottom: 8 }}>
201+
<input type="checkbox" checked={cfg.map.world} onChange={(e) => patchMap({ world: e.target.checked })} />
202+
{t('site.mapWorld')}
203+
</label>
204+
<label className="switch" style={{ paddingBottom: 8 }}>
205+
<input
206+
type="checkbox"
207+
checked={cfg.map.structures}
208+
onChange={(e) => patchMap({ structures: e.target.checked })}
209+
/>
210+
{t('site.mapStructures')}
211+
</label>
212+
<label className="switch" style={{ paddingBottom: 8 }}>
213+
<input
214+
type="checkbox"
215+
checked={cfg.map.loadAhead}
216+
onChange={(e) => patchMap({ loadAhead: e.target.checked })}
217+
/>
218+
{t('site.mapLoadAhead')}
219+
</label>
200220
</div>
201221
<p className="hint">{t('site.mapHint')}</p>
222+
<p className="hint">{t('site.mapLoadHint')}</p>
202223

203224
{/* What a STRANGER may read on a player's profile (#107). A player
204225
always sees their own — the toggles are about publishing, and an

0 commit comments

Comments
 (0)