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; }