Skip to content

Commit 65bd51f

Browse files
authored
Merge pull request #81 from barrulus/feat/settlemaker-water-context-v1
Add gated measured water context for Settlemaker village previews
2 parents ed20d3c + e94ffe3 commit 65bd51f

18 files changed

Lines changed: 1129 additions & 16 deletions
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Settlemaker measured village water
2+
3+
FMG implements the sender side of Settlemaker's `water-context-v1` contract
4+
(`settlemaker/docs/water-context-v1.md`, revision `8ebc23a`). The renderer must
5+
implement that contract before this mode is enabled.
6+
7+
## Rollout
8+
9+
`VITE_SETTLEMAKER_WATER_CONTEXT_URL` is a build-time opt-in. Leave it unset for
10+
normal production builds until a deployed Settlemaker release explicitly supports
11+
water-context v1 for villages. An old decoder ignores the new field and can still
12+
invent a nearby shore from the legacy bearing.
13+
14+
To exercise a supporting local renderer:
15+
16+
```sh
17+
VITE_SETTLEMAKER_WATER_CONTEXT_URL=http://127.0.0.1:5173/fmg npm run dev
18+
```
19+
20+
Production endpoints must use HTTPS and the `/fmg` path, without a query or
21+
fragment. Pin the supporting renderer release in the deployment and configure
22+
its endpoint when building FMG. This option routes both embedded and external
23+
village previews to the same URL. Population 1–1000 uses the new contract;
24+
cities continue using the existing production URL and payload.
25+
26+
## Sender behavior
27+
28+
- Survey a 3 km radius around the actual burg coordinates, independent of port
29+
status. Export the enclosing 6 km square as simple filled water pieces, with
30+
islands retained as dry land. Always include `coastlineGeometry`, including
31+
an explicit empty array for a dry survey. No simple-coast approximation is used.
32+
- Use `Coastline.getFeaturePath`, shared with drawing and saving. Loading adopts
33+
the saved SVG boundary; surveying does not regenerate or mutate it. Map changes,
34+
coastline edits and coastline settings invalidate the geometry cache/index.
35+
- Convert map coordinates once using the selected distance unit. Flatten curves
36+
adaptively within 0.2 m; report a conservative combined `geometryErrorM` of
37+
0.5 m. Do not round or simplify exported coordinates. Reject ambiguous narrow
38+
or crossing curved shores instead of silently changing local topology.
39+
- Measure exposed real shores from the same flattened boundary that supplies
40+
the polygons. Shared piece edges and survey-square closures are not banks.
41+
If real shores lie beyond coverage, search the canonical full boundary.
42+
- Unsupported custom units produce `unknown-units`, with no metric geometry.
43+
FMG currently has no custom metres-per-unit setting.
44+
- Local river widths are unavailable from the burg context. Omit river surveys
45+
and identify context rivers through `omittedRivers`; never substitute the
46+
kilometre-valued river mouth width for the width beside the burg.
47+
- Keep the compressed `i` envelope. If the encoded payload exceeds 8192 bytes,
48+
compression fails, or geometry cannot be surveyed safely, show the failure
49+
in the burg editor and on an external-open attempt. Do not discard measured
50+
state by falling back to a flat URL.
51+
52+
Surveys extending beyond the map rectangle fail explicitly; wrapped geography
53+
is not inferred. Very detailed coasts can exceed the URL budget. There is no
54+
automatic simplification or resurvey in this implementation.
55+
56+
## Renderer handoff and validation
57+
58+
Settlemaker owns water-union bank queries, metric layout, bounded planning,
59+
frame/coverage validation, input precedence and visible iframe diagnostics.
60+
The FMG sender does not implement those renderer behaviors or an iframe message
61+
protocol. Before enabling production, verify those behaviors against the
62+
supporting release in both embedded and external-open flows.
63+
64+
Focused tests cover distance/units, finite lakes and islands, peninsula geometry,
65+
curve inclusion and precision, narrow channels, union shore measurement, crop
66+
closures, unknown units, missing local river widths, saved geometry invalidation,
67+
URL-budget failures, and population 1000/1001 rollout boundaries. The 11,115 m
68+
Tarrimas-Ha case is synthetic geography reproducing the measured distance; it
69+
does not replay the original Borteland save.
70+
71+
Browser validation with the sender enabled covered 394 generated villages:
72+
391 encoded successfully, including 34 with water, and three correctly failed
73+
the URL budget. Saving/reloading with hidden water layers retained the exact
74+
saved boundary and surveying left displayed paths untouched. These checks used
75+
a local URL target to inspect payloads, not a running measured-water renderer.
76+
The older 1.112.1 map fixture also retained its saved boundary through loading
77+
and a subsequent measured survey.

