From 3995bd95ae73b584ab92ff94f4d8a888a6358abf Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 11:46:01 -0700 Subject: [PATCH] fix(terrain): a request's lake lands in the ground's deepest basin, and every water body it names floods 'a volcanic island with a lake' failed at the cook: the volcanic preset authors no lake layer, and the parser kept only the first water word, so the sea was lost too. - placeLakes puts each lake row the recipe has no layer for in the finished ground's deepest closed basin (fillDepressions), filled to its spill level, inside the square and above the sea; ground with no basin a metre deep floods its lowest inner cell two metres. Rivers keep routeRivers. - The request keeps every water body it names, once each: sea plus lake. Red: the lake spec threw 'no lake layer'. Green: ocean 1.5 m plus a lake at 4.5 m. Terrain 635/635. --- .../terrain/__tests__/terrain-mcp.spec.ts | 28 ++++++- packages/terrain/src/mcp/cook.ts | 73 ++++++++++++++++--- packages/terrain/src/mcp/tools.ts | 16 ++-- 3 files changed, 99 insertions(+), 18 deletions(-) diff --git a/packages/terrain/__tests__/terrain-mcp.spec.ts b/packages/terrain/__tests__/terrain-mcp.spec.ts index 6b5bbd46b7..8d90e76112 100644 --- a/packages/terrain/__tests__/terrain-mcp.spec.ts +++ b/packages/terrain/__tests__/terrain-mcp.spec.ts @@ -1242,10 +1242,30 @@ describe("terrain_generate biome cooks", () => { 120_000, ); - it("fails closed on a lake row the recipe has no layer for", async () => { - await expect(cook({ kit: "coastal", water: [{ kind: "lake", radius: 40 }] })).rejects.toThrow( - /no lake layer/, - ); + it("places a lake row the recipe has no layer for in the ground's deepest basin", async () => { + // "A volcanic island with a lake": the preset authors no lake, so the cook finds where water + // would stand (the deepest closed basin above the sea, filled to its spill level) and floods it. + const world = await cook({ + ...parseGenerateRequest({ request: "a volcanic island with a lake" }).input, + ground: "procedural", + }); + try { + const lake = world.recipe.layers.find( + (layer: { type: string; params: { kind?: string } }) => + layer.type === "water" && layer.params.kind === "lake", + ) as { params: { at: [number, number]; radius: number; level: number } }; + expect(lake.params.radius).toBe(105); + const half = world.recipe.config.size / 2; + for (const axis of lake.params.at) expect(Math.abs(axis)).toBeLessThan(half); + // Above the sea the island floods. + expect(lake.params.level).toBeGreaterThan(1.5); + expect(world.result.water).toEqual([ + { kind: "ocean", level: 1.5 }, + expect.objectContaining({ kind: "lake", radius: 105 }), + ]); + } finally { + await world.remove(); + } }, 120_000); }); diff --git a/packages/terrain/src/mcp/cook.ts b/packages/terrain/src/mcp/cook.ts index 987805106a..30326ac243 100644 --- a/packages/terrain/src/mcp/cook.ts +++ b/packages/terrain/src/mcp/cook.ts @@ -207,8 +207,8 @@ interface IWaterLayer { * A row has no position and no path, so a river or lake row re-shapes the recipe's own layer of that * kind, in order: a river sets the ribbon width (and depth), a lake its radius. A layer with no row * is removed, and an ocean row adds a layer, since the ocean needs no seed. A river row with no layer - * comes back to be routed down the finished ground (`routeRivers`); a lake row with no layer has no - * basin to sit in, so it fails instead of echoing. + * comes back to be routed down the finished ground (`routeRivers`), and a lake row with none to be + * placed in its deepest closed basin (`placeLakes`). */ function applyWaterRows( layers: IWaterLayer[], @@ -235,15 +235,10 @@ function applyWaterRows( continue; } const layer = (row.kind === "river" ? rivers : lakes).shift(); - if (layer === undefined && row.kind === "river") { + if (layer === undefined) { unrouted.push(row); continue; } - if (layer === undefined) { - throw new Error( - `cook: water row '${row.kind}' has no ${row.kind} layer in the recipe to take it.`, - ); - } if (row.kind === "river") { // The row's width is the ribbon the world floods; the layer's own width stays the carved bed. layer.params.waterWidth = @@ -265,6 +260,63 @@ function applyWaterRows( * from where its catchment is a tenth of the outlet's to the edge, every 16 m. Bed, depth and * shoulder are the forest starter's river (7 m bed for a 5.32 m ribbon). */ +/** + * Places each lake row the recipe had no layer for in the finished ground's deepest closed basin, + * filled to the level it spills at: where water would stand. Basins sit inside the square and clear + * of the sea and of each other; ground with no basin a metre deep takes the lowest such ground, + * flooded two metres. The ground decides, so one request and seed place the same lakes. + */ +function placeLakes( + recipe: ITerrainDocument & { layers: Layer[] }, + rows: readonly IBiomeWaterBody[], +): void { + const state = Terrain.fromJSON(recipe).evaluate(); + const { resolution: n, size } = state; + const filled = Float64Array.from(state.height); + fillDepressions(filled, n); + const sea = Math.max( + Number.NEGATIVE_INFINITY, + ...recipe.layers.flatMap((layer) => + layer.type === "water" && layer.params.kind === "ocean" ? [layer.params.level ?? 0] : [], + ), + ); + const world = (i: number): [number, number] => [ + ((i % n) / (n - 1) - 0.5) * size, + (Math.floor(i / n) / (n - 1) - 0.5) * size, + ]; + const inner: number[] = []; + for (let z = Math.floor(n / 8); z < n - Math.floor(n / 8); z += 1) + for (let x = Math.floor(n / 8); x < n - Math.floor(n / 8); x += 1) + if ((state.height[z * n + x] as number) > sea + 1) inner.push(z * n + x); + const depth = (i: number) => (filled[i] as number) - (state.height[i] as number); + const placed: [number, number, number][] = []; + rows.forEach((row, index) => { + const radius = row.radius as number; + const free = inner.filter((i) => { + const [x, z] = world(i); + return placed.every(([px, pz, r]) => Math.hypot(x - px, z - pz) > r + radius); + }); + const basin = free.reduce((best, i) => (best < 0 || depth(i) > depth(best) ? i : best), -1); + const low = free.reduce( + (best, i) => + best < 0 || (state.height[i] as number) < (state.height[best] as number) ? i : best, + -1, + ); + const at = basin >= 0 && depth(basin) >= 1 ? basin : low; + if (at < 0) throw new Error("cook: water row 'lake' found no ground above the sea to sit on."); + const level = at === basin ? (filled[at] as number) : (state.height[at] as number) + 2; + const [x, z] = world(at); + placed.push([x, z, radius]); + recipe.layers.push({ + enabled: true, + opacity: 1, + id: `lake-${String(index)}`, + type: "water", + params: { kind: "lake", at: [x, z], radius, level }, + } as Layer); + }); +} + function routeRivers( recipe: ITerrainDocument & { layers: Layer[] }, rows: readonly IBiomeWaterBody[], @@ -949,7 +1001,10 @@ async function cookInto( // A landform preset authors the ground and no scatter rules, so the biome file's foliage is placed // on it through the stages every starter scatter runs. // A river the recipe has no layer for runs down the finished ground, survey or preset alike. - if (unrouted.length > 0) routeRivers(recipe.recipe, unrouted); + const lakes = unrouted.filter((row) => row.kind === "lake"); + if (lakes.length > 0) placeLakes(recipe.recipe, lakes); + const rivers = unrouted.filter((row) => row.kind === "river"); + if (rivers.length > 0) routeRivers(recipe.recipe, rivers); const evaluated = Terrain.fromJSON(recipe.recipe).evaluate(); const state = applyPlacementOverrides( scatterFoliage(evaluated, biome, kind, assetTable), diff --git a/packages/terrain/src/mcp/tools.ts b/packages/terrain/src/mcp/tools.ts index 5488257199..36701b6245 100644 --- a/packages/terrain/src/mcp/tools.ts +++ b/packages/terrain/src/mcp/tools.ts @@ -466,12 +466,18 @@ export function parseGenerateRequest(input: unknown): IGenerateRequest { water = { value: spec.water, source: "argument" }; } else if (waterWord === undefined) { water = { value: null, source: "default" }; - } else if (parsed.landform === undefined && declaredWater(own.water, waterWord.row)) { - // The biome's authored recipe is the world, and it already floods this body: the word names it. - water = { value: own.water, source: { word: waterWord.word } }; } else { - parsed.water = [waterWord.row]; - water = { value: parsed.water, source: { word: waterWord.word } }; + // Every body the request names, once each: "a volcanic island with a lake" is a sea and a lake. + const bodies = WATER_WORD_ROWS.filter(([, names]) => words.some((w) => names.includes(w))).map( + ([row]) => row, + ); + if (parsed.landform === undefined && bodies.every((row) => declaredWater(own.water, row))) { + // The biome's authored recipe is the world, and it already floods these bodies: the words name them. + water = { value: own.water, source: { word: waterWord.word } }; + } else { + parsed.water = bodies; + water = { value: parsed.water, source: { word: waterWord.word } }; + } } const sizeArgument = parseSize(sizeMeters); const sizeWord = rowHit(SIZE_WORD_ROWS, words);