From ccf0b1818514a820a021137c920a8474698e2251 Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 17 Oct 2026 22:51:15 +0100 Subject: [PATCH] Export measured coasts and smooth local rivers for Settlemaker cities Survey water around city burgs, retain local river identity and widths, and transmit smooth source curves for riverfront planning. Preserve river geometry through the preview API with bounded survey metadata and explicit approximation diagnostics. --- docs/domain/settlemaker-water-context.md | 70 ++++- .../previews/burg-water-survey.test.ts | 62 ++++- src/services/previews/burg-water-survey.ts | 15 +- src/services/previews/river-curve.test.ts | 95 +++++++ src/services/previews/river-curve.ts | 49 ++++ .../previews/river-water-survey.test.ts | 255 ++++++++++++++++++ src/services/previews/river-water-survey.ts | 157 +++++++++++ .../previews/settlemaker-burg.test.ts | 72 ++++- src/services/previews/settlemaker-burg.ts | 34 ++- src/services/previews/settlemaker.test.ts | 180 ++++++++++--- src/services/previews/settlemaker.ts | 27 +- src/services/previews/water-context-types.ts | 12 +- src/utils/shoreline-geometry.ts | 17 +- 13 files changed, 972 insertions(+), 73 deletions(-) create mode 100644 src/services/previews/river-curve.test.ts create mode 100644 src/services/previews/river-curve.ts create mode 100644 src/services/previews/river-water-survey.test.ts create mode 100644 src/services/previews/river-water-survey.ts diff --git a/docs/domain/settlemaker-water-context.md b/docs/domain/settlemaker-water-context.md index 4dfa0a9aa0..95870352fd 100644 --- a/docs/domain/settlemaker-water-context.md +++ b/docs/domain/settlemaker-water-context.md @@ -1,4 +1,4 @@ -# Settlemaker measured village water +# Settlemaker measured settlement water FMG implements the sender side of Settlemaker's `water-context-v1` contract (`settlemaker/docs/water-context-v1.md`, revision `8ebc23a`). The renderer must @@ -8,7 +8,7 @@ implement that contract before this mode is enabled. `VITE_SETTLEMAKER_WATER_CONTEXT_URL` is a build-time opt-in. Leave it unset for normal production builds until a deployed Settlemaker release explicitly supports -water-context v1 for villages. An old decoder ignores the new field and can still +water-context v1 for both villages and cities. An old decoder ignores the new field and can still invent a nearby shore from the legacy bearing. To exercise a supporting local renderer: @@ -20,13 +20,28 @@ VITE_SETTLEMAKER_WATER_CONTEXT_URL=http://127.0.0.1:5173/fmg npm run dev Production endpoints must use HTTPS and the `/fmg` path, without a query or fragment. Pin the supporting renderer release in the deployment and configure its endpoint when building FMG. This option routes both embedded and external -village previews to the same URL. Population 1–1000 uses the new contract; -cities continue using the existing production URL and payload. +settlement previews to the same URL. Every positive population uses the measured +contract when enabled, including cities. Leaving the endpoint unset retains the +existing production integration. + +Villages initially survey 3 km; cities initially survey 4 km. These are starting +coverage envelopes, not guarantees of sufficient land for every settlement. +Set `VITE_SETTLEMAKER_WATER_SURVEY_RADIUS_M=6000` to request a larger envelope for +a deployment, or pass `{surveyRadiusM: 6000}` as the second argument to +`createSettlemakerPreview` for an individual request. The explicit request takes +precedence. Radius values must be finite and at least 3000 metres. + +The renderer checks its actual planning extent and reports `water-coverage-exceeded` +with `requiredSurveyRadiusM` when it needs more geography. Use that value for a +new request; FMG does not automatically receive or retry cross-origin renderer +errors. Export, compression and URL-budget failures remain visible in the burg +editor and on external-open attempts. Increasing coverage can exceed the URL +budget; it never silently replaces measured geometry with a bearing. ## Sender behavior -- Survey a 3 km radius around the actual burg coordinates, independent of port - status. Export the enclosing 6 km square as simple filled water pieces, with +- Survey the selected radius around the actual burg coordinates, independent of port + status. Export its enclosing square as simple filled water pieces, with islands retained as dry land. Always include `coastlineGeometry`, including an explicit empty array for a dry survey. No simple-coast approximation is used. - Use `Coastline.getFeaturePath`, shared with drawing and saving. Loading adopts @@ -41,9 +56,42 @@ cities continue using the existing production URL and payload. If real shores lie beyond coverage, search the canonical full boundary. - Unsupported custom units produce `unknown-units`, with no metric geometry. FMG currently has no custom metres-per-unit setting. -- Local river widths are unavailable from the burg context. Omit river surveys - and identify context rivers through `omittedRivers`; never substitute the - kilometre-valued river mouth width for the width beside the burg. +- Apply FMG's Catmull-Rom alpha 0.1 interpolation to the complete edited/meandered + centre anchors before clipping, with `courseSource: "fmg-catmull-rom-0.1"` + and `meander: false`. Capture full-precision cubic controls, flatten local + curves adaptively to 0.2 m, then quantize exported river coordinates to 0.01 m + (at most 0.0071 m extra error). Conservative cubic control hulls include bends + entering coverage even when their anchor chords remain outside. No new random + bends are introduced. This shares FMG's smoothing family, but does not claim + equality with the midpoint of separately offset and rounded painted banks. +- Evaluate the existing physical width model at every original course point + using cumulative discharge and the full-course point index. Convert those + kilometre-valued model widths with `* 1000`; map-unit conversion applies only + to coordinates. Interpolate widths linearly in each cubic segment's parameter + at adaptive stations, retaining the original anchor widths. `widthsM` carries + this profile and `widthM` is its nearest-burg fallback. Neither uses + `River.width`, which only describes the mouth. +- River widths are estimates, identified by `widthSource: "fmg-local-width-model"`. + Settlemaker reports `water-river-width-modelled` and approximate water status. + The geometry tolerance covers coast/lake geometry and interpolated centreline, not estimated river banks or agreement with the painted river + boundary or its visual midline. + The displayed river outline has illustrative map-unit offsets and is not + relabelled as physical survey geometry. Missing or zero local model widths + retain `omittedRivers` warnings instead of inventing a width. +- Survey all river courses for intersections, including crossings whose cell + centres fall outside the context window. Crop courses beyond the selected + survey square plus four maximum half-widths and one metre, matching the + consumer's capped bank miters at sharp bends, so artificial + endpoints cannot become local banks. Interpolate widths at crop points and + keep disconnected reentries as separate runs with the original river ID. + Detailed river courses share the existing URL budget and fail explicitly + when too large; no course is silently removed or simplified. +- For ports, send `harbourWaterKind` from the adjacent haven feature, or `river` + for an inland river port, with `harbourRiverId` from the burg cell's river ID. + This association prevents selecting an unrelated nearby river. A downstream + ocean ID does not turn a river port + into an ocean harbour. This identifies the local water type, not bathymetry + or a military role; FMG's `Naval` burg type only means it is a port. - Keep the compressed `i` envelope. If the encoded payload exceeds 8192 bytes, compression fails, or geometry cannot be surveyed safely, show the failure in the burg editor and on an external-open attempt. Do not discard measured @@ -64,7 +112,9 @@ supporting release in both embedded and external-open flows. Focused tests cover distance/units, finite lakes and islands, peninsula geometry, curve inclusion and precision, narrow channels, union shore measurement, crop closures, unknown units, missing local river widths, saved geometry invalidation, -URL-budget failures, and population 1000/1001 rollout boundaries. The 11,115 m +URL-budget failures, population 1000/1001, Tetlapan-sized and large city payloads, +larger survey radii, local harbour-water classification, river source/mouth width +differences, width-unit invariance, edited courses and coastal river payloads. The 11,115 m Tarrimas-Ha case is synthetic geography reproducing the measured distance; it does not replay the original Borteland save. diff --git a/src/services/previews/burg-water-survey.test.ts b/src/services/previews/burg-water-survey.test.ts index f9442f8f22..bac23aaa0a 100644 --- a/src/services/previews/burg-water-survey.test.ts +++ b/src/services/previews/burg-water-survey.test.ts @@ -1,9 +1,14 @@ // @vitest-environment node import { afterEach, describe, expect, it, vi } from "vitest"; import type { Burg } from "@/generators/burgs-generator"; +import { Coastline } from "@/generators/coastline-generator"; +import "@/generators/river-generator"; import { surveyBurgWater } from "./burg-water-survey"; -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); describe("unknown physical units", () => { it("reports custom units without reading geometry or asserting empty water", () => { @@ -13,3 +18,58 @@ describe("unknown physical units", () => { }); }); }); + +describe("city survey coverage", () => { + it("exports nearby river geometry alongside ocean polygons instead of marking every river omitted", () => { + vi.stubGlobal("options", { + map: { graph: { width: 100, height: 100 }, units: { distance: { unit: "km", scale: 1 } } } + }); + vi.stubGlobal("pack", { + cells: { i: [0], p: [[60, 50]], f: [2] }, + features: [undefined, { i: 1, type: "island" }, { i: 2, type: "ocean" }], + rivers: [{ i: 7, cells: [0, 1, 2], widthFactor: 1, sourceWidth: 0.15, width: 99 }] + }); + vi.spyOn(Coastline, "boundaryRevision", "get").mockReturnValue(1); + vi.spyOn(Coastline, "getFeaturePath").mockReturnValue("M0,0H50.2V100H0Z"); + vi.spyOn(Rivers, "addMeandering").mockReturnValue([ + [45, 50.1, 100], + [50, 50.1, 200], + [55, 50.1, 300] + ]); + const result = surveyBurgWater({ i: 7, x: 50, y: 50 } as Burg, [7, 99], 4000); + expect(result.waterContext).toMatchObject({ + status: "measured", + bodies: [{ kind: "ocean", distanceM: expect.closeTo(200) }], + omittedRivers: [{ riverId: "99", reason: "local-width-unavailable" }] + }); + if (!("rivers" in result)) throw new Error("Expected exported river"); + expect(result.rivers).toHaveLength(1); + expect(result.rivers![0]).toMatchObject({ id: "7", meander: false, widthSource: "fmg-local-width-model" }); + expect(result.rivers![0].centreline[1].y).toBeCloseTo(100); + expect(result.rivers![0].widthM).toBeLessThan(99000); + }); + + it.each([ + ["km", 1], + ["mi", 1.609344], + ["nmi", 1.852] + ] as const)("uses actual burg coordinates and a larger survey in %s", (unit, kmPerUnit) => { + vi.stubGlobal("options", { + map: { graph: { width: 100, height: 100 }, units: { distance: { unit, scale: 1 / kmPerUnit } } } + }); + vi.stubGlobal("pack", { + cells: { i: [0], p: [[60, 50]], f: [2] }, + features: [undefined, { i: 1, type: "island" }, { i: 2, type: "ocean" }] + }); + vi.spyOn(Coastline, "boundaryRevision", "get").mockReturnValue(1); + vi.spyOn(Coastline, "getFeaturePath").mockReturnValue("M0,0H50.04V100H0Z"); + const result = surveyBurgWater({ i: 7, x: 50, y: 50 } as Burg, [], 6000); + expect(result.waterContext).toMatchObject({ + status: "measured", + surveyRadiusM: 6000, + bodies: [{ kind: "ocean", distanceM: expect.closeTo(40, 5), bearingDeg: 90 }] + }); + if (result.waterContext.status !== "measured") throw new Error("Expected measured water"); + expect("coastlineGeometry" in result && result.coastlineGeometry[0].some(p => p.x === 6000)).toBe(true); + }); +}); diff --git a/src/services/previews/burg-water-survey.ts b/src/services/previews/burg-water-survey.ts index ecaf473435..e1ab2ca5c1 100644 --- a/src/services/previews/burg-water-survey.ts +++ b/src/services/previews/burg-water-survey.ts @@ -2,8 +2,9 @@ import { quadtree } from "d3"; import type { Burg } from "@/generators/burgs-generator"; import { Coastline } from "@/generators/coastline-generator"; import { getKmInDistanceUnit } from "@/utils/unitUtils"; +import { surveyBurgRivers } from "./river-water-survey"; import type { WaterSurvey } from "./water-context-types"; -import { WaterSurveyIndex } from "./water-survey"; +import { WATER_SURVEY_RADIUS_M, WaterSurveyIndex } from "./water-survey"; let cached: | { @@ -14,7 +15,7 @@ let cached: } | undefined; -export function surveyBurgWater(burg: Burg, omittedRiverIds: number[] = []): WaterSurvey { +export function surveyBurgWater(burg: Burg, omittedRiverIds: number[] = [], radiusM?: number): WaterSurvey { const kmPerUnit = getKmInDistanceUnit(); if (!kmPerUnit) return { @@ -52,14 +53,18 @@ export function surveyBurgWater(burg: Burg, omittedRiverIds: number[] = []): Wat }; } const oceanIndex = cached.oceans; - return cached.index.survey({ + const metresPerMapUnit = options.map.units.distance.scale * kmPerUnit * 1000; + const riverSurvey = surveyBurgRivers(burg, metresPerMapUnit, radiusM ?? WATER_SURVEY_RADIUS_M, omittedRiverIds); + const water = cached.index.survey({ burg, - metresPerMapUnit: options.map.units.distance.scale * kmPerUnit * 1000, + radiusM, + metresPerMapUnit, oceanAt: point => { const ocean = oceanIndex.find(point.x, point.y); if (!ocean) throw new Error("The map has no ocean feature for this measured water area"); return ocean[2]; }, - omittedRiverIds + omittedRiverIds: riverSurvey.omittedRiverIds }); + return { ...water, ...(riverSurvey.rivers.length ? { rivers: riverSurvey.rivers } : {}) }; } diff --git a/src/services/previews/river-curve.test.ts b/src/services/previews/river-curve.test.ts new file mode 100644 index 0000000000..44d0ae51c0 --- /dev/null +++ b/src/services/previews/river-curve.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment node + +import { curveCatmullRom, path } from "d3"; +import { describe, expect, it } from "vitest"; +import { closestPoint, parseShorePath } from "@/utils/shoreline-geometry"; +import { smoothRiverCourse } from "./river-curve"; +import { projectRiverSurvey } from "./river-water-survey"; + +const anchors = [ + { x: -5000, y: 0, width: 20 }, + { x: -1000, y: 800, width: 30 }, + { x: 0, y: 0, width: 40 }, + { x: 1000, y: 800, width: 50 }, + { x: 5000, y: 0, width: 60 } +]; +const distance = (p: { x: number; y: number }, points: { x: number; y: number }[]) => + Math.min( + ...points.slice(1).map((b, i) => { + const q = closestPoint(p, points[i], b); + return Math.hypot(p.x - q.x, p.y - q.y); + }) + ); + +describe("measured smooth river course", () => { + it("passes through original anchors and joins the banks with smooth tangents", () => { + const result = smoothRiverCourse(anchors, 6000); + for (const a of anchors) expect(result).toContainEqual(a); + expect(result.length).toBeGreaterThan(20); + for (const a of anchors.slice(1, -1)) { + const i = result.findIndex(p => p.x === a.x && p.y === a.y), + prev = result[i - 1], + next = result[i + 1]; + const v = [a.x - prev.x, a.y - prev.y], + w = [next.x - a.x, next.y - a.y]; + expect((v[0] * w[0] + v[1] * w[1]) / Math.hypot(...v) / Math.hypot(...w)).toBeGreaterThan(0.99); + } + expect(result.every(p => p.width >= 20 && p.width <= 60)).toBe(true); + }); + + it("converges to the full-precision FMG Catmull-Rom curve within the metric budget", () => { + const result = smoothRiverCourse(anchors, 6000, 0.2); + const finer = smoothRiverCourse(anchors, 6000, 0.02); + expect(finer.length).toBeGreaterThan(result.length); + const context = path(); + const curve = curveCatmullRom.alpha(0.1)(context); + curve.lineStart(); + for (const p of anchors) curve.point(p.x, p.y); + curve.lineEnd(); + const curves = parseShorePath(context.toString(), 1).slice(0, -1); + let maxError = 0; + for (const c of curves) + for (let i = 0; i <= 400; i++) { + const t = i / 400, + u = 1 - t, + p = c.points; + const q = { + x: p[0].x * u ** 3 + 3 * p[1].x * u * u * t + 3 * p[2].x * u * t * t + p[3].x * t ** 3, + y: p[0].y * u ** 3 + 3 * p[1].y * u * u * t + 3 * p[2].y * u * t * t + p[3].y * t ** 3 + }; + maxError = Math.max(maxError, distance(q, result)); + expect(distance(q, finer)).toBeLessThanOrEqual(0.021); + } + expect(maxError).toBeLessThanOrEqual(0.201); + }); + + it("retains an outside-anchor curve that bends into the survey", () => { + const points = [ + { x: 3300, y: -3000, width: 20 }, + { x: 3020, y: -1000, width: 30 }, + { x: 3020, y: 1000, width: 40 }, + { x: 3300, y: 3000, width: 50 } + ]; + const result = smoothRiverCourse(points, 3000); + expect(result.some(p => p.x < 3000)).toBe(true); + const survey = projectRiverSurvey( + 1, + result.map(p => [p.x, p.y, 0]), + result.map(p => p.width), + { x: 0, y: 0 }, + 1, + 3000 + ); + expect(survey.rivers).toHaveLength(1); + expect(survey.rivers[0].centreline.some(p => p.x < 3000)).toBe(true); + }); + + it("keeps full-course tangents and widths when the survey crop changes", () => { + const small = smoothRiverCourse(anchors, 1000), + large = smoothRiverCourse(anchors, 6000); + const local = small.filter(p => Math.abs(p.x) < 1000 && Math.abs(p.y) < 1000); + expect(local.length).toBeGreaterThan(10); + for (const p of local) expect(large).toContainEqual(p); + expect(anchors[2]).toEqual({ x: 0, y: 0, width: 40 }); + }); +}); diff --git a/src/services/previews/river-curve.ts b/src/services/previews/river-curve.ts new file mode 100644 index 0000000000..e450aaaad4 --- /dev/null +++ b/src/services/previews/river-curve.ts @@ -0,0 +1,49 @@ +import { curveCatmullRom, path } from "d3"; +import { flattenCurve } from "@/utils/shoreline-geometry"; +import type { WaterPoint } from "./water-context-types"; + +export interface RiverCurvePoint extends WaterPoint { + width: number; +} + +export const RIVER_CURVE_ERROR_M = 0.2; + +/** FMG's drawing interpolation applied to its centre anchors, without rounded cartographic bank offsets. */ +export function smoothRiverCourse( + points: RiverCurvePoint[], + radiusM: number, + toleranceM = RIVER_CURVE_ERROR_M +): RiverCurvePoint[] { + if (points.length < 2) return points; + const output: RiverCurvePoint[] = [points[0]]; + const padding = points.reduce((max, p) => (Number.isFinite(p.width) ? Math.max(max, p.width) : max), 0) * 2 + 1; + let at: WaterPoint = points[0], + segment = 0; + const context = path(); + const append = (controls: WaterPoint[]) => { + const start = points[segment], + end = points[++segment]; + // Complete-course neighbours define every tangent; distant curves need no tessellation. + const outside = ["x", "y"].some(axis => { + const values = controls.map(p => p[axis as "x" | "y"]); + return Math.min(...values) > radiusM + padding || Math.max(...values) < -radiusM - padding; + }); + if (outside) output.push(end); + else + flattenCurve(controls, toleranceM, (point, parameter) => { + if (parameter === 0) return; + output.push({ ...point, width: start.width + (end.width - start.width) * parameter }); + }); + at = controls.at(-1)!; + }; + context.moveTo = (x, y) => { + at = { x, y }; + }; + context.lineTo = (x, y) => append([at, { x, y }]); + context.bezierCurveTo = (x1, y1, x2, y2, x, y) => append([at, { x: x1, y: y1 }, { x: x2, y: y2 }, { x, y }]); + const curve = curveCatmullRom.alpha(0.1)(context); + curve.lineStart(); + for (const p of points) curve.point(p.x, p.y); + curve.lineEnd(); + return output; +} diff --git a/src/services/previews/river-water-survey.test.ts b/src/services/previews/river-water-survey.test.ts new file mode 100644 index 0000000000..202f978d10 --- /dev/null +++ b/src/services/previews/river-water-survey.test.ts @@ -0,0 +1,255 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import "@/generators/river-generator"; +import type { Burg } from "@/generators/burgs-generator"; +import type { River } from "@/generators/river-generator"; +import { projectRiverSurvey, surveyBurgRivers, surveyRiver } from "./river-water-survey"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const river = { i: 7, cells: [0, 1, 2, 3], widthFactor: 1, sourceWidth: 0.15, width: 99 } as River; +const burg = { x: 0, y: 0 } as Burg; + +describe("local model river surveys", () => { + it("preserves the deterministic edited course shared with drawing without changing saved anchors", () => { + vi.stubGlobal("options", { map: { graph: { width: 100, height: 100 } } }); + vi.stubGlobal("pack", { + cells: { + p: [ + [0, 0], + [10, 0], + [20, 0] + ], + h: [30, 30, 10], + fl: [100, 200, 300] + } + }); + const edited = { + ...river, + cells: [0, 1, 2], + points: [ + [0, 2], + [10, 4], + [20, 2] + ] as [number, number][] + }; + const original = structuredClone(edited); + const canonical = Rivers.addMeandering(edited.cells, edited.points); + const drawn = Rivers.getRiverPath(canonical, edited.widthFactor, edited.sourceWidth); + const survey = surveyRiver(edited, { x: 10, y: 20 }, 100, 3000).rivers[0]; + expect(survey.centreline.length).toBeGreaterThan(canonical.length); + expect(survey.courseSource).toBe("fmg-catmull-rom-0.1"); + for (const [x, y] of canonical) + expect(survey.centreline).toContainEqual({ + x: Math.round((x - 10) * 10000) / 100, + y: Math.round((y - 20) * 10000) / 100 + }); + expect(edited).toEqual(original); + expect( + Rivers.getRiverPath(Rivers.addMeandering(edited.cells, edited.points), edited.widthFactor, edited.sourceWidth) + ).toBe(drawn); + expect(survey.widthsM.every(width => width > 0)).toBe(true); + }); + + it("retains bends, varying widths and source identity without meandering again", () => { + const course: [number, number, number][] = [ + [-10000, 0, 0], + [-100, 0, 0], + [0, 80, 0], + [100, 0, 0], + [10000, 0, 0] + ]; + const result = projectRiverSurvey(7, course, [10, 20, 30, 40, 100], burg, 1, 3000); + expect(result.omitted).toBe(false); + expect(result.rivers).toHaveLength(1); + const survey = result.rivers[0]; + expect(survey).toMatchObject({ id: "7", widthSource: "fmg-local-width-model", meander: false }); + expect(survey.centreline.slice(1, -1)).toEqual([ + { x: -100, y: 0 }, + { x: 0, y: 80 }, + { x: 100, y: 0 } + ]); + expect(survey.widthsM.slice(1, -1)).toEqual([20, 30, 40]); + expect(survey.centreline[0].x).toBe(-3201); + expect(survey.centreline.at(-1)!.x).toBeCloseTo(3201, 8); + expect(survey.widthsM[0]).toBeCloseTo(10 + ((10000 - 3201) / 9900) * 10); + expect(survey.widthM).toBeGreaterThan(20); + expect(survey.widthM).toBeLessThan(40); + }); + + it("does not join disconnected river reentries across unsurveyed geography", () => { + const course: [number, number, number][] = [ + [-5000, -100, 0], + [5000, -100, 0], + [5000, 100, 0], + [-5000, 100, 0] + ]; + const result = projectRiverSurvey(7, course, [20, 20, 20, 20], burg, 1, 3000); + expect(result.rivers).toHaveLength(2); + expect(result.rivers.map(r => r.centreline.map(p => p.y))).toEqual([ + [-100, -100], + [100, 100] + ]); + }); + + it("keeps a river whose bank enters coverage while its centreline is outside", () => { + const result = projectRiverSurvey( + 7, + [ + [-5000, 3010, 0], + [5000, 3010, 0] + ], + [40, 40], + burg, + 1, + 3000 + ); + expect(result.rivers).toHaveLength(1); + }); + + it("retains an exterior sharp bend whose capped bank miter enters the survey", () => { + const result = projectRiverSurvey( + 7, + [ + [3100, -10, 0], + [3025, 0, 0], + [3100, 10, 0] + ], + [20, 20, 20], + burg, + 1, + 3000 + ); + expect(result.rivers).toHaveLength(1); + const course = result.rivers[0].centreline; + expect(course).toHaveLength(3); + expect(course[1]).toEqual({ x: 3025, y: 0 }); + expect(course[0].x).toBeCloseTo(3041); + expect(course[2].x).toBeCloseTo(3041); + expect((course[1].y - course[0].y) / (course[1].x - course[0].x)).toBeCloseTo(-10 / 75); + expect((course[2].y - course[1].y) / (course[2].x - course[1].x)).toBeCloseTo(10 / 75); + expect(course[1].x - (4 * result.rivers[0].widthsM[1]) / 2).toBe(2985); + }); + + it("reports an unavailable local width rather than clamping it or using mouth width", () => { + expect( + projectRiverSurvey( + 7, + [ + [-100, 0, 0], + [100, 0, 0] + ], + [0, 20], + burg, + 1, + 3000 + ) + ).toEqual({ rivers: [], omitted: true }); + const distant = projectRiverSurvey( + 7, + [ + [10000, 0, 0], + [0, 0, 0] + ], + [0, 20], + burg, + 1, + 3000 + ); + expect(distant.omitted).toBe(false); + expect(distant.rivers[0].widthsM.every(w => w > 0)).toBe(true); + }); + + it("keeps the full-course index and cumulative discharge before clipping", () => { + const course: [number, number, number][] = [ + [-20000, 0, 100], + [-10000, 0, 0], + [0, 0, 900], + [10000, 0, 0] + ]; + vi.spyOn(Rivers, "addMeandering").mockReturnValue(course); + const offset = vi.spyOn(Rivers, "getOffset"); + const projected = surveyRiver(river, burg, 1, 3000); + expect(offset.mock.calls.map(([p]) => [p.pointIndex, p.flux])).toEqual([ + [0, 100], + [1, 100], + [2, 900], + [3, 900] + ]); + expect(projected.rivers[0].widthsM).toHaveLength(3); + expect(projected.rivers[0].widthsM[1]).toBe(Rivers.getWidth(offset.mock.results[2].value) * 1000); + expect(projected.rivers[0].widthM).toBeLessThan(river.width * 1000); + }); + + it("converts course coordinates once without rescaling locally modelled kilometre widths", () => { + vi.spyOn(Rivers, "addMeandering").mockReturnValue([ + [-1, 0, 100], + [0, 0, 100], + [1, 0, 100] + ]); + const a = surveyRiver(river, burg, 1000, 3000).rivers[0]; + const b = surveyRiver(river, burg, 1609.344, 3000).rivers[0]; + expect(a.widthsM).toEqual(b.widthsM); + expect(a.widthM).toBe(b.widthM); + expect(a.centreline[0].x).toBe(-1000); + expect(b.centreline[0].x).toBeCloseTo(-1609.344, 2); + }); + + it("uses edited anchors, matching the renderer's mismatched-anchor fallback", () => { + const anchors: [number, number][] = [ + [0, 0], + [1, 2], + [2, 3], + [4, 5] + ]; + const course = vi.spyOn(Rivers, "addMeandering").mockReturnValue([ + [-10, 0, 100], + [10, 0, 100] + ]); + surveyRiver({ ...river, points: anchors }, burg, 1, 3000); + expect(course).toHaveBeenLastCalledWith(river.cells, anchors); + surveyRiver({ ...river, points: anchors.slice(1) }, burg, 1, 3000); + expect(course).toHaveBeenLastCalledWith(river.cells, undefined); + }); + + it("discovers crossings outside the coarse context and retains warnings for missing rivers", () => { + vi.stubGlobal("pack", { rivers: [river] }); + vi.spyOn(Rivers, "addMeandering").mockReturnValue([ + [-10000, 0, 100], + [10000, 0, 100] + ]); + const result = surveyBurgRivers(burg, 1, 3000, [99]); + expect(result.rivers).toHaveLength(1); + expect(result.omittedRiverIds).toEqual([99]); + }); + + it("does not report unrelated malformed rivers as missing beside this burg", () => { + vi.stubGlobal("pack", { + rivers: [ + { ...river, cells: [] }, + { ...river, i: 8, cells: [] } + ] + }); + expect(surveyBurgRivers(burg, 1, 3000, [8]).omittedRiverIds).toEqual([8]); + }); + + it("reports a local profile missing a width and rejects a one-point course", () => { + expect( + projectRiverSurvey( + 7, + [ + [-100, 0, 0], + [100, 0, 0] + ], + [20], + burg, + 1, + 3000 + ).omitted + ).toBe(true); + expect(projectRiverSurvey(7, [[0, 0, 0]], [20], burg, 1, 3000).omitted).toBe(true); + }); +}); diff --git a/src/services/previews/river-water-survey.ts b/src/services/previews/river-water-survey.ts new file mode 100644 index 0000000000..672f050e5c --- /dev/null +++ b/src/services/previews/river-water-survey.ts @@ -0,0 +1,157 @@ +import type { Burg } from "@/generators/burgs-generator"; +import type { River } from "@/generators/river-generator"; +import { smoothRiverCourse } from "./river-curve"; +import type { RiverSurvey, WaterPoint } from "./water-context-types"; + +interface WidthPoint extends WaterPoint { + width: number; +} + +function clipSegment(a: WidthPoint, b: WidthPoint, radius: number): [WidthPoint, WidthPoint] | undefined { + let low = 0, + high = 1; + const dx = b.x - a.x, + dy = b.y - a.y; + for (const [p, q] of [ + [-dx, a.x + radius], + [dx, radius - a.x], + [-dy, a.y + radius], + [dy, radius - a.y] + ]) { + if (!p) { + if (q < 0) return; + else continue; + } + const t = q / p; + if (p < 0) low = Math.max(low, t); + else high = Math.min(high, t); + if (low > high) return; + } + if (high - low < 1e-12) return; + const at = (t: number): WidthPoint => ({ + x: a.x + dx * t, + y: a.y + dy * t, + width: a.width + (b.width - a.width) * t + }); + return [at(low), at(high)]; +} + +export function projectRiverSurvey( + id: number, + course: [number, number, number][], + widthsM: number[], + burg: WaterPoint, + metresPerMapUnit: number, + radiusM: number +): { rivers: RiverSurvey[]; omitted: boolean; unlocated?: boolean } { + if (course.length < 2 || course.some(p => !Number.isFinite(p[0]) || !Number.isFinite(p[1]))) + return { rivers: [], omitted: true, unlocated: true }; + const points = course.map(([x, y], i) => ({ + x: (x - burg.x) * metresPerMapUnit, + y: (y - burg.y) * metresPerMapUnit, + width: widthsM.length === course.length ? widthsM[i] : NaN + })); + const runs: WidthPoint[][] = []; + let run: WidthPoint[] | undefined; + const same = (a: WidthPoint, b: WidthPoint) => Math.hypot(a.x - b.x, a.y - b.y) < 1e-6; + if (points.every(p => same(p, points[0]))) return { rivers: [], omitted: true, unlocated: true }; + // Match the consumer's bank-miter cap at sharp bends outside the survey. + const padding = widthsM.reduce((max, width) => (Number.isFinite(width) ? Math.max(max, width) : max), 0) * 2 + 1; + for (let i = 1; i < points.length; i++) { + if (same(points[i - 1], points[i])) continue; + const clipped = clipSegment(points[i - 1], points[i], radiusM + padding); + if (!clipped) { + run = undefined; + continue; + } + if (run && same(run.at(-1)!, clipped[0])) run.push(clipped[1]); + else { + run = clipped; + runs.push(run); + } + } + if (runs.some(points => points.some(p => !Number.isFinite(p.width) || p.width <= 0))) + return { rivers: [], omitted: true }; + return { + omitted: false, + rivers: runs.map(points => { + let nearest = Infinity, + widthM = points[0].width; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1], + b = points[i], + dx = b.x - a.x, + dy = b.y - a.y; + const t = Math.max(0, Math.min(1, -(a.x * dx + a.y * dy) / (dx * dx + dy * dy || 1))); + const distance = Math.hypot(a.x + dx * t, a.y + dy * t); + if (distance < nearest) { + nearest = distance; + widthM = a.width + (b.width - a.width) * t; + } + } + return { + id: String(id), + centreline: points.map(({ x, y }) => ({ x, y })), + widthsM: points.map(p => p.width), + widthM, + widthSource: "fmg-local-width-model", + meander: false + }; + }) + }; +} + +/** The same course and cumulative discharge as drawing, with local model widths in metres. */ +export function surveyRiver(river: River, burg: WaterPoint, metresPerMapUnit: number, radiusM: number) { + if (!river.cells || river.cells.length < 2) return { rivers: [], omitted: true, unlocated: true }; + const anchors = river.points?.length === river.cells.length ? river.points : undefined; + const course = Rivers.addMeandering(river.cells, anchors); + let flux = 0; + const widths = course.map(([, , pointFlux], pointIndex) => { + flux = Math.max(flux, pointFlux); + const offset = Rivers.getOffset({ + flux, + pointIndex, + widthFactor: river.widthFactor, + startingWidth: river.sourceWidth + }); + return Rivers.getWidth(offset) * 1000; + }); + if (course.some(p => !Number.isFinite(p[0]) || !Number.isFinite(p[1]))) + return { rivers: [], omitted: true, unlocated: true }; + const smooth = smoothRiverCourse( + course.map(([x, y], i) => ({ + x: (x - burg.x) * metresPerMapUnit, + y: (y - burg.y) * metresPerMapUnit, + width: widths[i] + })), + radiusM + ); + const projected = projectRiverSurvey( + river.i, + smooth.map(p => [p.x, p.y, 0]), + smooth.map(p => p.width), + { x: 0, y: 0 }, + 1, + radiusM + ); + for (const river of projected.rivers) { + river.courseSource = "fmg-catmull-rom-0.1"; + river.centreline = river.centreline.map(p => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 })); + river.widthsM = river.widthsM.map(width => Number(width.toPrecision(8))); + } + return projected; +} + +export function surveyBurgRivers(burg: Burg, metresPerMapUnit: number, radiusM: number, expectedIds: number[] = []) { + const rivers: RiverSurvey[] = [], + omitted = new Set(expectedIds); + for (const river of (pack.rivers ?? []).toSorted((a, b) => a.i - b.i)) { + const survey = surveyRiver(river, burg, metresPerMapUnit, radiusM); + if (survey.omitted) { + if (!survey.unlocated || expectedIds.includes(river.i)) omitted.add(river.i); + } else omitted.delete(river.i); + rivers.push(...survey.rivers); + } + return { rivers, omittedRiverIds: [...omitted].sort((a, b) => a - b) }; +} diff --git a/src/services/previews/settlemaker-burg.test.ts b/src/services/previews/settlemaker-burg.test.ts index 8a6fb75d09..1dc27f4689 100644 --- a/src/services/previews/settlemaker-burg.test.ts +++ b/src/services/previews/settlemaker-burg.test.ts @@ -18,14 +18,15 @@ afterEach(() => { vi.resetModules(); }); -async function preview(population: number, endpoint?: string) { +async function preview(population: number, endpoint?: string, surveyRadiusM?: number) { vi.resetModules(); vi.stubEnv("VITE_SETTLEMAKER_WATER_CONTEXT_URL", endpoint); vi.stubGlobal("options", { map: { units: { population: { urbanization: { density: 8 } } } } }); + vi.stubGlobal("pack", { cells: { haven: [], harbor: [], f: [], r: [] }, features: [] }); mocks.context.mockReturnValue({ burg: { population }, hydrology: { rivers: [] } }); mocks.survey.mockReturnValue({ waterContext: { version: 1, status: "unknown-units", sourceUnit: "hex" } }); const { createSettlemakerPreview } = await import("./settlemaker-burg"); - return createSettlemakerPreview({ i: 1, x: 10, y: 20, tradeRole: "hub" } as Burg); + return createSettlemakerPreview({ i: 1, x: 10, y: 20, tradeRole: "hub" } as Burg, { surveyRadiusM }); } describe("measured-water rollout", () => { @@ -44,10 +45,39 @@ describe("measured-water rollout", () => { }); }); - it("retains the legacy city path at population 1001", async () => { - await preview(1001, "https://water.example/fmg"); + it.each([1001, 16973, 100000])( + "surveys cities of population %s through the configured renderer", + async population => { + const result = await preview(population, "https://water.example/fmg"); + expect(mocks.survey).toHaveBeenCalledWith(expect.objectContaining({ x: 10, y: 20 }), [], 4000); + expect(mocks.url.mock.calls[0][1]).toHaveProperty("baseUrl", "https://water.example/fmg"); + expect(result.link).toBe(result.preview); + } + ); + + it("accepts an explicit survey radius when the renderer requests more coverage", async () => { + await preview(16973, "https://water.example/fmg", 7000); + expect(mocks.survey).toHaveBeenCalledWith(expect.anything(), [], 7000); + }); + + it("accepts a deployment survey radius without changing the endpoint", async () => { + vi.stubEnv("VITE_SETTLEMAKER_WATER_SURVEY_RADIUS_M", "6000"); + await preview(16973, "https://water.example/fmg"); + expect(mocks.survey).toHaveBeenCalledWith(expect.anything(), [], 6000); + }); + + it.each([0, 2999, NaN, Infinity])("rejects invalid radius %s without falling back", async radius => { + await expect(preview(16973, "https://water.example/fmg", radius)).rejects.toThrow("at least 3000"); expect(mocks.survey).not.toHaveBeenCalled(); - expect(mocks.url.mock.calls[0][1]).not.toHaveProperty("baseUrl"); + expect(mocks.url).not.toHaveBeenCalled(); + }); + + it("propagates survey failure without encoding a bearing-only city", async () => { + mocks.survey.mockImplementationOnce(() => { + throw new Error("Measured water survey extends beyond this map's known geography"); + }); + await expect(preview(16973, "https://water.example/fmg")).rejects.toThrow("known geography"); + expect(mocks.url).not.toHaveBeenCalled(); }); it("rejects the human builder endpoint before surveying", async () => { @@ -55,3 +85,35 @@ describe("measured-water rollout", () => { expect(mocks.survey).not.toHaveBeenCalled(); }); }); + +describe("harbour water body", () => { + it.each(["river", "lake", "ocean"] as const)("exports the intended river ID only for a %s port", async kind => { + await preview(9987, "http://127.0.0.1:5228/fmg"); + vi.stubGlobal("pack", { + cells: { haven: [1], harbor: [kind === "river" ? 0 : 1], f: [0, 2], r: [653] }, + features: [{}, { type: "ocean" }, { type: kind }] + }); + const { createSettlemakerPreview } = await import("./settlemaker-burg"); + await createSettlemakerPreview({ i: 403, cell: 0, port: 223, x: 396.98, y: 511.28 } as Burg); + const opts = mocks.url.mock.calls.at(-1)![1]; + expect(opts).toHaveProperty("harbourWaterKind", kind); + if (kind === "river") expect(opts).toHaveProperty("harbourRiverId", "653"); + else expect(opts).not.toHaveProperty("harbourRiverId"); + }); + + it.each(["ocean", "lake"])("uses the port's adjacent %s despite an unrelated drain feature", async type => { + vi.stubGlobal("pack", { + cells: { haven: [1], harbor: [1], f: [0, 2], r: [0] }, + features: [{}, { type: "ocean" }, { type }] + }); + const { harbourWaterKind } = await import("./settlemaker-burg"); + expect(harbourWaterKind({ cell: 0, port: 1 } as Burg)).toBe(type); + }); + + it("identifies an inland river port even when its drain feature is ocean", async () => { + vi.stubGlobal("pack", { cells: { haven: [0], harbor: [0], f: [1], r: [7] }, features: [{}, { type: "ocean" }] }); + const { harbourWaterKind } = await import("./settlemaker-burg"); + expect(harbourWaterKind({ cell: 0, port: 1 } as Burg)).toBe("river"); + expect(harbourWaterKind({ cell: 0, port: 0 } as Burg)).toBeUndefined(); + }); +}); diff --git a/src/services/previews/settlemaker-burg.ts b/src/services/previews/settlemaker-burg.ts index dba205820e..b7242297a4 100644 --- a/src/services/previews/settlemaker-burg.ts +++ b/src/services/previews/settlemaker-burg.ts @@ -1,14 +1,34 @@ import { buildBurgContext } from "@/generators/burg-context"; import type { Burg } from "@/generators/burgs-generator"; import { surveyBurgWater } from "./burg-water-survey"; -import { buildSettlemakerUrl } from "./settlemaker"; +import { type AzgaarBurgInput, buildSettlemakerUrl } from "./settlemaker"; -/** Set only for a deployed renderer explicitly supporting the village water-context-v1 contract. */ +/** Set only for a deployed renderer supporting measured water on both city and village layouts. */ export const MEASURED_WATER_RENDERER = import.meta.env.VITE_SETTLEMAKER_WATER_CONTEXT_URL as string | undefined; -export async function createSettlemakerPreview(burg: Burg): Promise<{ link: string; preview: string }> { +export function waterSurveyRadius(population: number, override?: number): number { + const configured = import.meta.env.VITE_SETTLEMAKER_WATER_SURVEY_RADIUS_M as string | undefined; + const radius = override ?? (configured ? Number(configured) : population > 1000 ? 4000 : 3000); + if (!Number.isFinite(radius) || radius < 3000) + throw new Error("The measured water survey radius must be at least 3000 metres"); + return radius; +} + +export function harbourWaterKind(burg: Burg): AzgaarBurgInput["harbourWaterKind"] { + if (!burg.port) return undefined; + const haven = pack.cells.haven[burg.cell]; + const local = pack.cells.harbor[burg.cell] > 0 ? pack.features[pack.cells.f[haven]] : undefined; + if (local?.type === "ocean" || local?.type === "lake") return local.type; + if (pack.cells.r[burg.cell]) return "river"; + return undefined; +} + +export async function createSettlemakerPreview( + burg: Burg, + surveyOptions: { surveyRadiusM?: number } = {} +): Promise<{ link: string; preview: string }> { const ctx = buildBurgContext(burg); - const measured = MEASURED_WATER_RENDERER && ctx.burg.population >= 1 && ctx.burg.population <= 1000; + const measured = MEASURED_WATER_RENDERER && ctx.burg.population >= 1; const opts = { urbanDensity: options.map.units.population.urbanization.density, trade: burg.tradeRole === "hub" @@ -24,12 +44,16 @@ export async function createSettlemakerPreview(burg: Burg): Promise<{ link: stri ) { throw new Error("The measured water preview renderer address is invalid"); } + const waterKind = harbourWaterKind(burg); return buildSettlemakerUrl(ctx, { ...opts, + harbourWaterKind: waterKind, + ...(waterKind === "river" ? { harbourRiverId: String(pack.cells.r[burg.cell]) } : {}), baseUrl: endpoint.toString(), waterSurvey: surveyBurgWater( burg, - ctx.hydrology.rivers.map(river => river.id) + ctx.hydrology.rivers.map(river => river.id), + waterSurveyRadius(ctx.burg.population, surveyOptions.surveyRadiusM) ) }); } diff --git a/src/services/previews/settlemaker.test.ts b/src/services/previews/settlemaker.test.ts index bd67a3dbe0..83fbd0a90a 100644 --- a/src/services/previews/settlemaker.test.ts +++ b/src/services/previews/settlemaker.test.ts @@ -7,6 +7,7 @@ import { SETTLEMAKER_BASE_URL, toSettlemakerInput } from "./settlemaker"; +import { WaterSurveyIndex } from "./water-survey"; // Stub WARN global to suppress console output in tests globalThis.WARN = false; @@ -59,6 +60,23 @@ const ctx = (over: Partial = {}): BurgContext => }) as BurgContext; describe("toSettlemakerInput", () => { + it("associates a river port with its intended river and excludes the ID from other water kinds", () => { + expect(toSettlemakerInput(ctx(), { harbourWaterKind: "river", harbourRiverId: "653" })).toMatchObject({ + harbourWaterKind: "river", + harbourRiverId: "653" + }); + for (const harbourWaterKind of ["lake", "ocean", undefined] as const) + expect(toSettlemakerInput(ctx(), { harbourWaterKind, harbourRiverId: "653" })).not.toHaveProperty( + "harbourRiverId" + ); + expect( + toSettlemakerInput(ctx({ burg: { ...ctx().burg, port: false } }), { + harbourWaterKind: "river", + harbourRiverId: "653" + }) + ).not.toHaveProperty("harbourRiverId"); + }); + it("always emits all seven booleans, including the false ones", () => { const input = toSettlemakerInput(ctx(), {}); for (const key of ["port", "citadel", "walls", "plaza", "temple", "shanty", "capital"] as const) { @@ -232,6 +250,66 @@ async function decodeIParam(url: string) { } describe("buildSettlemakerUrl", () => { + it("preserves a modelled local river profile and the measured mouth coast in both links", async () => { + const survey = new WaterSurveyIndex([{ id: 1, kind: "island", path: "M-10000,-10000H200V10000H-10000Z" }], { + minX: -10000, + minY: -10000, + maxX: 10000, + maxY: 10000 + }).survey({ burg: { x: 0, y: 0 }, metresPerMapUnit: 1, radiusM: 4000, oceanAt: () => 9 }); + const rivers = [ + { + id: "7", + centreline: [ + { x: -4100, y: 100 }, + { x: 0, y: 120 }, + { x: 4100, y: 100 } + ], + widthM: 30, + widthsM: [20, 30, 40], + widthSource: "fmg-local-width-model" as const, + courseSource: "fmg-catmull-rom-0.1" as const, + meander: false as const + } + ]; + const { link, preview } = await buildSettlemakerUrl(ctx({ burg: { ...ctx().burg, population: 16973 } }), { + waterSurvey: { ...survey, rivers } + }); + expect(preview).toBe(link); + const { burg } = await decodeIParam(link); + expect(burg.rivers).toEqual(rivers); + expect(burg.coastlineGeometry).toEqual(survey.coastlineGeometry); + expect(burg.waterContext.omittedRivers).toBeUndefined(); + }); + + it("rejects an oversized river course without silently deleting the river", async () => { + const centreline = Array.from({ length: 8000 }, (_, i) => ({ x: Math.sin(i) * 4000, y: Math.cos(i) * 4000 })); + await expect( + buildSettlemakerUrl(ctx({ burg: { ...ctx().burg, population: 16973 } }), { + waterSurvey: { + waterContext: { + version: 1, + status: "measured", + coordinateSpace: "burg-local-metres", + surveyRadiusM: 4000, + geometryErrorM: 0.5, + bodies: [] + }, + coastlineGeometry: [], + rivers: [ + { + centreline, + widthM: 20, + widthsM: centreline.map(() => 20), + widthSource: "fmg-local-width-model", + meander: false + } + ] + } + }) + ).rejects.toThrow("too detailed"); + }); + it("round-trips an authoritative empty water survey alongside the legacy bearing", async () => { const { link, preview } = await buildSettlemakerUrl(ctx(), { baseUrl: "https://renderer.example/fmg", @@ -256,47 +334,77 @@ describe("buildSettlemakerUrl", () => { }); }); - it("preserves unknown custom units without making an empty geographic survey", async () => { - const { link } = await buildSettlemakerUrl(ctx(), { - waterSurvey: { - waterContext: { version: 1, status: "unknown-units", sourceUnit: "hexes" } - } - }); - const { burg } = await decodeIParam(link); - expect(burg.waterContext).toEqual({ version: 1, status: "unknown-units", sourceUnit: "hexes" }); - expect(burg).not.toHaveProperty("coastlineGeometry"); - }); - - it("rejects measured payloads for cities", async () => { - await expect( - buildSettlemakerUrl(ctx({ burg: { ...ctx().burg, population: 1001 } }), { + it.each([13, 16973])( + "preserves unknown custom units for population %s without asserting empty water", + async population => { + const { link } = await buildSettlemakerUrl(ctx({ burg: { ...ctx().burg, population } }), { waterSurvey: { waterContext: { version: 1, status: "unknown-units", sourceUnit: "hexes" } } - }) - ).rejects.toThrow("only for villages"); - }); + }); + const { burg } = await decodeIParam(link); + expect(burg.waterContext).toEqual({ version: 1, status: "unknown-units", sourceUnit: "hexes" }); + expect(burg).not.toHaveProperty("coastlineGeometry"); + } + ); - it("rejects oversized measured geometry without dropping authoritative water state", async () => { - const coastlineGeometry = [ - Array.from({ length: 8000 }, (_, i) => ({ x: Math.sin(i) * 3000, y: Math.cos(i) * 3000 })) - ]; - await expect( - buildSettlemakerUrl(ctx(), { - waterSurvey: { - waterContext: { - version: 1, - status: "measured", - coordinateSpace: "burg-local-metres", - surveyRadiusM: 3000, - geometryErrorM: 0.5, - bodies: [] - }, - coastlineGeometry + it.each([1000, 1001, 16973, 100000])( + "round-trips actual surveyed water for population %s in both preview links", + async population => { + const radiusM = population > 1000 ? 4000 : 3000; + const waterSurvey = new WaterSurveyIndex([{ id: 1, kind: "island", path: "M-10000,-10000H140V10000H-10000Z" }], { + minX: -10000, + minY: -10000, + maxX: 10000, + maxY: 10000 + }).survey({ burg: { x: 100, y: 20 }, metresPerMapUnit: 1, radiusM, oceanAt: () => 9 }); + const { link, preview } = await buildSettlemakerUrl(ctx({ burg: { ...ctx().burg, population } }), { + baseUrl: "https://renderer.example/fmg", + waterSurvey, + harbourWaterKind: "ocean" + }); + expect(preview).toBe(link); + const external = await decodeIParam(link), + embedded = await decodeIParam(preview); + expect(embedded).toEqual(external); + expect(external.burg).toMatchObject({ + population, + port: true, + harbourWaterKind: "ocean", + waterContext: { + status: "measured", + surveyRadiusM: radiusM, + bodies: [{ kind: "ocean", distanceM: 40, bearingDeg: 90 }] } - }) - ).rejects.toThrow("too detailed"); - }); + }); + expect(external.burg.coastlineGeometry).toEqual(waterSurvey.coastlineGeometry); + expect(new URL(link).searchParams.get("i")!.length).toBeLessThan(MAX_ENCODED_PAYLOAD_BYTES); + } + ); + + it.each([13, 16973])( + "rejects oversized measured geometry for population %s without dropping water state", + async population => { + const coastlineGeometry = [ + Array.from({ length: 8000 }, (_, i) => ({ x: Math.sin(i) * 3000, y: Math.cos(i) * 3000 })) + ]; + await expect( + buildSettlemakerUrl(ctx({ burg: { ...ctx().burg, population } }), { + waterSurvey: { + waterContext: { + version: 1, + status: "measured", + coordinateSpace: "burg-local-metres", + surveyRadiusM: 3000, + geometryErrorM: 0.5, + bodies: [] + }, + coastlineGeometry + } + }) + ).rejects.toThrow("too detailed"); + } + ); it("round-trips through the documented codec into a v1 envelope", async () => { const { link } = await buildSettlemakerUrl(ctx(), {}); diff --git a/src/services/previews/settlemaker.ts b/src/services/previews/settlemaker.ts index e7b66a2de2..f68c1be83f 100644 --- a/src/services/previews/settlemaker.ts +++ b/src/services/previews/settlemaker.ts @@ -1,6 +1,6 @@ import type { BurgContext, Corridor } from "@/generators/burg-context"; import { hashSeedToInt } from "@/generators/burg-context"; -import type { WaterContext, WaterSurvey } from "./water-context-types"; +import type { RiverSurvey, WaterContext, WaterSurvey } from "./water-context-types"; type LandRouteClass = "royal" | "main" | "market" | "town" | "local" | "trail" | "footpath"; @@ -31,9 +31,12 @@ export interface AzgaarBurgInput { roadBearings?: SettlemakerRoadBearing[]; oceanBearing?: number; harbourSize?: "large" | "small"; + harbourWaterKind?: "ocean" | "lake" | "river"; + harbourRiverId?: string; urbanDensity?: number; coreCapacity?: number; coastlineGeometry?: Array>; + rivers?: RiverSurvey[]; waterContext?: WaterContext; biome?: string; trade?: boolean; @@ -60,7 +63,12 @@ export const LAND_ROUTE_KINDS: Record = { roads: "main */ export function toSettlemakerInput( ctx: BurgContext, - opts: { urbanDensity?: number; trade?: boolean } + opts: { + urbanDensity?: number; + trade?: boolean; + harbourWaterKind?: AzgaarBurgInput["harbourWaterKind"]; + harbourRiverId?: string; + } ): AzgaarBurgInput { const { burg, hydrology, terrain, climate } = ctx; @@ -95,6 +103,8 @@ export function toSettlemakerInput( if (hydrology.oceanBearingDeg !== undefined) input.oceanBearing = hydrology.oceanBearingDeg; if (burg.port && hydrology.harbourSize) input.harbourSize = hydrology.harbourSize; + if (burg.port && opts.harbourWaterKind) input.harbourWaterKind = opts.harbourWaterKind; + if (burg.port && opts.harbourWaterKind === "river" && opts.harbourRiverId) input.harbourRiverId = opts.harbourRiverId; if (climate.biome) input.biome = climate.biome; if (burg.culture) input.culture = burg.culture; input.elevation = terrain.elevationM; @@ -122,12 +132,19 @@ export async function encodeJsonParam(value: unknown): Promise { export async function buildSettlemakerUrl( ctx: BurgContext, - opts: { urbanDensity?: number; trade?: boolean; waterSurvey?: WaterSurvey; baseUrl?: string } + opts: { + urbanDensity?: number; + trade?: boolean; + harbourWaterKind?: AzgaarBurgInput["harbourWaterKind"]; + harbourRiverId?: string; + waterSurvey?: WaterSurvey; + baseUrl?: string; + } ): Promise<{ link: string; preview: string }> { const input = toSettlemakerInput(ctx, opts); if (opts.waterSurvey) { - if (ctx.burg.population < 1 || ctx.burg.population > 1000) - throw new Error("Measured water is supported only for villages"); + if (!Number.isFinite(ctx.burg.population) || ctx.burg.population < 1) + throw new Error("Measured water requires a positive settlement population"); Object.assign(input, opts.waterSurvey); } const seed = hashSeedToInt(ctx.burg.seedKey); diff --git a/src/services/previews/water-context-types.ts b/src/services/previews/water-context-types.ts index bd96376476..5ca0e8a7f0 100644 --- a/src/services/previews/water-context-types.ts +++ b/src/services/previews/water-context-types.ts @@ -3,6 +3,16 @@ export interface WaterPoint { y: number; } +export interface RiverSurvey { + id?: string; + centreline: WaterPoint[]; + widthM: number; + widthsM: number[]; + widthSource: "fmg-local-width-model"; + courseSource?: "fmg-catmull-rom-0.1"; + meander: false; +} + export interface MeasuredWaterContext { version: 1; status: "measured"; @@ -22,5 +32,5 @@ export interface MeasuredWaterContext { export type WaterContext = MeasuredWaterContext | { version: 1; status: "unknown-units"; sourceUnit: string }; export type WaterSurvey = - | { waterContext: MeasuredWaterContext; coastlineGeometry: WaterPoint[][] } + | { waterContext: MeasuredWaterContext; coastlineGeometry: WaterPoint[][]; rivers?: RiverSurvey[] } | { waterContext: Extract }; diff --git a/src/utils/shoreline-geometry.ts b/src/utils/shoreline-geometry.ts index 575c443077..64430b7080 100644 --- a/src/utils/shoreline-geometry.ts +++ b/src/utils/shoreline-geometry.ts @@ -90,9 +90,14 @@ export function parseShorePath(path: string, feature: number): ShoreCurve[] { } /** A Bezier lies in its control hull: chord distance bounds the flattening error. */ -export function flattenCurve(points: Point[], tolerance: number): Point[] { +export function flattenCurve( + points: Point[], + tolerance: number, + onPoint?: (point: Point, parameter: number) => void +): Point[] { const result: Point[] = [points[0]]; - function split(p: Point[], depth: number) { + onPoint?.(points[0], 0); + function split(p: Point[], depth: number, from: number, to: number) { const a = p[0], b = p.at(-1)!; const dx = b.x - a.x, @@ -113,6 +118,7 @@ export function flattenCurve(points: Point[], tolerance: number): Point[] { }); if (flat) { result.push(b); + onPoint?.(b, to); return; } if (depth === 24) throw new Error("Shoreline exceeds the measured preview precision limit"); @@ -124,10 +130,11 @@ export function flattenCurve(points: Point[], tolerance: number): Point[] { left.push(row[0]); right.push(row.at(-1)!); } - split(left, depth + 1); - split(right.reverse(), depth + 1); + const middle = (from + to) / 2; + split(left, depth + 1, from, middle); + split(right.reverse(), depth + 1, middle, to); } - split(points, 0); + split(points, 0, 0, 1); return result; }