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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 23 additions & 17 deletions docs/domain/settlemaker-water-context.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# 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
implement that contract before this mode is enabled.
(`settlemaker/docs/water-context-v1.md`). The production renderer supports this
contract for both villages and cities.

## Rollout

`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 both villages and cities. An old decoder ignores the new field and can still
invent a nearby shore from the legacy bearing.
Normal builds export measured water to `https://settlemaker.com/fmg` by default.
`VITE_SETTLEMAKER_WATER_CONTEXT_URL` is an optional build-time endpoint override,
not an enable switch. An unset or empty override uses the production endpoint.
This prevents river ports from being exported as ocean-bearing-only previews.

To exercise a supporting local renderer:

Expand All @@ -18,14 +18,16 @@ 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
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.
fragment. Any custom endpoint must support water-context v1 for both villages
and cities. Both embedded and external settlement previews use the same URL,
and every positive population uses the measured contract. Previously saved
bearing-only URLs cannot recover river geometry; reopen the burg preview from
the source map to create a new URL.

Villages initially survey 3 km; cities initially survey 4 km. These are starting
coverage envelopes, not guarantees of sufficient land for every settlement.
Villages initially survey 3 km. Cities start at 4 km and scale with population
to cover their cultivated envelope and navigation margin (8 km for Erdemeli's
173,976 residents, 13 km for 500,000). These are starting coverage estimates,
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
Expand All @@ -49,8 +51,12 @@ budget; it never silently replaces measured geometry with a bearing.
coastline edits and coastline settings invalidate the geometry cache/index.
- Convert map coordinates once using the selected distance unit. Flatten curves
adaptively within 0.2 m; report a conservative combined `geometryErrorM` of
0.5 m. Do not round or simplify exported coordinates. Reject ambiguous narrow
or crossing curved shores instead of silently changing local topology.
0.5 m. Pack adjacent fill pieces without filling islands or closing channels;
remove collinear arithmetic noise and shorten coordinate tails to five decimal
metres only when edge contacts, crossings and rings remain unchanged. Reserve
the rounding error within the geometry budget and retain full precision when
those checks fail. Reject ambiguous narrow or crossing curved shores instead
of silently changing local topology.
- Measure exposed real shores from the same flattened boundary that supplies
the polygons. Shared piece edges and survey-square closures are not banks.
If real shores lie beyond coverage, search the canonical full boundary.
Expand Down Expand Up @@ -106,8 +112,8 @@ automatic simplification or resurvey in this implementation.
Settlemaker owns water-union bank queries, metric layout, bounded planning,
frame/coverage validation, input precedence and visible iframe diagnostics.
The FMG sender does not implement those renderer behaviors or an iframe message
protocol. Before enabling production, verify those behaviors against the
supporting release in both embedded and external-open flows.
protocol. Verify those behaviors against the supporting release in both
embedded and external-open flows when changing the integration.

Focused tests cover distance/units, finite lakes and islands, peninsula geometry,
curve inclusion and precision, narrow channels, union shore measurement, crop
Expand Down
51 changes: 36 additions & 15 deletions src/services/previews/settlemaker-burg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("@/generators/burg-context", () => ({ buildBurgContext: mocks.context }));
vi.mock("./burg-water-survey", () => ({ surveyBurgWater: mocks.survey }));
vi.mock("./settlemaker", () => ({ buildSettlemakerUrl: mocks.url }));
vi.mock("./settlemaker", () => ({
buildSettlemakerUrl: mocks.url,
SETTLEMAKER_BASE_URL: "https://settlemaker.com/fmg"
}));