‎src/controllers/burg-editor.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -754,9 +754,10 @@ function getPreviewTextureBudgetK(): number {
754754
}
755755

756756
async function updateBurgPreview(burg: Burg): Promise<void> {
757-
const preview = (await Burgs.getPreview(burg)).preview;
757+
const { preview, error } = await Burgs.getPreview(burg);
758758
if (!preview) {
759-
ensureEl("burgPreviewSection").style.display = "none";
759+
ensureEl("burgPreviewSection").style.display = error ? "block" : "none";
760+
ensureEl("burgPreviewObject").textContent = error ?? "";
760761
return;
761762
}
762763

@@ -790,8 +791,9 @@ async function updateBurgPreview(burg: Burg): Promise<void> {
790791
async function openBurgLink(): Promise<void> {
791792
const id = getSelectedId();
792793
const burg = pack.burgs[id];
793-
const link = (await Burgs.getPreview(burg)).link;
794+
const { link, error } = await Burgs.getPreview(burg);
794795
if (link) openURL(link);
796+
else if (error) tip(error, true, "error", 8000);
795797
}
796798

797799
async function setCustomPreview(): Promise<void> {

‎src/generators/burg-context.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getKmInDistanceUnit } from "@/utils/unitUtils";
12
import { rn } from "../utils";
23
import type { Burg } from "./burgs-generator";
34

@@ -604,7 +605,7 @@ export function buildBurgContext(burg: Burg): BurgContext {
604605
scale: populationRate,
605606
urbanization: { rate: urbanization }
606607
} = options.map.units.population;
607-
const distanceScale = options.map.units.distance.scale;
608+
const distanceScale = options.map.units.distance.scale * (getKmInDistanceUnit() || 1);
608609

609610
const population = scaledPopulation(burg.population ?? 0, populationRate, urbanization);
610611
const radiusKm = effectiveWindowRadiusKm(DEFAULT_WINDOW_RADIUS_KM, distanceScale, Number(grid?.spacing ?? 0));

‎src/generators/burgs-generator.ts‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { quadtree } from "d3-quadtree";
22
import { AUTO_BURG_LIMIT } from "@/components/options-schema";
33
import { Emblems } from "@/generators/emblems-generator";
4-
import { buildSettlemakerUrl } from "@/services/previews/settlemaker";
4+
import { createSettlemakerPreview } from "@/services/previews/settlemaker-burg";
55
import type { BurgGroup } from "@/types/burg-groups";
66
import type { Emblem } from "@/types/emblems";
77
import { safeParseJSON } from "@/utils/stringUtils";
88
import { each, gauss, minmax, normalize, P, rn } from "../utils";
9-
import { buildBurgContext } from "./burg-context";
109
import { type CultureType, DEFAULT_CULTURE_TYPE } from "./cultures-generator";
1110
import { NON_NAVIGABLE_LAKE_GROUPS } from "./features";
1211
import type { Label } from "./labels-generator";
@@ -1425,7 +1424,7 @@ class BurgModule {
14251424
return { link, preview: `${link}&preview=1` };
14261425
}
14271426

1428-
async getPreview(burg: Burg): Promise<{ link: string | null; preview: string | null }> {
1427+
async getPreview(burg: Burg): Promise<{ link: string | null; preview: string | null; error?: string }> {
14291428
const previewGeneratorsMap: Record<
14301429
string,
14311430
(
@@ -1435,11 +1434,7 @@ class BurgModule {
14351434
"watabou-city": (burg: Burg) => this.createWatabouCityLinks(burg),
14361435
"watabou-village": (burg: Burg) => this.createWatabouVillageLinks(burg),
14371436
"watabou-dwelling": (burg: Burg) => this.createWatabouDwellingLinks(burg),
1438-
settlemaker: (burg: Burg) =>
1439-
buildSettlemakerUrl(buildBurgContext(burg), {
1440-
urbanDensity: options.map.units.population.urbanization.density,
1441-
trade: burg.tradeRole === "hub"
1442-
})
1437+
settlemaker: createSettlemakerPreview
14431438
};
14441439
if (burg.link) return { link: burg.link, preview: burg.link };
14451440

@@ -1451,7 +1446,11 @@ class BurgModule {
14511446
} catch (error) {
14521447
// Never throw into the editor: a broken preview must not take the dialog down.
14531448
ERROR && console.error("Failed to build burg preview", error);
1454-
return { link: null, preview: null };
1449+
return {
1450+
link: null,
1451+
preview: null,
1452+
error: error instanceof Error ? error.message : "Cannot create this burg preview"
1453+
};
14551454
}
14561455
}
14571456

‎src/generators/coastline-generator.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ describe("settings", () => {
4949
});
5050

5151
describe("getFeaturePath", () => {
52+
it("retains a loaded displayed boundary until its geometry or settings change", () => {
53+
pack.features = [undefined, island] as unknown as typeof pack.features;
54+
const saved = "M10,10L90,10L90,90L10,90Z";
55+
Coastline.restorePaths([[1, saved]]);
56+
const revision = Coastline.boundaryRevision;
57+
expect(Coastline.getFeaturePath(island)).toBe(saved);
58+
expect(Coastline.boundaryRevision).toBe(revision);
59+
pack.vertices.p[0] = [12, 10];
60+
expect(Coastline.getFeaturePath(island)).not.toBe(saved);
61+
expect(Coastline.boundaryRevision).toBeGreaterThan(revision);
62+
Coastline.restorePaths([[1, saved]]);
63+
Coastline.update({ enabled: false });
64+
expect(Coastline.getFeaturePath(island)).not.toBe(saved);
65+
});
66+
5267
it("reproduces the same coastline for the same seed and settings", () => {
5368
const path = Coastline.getFeaturePath(island);
5469

‎src/generators/coastline-generator.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,41 @@ function buildCoastlinePath({ points, origIndices }: FractalizedShape): string {
247247
*/
248248
class CoastlineGenerator {
249249
readonly PROFILE_SIZE = PROFILE_SIZE;
250+
private paths = new WeakMap<Feature, { source: string; path: string }>();
251+
private graph?: typeof pack.vertices;
252+
private configuration = "";
253+
private revision = 0;
254+
255+
get boundaryRevision(): number {
256+
const configuration = JSON.stringify([
257+
options.map.seed,
258+
options.map.graph.width,
259+
options.map.graph.height,
260+
this.settings
261+
]);
262+
if (this.graph !== pack.vertices || this.configuration !== configuration) {
263+
this.graph = pack.vertices;
264+
this.configuration = configuration;
265+
this.paths = new WeakMap();
266+
this.revision++;
267+
}
268+
return this.revision;
269+
}
270+
271+
/** IO adopts the saved display geometry before any layer redraw can replace it. */
272+
restorePaths(paths: Iterable<[number, string]>): void {
273+
this.boundaryRevision;
274+
this.paths = new WeakMap();
275+
for (const [id, path] of paths) {
276+
const feature = pack.features[id];
277+
if (feature && path) this.paths.set(feature, { source: this.sourceKey(feature), path });
278+
}
279+
this.revision++;
280+
}
281+
282+
private sourceKey(feature: Feature): string {
283+
return JSON.stringify([feature.type, feature.vertices.map(vertex => pack.vertices.p[vertex])]);
284+
}
250285

251286
/** Settings of the map on screen: a fact, read at render time and saved with the file */
252287
get settings(): CoastlineSettings {
@@ -265,6 +300,10 @@ class CoastlineGenerator {
265300

266301
/** Closed SVG path of the feature outline, fractalized as configured */
267302
getFeaturePath(feature: Feature): string {
303+
this.boundaryRevision;
304+
const source = this.sourceKey(feature);
305+
const cached = this.paths.get(feature);
306+
if (cached?.source === source) return cached.path;
268307
const points = feature.vertices.map(vertex => pack.vertices.p[vertex]);
269308
if (points.some(point => point === undefined)) {
270309
ERROR && console.error("Undefined point in getFeaturePath");
@@ -274,7 +313,10 @@ class CoastlineGenerator {
274313
const simplifiedPoints = simplify(points, SIMPLIFICATION_TOLERANCE);
275314
const clippedPoints = clipPoly(simplifiedPoints, options.map.graph.width, options.map.graph.height, 1);
276315
const shape = this.fractalizeFeature(clippedPoints, feature);
277-
return `${round(buildCoastlinePath(shape))}Z`;
316+
const path = `${round(buildCoastlinePath(shape))}Z`;
317+
this.paths.set(feature, { source, path });
318+
this.revision++;
319+
return path;
278320
}
279321

280322
/** Displace a polygon into a naturalistic coastline. Deterministic: the same rand and settings repeat the shape */

‎src/services/io/load.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { syncOptionInputs } from "@/components/options/tabs/options-tab";
77
import { clearMainTip, tip } from "@/components/tooltips";
88
import { applyDefaultViewboxEvents } from "@/components/viewbox-events";
99
import { resetZoom } from "@/components/zoom";
10+
import { Coastline } from "@/generators/coastline-generator";
1011
import { GraphOverride } from "@/generators/graph-override";
1112
import { invalidateEmblems } from "@/renderers/draw-emblems";
1213
import { onLegendClick } from "@/renderers/draw-legend";
@@ -383,6 +384,12 @@ async function parseLoadedData(data: string[], mapVersion: string | null): Promi
383384

384385
if (data[50]) Layers.restore(JSON.parse(data[50]));
385386
if (data[51]) GraphOverride.restore(JSON.parse(data[51]));
387+
Coastline.restorePaths(
388+
Array.from(document.querySelectorAll<SVGPathElement>("#featurePaths > path"), path => [
389+
Number(path.dataset.f ?? path.id.replace("feature_", "")),
390+
path.getAttribute("d") ?? ""
391+
])
392+
);
386393

387394
Goods.sync();
388395
Markets.sync();
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// @vitest-environment node
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import type { Burg } from "@/generators/burgs-generator";
4+
import { surveyBurgWater } from "./burg-water-survey";
5+
6+
afterEach(() => vi.unstubAllGlobals());
7+
8+
describe("unknown physical units", () => {
9+
it("reports custom units without reading geometry or asserting empty water", () => {
10+
vi.stubGlobal("options", { map: { units: { distance: { unit: "hexes", scale: 3 } } } });
11+
expect(surveyBurgWater({ i: 7, x: 100, y: 100 } as Burg)).toEqual({
12+
waterContext: { version: 1, status: "unknown-units", sourceUnit: "hexes" }
13+
});
14+
});
15+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { quadtree } from "d3";
2+
import type { Burg } from "@/generators/burgs-generator";
3+
import { Coastline } from "@/generators/coastline-generator";
4+
import { getKmInDistanceUnit } from "@/utils/unitUtils";
5+
import type { WaterSurvey } from "./water-context-types";
6+
import { WaterSurveyIndex } from "./water-survey";
7+
8+
let cached:
9+
| {
10+
features: typeof pack.features;
11+
revision: number;
12+
index: WaterSurveyIndex;
13+
oceans: ReturnType<typeof quadtree<[number, number, number]>>;
14+
}
15+
| undefined;
16+
17+
export function surveyBurgWater(burg: Burg, omittedRiverIds: number[] = []): WaterSurvey {
18+
const kmPerUnit = getKmInDistanceUnit();
19+
if (!kmPerUnit)
20+
return {
21+
waterContext: {
22+
version: 1,
23+
status: "unknown-units",
24+
sourceUnit: options.map.units.distance.unit
25+
}
26+
};
27+
if (!cached || cached.features !== pack.features || cached.revision !== Coastline.boundaryRevision) {
28+
const boundaries = pack.features
29+
.filter(feature => feature && feature.type !== "ocean")
30+
.map(feature => ({
31+
id: feature.i,
32+
kind: feature.type as "island" | "lake",
33+
path: Coastline.getFeaturePath(feature)
34+
}));
35+
const oceans = quadtree<[number, number, number]>()
36+
.x(p => p[0])
37+
.y(p => p[1]);
38+
for (const cell of pack.cells.i)
39+
if (pack.features[pack.cells.f[cell]]?.type === "ocean") {
40+
oceans.add([pack.cells.p[cell][0], pack.cells.p[cell][1], pack.cells.f[cell]]);
41+
}
42+
cached = {
43+
features: pack.features,
44+
revision: Coastline.boundaryRevision,
45+
oceans,
46+
index: new WaterSurveyIndex(boundaries, {
47+
minX: 0,
48+
minY: 0,
49+
maxX: options.map.graph.width,
50+
maxY: options.map.graph.height
51+
})
52+
};
53+
}
54+
const oceanIndex = cached.oceans;
55+
return cached.index.survey({
56+
burg,
57+
metresPerMapUnit: options.map.units.distance.scale * kmPerUnit * 1000,
58+
oceanAt: point => {
59+
const ocean = oceanIndex.find(point.x, point.y);
60+
if (!ocean) throw new Error("The map has no ocean feature for this measured water area");
61+
return ocean[2];
62+
},
63+
omittedRiverIds
64+
});
65+
}

0 commit comments

Comments
 (0)