Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions packages/terrain/__tests__/terrain-mcp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
73 changes: 64 additions & 9 deletions packages/terrain/src/mcp/cook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -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 =
Expand All @@ -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[],
Expand Down Expand Up @@ -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),
Expand Down
16 changes: 11 additions & 5 deletions packages/terrain/src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading