diff --git a/docs/openapi.json b/docs/openapi.json index 9047a6c..4970ebb 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-rects` (a shape of more than 64 separate pieces after tidying, or more than 1024 sent), `too-many-areas` (max 200), `area-not-found`. A shape too complex to store is refused, never silently trimmed.", + "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/core/serverRegistry.ts b/src/main/core/serverRegistry.ts index 787bce2..c892313 100644 --- a/src/main/core/serverRegistry.ts +++ b/src/main/core/serverRegistry.ts @@ -6,6 +6,7 @@ import { resolveBaseDir, dataDir } from '../paths' import { detectServer } from './serverDetect' import * as metrics from './metrics' import * as events from './events' +import * as chunkAreas from './chunkAreas' import { log } from '../logger' import { PROXY_TYPES } from '@shared/types' import type { ServerConfig, ServerType, JavaArgsConfig } from '@shared/types' @@ -119,6 +120,10 @@ export function removeServer(id: string, deleteFiles: boolean): void { }) metrics.dropServer(id) events.dropServer(id) + // Areas are keyed by server id and live in their own file, so nothing else + // removes them. A later server issued the same id would inherit somebody + // else's map annotations. + chunkAreas.forgetServerAreas(id) if (deleteFiles && target && existsSync(target.path)) { try { rmSync(target.path, { recursive: true, force: 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..35a256b 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,193 @@ 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') + } + + // Removing ONE chunk from a big selection. This is the case the first + // version got wrong and no test caught: a 20x20 region is a single rect, + // and expanding it to one rect per chunk to filter produced 400 — past + // the input ceiling, so 143 chunks vanished with no error at all. The + // fix splits the rectangle around the chunk instead, so the size of the + // selection cannot matter. + { + const big = areasMod.normalizeRects(at([[0, 0, 19, 19]])) + if (big.length !== 1 || areasMod.areaChunkCount({ rects: big }) !== 400) { + return fail('a 20x20 selection is not one 400-chunk rect') + } + const cut = areasMod.subtractChunk(big, 10, 10) + if (areasMod.areaChunkCount({ rects: cut }) !== 399) { + return fail('removing one chunk from 400 left ' + areasMod.areaChunkCount({ rects: cut })) + } + if (areasMod.areaHas({ rects: cut }, 10, 10)) return fail('the removed chunk is still covered') + // Every other chunk survives, and none is covered twice — a split that + // overlaps would make the area larger than the shape it draws. + for (let x = 0; x <= 19; x++) { + for (let z = 0; z <= 19; z++) { + const hits = cut.filter((r) => areasMod.rectHas(r, x, z)).length + const want = x === 10 && z === 10 ? 0 : 1 + if (hits !== want) return fail('chunk ' + x + ',' + z + ' is covered ' + hits + ' times') + } + } + // Removing a corner, an edge and the last chunk of all. + if (areasMod.areaChunkCount({ rects: areasMod.subtractChunk(big, 0, 0) }) !== 399) { + return fail('removing the corner went wrong') + } + if (areasMod.areaChunkCount({ rects: areasMod.subtractChunk(big, 19, 5) }) !== 399) { + return fail('removing an edge chunk went wrong') + } + const one = areasMod.normalizeRects(at([[7, 7, 7, 7]])) + if (areasMod.subtractChunk(one, 7, 7).length !== 0) return fail('removing the only chunk left something') + // A chunk that was never in the selection changes nothing. + if (areasMod.areaChunkCount({ rects: areasMod.subtractChunk(big, 99, 99) }) !== 400) { + return fail('removing an unselected chunk changed the selection') + } + } + + // Too many pieces is REFUSED, not trimmed. `normalizeRects` used to slice + // its input, so an API caller who sent a hundred scattered chunks got a + // 200 and lost most of them — with nothing to say which. + { + const scattered = at( + Array.from({ length: areasMod.MAX_RECTS_PER_AREA + 20 }, (_, i) => [i * 2, 0, i * 2, 0]) + ) + const c = areasMod.checkArea({ name: 'swiss cheese', rects: scattered }) + if (c.ok) return fail('a shape with too many pieces was accepted') + if (c.error !== 'too-many-rects') return fail('wrong reason: ' + c.error) + const flood = at(Array.from({ length: areasMod.MAX_INPUT_RECTS + 1 }, (_, i) => [i * 2, 0, i * 2, 0])) + const c2 = areasMod.checkArea({ name: 'flood', rects: flood }) + if (c2.ok || c2.error !== 'too-many-rects') return fail('a flood of rects was not refused: ' + JSON.stringify(c2)) + // ...and a shape that merges down to few enough pieces is still fine, + // however many rectangles it arrived as. + const contiguous = at(Array.from({ length: 300 }, (_, i) => [i, 0, i, 0])) + const c3 = areasMod.checkArea({ name: 'a long road', rects: contiguous }) + if (!c3.ok) return fail('300 chunks in a row were refused: ' + c3.error) + if (c3.value.rects.length !== 1) return fail('300 chunks in a row did not merge to 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) @@ -4047,6 +4236,13 @@ function runPageScript(html: string, seed: Record = {}): PageRu fill: () => {}, fillRect: () => {}, fillText: () => {}, + // Areas draw outlines and a dashed selection (#144). A stub that is + // missing a method the page calls fails the whole run with a TypeError, + // which reads like a bug in the page rather than a gap in the stub. + strokeRect: () => {}, + strokeText: () => {}, + setLineDash: () => {}, + drawImage: () => {}, set font(_v: string) {}, set fillStyle(_v: string) {}, set strokeStyle(_v: string) {}, @@ -4999,6 +5195,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 +6734,132 @@ 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) + + // The panel's chunk picker. Clicking builds a selection, clicking the + // same chunk again takes it back, and the result is tidied the way the + // server will tidy it — so the count the operator reads is the count + // that gets stored. + const pnl = panel.ctx as { AREA_PICK: areasMod.ChunkRect[]; AREA_PICKING: boolean } + pnl.AREA_PICK = [] + pnl.AREA_PICKING = true + for (let cx = 0; cx < 4; cx++) pctx['areaPickChunk'](cx, 0) + if (pnl.AREA_PICK.length !== 1) { + return fail('the picker did not merge a row: ' + JSON.stringify(pnl.AREA_PICK)) + } + if (areasMod.areaChunkCount({ rects: pnl.AREA_PICK }) !== 4) return fail('the picker lost a chunk') + // Taking one out of the MIDDLE is the case that matters: the rect it + // sits in covers three others, and dropping the rect drops them too. + // The panel splits the rectangle, the same as the app does. + pctx['areaPickChunk'](1, 0) + if (areasMod.areaChunkCount({ rects: pnl.AREA_PICK }) !== 3) { + return fail('removing one chunk took ' + (4 - areasMod.areaChunkCount({ rects: pnl.AREA_PICK })) + ' with it') + } + for (const c of [0, 2, 3]) { + if (!pnl.AREA_PICK.some((r) => areasMod.rectHas(r, c, 0))) return fail('chunk ' + c + ' was lost') + } + if (pnl.AREA_PICK.some((r) => areasMod.rectHas(r, 1, 0))) return fail('the removed chunk came back') + // The panel tidies with its own copy of the merge, so it has to agree + // with the shared one — otherwise the operator counts one thing and + // the server stores another. + const theirsTidy = pctx['areaTidy']([ + { x1: 0, z1: 0, x2: 0, z2: 0 }, { x1: 1, z1: 0, x2: 1, z2: 0 }, + { x1: 5, z1: 5, x2: 9, z2: 9 }, { x1: 6, z1: 6, x2: 7, z2: 7 } + ]) as areasMod.ChunkRect[] + const mineTidy = areasMod.normalizeRects([ + { x1: 0, z1: 0, x2: 0, z2: 0 }, { x1: 1, z1: 0, x2: 1, z2: 0 }, + { x1: 5, z1: 5, x2: 9, z2: 9 }, { x1: 6, z1: 6, x2: 7, z2: 7 } + ]) + // By value, not by JSON: the two build their objects with the fields + // in different orders, which `JSON.stringify` reports as a difference + // and no consumer of these rects can even observe. + const canon = (rs: areasMod.ChunkRect[]): string => + rs.map((r) => [r.x1, r.z1, r.x2, r.z2].join(',')).join(' ') + if (canon(theirsTidy) !== canon(mineTidy)) { + return fail('the panel tidies differently: ' + canon(theirsTidy) + ' vs ' + canon(mineTidy)) + } + // And the panel must survive the big-selection case too — its own + // removal is a second implementation, so it gets the same test. + pnl.AREA_PICK = [{ x1: 0, z1: 0, x2: 19, z2: 19 }] + pctx['areaPickChunk'](10, 10) + if (areasMod.areaChunkCount({ rects: pnl.AREA_PICK }) !== 399) { + return fail( + 'the panel lost chunks removing one from 400: ' + + areasMod.areaChunkCount({ rects: pnl.AREA_PICK }) + ) + } + if (canon(pnl.AREA_PICK) !== canon(areasMod.subtractChunk([{ x1: 0, z1: 0, x2: 19, z2: 19 }], 10, 10))) { + return fail('the panel splits a rectangle differently from the app') + } + pnl.AREA_PICKING = false + pnl.AREA_PICK = [] + } + // #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/panelHtml.ts b/src/main/web/panelHtml.ts index b3b7ee7..7b3ba11 100644 --- a/src/main/web/panelHtml.ts +++ b/src/main/web/panelHtml.ts @@ -7,6 +7,7 @@ import { iconSvg, STRUCTURE_ICONS } from '@shared/mapIcons' import { usageSamples, API_KEY_HEADER, USAGE_NOTES } from '@shared/apiUsage' import { API_PREFIX } from '@shared/apiSurface' import { MAP_CSS, MAP_HTML, MAP_JS } from '@shared/mapUi' +import { AREA_COLOURS } from '@shared/chunkAreas' export function getPanelHtml(): string { return ` @@ -345,6 +346,50 @@ h2{margin:8px 0;font-weight:800;letter-spacing:-.4px} + {/* Open only when asked for: the map is for looking at, and a permanently + visible editing panel takes a third of it. Drawing areas and EDITING + them are two decisions — the web panel keeps them apart the same way. */} + {showAreaCard && ( +
+
+ {t('map.areas')} + + {t('map.areasCount', { n: areasFor(areas, dim).length, dim })} + +
+ +
+ + {areasFor(areas, dim).length > 0 && ( +
+ {areasFor(areas, dim).map((a) => ( + + ))} +
+ )} + + {editing && ( + { + setEditing(null) + setPicking(false) + setPicked([]) + }} + onSave={async (input) => { + await window.msms.saveChunkArea(serverId, { + ...input, + ...(editing === 'new' ? {} : { areaId: editing.id }) + }) + reloadAreas() + setEditing(null) + setPicking(false) + setPicked([]) + }} + onDelete={ + editing === 'new' + ? undefined + : async () => { + await window.msms.deleteChunkArea(serverId, editing.id) + reloadAreas() + setEditing(null) + setPicking(false) + setPicked([]) + } + } + /> + )} +
+ )} + {/* Per-server, persisted, and applied without a restart — the map is where an operator meets the cost, so it is where the dials belong (#133). */} {showPerf && ( @@ -690,9 +907,34 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { style={{ width: '100%', height: '100%', display: 'block' }} onMouseDown={(e) => { drag.current = { x: e.clientX, y: e.clientY } + downAt.current = { x: e.clientX, y: e.clientY } e.preventDefault() }} onMouseUp={() => (drag.current = null)} + onClick={(e) => { + if (!view) return + // Distance, not a flag: every click carries a mousedown, and a pan + // that happens to end inside an area must not select or pin it. + const d0 = downAt.current + if (d0 && (Math.abs(e.clientX - d0.x) > 4 || Math.abs(e.clientY - d0.y) > 4)) return + const w = screenToWorld(localPoint(e), view, vp) + const c = chunkOf(w.x, w.z) + if (picking) { + // Click a chunk to add it, click it again to take it back — the + // only way to undo a misclick without starting the selection over. + const has = picked.some((r) => c.cx >= r.x1 && c.cx <= r.x2 && c.cz >= r.z1 && c.cz <= r.z2) + setPicked( + has + ? // Splits the rectangle around the chunk rather than dropping + // it: rects are merged on the way in, so the one under the + // pointer usually covers dozens of others. + subtractChunk(picked, c.cx, c.cz) + : normalizeRects([...picked, { x1: c.cx, z1: c.cz, x2: c.cx, z2: c.cz }]) + ) + return + } + if (showAreas) setPinned(areaAt(areas, c.cx, c.cz, dim) ?? null) + }} onMouseLeave={() => { drag.current = null setCursor(null) @@ -723,8 +965,41 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element { }} > X {Math.round(cursor.x)} Z {Math.round(cursor.z)} + {picking && ( + <> + {' · '} + {t('map.chunkAt', { cx: chunkOf(cursor.x, cursor.z).cx, cz: chunkOf(cursor.x, cursor.z).cz })} + + )}
)} + {/* Hovering names the area; clicking keeps the name up. Both, because a + tooltip that only follows the pointer cannot be read on a touchpad + while reaching for a button. */} + {showAreas && + (() => { + const hover = + pinned ?? (cursor ? areaAt(areas, chunkOf(cursor.x, cursor.z).cx, chunkOf(cursor.x, cursor.z).cz, dim) : undefined) + if (!hover) return null + return ( +
+ {hover.name} + {hover.note &&
{hover.note}
} + {pinned && ( + + )} +
+ ) + })()} {shown.length === 0 && (
) } + +/** + * Create or edit one area. + * + * Two ways in, because they suit different jobs: clicking chunks on the map is + * how you draw the shape of a town you can see, and typing coordinates is how + * you enter the four hundred chunks somebody sent you in a message. They edit + * the same selection, so switching between them mid-edit loses nothing. + * + * Validation is `checkArea`, the same function the HTTP route calls. A form that + * decides for itself what is acceptable is a form that eventually disagrees with + * the server, and the operator is the one who finds out. + */ +function AreaEditor({ + area, + dim, + picking, + picked, + onPickingChange, + onPickedChange, + onClose, + onSave, + onDelete +}: { + area: ChunkArea | null + dim: string + picking: boolean + picked: ChunkRect[] + onPickingChange: (v: boolean) => void + onPickedChange: (r: ChunkRect[]) => void + onClose: () => void + onSave: (input: { + name: string + note: string + colour: string + dim: string + rects: ChunkRect[] + hidden: boolean + }) => Promise + onDelete?: () => Promise +}): JSX.Element { + const { t } = useTranslation() + const [name, setName] = useState(area?.name ?? '') + const [note, setNote] = useState(area?.note ?? '') + const [colour, setColour] = useState(area?.colour ?? AREA_COLOURS[0]) + const [hidden, setHidden] = useState(!!area?.hidden) + const [typed, setTyped] = useState('') + const [bad, setBad] = useState([]) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + // The area belongs to the dimension being viewed. Editing one from a different + // dimension keeps its own, so opening the nether does not silently move a town. + const targetDim = area?.dim ?? dim + const check = checkArea({ name, note, colour, dim: targetDim, rects: picked, hidden }) + + const applyTyped = (): void => { + const parsed = parseChunkInput(typed) + setBad(parsed.bad) + // Added to the selection rather than replacing it: typing is often how you + // finish a shape you started by clicking. + onPickedChange(normalizeRects([...picked, ...parsed.rects])) + setTyped('') + } + + return ( +
+
+ setName(e.target.value)} + /> +
+ {AREA_COLOURS.map((c) => ( +
+
+ setNote(e.target.value)} + /> + +
+ + + {t('map.areaChunks', { n: areaChunkCount({ rects: picked }), r: picked.length })} + + {picked.length > 0 && ( + + )} +
+ +
+ +
+ setTyped(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') applyTyped() + }} + /> + +
+ {bad.length > 0 && ( +

+ {t('map.areaBadLines', { lines: bad.join(', ') })} +

+ )} + {/* The refusal the server would give, shown before the request rather than + after it — `checkArea` is the same function on both sides. */} + {!check.ok && (name || picked.length > 0) && ( +

{t('map.areaErr_' + check.error)}

+ )} + {error &&

{error}

} + +
+ + +
+ {onDelete && ( + + )} +
+
+ ) +} diff --git a/src/renderer/src/locales/en.ts b/src/renderer/src/locales/en.ts index beb5adb..17c550f 100644 --- a/src/renderer/src/locales/en.ts +++ b/src/renderer/src/locales/en.ts @@ -145,6 +145,28 @@ export default { 'The delay keeps the app responsive while regions are read — lower is faster and leaves less room for everything else running in the same process.', structures: 'Structures', allStructures: 'All structures', + areas: 'Areas', + areasCount: '{{n}} in {{dim}}', + areaEdit: 'Edit areas', + 'areaErr_too-many-rects': 'That shape has too many separate pieces — join some up or make fewer areas.', + areaNew: 'New area', + areaName: 'Area name, e.g. spawn town', + areaNote: 'Note — shown under the name, e.g. owner: CaYatur', + areaPick: 'Pick chunks on the map', + areaPickingOn: 'Picking — click chunks to add or remove', + areaChunks: '{{n}} chunks in {{r}} rectangles', + areaClear: 'Clear selection', + areaHidden: 'Operator only', + areaTypePlaceholder: 'Or type: 10,20 or 30,40 - 32,42', + areaTypeAdd: 'Add', + areaBadLines: 'Not understood: {{lines}}', + areaCreate: 'Create', + chunkAt: 'chunk {{cx}}, {{cz}}', + 'areaErr_name-required': 'Give the area a name.', + 'areaErr_name-too-long': 'That name is too long (48 characters).', + 'areaErr_note-too-long': 'That note is too long (280 characters).', + 'areaErr_no-chunks': 'Pick at least one chunk, or type some coordinates.', + 'areaErr_too-many-chunks': 'That is more than 65536 chunks — draw a smaller area.', structure_village: 'Villages', structure_dungeon: 'Dungeons & ruins', structure_temple: 'Temples', diff --git a/src/renderer/src/locales/tr.ts b/src/renderer/src/locales/tr.ts index 52b236b..e0c4463 100644 --- a/src/renderer/src/locales/tr.ts +++ b/src/renderer/src/locales/tr.ts @@ -147,6 +147,28 @@ const tr: typeof en = { 'Bekleme, bölgeler okunurken uygulamanın yanıt verir kalmasını sağlar — düşürmek daha hızlıdır ama aynı süreçte çalışan her şeye daha az yer bırakır.', structures: 'Yapılar', allStructures: 'Tüm yapılar', + areas: 'Alanlar', + areasCount: '{{dim}} içinde {{n}} tane', + areaEdit: 'Alanları düzenle', + 'areaErr_too-many-rects': 'Bu şekil çok fazla ayrı parçadan oluşuyor — birleştirin ya da daha az alan yapın.', + areaNew: 'Yeni alan', + areaName: 'Alan adı, örn. spawn kasabası', + areaNote: 'Ek açıklama — adın altında görünür, örn. bu alan sahibi: CaYatur', + areaPick: 'Harita üzerinden chunk seç', + areaPickingOn: 'Seçim açık — eklemek veya çıkarmak için chunk’a tıklayın', + areaChunks: '{{r}} dikdörtgen içinde {{n}} chunk', + areaClear: 'Seçimi temizle', + areaHidden: 'Yalnızca yönetici', + areaTypePlaceholder: 'Ya da yazın: 10,20 veya 30,40 - 32,42', + areaTypeAdd: 'Ekle', + areaBadLines: 'Anlaşılmadı: {{lines}}', + areaCreate: 'Oluştur', + chunkAt: 'chunk {{cx}}, {{cz}}', + 'areaErr_name-required': 'Alana bir ad verin.', + 'areaErr_name-too-long': 'Bu ad çok uzun (48 karakter).', + 'areaErr_note-too-long': 'Bu açıklama çok uzun (280 karakter).', + 'areaErr_no-chunks': 'En az bir chunk seçin ya da koordinat yazın.', + 'areaErr_too-many-chunks': 'Bu 65536 chunk’tan fazla — daha küçük bir alan çizin.', structure_village: 'Köyler', structure_dungeon: 'Zindanlar ve kalıntılar', structure_temple: 'Tapınaklar', diff --git a/src/shared/apiSurface.ts b/src/shared/apiSurface.ts index de8b3b9..ca91f6c 100644 --- a/src/shared/apiSurface.ts +++ b/src/shared/apiSurface.ts @@ -201,6 +201,28 @@ export const API_ROUTES: ApiRoute[] = [ { method: 'POST', path: '/servers/{id}/map/perf', gate: 'settings', group: 'players', summary: 'Change it. Values are clamped on the way in.', params: [serverId], body: { cache: 'Keep parsed tiles on disk (default on).', memoryRegions: 'Regions held in memory, 2-64.', parseGapMs: 'Minimum gap between region parses, 0-5000.', cacheLimitMB: 'On-disk ceiling, oldest evicted first.' } }, { method: 'DELETE', path: '/servers/{id}/map/cache', gate: 'settings', group: 'players', summary: 'Drop this server\'s cached map tiles.', params: [serverId], notes: 'Only this server\'s: the cache filename carries the owner so one server\'s clear cannot take another\'s with it.' }, { method: 'GET', path: '/servers/{id}/map/tiles', gate: 'view', group: 'players', summary: 'Rendered surface colours for the requested chunks.', params: [serverId], notes: 'Ask with `?c=cx,cz;cx,cz` (max 64) and `?dim=`. Answers only with regions already parsed and queues the rest — `pending` says how many are still coming, so a caller polls rather than blocking. A request never parses a region itself.' }, + // ---- chunk areas ---- + { method: 'GET', path: '/servers/{id}/areas', gate: 'view', group: 'areas', summary: 'Named chunk areas, including hidden ones.', params: [serverId], notes: 'The public map serves its own copy without the hidden areas or the timestamps.' }, + { + method: 'POST', + path: '/servers/{id}/areas', + gate: 'settings', + group: 'areas', + summary: 'Create an area, or edit one by passing `areaId`.', + params: [serverId], + body: { + areaId: 'Omit to create; pass an existing id to replace that area.', + name: 'Shown on hover and on click. 1-48 characters.', + note: 'The second line of the tooltip, e.g. who owns the area. Up to 280 characters.', + colour: '`#rrggbb` (`#rgb` is expanded). Anything else falls back to the first palette colour.', + dim: '`overworld` | `nether` | `end`, or a modded key. An area belongs to exactly one.', + rects: 'Array of `{x1,z1,x2,z2}` in CHUNK coordinates, inclusive, corners in any order.', + hidden: 'Keep it off every map but the operator\'s own.' + }, + notes: 'Rectangles 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-rects` (a shape of more than 64 separate pieces after tidying, or more than 1024 sent), `too-many-areas` (max 200), `area-not-found`. A shape too complex to store is refused, never silently trimmed.' + }, + { method: 'DELETE', path: '/servers/{id}/areas', gate: 'settings', group: 'areas', summary: 'Delete one area.', params: [serverId, { name: 'areaId', in: 'query', required: true, description: 'Area to delete.' }] }, + { method: 'GET', path: '/servers/{id}/player-requests', gate: 'settings', group: 'players', summary: 'Account claims waiting for a human, on a server running in offline mode.', params: [serverId], notes: 'Empty unless `online-mode=false`: with Mojang authentication on, the in-game code proves ownership by itself.' }, { method: 'POST', path: '/servers/{id}/player-requests/approve', gate: 'settings', group: 'players', summary: 'Vouch for a claim; the verification code is then whispered in game.', params: [serverId], body: { id: 'Request to approve.' }, notes: 'Gated on `settings` rather than `players`: this grants credentials to a website account with a balance, which is authority over an identity, not over a session.' }, { method: 'POST', path: '/servers/{id}/player-requests/deny', gate: 'settings', group: 'players', summary: 'Drop a claim without issuing a code.', params: [serverId], body: { id: 'Request to deny.' } }, diff --git a/src/shared/chunkAreas.ts b/src/shared/chunkAreas.ts new file mode 100644 index 0000000..5693c4a --- /dev/null +++ b/src/shared/chunkAreas.ts @@ -0,0 +1,410 @@ +import { normalizeDimension } from './livemap' + +/** + * Named, coloured regions of the map, measured in chunks (#144). + * + * An operator wants to say "these chunks are the spawn town, and this note + * explains who owns them" and have that appear to everybody looking at the map + * — the desktop app, the admin panel, the public site and the map page. + * + * All four draw it, so all four have to agree on what an area covers, which one + * wins where they overlap, and what a visitor is allowed to read. That is what + * this file is: no I/O, no rendering, just the answers. The alternative is four + * implementations that disagree at the edges, which is how this codebase ended + * up with three different maps in #129. + */ + +/** + * An inclusive rectangle in CHUNK coordinates. + * + * Rectangles rather than a list of chunks. A region 100 chunks square is one + * rect or ten thousand pairs, and this payload is served to a public page on + * every map load. An arbitrary shape is still expressible — it is several rects + * — and a single clicked chunk is a 1x1, so the UI that selects by clicking and + * the operator who types coordinates produce the same structure. + */ +export interface ChunkRect { + x1: number + z1: number + x2: number + z2: number +} + +export interface ChunkArea { + id: string + name: string + /** The "bu alan sahibi: ..." line. Shown on hover and on click. */ + note: string + /** `#rrggbb`. */ + colour: string + /** Which dimension this belongs to; an area without one paints the nether with overworld rectangles. */ + dim: string + rects: ChunkRect[] + /** + * Kept off every map but the operator's own. + * + * The point of the feature is that areas are visible to everyone, so this is + * off by default. It exists because an operator can have a reason to mark + * chunks without announcing them — an investigation, a build in progress — + * and the alternative is that they use the note field to lie. + */ + hidden?: boolean + createdAt: number + updatedAt: number +} + +/** + * An area as the PUBLIC map presents it. + * + * A separate type, for the same reason `PublicMapPlayer` is one: the difference + * has to be visible at every call site that returns one. Timestamps say when an + * operator was working, and `hidden` would tell a visitor that hidden areas + * exist — neither is any of their business. + */ +export interface PublicChunkArea { + id: string + name: string + note: string + colour: string + dim: string + rects: ChunkRect[] +} + +/** Minecraft's world border in chunks: 30,000,000 blocks / 16. */ +export const MAX_CHUNK = 1_875_000 + +export const MAX_AREAS = 200 +export const MAX_RECTS_PER_AREA = 64 +/** + * How many rectangles `normalizeRects` will look at before giving up. + * + * Generous, because clicking chunks one at a time is a real way to build a + * selection and each click arrives as its own 1x1 — they merge as they go, so + * the list only stays long for a genuinely scattered shape. Past this, the + * answer is a refusal, never a shorter list. + */ +export const MAX_INPUT_RECTS = 1024 +/** + * A cap on area, not on ambition: 65,536 chunks is 1024 blocks square, which is + * a large town. Without a cap one typo — a missing minus, a pasted coordinate in + * blocks rather than chunks — asks every map to test a million chunks per frame. + */ +export const MAX_CHUNKS_PER_AREA = 65_536 +export const MAX_NAME = 48 +export const MAX_NOTE = 280 + +/** Suggested colours. Any valid `#rrggbb` is accepted; these are what the pickers offer. */ +export const AREA_COLOURS = [ + '#e5484d', + '#f76b15', + '#ffb224', + '#46a758', + '#12a594', + '#0091ff', + '#8e4ec6', + '#e93d82' +] + +const clampChunk = (n: number): number => Math.min(MAX_CHUNK, Math.max(-MAX_CHUNK, Math.trunc(n))) + +/** Corners in any order become `x1 <= x2`, `z1 <= z2`, inside the world border. */ +export function normalizeRect(r: { + x1: number + z1: number + x2: number + z2: number +}): ChunkRect | null { + const vals = [r.x1, r.z1, r.x2, r.z2] + if (vals.some((v) => typeof v !== 'number' || !Number.isFinite(v))) return null + const x1 = clampChunk(Math.min(r.x1, r.x2)) + const x2 = clampChunk(Math.max(r.x1, r.x2)) + const z1 = clampChunk(Math.min(r.z1, r.z2)) + const z2 = clampChunk(Math.max(r.z1, r.z2)) + return { x1, z1, x2, z2 } +} + +export function rectChunks(r: ChunkRect): number { + return (r.x2 - r.x1 + 1) * (r.z2 - r.z1 + 1) +} + +export function rectHas(r: ChunkRect, cx: number, cz: number): boolean { + return cx >= r.x1 && cx <= r.x2 && cz >= r.z1 && cz <= r.z2 +} + +const contains = (outer: ChunkRect, inner: ChunkRect): boolean => + inner.x1 >= outer.x1 && inner.x2 <= outer.x2 && inner.z1 >= outer.z1 && inner.z2 <= outer.z2 + +/** + * Tidy a selection: normalise every rect, drop the ones already covered by + * another, merge neighbours that line up. + * + * Clicking chunks one at a time produces a pile of 1x1s, and a dragged box + * redrawn twice produces duplicates. Both would be stored, sent and tested + * forever. Merging only joins rects that share a full edge, so the union of the + * output is exactly the union of the input — this tidies the shape, it never + * changes which chunks are covered. + */ +export function normalizeRects(list: unknown): ChunkRect[] { + if (!Array.isArray(list)) return [] + let out: ChunkRect[] = [] + // A ceiling on the WORK, not on the answer. The merge below restarts its scan + // after every join, so it is superlinear and an unbounded list would hang the + // browser tab it runs in. Anything past this is refused by `checkArea` rather + // than quietly trimmed here — `.slice()` used to sit on this line, and a + // caller who sent more than it allowed got a 200 and lost the rest. + for (const raw of list.slice(0, MAX_INPUT_RECTS)) { + const r = raw && typeof raw === 'object' ? normalizeRect(raw as ChunkRect) : null + if (r) out.push(r) + } + + // Merge until nothing more lines up. Bounded by the rect count, which is + // capped above, so this cannot spin. + let merged = true + while (merged && out.length > 1) { + merged = false + outer: for (let i = 0; i < out.length; i++) { + for (let j = i + 1; j < out.length; j++) { + const a = out[i] + const b = out[j] + let joined: ChunkRect | null = null + if (a.z1 === b.z1 && a.z2 === b.z2 && (a.x2 + 1 === b.x1 || b.x2 + 1 === a.x1)) { + joined = { x1: Math.min(a.x1, b.x1), x2: Math.max(a.x2, b.x2), z1: a.z1, z2: a.z2 } + } else if (a.x1 === b.x1 && a.x2 === b.x2 && (a.z2 + 1 === b.z1 || b.z2 + 1 === a.z1)) { + joined = { x1: a.x1, x2: a.x2, z1: Math.min(a.z1, b.z1), z2: Math.max(a.z2, b.z2) } + } else if (contains(a, b)) { + joined = a + } else if (contains(b, a)) { + joined = b + } + if (joined) { + out = out.filter((_, k) => k !== i && k !== j) + out.push(joined) + merged = true + break outer + } + } + } + } + + // Stable order, so two identical selections serialise identically and a diff + // of the stored file shows real edits rather than reshuffling. + out.sort((a, b) => a.x1 - b.x1 || a.z1 - b.z1 || a.x2 - b.x2 || a.z2 - b.z2) + return out +} + +export function areaChunkCount(a: Pick): number { + let n = 0 + for (const r of a.rects) n += rectChunks(r) + return n +} + +export function areaHas(a: Pick, cx: number, cz: number): boolean { + for (const r of a.rects) if (rectHas(r, cx, cz)) return true + return false +} + +/** + * Which area owns this chunk, when several do. + * + * 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. The alternative, an explicit + * z-order field, is one more thing for four separate UIs to get right and for an + * operator to have to think about. + * + * Ties break on the most recent edit, then on id, so the answer is total: every + * surface resolves the same chunk to the same area, which is the whole point of + * deciding it here instead of in each of them. + */ +export function areaIndex(areas: ChunkArea[], dim: string): ChunkArea[] { + const want = normalizeDimension(dim) + return areas + .filter((a) => normalizeDimension(a.dim) === want) + .map((a) => ({ a, size: areaChunkCount(a) })) + .sort((p, q) => p.size - q.size || q.a.updatedAt - p.a.updatedAt || (q.a.id > p.a.id ? 1 : -1)) + .map((p) => p.a) +} + +/** First hit in a list `areaIndex` has already ordered. */ +export function areaAtIndexed(sorted: ChunkArea[], cx: number, cz: number): ChunkArea | undefined { + for (const a of sorted) if (areaHas(a, cx, cz)) return a + return undefined +} + +/** + * The one-off lookup: what is under this chunk? + * + * A renderer must NOT call this per chunk — it re-sorts and re-measures every + * area each time, which at 200 areas over a screenful of chunks is millions of + * comparisons a frame. Hoist `areaIndex` out of the loop and call + * `areaAtIndexed`. This signature is for the hover readout, which happens once + * per pointer move. + */ +export function areaAt( + areas: ChunkArea[], + cx: number, + cz: number, + dim: string +): ChunkArea | undefined { + return areaAtIndexed(areaIndex(areas, dim), cx, cz) +} + +/** Areas that touch this dimension, so a surface drawing one dimension tests only its own. */ +export function areasFor(areas: ChunkArea[], dim: string): ChunkArea[] { + const want = normalizeDimension(dim) + return areas.filter((a) => normalizeDimension(a.dim) === want) +} + +const HEX = /^#[0-9a-f]{6}$/i + +export function normalizeColour(c: unknown): string { + if (typeof c !== 'string') return AREA_COLOURS[0] + const s = c.trim() + if (HEX.test(s)) return s.toLowerCase() + // `#abc` is valid CSS and would render, but storing both forms means two + // spellings of one colour and a palette that never matches the swatch. + if (/^#[0-9a-f]{3}$/i.test(s)) { + return ('#' + s[1] + s[1] + s[2] + s[2] + s[3] + s[3]).toLowerCase() + } + return AREA_COLOURS[0] +} + +export type AreaInput = { + name?: unknown + note?: unknown + colour?: unknown + dim?: unknown + rects?: unknown + hidden?: unknown +} + +export type AreaCheck = + | { ok: true; value: Omit } + | { ok: false; error: string } + +/** + * Validate an area from anywhere — the panel, the desktop app, or a stranger + * with an API key. The API is the reason this returns a reason: a UI can grey + * out the save button, an HTTP caller gets whatever the body said. + */ +export function checkArea(input: AreaInput): AreaCheck { + const name = typeof input.name === 'string' ? input.name.trim() : '' + if (!name) return { ok: false, error: 'name-required' } + if (name.length > MAX_NAME) return { ok: false, error: 'name-too-long' } + const note = typeof input.note === 'string' ? input.note.trim() : '' + if (note.length > MAX_NOTE) return { ok: false, error: 'note-too-long' } + + // Checked BEFORE normalising, because normalising is where a too-long list + // used to be silently shortened. A caller who sends more than can be stored + // has to hear about it — a 200 that kept two thirds of the shape is worse + // than a 400, because nothing tells them which third went. + if (Array.isArray(input.rects) && input.rects.length > MAX_INPUT_RECTS) { + return { ok: false, error: 'too-many-rects' } + } + const rects = normalizeRects(input.rects) + if (!rects.length) return { ok: false, error: 'no-chunks' } + if (rects.length > MAX_RECTS_PER_AREA) return { ok: false, error: 'too-many-rects' } + const size = areaChunkCount({ rects }) + if (size > MAX_CHUNKS_PER_AREA) return { ok: false, error: 'too-many-chunks' } + + return { + ok: true, + value: { + name, + note, + colour: normalizeColour(input.colour), + dim: normalizeDimension(input.dim), + rects, + ...(input.hidden ? { hidden: true } : {}) + } + } +} + +/** Strip an area down to what a visitor may read, and drop the hidden ones entirely. */ +export function publicChunkAreas(areas: ChunkArea[], dim?: string): PublicChunkArea[] { + const want = dim === undefined || dim === '' ? undefined : normalizeDimension(dim) + const out: PublicChunkArea[] = [] + for (const a of areas) { + if (a.hidden) continue + if (want !== undefined && normalizeDimension(a.dim) !== want) continue + out.push({ + id: a.id, + name: a.name, + note: a.note, + colour: a.colour, + dim: normalizeDimension(a.dim), + rects: a.rects + }) + } + return out +} + +/** + * Parse typed chunk coordinates — the half of the feature that exists because + * clicking 400 chunks is not a plan. + * + * Accepts, one per line or comma-separated: + * `10,20` a single chunk + * `10,20 - 15,25` a rectangle, corners in any order + * `10 20` spaces work too, because people type what they see + * + * Returns what it understood and what it did not, rather than failing whole: a + * pasted list with one bad line should not throw away the other forty. + */ +export function parseChunkInput(text: string): { rects: ChunkRect[]; bad: string[] } { + const bad: string[] = [] + const rects: ChunkRect[] = [] + const lines = String(text || '') + .split(/[\n;]+/) + .map((l) => l.trim()) + .filter(Boolean) + + for (const line of lines) { + const nums = line.match(/-?\d+/g) + if (!nums || (nums.length !== 2 && nums.length !== 4)) { + bad.push(line) + continue + } + const n = nums.map(Number) + const r = + n.length === 2 + ? normalizeRect({ x1: n[0], z1: n[1], x2: n[0], z2: n[1] }) + : normalizeRect({ x1: n[0], z1: n[1], x2: n[2], z2: n[3] }) + if (r) rects.push(r) + else bad.push(line) + } + return { rects: normalizeRects(rects), bad } +} + +/** + * Take one chunk OUT of a selection. + * + * By splitting the rectangle that contains it into the (at most four) pieces + * around it — never by expanding the selection to one rect per chunk and + * filtering. That expansion was the first version and it lost data: a 20x20 + * region is one rectangle, expands to 400, and `normalizeRects` capped its input + * at 256, so removing one interior chunk silently deleted 143 others. Splitting + * touches only the rectangle involved and cannot grow the list by more than + * three, whatever the selection's size. + */ +export function subtractChunk(rects: ChunkRect[], cx: number, cz: number): ChunkRect[] { + const out: ChunkRect[] = [] + for (const r of rects) { + if (!rectHas(r, cx, cz)) { + out.push(r) + continue + } + // Left and right span the full depth; top and bottom are the leftovers in + // the removed chunk's own column, so the four pieces never overlap. + if (cx > r.x1) out.push({ x1: r.x1, z1: r.z1, x2: cx - 1, z2: r.z2 }) + if (cx < r.x2) out.push({ x1: cx + 1, z1: r.z1, x2: r.x2, z2: r.z2 }) + if (cz > r.z1) out.push({ x1: cx, z1: r.z1, x2: cx, z2: cz - 1 }) + if (cz < r.z2) out.push({ x1: cx, z1: cz + 1, x2: cx, z2: r.z2 }) + } + return normalizeRects(out) +} + +/** Block coordinates to the chunk containing them. Negative-safe, which `/16|0` is not. */ +export function chunkOf(x: number, z: number): { cx: number; cz: number } { + return { cx: Math.floor(x / 16), cz: Math.floor(z / 16) } +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 25ad712..11d723d 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -43,6 +43,7 @@ import type { McVersion, BuildInfo, CreateServerOptions, CreateProgress } from ' import type { ModEntry, ModrinthDetail, ModrinthHit, ModUpdateReport } from './mods' import type { BridgeInstallResult, BridgeStatus } from './bridgeRelease' import type { StructureMark } from './regionFormat' +import type { AreaInput, ChunkArea } from './chunkAreas' import type { WebStatus, WebUserView, @@ -137,6 +138,9 @@ export const IPC = { mapTiles: 'map:tiles', mapCacheClear: 'map:cache-clear', apiKeyDisabled: 'apikey:disabled', + areasList: 'areas:list', + areasSave: 'areas:save', + areasDelete: 'areas:delete', javaList: 'java:list', javaResolve: 'java:resolve', @@ -359,6 +363,15 @@ export interface MsmsApi { /** Drop every cached region. Returns how many files went. */ clearMapCache(): Promise + /** + * Named chunk areas (#144). The operator's own list — hidden areas included, + * which is the difference between this and what the public site is served. + */ + listChunkAreas(serverId: string): Promise + /** Create when `areaId` is absent, replace that area when it is present. */ + saveChunkArea(serverId: string, input: AreaInput & { areaId?: string }): Promise + deleteChunkArea(serverId: string, areaId: string): Promise + /** Switch a key off, reversibly. Revoke is the permanent one. */ setApiKeyDisabled(id: string, disabled: boolean): Promise diff --git a/src/shared/mapUi.ts b/src/shared/mapUi.ts index c0cfa65..af967d4 100644 --- a/src/shared/mapUi.ts +++ b/src/shared/mapUi.ts @@ -60,6 +60,17 @@ export const MAP_CSS = ` border:1px solid var(--line,var(--border,rgba(255,255,255,.14)));background:var(--elev,rgba(255,255,255,.05))} .mp-chip:hover{border-color:var(--accent,#dc2727)} .mp-chip span{opacity:.6;font-weight:600;margin-left:5px} +/* The area tooltip. Bottom-right, opposite the ungenerated note and clear of the + coordinate readout, so a claimed chunk at the edge of the world can still show + both. Clickable, because on a phone the pin is the only way to read it. */ +.mp-areatip{position:absolute;right:10px;bottom:10px;max-width:250px;padding:8px 11px;border-radius:10px; + font-size:12.5px;line-height:1.4;background:rgba(0,0,0,.78);color:#fff; + border:1px solid rgba(255,255,255,.16)} +.mp-areatip.hidden{display:none} +.mp-areatip b{display:block;font-size:13px;margin-bottom:2px} +.mp-areatip div{opacity:.85} +.mp-areatip .mp-x{margin-top:6px;padding:3px 8px;border-radius:7px;font-size:11px;cursor:pointer; + font-family:inherit;color:inherit;border:1px solid rgba(255,255,255,.2);background:transparent} ` /** @@ -87,6 +98,7 @@ export const MAP_HTML = ` +