Skip to content

Commit 41b5d00

Browse files
committed
Self-review: removing one chunk from a big selection deleted 143 others (#144)
`normalizeRects` sliced its INPUT at 256 rectangles, and the removal path expanded the selection to one rectangle per chunk before filtering. So a 20x20 region — one rectangle, 400 chunks — became 400 rectangles, then 399, then the first 256. A hundred and forty-three chunks disappeared with no error, no warning, and nothing to say which ones. The first test used a row of four chunks, which is exactly why it stayed green. Restoring the slice now fails with "removing one chunk from 400 left 256". Removal no longer expands anything. It splits the rectangle AROUND the chunk into the at most four pieces beside it, which touches only the rectangle involved and cannot grow the list by more than three, whatever the selection's size. The test now removes an interior chunk, a corner, an edge, the only chunk, and one that was never selected — and checks every one of the other 399 is covered exactly once, so a split that overlapped would fail too. The same slice had two more consequences, both fixed by refusing instead of trimming: An API caller who POSTed a hundred scattered chunks got a 200 with sixty-four of them stored. `checkArea` counted the chunks and the emptiness but never noticed rectangles had been dropped. There is now a named `too-many-rects`, documented beside `too-many-chunks`, and the cap is checked before normalising as well as after — a shape too complex to store is refused, never silently trimmed. Three hundred chunks in a ROW still pass, because they merge to one rectangle. The panel's own tidy had no cap at all, so above 256 it and the app disagreed about what had been selected. The panel now splits the same way, and the smoke checks its answer against `subtractChunk`'s on the 400-chunk case rather than only on four. Also: the desktop editor was permanently open, against its own comment saying it should not be — drawing areas and editing them are two decisions, which is how the web panel already had it. And `forgetServerAreas` was written, documented as "called when a server is forgotten", and called by nothing; `removeServer` calls it now, so a later server issued the same id cannot inherit someone else's annotations. Verified: 12/12 gates.
1 parent 0810e01 commit 41b5d00

9 files changed

Lines changed: 168 additions & 35 deletions

File tree

docs/openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2887,7 +2887,7 @@
28872887
"post": {
28882888
"operationId": "postServersIdAreas",
28892889
"summary": "Create an area, or edit one by passing `areaId`.",
2890-
"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`.",
2890+
"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.",
28912891
"tags": [
28922892
"areas"
28932893
],

src/main/core/serverRegistry.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { resolveBaseDir, dataDir } from '../paths'
66
import { detectServer } from './serverDetect'
77
import * as metrics from './metrics'
88
import * as events from './events'
9+
import * as chunkAreas from './chunkAreas'
910
import { log } from '../logger'
1011
import { PROXY_TYPES } from '@shared/types'
1112
import type { ServerConfig, ServerType, JavaArgsConfig } from '@shared/types'
@@ -119,6 +120,10 @@ export function removeServer(id: string, deleteFiles: boolean): void {
119120
})
120121
metrics.dropServer(id)
121122
events.dropServer(id)
123+
// Areas are keyed by server id and live in their own file, so nothing else
124+
// removes them. A later server issued the same id would inherit somebody
125+
// else's map annotations.
126+
chunkAreas.forgetServerAreas(id)
122127
if (deleteFiles && target && existsSync(target.path)) {
123128
try {
124129
rmSync(target.path, { recursive: true, force: true })

src/main/smoke.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2279,6 +2279,67 @@ export async function runWorldsSmoke(): Promise<void> {
22792279
return fail('two diagonal chunks were merged into one rect')
22802280
}
22812281

2282+
// Removing ONE chunk from a big selection. This is the case the first
2283+
// version got wrong and no test caught: a 20x20 region is a single rect,
2284+
// and expanding it to one rect per chunk to filter produced 400 — past
2285+
// the input ceiling, so 143 chunks vanished with no error at all. The
2286+
// fix splits the rectangle around the chunk instead, so the size of the
2287+
// selection cannot matter.
2288+
{
2289+
const big = areasMod.normalizeRects(at([[0, 0, 19, 19]]))
2290+
if (big.length !== 1 || areasMod.areaChunkCount({ rects: big }) !== 400) {
2291+
return fail('a 20x20 selection is not one 400-chunk rect')
2292+
}
2293+
const cut = areasMod.subtractChunk(big, 10, 10)
2294+
if (areasMod.areaChunkCount({ rects: cut }) !== 399) {
2295+
return fail('removing one chunk from 400 left ' + areasMod.areaChunkCount({ rects: cut }))
2296+
}
2297+
if (areasMod.areaHas({ rects: cut }, 10, 10)) return fail('the removed chunk is still covered')
2298+
// Every other chunk survives, and none is covered twice — a split that
2299+
// overlaps would make the area larger than the shape it draws.
2300+
for (let x = 0; x <= 19; x++) {
2301+
for (let z = 0; z <= 19; z++) {
2302+
const hits = cut.filter((r) => areasMod.rectHas(r, x, z)).length
2303+
const want = x === 10 && z === 10 ? 0 : 1
2304+
if (hits !== want) return fail('chunk ' + x + ',' + z + ' is covered ' + hits + ' times')
2305+
}
2306+
}
2307+
// Removing a corner, an edge and the last chunk of all.
2308+
if (areasMod.areaChunkCount({ rects: areasMod.subtractChunk(big, 0, 0) }) !== 399) {
2309+
return fail('removing the corner went wrong')
2310+
}
2311+
if (areasMod.areaChunkCount({ rects: areasMod.subtractChunk(big, 19, 5) }) !== 399) {
2312+
return fail('removing an edge chunk went wrong')
2313+
}
2314+
const one = areasMod.normalizeRects(at([[7, 7, 7, 7]]))
2315+
if (areasMod.subtractChunk(one, 7, 7).length !== 0) return fail('removing the only chunk left something')
2316+
// A chunk that was never in the selection changes nothing.
2317+
if (areasMod.areaChunkCount({ rects: areasMod.subtractChunk(big, 99, 99) }) !== 400) {
2318+
return fail('removing an unselected chunk changed the selection')
2319+
}
2320+
}
2321+
2322+
// Too many pieces is REFUSED, not trimmed. `normalizeRects` used to slice
2323+
// its input, so an API caller who sent a hundred scattered chunks got a
2324+
// 200 and lost most of them — with nothing to say which.
2325+
{
2326+
const scattered = at(
2327+
Array.from({ length: areasMod.MAX_RECTS_PER_AREA + 20 }, (_, i) => [i * 2, 0, i * 2, 0])
2328+
)
2329+
const c = areasMod.checkArea({ name: 'swiss cheese', rects: scattered })
2330+
if (c.ok) return fail('a shape with too many pieces was accepted')
2331+
if (c.error !== 'too-many-rects') return fail('wrong reason: ' + c.error)
2332+
const flood = at(Array.from({ length: areasMod.MAX_INPUT_RECTS + 1 }, (_, i) => [i * 2, 0, i * 2, 0]))
2333+
const c2 = areasMod.checkArea({ name: 'flood', rects: flood })
2334+
if (c2.ok || c2.error !== 'too-many-rects') return fail('a flood of rects was not refused: ' + JSON.stringify(c2))
2335+
// ...and a shape that merges down to few enough pieces is still fine,
2336+
// however many rectangles it arrived as.
2337+
const contiguous = at(Array.from({ length: 300 }, (_, i) => [i, 0, i, 0]))
2338+
const c3 = areasMod.checkArea({ name: 'a long road', rects: contiguous })
2339+
if (!c3.ok) return fail('300 chunks in a row were refused: ' + c3.error)
2340+
if (c3.value.rects.length !== 1) return fail('300 chunks in a row did not merge to one rect')
2341+
}
2342+
22822343
// Dimension scoping. Without it, an area drawn in the overworld paints the
22832344
// same rectangle over the nether, where it means nothing.
22842345
const over = mk({ id: 'o', rects: at([[0, 0, 9, 9]]) })
@@ -6754,6 +6815,7 @@ export async function runWebSmoke(): Promise<void> {
67546815
if (areasMod.areaChunkCount({ rects: pnl.AREA_PICK }) !== 4) return fail('the picker lost a chunk')
67556816
// Taking one out of the MIDDLE is the case that matters: the rect it
67566817
// sits in covers three others, and dropping the rect drops them too.
6818+
// The panel splits the rectangle, the same as the app does.
67576819
pctx['areaPickChunk'](1, 0)
67586820
if (areasMod.areaChunkCount({ rects: pnl.AREA_PICK }) !== 3) {
67596821
return fail('removing one chunk took ' + (4 - areasMod.areaChunkCount({ rects: pnl.AREA_PICK })) + ' with it')
@@ -6781,6 +6843,19 @@ export async function runWebSmoke(): Promise<void> {
67816843
if (canon(theirsTidy) !== canon(mineTidy)) {
67826844
return fail('the panel tidies differently: ' + canon(theirsTidy) + ' vs ' + canon(mineTidy))
67836845
}
6846+
// And the panel must survive the big-selection case too — its own
6847+
// removal is a second implementation, so it gets the same test.
6848+
pnl.AREA_PICK = [{ x1: 0, z1: 0, x2: 19, z2: 19 }]
6849+
pctx['areaPickChunk'](10, 10)
6850+
if (areasMod.areaChunkCount({ rects: pnl.AREA_PICK }) !== 399) {
6851+
return fail(
6852+
'the panel lost chunks removing one from 400: ' +
6853+
areasMod.areaChunkCount({ rects: pnl.AREA_PICK })
6854+
)
6855+
}
6856+
if (canon(pnl.AREA_PICK) !== canon(areasMod.subtractChunk([{ x1: 0, z1: 0, x2: 19, z2: 19 }], 10, 10))) {
6857+
return fail('the panel splits a rectangle differently from the app')
6858+
}
67846859
pnl.AREA_PICKING = false
67856860
pnl.AREA_PICK = []
67866861
}

src/main/web/panelHtml.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -991,13 +991,18 @@ function areaPickChunk(cx,cz){
991991
for(var i=0;i<AREA_PICK.length;i++){var r=AREA_PICK[i];
992992
if(cx>=r.x1&&cx<=r.x2&&cz>=r.z1&&cz<=r.z2)has=true}
993993
if(has){
994-
/* Expand and drop the one chunk. Removing the whole rectangle that happens to
995-
contain it would throw away the forty others it was merged with. */
996-
var flat=[];
994+
/* Split the rectangle AROUND the chunk. Expanding the selection to one rect
995+
per chunk and filtering was the first version, and it lost data on any
996+
selection past a few hundred chunks. Splitting touches only the rectangle
997+
involved and can add at most three. */
998+
var cut=[];
997999
for(var j=0;j<AREA_PICK.length;j++){var q=AREA_PICK[j];
998-
for(var x=q.x1;x<=q.x2;x++)for(var z=q.z1;z<=q.z2;z++)
999-
if(!(x===cx&&z===cz))flat.push({x1:x,z1:z,x2:x,z2:z})}
1000-
AREA_PICK=areaTidy(flat)}
1000+
if(!(cx>=q.x1&&cx<=q.x2&&cz>=q.z1&&cz<=q.z2)){cut.push(q);continue}
1001+
if(cx>q.x1)cut.push({x1:q.x1,z1:q.z1,x2:cx-1,z2:q.z2});
1002+
if(cx<q.x2)cut.push({x1:cx+1,z1:q.z1,x2:q.x2,z2:q.z2});
1003+
if(cz>q.z1)cut.push({x1:cx,z1:q.z1,x2:cx,z2:cz-1});
1004+
if(cz<q.z2)cut.push({x1:cx,z1:cz+1,x2:cx,z2:q.z2})}
1005+
AREA_PICK=areaTidy(cut)}
10011006
else AREA_PICK=areaTidy(AREA_PICK.concat([{x1:cx,z1:cz,x2:cx,z2:cz}]));
10021007
areaCountText();mapDraw()}
10031008
function areaAddTyped(){

src/renderer/src/components/LiveMap.tsx

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
areasFor,
1515
chunkOf,
1616
normalizeRects,
17-
expandRects,
17+
subtractChunk,
1818
parseChunkInput,
1919
checkArea,
2020
areaChunkCount,
@@ -232,6 +232,8 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
232232
// label the operator wrote for people to read, so hiding it defeats the point.
233233
const [areas, setAreas] = useState<ChunkArea[]>([])
234234
const [showAreas, setShowAreas] = useState(true)
235+
// Drawing them is on; the editor is not. Two separate decisions.
236+
const [showAreaCard, setShowAreaCard] = useState(false)
235237
const [pinned, setPinned] = useState<ChunkArea | null>(null)
236238
const [editing, setEditing] = useState<ChunkArea | 'new' | null>(null)
237239
// Chunks picked by clicking, while the picker is open. A mode rather than a
@@ -687,14 +689,32 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
687689
<button className={`btn sm ${showAreas ? 'primary' : ''}`} onClick={() => setShowAreas((v) => !v)}>
688690
<Shapes size={13} /> {t('map.areas')}
689691
</button>
692+
<button
693+
className={`btn sm ${showAreaCard ? 'primary' : ''}`}
694+
onClick={() => {
695+
const next = !showAreaCard
696+
setShowAreaCard(next)
697+
// Editing implies looking. Opening the editor over a map that is not
698+
// drawing areas would be picking chunks against an invisible shape.
699+
if (next) setShowAreas(true)
700+
else {
701+
setEditing(null)
702+
setPicking(false)
703+
setPicked([])
704+
}
705+
}}
706+
>
707+
{t('map.areaEdit')}
708+
</button>
690709
<button className={`btn sm ${showPerf ? 'primary' : ''}`} onClick={() => setShowPerf((v) => !v)}>
691710
<Gauge size={13} /> {t('map.performance')}
692711
</button>
693712
</div>
694713

695-
{/* The area editor. Open only when asked for: the map is for looking at,
696-
and a permanently visible editing panel would take a third of it. */}
697-
{showAreas && (
714+
{/* Open only when asked for: the map is for looking at, and a permanently
715+
visible editing panel takes a third of it. Drawing areas and EDITING
716+
them are two decisions — the web panel keeps them apart the same way. */}
717+
{showAreaCard && (
698718
<div className="card" style={{ padding: 12, display: 'grid', gap: 8 }}>
699719
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
700720
<b style={{ fontSize: 13 }}>{t('map.areas')}</b>
@@ -905,12 +925,10 @@ export function LiveMap({ serverId }: { serverId: string }): JSX.Element {
905925
const has = picked.some((r) => c.cx >= r.x1 && c.cx <= r.x2 && c.cz >= r.z1 && c.cz <= r.z2)
906926
setPicked(
907927
has
908-
? // Rects are merged on the way in, so removing a chunk means
909-
// re-deriving the selection without it rather than dropping
910-
// whichever rectangle happens to contain it.
911-
normalizeRects(
912-
expandRects(picked).filter((p) => !(p.x1 === c.cx && p.z1 === c.cz))
913-
)
928+
? // Splits the rectangle around the chunk rather than dropping
929+
// it: rects are merged on the way in, so the one under the
930+
// pointer usually covers dozens of others.
931+
subtractChunk(picked, c.cx, c.cz)
914932
: normalizeRects([...picked, { x1: c.cx, z1: c.cz, x2: c.cx, z2: c.cz }])
915933
)
916934
return

src/renderer/src/locales/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ export default {
147147
allStructures: 'All structures',
148148
areas: 'Areas',
149149
areasCount: '{{n}} in {{dim}}',
150+
areaEdit: 'Edit areas',
151+
'areaErr_too-many-rects': 'That shape has too many separate pieces — join some up or make fewer areas.',
150152
areaNew: 'New area',
151153
areaName: 'Area name, e.g. spawn town',
152154
areaNote: 'Note — shown under the name, e.g. owner: CaYatur',

src/renderer/src/locales/tr.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ const tr: typeof en = {
149149
allStructures: 'Tüm yapılar',
150150
areas: 'Alanlar',
151151
areasCount: '{{dim}} içinde {{n}} tane',
152+
areaEdit: 'Alanları düzenle',
153+
'areaErr_too-many-rects': 'Bu şekil çok fazla ayrı parçadan oluşuyor — birleştirin ya da daha az alan yapın.',
152154
areaNew: 'Yeni alan',
153155
areaName: 'Alan adı, örn. spawn kasabası',
154156
areaNote: 'Ek açıklama — adın altında görünür, örn. bu alan sahibi: CaYatur',

src/shared/apiSurface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ export const API_ROUTES: ApiRoute[] = [
219219
rects: 'Array of `{x1,z1,x2,z2}` in CHUNK coordinates, inclusive, corners in any order.',
220220
hidden: 'Keep it off every map but the operator\'s own.'
221221
},
222-
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-areas` (max 200), `area-not-found`.'
222+
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.'
223223
},
224224
{ 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.' }] },
225225

0 commit comments

Comments
 (0)