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
59 changes: 39 additions & 20 deletions docs/domain/settlemaker-water-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ 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 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.
Villages initially reserve 6 km. Cities start at 8 km and scale with population
(16 km for 173,976 residents, 26 km for 500,000). The reserve allows waterfront
alignment and development on both river banks. If an associated shore is farther
away, FMG searches within the map and surveys at least its measured distance plus
the reserve, rounded up to a kilometre. These remain 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 @@ -36,9 +38,9 @@ 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.
errors. Export and compression failures remain visible in the burg editor and on
external-open attempts. Large encoded payloads use a URL fragment, keeping
geometry out of the HTTP request without dropping any water data.

## Sender behavior

Expand Down Expand Up @@ -90,22 +92,33 @@ budget; it never silently replaces measured geometry with a bearing.
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.
Associated rivers are surveyed around their nearest source reach even when
marker displacement puts them outside the ordinary burg survey. Coordinates
remain in the original burg-local frame. Available positive-width reaches are
retained when an associated river has an unavailable source tip; omitted tips
still produce a width warning.
- Send `waterfront` independently of port status. Prefer the river in the burg's
cell, otherwise the nearest immediate neighbouring river cell. Include
`riverId`, plus `shoreKind` and `shoreFeatureId` for an adjacent ocean or lake.
These semantic relationships let Settlemaker align water with the settlement
while retaining its measured source geometry and recording the approximation.
A cartographic marker offset is not a physical river-bank clearance.
- 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.
for an inland river port, with `harbourRiverId` from that associated river.
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
state by falling back to a flat URL.
- Keep the compressed version-1 `i` envelope. Geometry uses
`geometryEncoding: "xy-tuples"`: coast vertices and river centreline points are
lossless `[x, y]` pairs. Encoded values up to 8192 characters use `?i=`, larger
values use `#i=` for both embedded and external previews. Compression or survey
failures remain visible; there is no geometry-dropping flat-URL fallback.

Surveys extending beyond the map rectangle fail explicitly; wrapped geography
is not inferred. Very detailed coasts can exceed the URL budget. There is no
automatic simplification or resurvey in this implementation.
is not inferred. The associated-shore search is bounded by the available map
rectangle and fails explicitly if the named feature cannot be found. Detailed
geometry is preserved through fragment transport without automatic simplification.

## Renderer handoff and validation

Expand All @@ -118,16 +131,22 @@ 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
closures, unknown units, missing local river widths, saved geometry invalidation,
URL-budget failures, population 1000/1001, Tetlapan-sized and large city payloads,
query/fragment transport, compact geometry, 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.

Browser validation with the sender enabled covered 394 generated villages:
391 encoded successfully, including 34 with water, and three correctly failed
the URL budget. Saving/reloading with hidden water layers retained the exact
Earlier browser validation, before fragment transport, covered 394 generated
villages: 391 encoded successfully, including 34 with water, and three exceeded
the former URL budget. Saving/reloading with hidden water layers retained the exact
saved boundary and surveying left displayed paths untouched. These checks used
a local URL target to inspect payloads, not a running measured-water renderer.
The older 1.112.1 map fixture also retained its saved boundary through loading
and a subsequent measured survey.

The Lougy regressions preserve Hasingsbury's non-port river, Newcester's original
river despite a 20 km marker displacement, Kinglewes's river/coast association,
and Helster's river port. Shore-search tests cover a coast 19 km beyond the marker
and a missing named feature. Complete emitted URLs are also checked against the
Settlemaker decoder and renderer during integration validation.
37 changes: 37 additions & 0 deletions src/services/previews/burg-water-survey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,40 @@ describe("city survey coverage", () => {
expect("coastlineGeometry" in result && result.coastlineGeometry[0].some(p => p.x === 6000)).toBe(true);
});
});

describe("associated shoreline coverage", () => {
function setup() {
vi.stubGlobal("options", {
map: { graph: { width: 100, height: 100 }, units: { distance: { unit: "km", scale: 1 } } }
});
vi.stubGlobal("pack", {
cells: { i: [0], p: [[90, 50]], f: [2] },
features: [undefined, { i: 1, type: "island" }, { i: 2, type: "ocean" }],
rivers: []
});
vi.spyOn(Coastline, "boundaryRevision", "get").mockReturnValue(1);
vi.spyOn(Coastline, "getFeaturePath").mockReturnValue("M0,0H69V100H0Z");
}

it("finds the associated coast beyond the initial survey and keeps a planning reserve after alignment", () => {
setup();
const result = surveyBurgWater({ x: 50, y: 50 } as Burg, [], 8000, undefined, {
kind: "ocean",
featureId: "2"
});
expect(result.waterContext).toMatchObject({
surveyRadiusM: 27000,
bodies: [{ featureId: "2", kind: "ocean", distanceM: 19000 }]
});
});

it("rejects an absent associated feature after searching the available map", () => {
setup();
expect(() =>
surveyBurgWater({ x: 50, y: 50 } as Burg, [], 8000, undefined, {
kind: "lake",
featureId: "99"
})
).toThrow("associated shoreline");
});
});
56 changes: 43 additions & 13 deletions src/services/previews/burg-water-survey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ let cached:
}
| undefined;

export function surveyBurgWater(burg: Burg, omittedRiverIds: number[] = [], radiusM?: number): WaterSurvey {
export function surveyBurgWater(
burg: Burg,
omittedRiverIds: number[] = [],
radiusM?: number,
requiredRiverId?: string,
requiredShore?: { kind: "ocean" | "lake"; featureId?: string }
): WaterSurvey {
const kmPerUnit = getKmInDistanceUnit();
if (!kmPerUnit)
return {
Expand Down Expand Up @@ -54,17 +60,41 @@ export function surveyBurgWater(burg: Burg, omittedRiverIds: number[] = [], radi
}
const oceanIndex = cached.oceans;
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,
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: riverSurvey.omittedRiverIds
});
const baseRadius = radiusM ?? WATER_SURVEY_RADIUS_M;
const survey = (radiusM: number) =>
cached!.index.survey({
burg,
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];
}
});
let radius = baseRadius;
let water = survey(radius);
if (requiredShore) {
const matches = (body: (typeof water.waterContext.bodies)[number]) =>
body.kind === requiredShore.kind && (!requiredShore.featureId || body.featureId === requiredShore.featureId);
const mapRadius =
Math.min(burg.x, burg.y, options.map.graph.width - burg.x, options.map.graph.height - burg.y) * metresPerMapUnit;
let body = water.waterContext.bodies.find(matches);
while (!body) {
const nextRadius = Math.min(radius * 2, mapRadius);
if (nextRadius <= radius) throw new Error("Cannot locate this settlement's associated shoreline within the map");
radius = nextRadius;
water = survey(radius);
body = water.waterContext.bodies.find(matches);
}
radius = Math.ceil((baseRadius + body.distanceM) / 1000) * 1000;
water = survey(radius);
}
const riverSurvey = surveyBurgRivers(burg, metresPerMapUnit, radius, omittedRiverIds, requiredRiverId);
if (riverSurvey.omittedRiverIds.length)
water.waterContext.omittedRivers = riverSurvey.omittedRiverIds.map(id => ({
riverId: String(id),
reason: "local-width-unavailable"
}));
return { ...water, ...(riverSurvey.rivers.length ? { rivers: riverSurvey.rivers } : {}) };
}
Loading
Loading