afterEach(() => {
vi.unstubAllEnvs();
Expand All @@ -30,10 +33,26 @@ async function preview(population: number, endpoint?: string, surveyRadiusM?: nu
}

describe("measured-water rollout", () => {
it("keeps current production payloads unchanged until a supporting endpoint is configured", async () => {
await preview(10);
expect(mocks.survey).not.toHaveBeenCalled();
expect(mocks.url.mock.calls[0][1]).toEqual({ urbanDensity: 8, trade: true });
it.each([
[10, 3000],
[1000, 3000],
[16973, 4000],
[173976, 8000]
])("exports measured water by default for population %s", async (population, radius) => {
await preview(population);
expect(mocks.survey).toHaveBeenCalledWith(expect.anything(), [], radius);
expect(mocks.url.mock.calls[0][1]).toMatchObject({
urbanDensity: 8,
trade: true,
baseUrl: "https://settlemaker.com/fmg",
waterSurvey: { waterContext: { version: 1 } }
});
});

it("uses measured production water when the endpoint override is empty", async () => {
await preview(173976, "");
expect(mocks.survey).toHaveBeenCalledOnce();
expect(mocks.url.mock.calls[0][1]).toHaveProperty("baseUrl", "https://settlemaker.com/fmg");
});

it("enables the versioned survey through the configured renderer for population 1000", async () => {
Expand All @@ -45,15 +64,17 @@ describe("measured-water rollout", () => {
});
});

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.each([
[1001, 4000],
[16973, 4000],
[100000, 7000],
[500000, 13000]
])("surveys cities of population %s through the configured renderer", async (population, radius) => {
const result = await preview(population, "https://water.example/fmg");
expect(mocks.survey).toHaveBeenCalledWith(expect.objectContaining({ x: 10, y: 20 }), [], radius);
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);
Expand Down Expand Up @@ -88,7 +109,7 @@ describe("measured-water rollout", () => {

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");
await preview(9987);
vi.stubGlobal("pack", {
cells: { haven: [1], harbor: [kind === "river" ? 0 : 1], f: [0, 2], r: [653] },
features: [{}, { type: "ocean" }, { type: kind }]
Expand Down
16 changes: 9 additions & 7 deletions src/services/previews/settlemaker-burg.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { buildBurgContext } from "@/generators/burg-context";
import type { Burg } from "@/generators/burgs-generator";
import { surveyBurgWater } from "./burg-water-survey";
import { type AzgaarBurgInput, buildSettlemakerUrl } from "./settlemaker";
import { type AzgaarBurgInput, buildSettlemakerUrl, SETTLEMAKER_BASE_URL } from "./settlemaker";

/** 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;
/** Override the supporting production renderer for local testing or another deployment. */
export const MEASURED_WATER_RENDERER =
(import.meta.env.VITE_SETTLEMAKER_WATER_CONTEXT_URL as string | undefined) || SETTLEMAKER_BASE_URL;

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);
// Allow for the city's cultivated envelope, its diagonal and a navigation margin.
const cityRadius = Math.max(4000, Math.ceil((Math.sqrt(population * 240) + 1500) / 1000) * 1000);
const radius = override ?? (configured ? Number(configured) : population > 1000 ? cityRadius : 3000);
if (!Number.isFinite(radius) || radius < 3000)
throw new Error("The measured water survey radius must be at least 3000 metres");
return radius;
Expand All @@ -28,13 +31,12 @@ export async function createSettlemakerPreview(
surveyOptions: { surveyRadiusM?: number } = {}
): Promise<{ link: string; preview: string }> {
const ctx = buildBurgContext(burg);
const measured = MEASURED_WATER_RENDERER && ctx.burg.population >= 1;
const opts = {
urbanDensity: options.map.units.population.urbanization.density,
trade: burg.tradeRole === "hub"
};
if (!measured) return buildSettlemakerUrl(ctx, opts);
const endpoint = new URL(MEASURED_WATER_RENDERER!);
if (ctx.burg.population < 1) return buildSettlemakerUrl(ctx, opts);
const endpoint = new URL(MEASURED_WATER_RENDERER);
if (
endpoint.search ||
endpoint.hash ||
Expand Down
51 changes: 51 additions & 0 deletions src/services/previews/water-coordinate-pack.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { packWaterCoordinates, WATER_COORDINATE_ERROR_M } from "./water-coordinate-pack";

const box = (x: number, y: number, w: number, h: number) => [
{ x, y },
{ x: x + w, y },
{ x: x + w, y: y + h },
{ x, y: y + h }
];
describe("bounded water coordinate packing", () => {
it("shortens numeric tails within a ten-micrometre budget without mutating source", () => {
const source = [box(0.123456789123, 10.0123456789, 100, 100)],
before = structuredClone(source),
packed = packWaterCoordinates(source);
expect(packed).not.toEqual(source);
expect(source).toEqual(before);
for (let i = 0; i < 4; i++)
expect(Math.hypot(packed[0][i].x - source[0][i].x, packed[0][i].y - source[0][i].y)).toBeLessThan(
WATER_COORDINATE_ERROR_M
);
});
it.each([1e-10, 1e-6])("retains exact geometry if rounding would close a %sm channel", width => {
const source = [box(0, 0, 10, 100), box(10 + width, 0, 10, 100)];
expect(packWaterCoordinates(source)).toBe(source);
});
it.each([1e-10, 1e-6])("retains %sm thin rings rather than collapsing them", width => {
const source = [box(0, 0, width, 100)];
expect(packWaterCoordinates(source)).toBe(source);
});
it("keeps microscopic source edges while compacting unrelated vertices", () => {
const source = [
[
{ x: -12.093654374897246, y: -1104.742361250057 },
{ x: 12.408731422223605, y: -1126.610817121642 },
{ x: 12.4087314222236, y: -1126.610817121642 },
{ x: 36.48981735362183, y: -1148.5428133008445 },
{ x: 100.123456789123, y: 0.123456789123 }
]
];
const packed = packWaterCoordinates(source);
expect(packed[0][1]).toEqual(source[0][1]);
expect(packed[0][2]).toEqual(source[0][2]);
expect(packed[0][4]).not.toEqual(source[0][4]);
expect(packWaterCoordinates(packed)).toEqual(packed);
});
it("retains holes and coincident piece joins", () => {
const source = [box(0, 0, 100, 20), box(0, 80, 100, 20), box(0, 20, 20, 60), box(80, 20, 20, 60)];
expect(packWaterCoordinates(source)).toEqual(source);
});
});
97 changes: 97 additions & 0 deletions src/services/previews/water-coordinate-pack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { WaterPoint as Point } from "./water-context-types";

export const WATER_COORDINATE_ERROR_M = 1e-5;
const same = (a: Point, b: Point) => a.x === b.x && a.y === b.y;
const orient = (a: Point, b: Point, c: Point) => Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
const on = (p: Point, a: Point, b: Point) =>
p.x >= Math.min(a.x, b.x) && p.x <= Math.max(a.x, b.x) && p.y >= Math.min(a.y, b.y) && p.y <= Math.max(a.y, b.y);
const area = (ring: Point[]) =>
ring.reduce((sum, p, i) => {
const q = ring[(i + 1) % ring.length];
return sum + p.x * q.y - q.x * p.y;
}, 0);

function relation(a: Point, b: Point, c: Point, d: Point): string {
const abC = orient(a, b, c),
abD = orient(a, b, d),
cdA = orient(c, d, a),
cdB = orient(c, d, b);
if (abC * abD < 0 && cdA * cdB < 0) return "cross";
if (!abC && !abD && !cdA && !cdB) {
const axis = a.x !== b.x ? "x" : "y";
const overlap =
Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis])) -
Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis]));
return overlap > 0 ? "overlap" : overlap === 0 ? "touch" : "none";
}
return (!abC && on(c, a, b)) || (!abD && on(d, a, b)) || (!cdA && on(a, c, d)) || (!cdB && on(b, c, d))
? "touch"
: "none";
}

function containment(point: Point, ring: Point[]): number {
let winding = 0;
for (let i = 0; i < ring.length; i++) {
const a = ring[i],
b = ring[(i + 1) % ring.length],
side = orient(a, b, point);
if (!side && on(point, a, b)) return 0;
if (a.y <= point.y && b.y > point.y && side > 0) winding++;
if (a.y > point.y && b.y <= point.y && side < 0) winding--;
}
return winding ? 1 : -1;
}

/** Shorten arithmetic tails while retaining sensitive vertices at their original precision. */
export function packWaterCoordinates(polygons: Point[][]): Point[][] {
const rounded = polygons.map(ring => ring.map(p => ({ x: Number(p.x.toFixed(5)), y: Number(p.y.toFixed(5)) })));
const edges = polygons.flatMap((ring, r) =>
ring.map((a, i) => ({ r, i, j: (i + 1) % ring.length, a, b: ring[(i + 1) % ring.length] }))
);
let changed = true;
while (changed) {
changed = false;
const retain = (r: number, i: number) => {
if (same(rounded[r][i], polygons[r][i])) return;
rounded[r][i] = polygons[r][i];
changed = true;
};
for (let r = 0; r < polygons.length; r++) {
if (Math.sign(area(polygons[r])) !== Math.sign(area(rounded[r])))
for (let i = 0; i < polygons[r].length; i++) retain(r, i);
}
for (const e of edges) {
if (Math.hypot(e.a.x - rounded[e.r][e.i].x, e.a.y - rounded[e.r][e.i].y) > WATER_COORDINATE_ERROR_M)
retain(e.r, e.i);
if (same(rounded[e.r][e.i], rounded[e.r][e.j]) && !same(e.a, e.b)) {
retain(e.r, e.i);
retain(e.r, e.j);
}
}
for (let i = 0; i < edges.length; i++)
for (let j = i + 1; j < edges.length; j++) {
const a = edges[i],
b = edges[j];
if (
relation(a.a, a.b, b.a, b.b) ===
relation(rounded[a.r][a.i], rounded[a.r][a.j], rounded[b.r][b.i], rounded[b.r][b.j])
)
continue;
retain(a.r, a.i);
retain(a.r, a.j);
retain(b.r, b.i);
retain(b.r, b.j);
}
for (let r = 0; r < polygons.length; r++)
for (let q = r + 1; q < polygons.length; q++) {
if (
containment(polygons[r][0], polygons[q]) === containment(rounded[r][0], rounded[q]) &&
containment(polygons[q][0], polygons[r]) === containment(rounded[q][0], rounded[r])
)
continue;
for (let i = 0; i < polygons[r].length; i++) retain(r, i);
for (let i = 0; i < polygons[q].length; i++) retain(q, i);
}
}
return rounded.every((ring, r) => ring.every((p, i) => same(p, polygons[r][i]))) ? polygons : rounded;
}
Loading
Loading