From 973f90e452f543be71644be90f867592d204ccae Mon Sep 17 00:00:00 2001 From: CaYatur Date: Wed, 29 Jul 2026 16:14:05 +0300 Subject: [PATCH 1/3] Named chunk areas, drawn on every map surface (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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". --- docs/openapi.json | 151 ++++++++ src/main/core/chunkAreas.ts | 120 +++++++ src/main/ipc/register.ts | 31 ++ src/main/smoke.ts | 289 ++++++++++++++++ src/main/web/server.ts | 76 ++++ src/preload/index.ts | 3 + src/renderer/src/components/LiveMap.tsx | 440 +++++++++++++++++++++++- src/renderer/src/locales/en.ts | 20 ++ src/renderer/src/locales/tr.ts | 20 ++ src/shared/apiSurface.ts | 22 ++ src/shared/chunkAreas.ts | 384 +++++++++++++++++++++ src/shared/ipc.ts | 13 + src/shared/mapUi.ts | 141 +++++++- 13 files changed, 1704 insertions(+), 6 deletions(-) create mode 100644 src/main/core/chunkAreas.ts create mode 100644 src/shared/chunkAreas.ts diff --git a/docs/openapi.json b/docs/openapi.json index 9047a6c..06e267b 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -51,6 +51,9 @@ { "name": "store" }, + { + "name": "areas" + }, { "name": "site" }, @@ -2838,6 +2841,154 @@ } } }, + "/api/v1/servers/{id}/areas": { + "get": { + "operationId": "getServersIdAreas", + "summary": "Named chunk areas, including hidden ones.", + "description": "Scope `view` on the server.\n\nThe public map serves its own copy without the hidden areas or the timestamps.", + "tags": [ + "areas" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Server id, as returned by GET /servers.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success." + }, + "400": { + "description": "Malformed request, or a missing confirmation." + }, + "401": { + "description": "No usable credential." + }, + "403": { + "description": "Authenticated, but not permitted — the body names what was needed." + }, + "404": { + "description": "No such server, or no such route." + }, + "409": { + "description": "Conflicts with the current state (running server, name taken, …)." + }, + "429": { + "description": "Rate limited. `Retry-After` says for how long." + } + } + }, + "post": { + "operationId": "postServersIdAreas", + "summary": "Create an area, or edit one by passing `areaId`.", + "description": "Scope `settings` on the server.\n\nBody fields:\n- `areaId` — Omit to create; pass an existing id to replace that area.\n- `name` — Shown on hover and on click. 1-48 characters.\n- `note` — The second line of the tooltip, e.g. who owns the area. Up to 280 characters.\n- `colour` — `#rrggbb` (`#rgb` is expanded). Anything else falls back to the first palette colour.\n- `dim` — `overworld` | `nether` | `end`, or a modded key. An area belongs to exactly one.\n- `rects` — Array of `{x1,z1,x2,z2}` in CHUNK coordinates, inclusive, corners in any order.\n- `hidden` — Keep it off every map but the operator's own.\n\nRectangles are tidied on the way in: duplicates and contained rects are dropped and neighbours that share a full edge are merged, so the stored shape covers exactly the chunks you sent. Refusals name themselves: `name-required`, `name-too-long`, `note-too-long`, `no-chunks`, `too-many-chunks` (max 65536), `too-many-areas` (max 200), `area-not-found`.", + "tags": [ + "areas" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Server id, as returned by GET /servers.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "responses": { + "200": { + "description": "Success." + }, + "400": { + "description": "Malformed request, or a missing confirmation." + }, + "401": { + "description": "No usable credential." + }, + "403": { + "description": "Authenticated, but not permitted — the body names what was needed." + }, + "404": { + "description": "No such server, or no such route." + }, + "409": { + "description": "Conflicts with the current state (running server, name taken, …)." + }, + "429": { + "description": "Rate limited. `Retry-After` says for how long." + } + } + }, + "delete": { + "operationId": "deleteServersIdAreas", + "summary": "Delete one area.", + "description": "Scope `settings` on the server.", + "tags": [ + "areas" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Server id, as returned by GET /servers.", + "schema": { + "type": "string" + } + }, + { + "name": "areaId", + "in": "query", + "required": true, + "description": "Area to delete.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success." + }, + "400": { + "description": "Malformed request, or a missing confirmation." + }, + "401": { + "description": "No usable credential." + }, + "403": { + "description": "Authenticated, but not permitted — the body names what was needed." + }, + "404": { + "description": "No such server, or no such route." + }, + "409": { + "description": "Conflicts with the current state (running server, name taken, …)." + }, + "429": { + "description": "Rate limited. `Retry-After` says for how long." + } + } + } + }, "/api/v1/servers/{id}/player-requests": { "get": { "operationId": "getServersIdPlayerRequests", diff --git a/src/main/core/chunkAreas.ts b/src/main/core/chunkAreas.ts new file mode 100644 index 0000000..ff3f8e6 --- /dev/null +++ b/src/main/core/chunkAreas.ts @@ -0,0 +1,120 @@ +import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { dataDir } from '../paths' +import { log } from '../logger' +import { checkArea, publicChunkAreas, MAX_AREAS } from '@shared/chunkAreas' +import type { AreaInput, ChunkArea, PublicChunkArea } from '@shared/chunkAreas' + +/** + * Where chunk areas live (#144). + * + * Their own file, keyed by server, rather than a field on `ServerConfig`. Two + * hundred areas of sixty-four rectangles is a lot of JSON to carry inside the + * record that is read on every server list, every status poll and every start — + * and areas change on a completely different schedule to the rest of it. + * + * All the rules live in `@shared/chunkAreas`; this file is the disk and the ids. + */ + +type Store = Record + +let store: Store = {} +let loaded = false + +function storePath(): string { + return join(dataDir(), 'chunk-areas.json') +} + +function load(): void { + if (loaded) return + loaded = true + try { + store = existsSync(storePath()) ? (JSON.parse(readFileSync(storePath(), 'utf-8')) as Store) : {} + } catch (e) { + log.warn('chunkAreas: could not read chunk-areas.json:', e) + store = {} + } +} + +function save(): void { + const p = storePath() + const tmp = p + '.tmp' + writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8') + renameSync(tmp, p) +} + +export function listAreas(serverId: string): ChunkArea[] { + load() + return store[serverId] ? [...store[serverId]] : [] +} + +/** What the public site and the map page are allowed to send a visitor. */ +export function listPublicAreas(serverId: string, dim?: string): PublicChunkArea[] { + return publicChunkAreas(listAreas(serverId), dim) +} + +export function createArea(serverId: string, input: AreaInput): ChunkArea { + load() + const check = checkArea(input) + if (!check.ok) throw new Error(check.error) + const list = store[serverId] ?? [] + // A cap, because this list is sent to a public page and tested per chunk. The + // refusal is loud rather than silently dropping the newest one. + if (list.length >= MAX_AREAS) throw new Error('too-many-areas') + const now = Date.now() + const area: ChunkArea = { id: randomUUID(), ...check.value, createdAt: now, updatedAt: now } + store[serverId] = [...list, area] + save() + return area +} + +/** + * Replace an area's contents. `createdAt` is kept and `updatedAt` moves, which + * is not bookkeeping: `updatedAt` is the tie-break when two areas of equal size + * cover one chunk, so an edit has to be visible to `areaAt`. + */ +export function updateArea(serverId: string, id: string, input: AreaInput): ChunkArea { + load() + const list = store[serverId] ?? [] + const i = list.findIndex((a) => a.id === id) + if (i < 0) throw new Error('area-not-found') + const check = checkArea(input) + if (!check.ok) throw new Error(check.error) + const next: ChunkArea = { + ...list[i], + ...check.value, + // `checkArea` only sets `hidden` when it is true, so spreading it cannot + // clear the flag - an area unhidden through the API would stay hidden. + hidden: !!input.hidden, + updatedAt: Date.now() + } + if (!next.hidden) delete next.hidden + const copy = [...list] + copy[i] = next + store[serverId] = copy + save() + return next +} + +export function deleteArea(serverId: string, id: string): void { + load() + const list = store[serverId] ?? [] + if (!list.some((a) => a.id === id)) throw new Error('area-not-found') + store[serverId] = list.filter((a) => a.id !== id) + save() +} + +/** Called when a server is forgotten, so its areas do not outlive it on disk. */ +export function forgetServerAreas(serverId: string): void { + load() + if (!store[serverId]) return + delete store[serverId] + save() +} + +/** Test seam: the smoke needs a clean slate without deleting the user's file. */ +export function _reset(): void { + store = {} + loaded = true +} diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index 48841ad..0adf050 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -20,6 +20,9 @@ import * as rcon from '../core/rcon' import * as mods from '../core/mods' import * as bridgeInstall from '../core/bridgeInstall' import * as worldTiles from '../core/worldTiles' +import * as chunkAreas from '../core/chunkAreas' +import { areaChunkCount } from '@shared/chunkAreas' +import type { AreaInput } from '@shared/chunkAreas' import * as backups from '../core/backups' import * as worlds from '../core/worlds' import * as scheduler from '../core/scheduler' @@ -477,6 +480,34 @@ export function registerIpc(): void { }) return k }) + // Named chunk areas (#144). The rules and the store are shared with the HTTP + // routes — this is the same call the panel makes, reached a different way. + H(IPC.areasList, (_e, serverId: string) => chunkAreas.listAreas(serverId)) + H(IPC.areasSave, (_e, serverId: string, input: AreaInput & { areaId?: string }) => { + const area = input.areaId + ? chunkAreas.updateArea(serverId, input.areaId, input) + : chunkAreas.createArea(serverId, input) + audit.record({ + source: 'panel', + action: input.areaId ? 'area.update' : 'area.create', + actor: 'operator', + target: area.name, + detail: area.dim + ' ' + areaChunkCount(area) + ' chunks', + serverId + }) + return area + }) + H(IPC.areasDelete, (_e, serverId: string, areaId: string) => { + const gone = chunkAreas.listAreas(serverId).find((a) => a.id === areaId) + chunkAreas.deleteArea(serverId, areaId) + audit.record({ + source: 'panel', + action: 'area.delete', + actor: 'operator', + target: gone?.name ?? areaId, + serverId + }) + }) H(IPC.apiKeyRevoke, (_e, keyId: string) => { const k = apikeys.revokeKey(keyId) audit.record({ source: 'panel', action: 'apikey.revoke', actor: 'operator', target: k.label }) diff --git a/src/main/smoke.ts b/src/main/smoke.ts index b83bbd2..7e4b14a 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -126,6 +126,8 @@ import * as metrics from './core/metrics' import * as eventsMod from './core/events' import * as alertsMod from './core/alerts' import * as worldsMod from './core/worlds' +import * as areasMod from '@shared/chunkAreas' +import * as areasMod2 from './core/chunkAreas' import { isValidMcName, isValidWorldName, @@ -2228,6 +2230,132 @@ export async function runWorldsSmoke(): Promise { rmSync(emptyZip, { force: true }) console.log('WORLDS-SMOKE: import refuses zip-slip and worldless archives, cleans up after itself') + // --- 12. chunk areas: the rules four map surfaces have to share (#144) --- + { + const at = (rs: number[][]): { x1: number; z1: number; x2: number; z2: number }[] => + rs.map((r) => ({ x1: r[0], z1: r[1], x2: r[2], z2: r[3] })) + const mk = (over: Partial): areasMod.ChunkArea => ({ + id: 'a', + name: 'A', + note: '', + colour: '#e5484d', + dim: 'overworld', + rects: [{ x1: 0, z1: 0, x2: 0, z2: 0 }], + createdAt: 1, + updatedAt: 1, + ...over + }) + + // Corners in any order. An operator dragging up-and-left produces x2 < x1, + // and a rect stored that way covers nothing at all. + const back = areasMod.normalizeRect({ x1: 5, z1: 9, x2: 1, z2: 2 }) + if (!back || back.x1 !== 1 || back.x2 !== 5 || back.z1 !== 2 || back.z2 !== 9) { + return fail('a backwards rect was not straightened: ' + JSON.stringify(back)) + } + if (areasMod.normalizeRect({ x1: 0, z1: 0, x2: NaN, z2: 0 })) return fail('NaN made a rect') + const huge = areasMod.normalizeRect({ x1: 0, z1: 0, x2: 9e9, z2: 0 }) + if (!huge || huge.x2 !== areasMod.MAX_CHUNK) return fail('a rect escaped the world border') + + // Merging must not change WHICH chunks are covered - only how they are + // written down. Four clicked chunks in a row are one rect; the union is + // identical either way, and that is the property worth testing. + const clicked = at([[0, 0, 0, 0], [1, 0, 1, 0], [2, 0, 2, 0], [3, 0, 3, 0]]) + const merged = areasMod.normalizeRects(clicked) + if (merged.length !== 1) return fail('four chunks in a row did not merge: ' + JSON.stringify(merged)) + for (let cx = -1; cx <= 4; cx++) { + const before = clicked.some((r) => areasMod.rectHas(r, cx, 0)) + const after = merged.some((r) => areasMod.rectHas(r, cx, 0)) + if (before !== after) return fail('merging changed coverage at chunk ' + cx) + } + // A rect inside another disappears into it; a duplicate collapses. + if (areasMod.normalizeRects(at([[0, 0, 9, 9], [2, 2, 3, 3]])).length !== 1) { + return fail('a contained rect survived') + } + if (areasMod.normalizeRects(at([[0, 0, 4, 4], [0, 0, 4, 4]])).length !== 1) { + return fail('a duplicate rect survived') + } + // Rects that merely touch at a corner are not neighbours. + if (areasMod.normalizeRects(at([[0, 0, 0, 0], [1, 1, 1, 1]])).length !== 2) { + return fail('two diagonal chunks were merged into one rect') + } + + // Dimension scoping. Without it, an area drawn in the overworld paints the + // same rectangle over the nether, where it means nothing. + const over = mk({ id: 'o', rects: at([[0, 0, 9, 9]]) }) + const nether = mk({ id: 'n', dim: 'nether', rects: at([[0, 0, 9, 9]]) }) + if (areasMod.areaAt([over, nether], 5, 5, 'overworld')?.id !== 'o') return fail('overworld lookup') + if (areasMod.areaAt([over, nether], 5, 5, 'nether')?.id !== 'n') return fail('nether lookup') + if (areasMod.areaAt([over], 5, 5, 'nether')) return fail('an overworld area answered in the nether') + // `the_nether` and `minecraft:the_nether` are the same place. + if (areasMod.areaAt([nether], 5, 5, 'minecraft:the_nether')?.id !== 'n') return fail('dimension aliasing') + + // Smallest wins, so the specific label beats the containing one. + const town = mk({ id: 'town', rects: at([[0, 0, 99, 99]]) }) + const plot = mk({ id: 'plot', rects: at([[4, 4, 5, 5]]) }) + if (areasMod.areaAt([town, plot], 5, 5, 'overworld')?.id !== 'plot') return fail('the big area won') + if (areasMod.areaAt([plot, town], 5, 5, 'overworld')?.id !== 'plot') return fail('order changed the answer') + if (areasMod.areaAt([town, plot], 50, 50, 'overworld')?.id !== 'town') return fail('outside the plot') + // Same size: the later edit wins, and the answer is stable either way round. + const older = mk({ id: 'x', rects: at([[0, 0, 1, 1]]), updatedAt: 10 }) + const newer = mk({ id: 'y', rects: at([[0, 0, 1, 1]]), updatedAt: 20 }) + if (areasMod.areaAt([older, newer], 0, 0, 'overworld')?.id !== 'y') return fail('tie-break') + if (areasMod.areaAt([newer, older], 0, 0, 'overworld')?.id !== 'y') return fail('tie-break is order-dependent') + // The indexed form is what renderers use; it must agree with the one-off. + const idx = areasMod.areaIndex([town, plot], 'overworld') + if (areasMod.areaAtIndexed(idx, 5, 5)?.id !== 'plot') return fail('the indexed lookup disagrees') + + // What a visitor may read. A field added to `ChunkArea` and forgotten here + // is how private data reaches a public page, so this asserts the shape + // exactly rather than spot-checking it. + const secret = mk({ id: 's', name: 'staff', hidden: true }) + const shown = mk({ id: 'p', name: 'spawn', note: 'bu alan sahibi: CaYatur' }) + const pub = areasMod.publicChunkAreas([secret, shown]) + if (pub.length !== 1 || pub[0].id !== 'p') return fail('a hidden area was published') + if (pub[0].note !== 'bu alan sahibi: CaYatur') return fail('the note did not survive') + const keys = Object.keys(pub[0]).sort().join(',') + if (keys !== 'colour,dim,id,name,note,rects') return fail('public area shape drifted: ' + keys) + + // Typed coordinates, the half that exists because clicking 400 chunks is + // not a plan. One bad line must not throw away the good ones. + const typed = areasMod.parseChunkInput('10,20\n30 40 - 32 42\nnonsense\n-5,-5') + if (typed.bad.length !== 1) return fail('bad lines: ' + JSON.stringify(typed.bad)) + if (!typed.rects.some((r) => areasMod.rectHas(r, 31, 41))) return fail('the ranged line was lost') + if (!typed.rects.some((r) => areasMod.rectHas(r, -5, -5))) return fail('negative chunks were lost') + if (typed.rects.some((r) => areasMod.rectHas(r, 11, 20))) return fail('a single chunk grew') + + // Validation, which the API leans on: every refusal names its reason. + const bad: [string, areasMod.AreaInput][] = [ + ['name-required', { name: ' ', rects: at([[0, 0, 0, 0]]) }], + ['no-chunks', { name: 'x', rects: [] }], + ['name-too-long', { name: 'n'.repeat(areasMod.MAX_NAME + 1), rects: at([[0, 0, 0, 0]]) }], + ['note-too-long', { name: 'x', note: 'n'.repeat(areasMod.MAX_NOTE + 1), rects: at([[0, 0, 0, 0]]) }], + ['too-many-chunks', { name: 'x', rects: at([[0, 0, 4000, 4000]]) }] + ] + for (const [why, input] of bad) { + const c = areasMod.checkArea(input) + if (c.ok || c.error !== why) return fail('expected ' + why + ', got ' + JSON.stringify(c)) + } + const good = areasMod.checkArea({ + name: ' test alanı ', + note: 'bu alan sahibi: CaYatur', + colour: '#ABC', + dim: 'THE_NETHER', + rects: at([[3, 3, 3, 3], [4, 3, 4, 3]]) + }) + if (!good.ok) return fail('a good area was refused: ' + good.error) + if (good.value.name !== 'test alanı') return fail('the name was not trimmed') + if (good.value.colour !== '#aabbcc') return fail('short hex was not expanded: ' + good.value.colour) + if (good.value.dim !== 'nether') return fail('the dimension was not normalised: ' + good.value.dim) + if (good.value.rects.length !== 1) return fail('adjacent chunks were not merged on save') + if (areasMod.normalizeColour('rgb(1,2,3)') !== areasMod.AREA_COLOURS[0]) return fail('a junk colour got through') + + // Negative block coordinates are the classic off-by-one: `-1/16|0` is 0, + // which puts the chunk west of spawn one chunk east of it. + if (areasMod.chunkOf(-1, -1).cx !== -1) return fail('chunkOf rounds negatives towards zero') + if (areasMod.chunkOf(16, 31).cx !== 1 || areasMod.chunkOf(16, 31).cz !== 1) return fail('chunkOf') + console.log('WORLDS-SMOKE: chunk areas OK (merge keeps coverage, smallest wins, hidden stays hidden)') + } + cleanup() console.log('WORLDS-SMOKE: PASS') app.exit(0) @@ -4999,6 +5127,99 @@ export async function runWebSmoke(): Promise { } } + // ---- named chunk areas over HTTP (#144) ---- + { + const areasUrl = '/api/servers/' + id + '/areas' + const before = siteMod.getSiteConfig().map + areasMod2._reset() + try { + if ((await get(areasUrl)).status !== 401) return fail('the area list answered without a token') + if ((await post(areasUrl, { name: 'x', rects: [{ x1: 0, z1: 0, x2: 0, z2: 0 }] }, ft)).status !== 403) { + return fail('a session without `settings` could write an area') + } + + // Two adjacent chunks go in; one rectangle comes back. The tidy-up is + // in the shared layer and this proves the route actually runs it, + // rather than storing whatever the caller sent. + let r2 = await post( + areasUrl, + { + name: 'test alanı', + note: 'bu alan sahibi: CaYatur', + colour: '#46a758', + dim: 'overworld', + rects: [{ x1: 10, z1: 10, x2: 10, z2: 10 }, { x1: 11, z1: 10, x2: 11, z2: 10 }] + }, + ot + ) + if (r2.status !== 200) return fail('creating an area: ' + r2.status + ' ' + (await r2.text())) + const made = (await r2.json()) as { id: string; rects: unknown[]; name: string } + if (made.rects.length !== 1) return fail('the route stored an untidied selection') + if (made.name !== 'test alanı') return fail('the name did not survive the round trip') + + // Editing keeps the id — a UI that renders by id would otherwise see + // every edit as a delete and an insert. + r2 = await post(areasUrl, { areaId: made.id, name: 'renamed', dim: 'overworld', rects: [{ x1: 10, z1: 10, x2: 10, z2: 10 }], hidden: true }, ot) + if (r2.status !== 200) return fail('editing an area: ' + r2.status) + const edited = (await r2.json()) as { id: string; hidden?: boolean; name: string } + if (edited.id !== made.id) return fail('an edit changed the id') + if (!edited.hidden) return fail('the area did not hide') + + // ...and unhiding has to work. `checkArea` only sets `hidden` when it + // is true, so a naive spread would leave a hidden area hidden forever. + r2 = await post(areasUrl, { areaId: made.id, name: 'renamed', dim: 'overworld', rects: [{ x1: 10, z1: 10, x2: 10, z2: 10 }], hidden: false }, ot) + if ((await r2.json() as { hidden?: boolean }).hidden) return fail('an area could not be unhidden') + + // Every refusal names itself, because an API caller has nothing else + // to go on. + r2 = await post(areasUrl, { name: '', rects: [{ x1: 0, z1: 0, x2: 0, z2: 0 }] }, ot) + if (r2.status !== 400 || (await r2.json() as { error: string }).error !== 'name-required') { + return fail('a nameless area was not refused by name') + } + r2 = await post(areasUrl, { name: 'huge', rects: [{ x1: 0, z1: 0, x2: 4000, z2: 4000 }] }, ot) + if ((await r2.json() as { error: string }).error !== 'too-many-chunks') return fail('an enormous area got through') + r2 = await post(areasUrl, { areaId: 'nope', name: 'x', rects: [{ x1: 0, z1: 0, x2: 0, z2: 0 }] }, ot) + if (r2.status !== 404) return fail('editing a missing area: ' + r2.status) + + // The public feed. A hidden area must not appear, and neither must the + // timestamps — this is the check that a field added to `ChunkArea` + // later does not quietly reach a stranger. + const hidden = await post(areasUrl, { name: 'staff only', dim: 'overworld', hidden: true, rects: [{ x1: 50, z1: 50, x2: 51, z2: 51 }] }, ot) + if (hidden.status !== 200) return fail('creating a hidden area: ' + hidden.status) + siteMod.setSiteConfig({ map: { ...before, enabled: true, serverId: id, fixedDim: '' } }) + const pr = await sget('/api/public/map/areas?dim=overworld') + if (pr.status !== 200) return fail('the public area feed: ' + pr.status) + const pub = (await pr.json()) as { areas: Record[] } + if (pub.areas.length !== 1) return fail('the public feed carried ' + pub.areas.length + ' areas, expected 1') + if (pub.areas[0].name === 'staff only') return fail('a hidden area reached the public site') + const shape = Object.keys(pub.areas[0]).sort().join(',') + if (shape !== 'colour,dim,id,name,note,rects') return fail('the public area shape drifted: ' + shape) + + // A dimension the areas are not in returns none of them, rather than + // painting overworld rectangles over the nether. + const nether = (await (await sget('/api/public/map/areas?dim=nether')).json()) as { areas: unknown[] } + if (nether.areas.length !== 0) return fail('overworld areas appeared in the nether') + + // With the map unpublished there is no such resource at all — 404, not + // an empty list, for the same reason `/api/public/map` answers 404. + siteMod.setSiteConfig({ map: { ...before, enabled: false } }) + if ((await sget('/api/public/map/areas')).status !== 404) return fail('areas leaked with the map off') + + const del2 = await del(areasUrl + '?areaId=' + made.id, ot) + if (del2.status !== 200) return fail('deleting an area: ' + del2.status) + if ((await del(areasUrl + '?areaId=' + made.id, ot)).status !== 404) return fail('a second delete was not a 404') + const left = (await (await get(areasUrl, ot)).json()) as { areas: { id: string; name: string }[] } + if (!Array.isArray(left.areas)) return fail('the area list lost its shape') + if (left.areas.some((a) => a.id === made.id)) return fail('a deleted area came back') + // The operator's own list keeps the hidden one the public feed dropped. + if (!left.areas.some((a) => a.name === 'staff only')) return fail('the hidden area vanished for the operator too') + } finally { + siteMod.setSiteConfig({ map: before }) + areasMod2._reset() + } + console.log('WEB-SMOKE: chunk areas over HTTP OK (gated, tidied, hidden ones stay off the public feed)') + } + console.log('WEB-SMOKE: profile visibility OK (' + checked + ' checks, omitted not hidden, per-field toggles)') // ---- the refresh budget (#117) ---- @@ -6445,6 +6666,74 @@ export async function runWebSmoke(): Promise { if (pmap.MAP.view.scale !== 2) return fail('the page zoom did not change scale') } + // #144: and its own copy of the chunk-area rules, for the same reason. + // Which area owns a chunk has to read the same on all four surfaces, so + // the page's answer is compared to `areaAt`'s over a battery that + // includes every case the rule is made of — nesting, ties, dimensions, + // and the negative coordinates that `|0` gets wrong. + { + const pctx = panel.ctx as Record unknown> + const mk = (o: Partial): areasMod.ChunkArea => ({ + id: 'a', name: 'A', note: '', colour: '#e5484d', dim: 'overworld', + rects: [{ x1: 0, z1: 0, x2: 0, z2: 0 }], createdAt: 1, updatedAt: 1, ...o + }) + const battery: areasMod.ChunkArea[] = [ + mk({ id: 'town', rects: [{ x1: -20, z1: -20, x2: 20, z2: 20 }] }), + // Three deep, so "smallest wins" is tested against a chain rather + // than a single pair — a rule that picks the smaller of two can + // still pick the wrong one of three. + mk({ id: 'district', rects: [{ x1: -10, z1: -10, x2: 0, z2: 0 }] }), + mk({ id: 'plot', rects: [{ x1: -5, z1: -5, x2: -1, z2: -1 }] }), + mk({ id: 'tieA', rects: [{ x1: 38, z1: 38, x2: 41, z2: 41 }], updatedAt: 5 }), + mk({ id: 'tieB', rects: [{ x1: 38, z1: 38, x2: 41, z2: 41 }], updatedAt: 9 }), + mk({ id: 'hell', dim: 'the_nether', rects: [{ x1: -20, z1: -20, x2: 20, z2: 20 }] }), + mk({ id: 'custom', dim: 'MyWorld', rects: [{ x1: 0, z1: 0, x2: 4, z2: 4 }] }) + ] + // EVERY chunk in the range, not a sampled stride. A stride of 3 and 7 + // stepped straight over the 3x3 plot and the 2x2 tie pair, so the + // battery compared only the cases where nothing overlaps — it stayed + // green with the page's smallest-wins rule deleted outright. + let compared = 0 + let overlaps = 0 + for (const dim of ['overworld', 'nether', 'minecraft:the_nether', 'MyWorld', 'end']) { + for (let cx = -25; cx <= 45; cx++) { + for (let cz = -25; cz <= 45; cz++) { + const mine = areasMod.areaAt(battery, cx, cz, dim) + const theirs = pctx['mapAreaAt'](battery, cx, cz, dim) as areasMod.ChunkArea | null + if ((mine?.id ?? null) !== (theirs?.id ?? null)) { + return fail( + 'the page disagrees about ' + cx + ',' + cz + ' in ' + dim + + ': app says ' + (mine?.id ?? 'none') + ', page says ' + (theirs?.id ?? 'none') + ) + } + compared++ + // Count the chunks where the rule actually has to choose. A + // battery that never lands on a contested chunk proves nothing, + // and that is exactly how the first version of this passed. + if (battery.filter((a) => areasMod.areaAt([a], cx, cz, dim)).length > 1) overlaps++ + } + } + } + // The chunk each block belongs to, which is where `|0` bites: -1/16|0 + // is 0, so a boundary at x=0 would be off by one all the way down. + for (const b of [-1, -16, -17, 0, 15, 16, 31, -1000]) { + const mine = areasMod.chunkOf(b, b) + const theirs = pctx['mapChunkOf'](b, b) as { cx: number; cz: number } + if (mine.cx !== theirs.cx || mine.cz !== theirs.cz) { + return fail('the page puts block ' + b + ' in chunk ' + theirs.cx + ', not ' + mine.cx) + } + } + // The dimension normaliser they both depend on, including the custom + // world whose case must survive because it becomes a folder name. + for (const d of ['', 'normal', 'THE_END', 'minecraft:the_nether', 'MyWorld', 'nether']) { + if (normalizeDimension(d) !== pctx['mapNormDim'](d)) { + return fail('the page normalises ' + JSON.stringify(d) + ' differently') + } + } + if (compared < 5000) return fail('the area cross-check barely ran: ' + compared) + if (overlaps < 20) return fail('the battery never hit a contested chunk: ' + overlaps) + } + // #104: the same empty state on the PUBLIC page must not talk about // plugins. A visitor did not come to hear which jar the operator has // not installed, and it is an operator's business told to the internet. diff --git a/src/main/web/server.ts b/src/main/web/server.ts index d2a9169..1e92c46 100644 --- a/src/main/web/server.ts +++ b/src/main/web/server.ts @@ -19,6 +19,9 @@ import * as mods from '../core/mods' import * as bridgeInstall from '../core/bridgeInstall' import * as rcon from '../core/rcon' import * as worldTiles from '../core/worldTiles' +import * as chunkAreas from '../core/chunkAreas' +import { areaChunkCount } from '@shared/chunkAreas' +import type { AreaInput } from '@shared/chunkAreas' import { normalizeMapPerf } from '@shared/tileCache' import { listJavaInstalls } from '../core/javaScan' import { installJava } from '../core/javaProvision' @@ -761,6 +764,22 @@ async function handlePublic( ) } + // Named chunk areas, as a visitor may read them (#144). + // + // Tied to publishing the map at all, not to publishing the terrain: an area is + // a label the operator wrote on purpose, so it is fit to show wherever the map + // is. `listPublicAreas` drops the hidden ones and the timestamps; the panel's + // own route is the one that returns everything. + if (sub === 'map/areas' && method === 'GET') { + const cfg = site.publicMapConfig() + if (!cfg) return sendJson(res, 404, { error: 'not-found' }) + const q = new URL(req.url ?? '/', 'http://localhost').searchParams + const dim = cfg.fixedDim + ? normalizeDimension(cfg.fixedDim) + : normalizeDimension(q.get('dim') ?? 'overworld') + return sendJson(res, 200, { dimension: dim, areas: chunkAreas.listPublicAreas(cfg.serverId, dim) }) + } + const sid = site.siteServerId() if (sub === 'store' && method === 'GET') { if (!sid || !getServer(sid)) return sendJson(res, 200, { currency: '', products: [] }) @@ -1295,6 +1314,63 @@ async function handlePanel(req: IncomingMessage, res: ServerResponse): Promise