diff --git a/docs/domain/settlemaker-water-context.md b/docs/domain/settlemaker-water-context.md index 94bd1f2f3..ba69b5244 100644 --- a/docs/domain/settlemaker-water-context.md +++ b/docs/domain/settlemaker-water-context.md @@ -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 @@ -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 @@ -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 @@ -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. diff --git a/src/services/previews/burg-water-survey.test.ts b/src/services/previews/burg-water-survey.test.ts index bac23aaa0..d2fa46623 100644 --- a/src/services/previews/burg-water-survey.test.ts +++ b/src/services/previews/burg-water-survey.test.ts @@ -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"); + }); +}); diff --git a/src/services/previews/burg-water-survey.ts b/src/services/previews/burg-water-survey.ts index e1ab2ca5c..f9fd80dab 100644 --- a/src/services/previews/burg-water-survey.ts +++ b/src/services/previews/burg-water-survey.ts @@ -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 { @@ -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 } : {}) }; } diff --git a/src/services/previews/fixtures/lougy-waterfront.json b/src/services/previews/fixtures/lougy-waterfront.json new file mode 100644 index 000000000..cec8588f8 --- /dev/null +++ b/src/services/previews/fixtures/lougy-waterfront.json @@ -0,0 +1 @@ +{"graph":{"width":944,"height":939,"points":10000},"metresPerMapUnit":3218.688,"burgs":[{"i":3,"name":"Helster","cell":3780,"x":448.08,"y":613.01,"port":1},{"i":4,"name":"Newcester","cell":258,"x":481.83,"y":230.61,"port":1},{"i":12,"name":"Kinglewes","cell":4167,"x":375.03,"y":679.15,"port":1},{"i":66,"name":"Hasingsbury","cell":201,"x":446.99,"y":219.1}],"cells":{"p":{"0":[240.83,174.42],"152":[434.1,215.7],"153":[438.4,208.16],"154":[442.76,207.65],"156":[450.9,216.5],"200":[439.09,222.02],"201":[446.41,218.95],"206":[477.72,224.87],"207":[482.53,223.79],"252":[430.53,232.53],"254":[450.65,226.73],"257":[473.93,230.18],"258":[479.1,236.2],"259":[488.21,226.97],"260":[491.51,231.83],"317":[417.17,238.05],"322":[468.1,236],"323":[478.28,243.34],"324":[484.31,242.23],"325":[490.25,237.94],"395":[468.36,249.18],"477":[412.63,255.04],"482":[459.45,261.6],"483":[462.49,258.34],"544":[411.78,267.9],"613":[405.96,279.14],"618":[459.82,275.98],"691":[398.57,285.44],"698":[462.79,286.32],"777":[401.87,292.48],"784":[468.02,297.67],"865":[402.15,308.42],"866":[406.55,303.67],"872":[467.89,308.89],"956":[409.34,319.04],"1042":[399.66,324.64],"1132":[409.34,337.71],"1220":[418.69,343.98],"1221":[429.63,345.29],"1308":[436.39,350.31],"1309":[447.43,353.96],"1394":[454.66,363.39],"1478":[462.01,370.55],"1559":[468.81,384.22],"1560":[475.45,383.34],"2984":[554.1,534.05],"3083":[566.19,539.01],"3178":[556.14,548.77],"3282":[544.46,561.26],"3283":[553.42,562.68],"3284":[559.55,556.22],"3381":[529.79,570.02],"3382":[538.8,571.35],"3479":[527.46,579.65],"3565":[490.53,587.49],"3566":[500.75,589.6],"3567":[510.25,587.64],"3568":[518.95,588.48],"3645":[482.98,597.03],"3715":[435.86,609.84],"3716":[444.38,603.36],"3717":[460.23,603.71],"3718":[467.33,606.63],"3719":[475.33,611.04],"3779":[433.87,613.88],"3780":[447.6,612.64],"3781":[454.91,620.51],"3782":[464.9,613.4],"3842":[437.72,627.04],"3843":[444.22,624.55],"3901":[437.26,631.19],"3967":[436.66,641.11],"4047":[440.94,651.15],"4110":[367.65,667.42],"4123":[436.79,659.75],"4165":[363.14,671.42],"4166":[374.26,670.76],"4167":[372.8,677.7],"4168":[379.47,676.37],"4200":[364.3,685.54],"4201":[371.34,684.57],"4202":[377.89,679.76]},"h":{"0":17,"152":20,"153":19,"154":18,"156":20,"200":21,"201":20,"206":20,"207":18,"252":25,"254":21,"257":20,"258":20,"259":19,"260":22,"317":29,"322":22,"323":23,"324":23,"325":23,"395":24,"477":33,"482":28,"483":27,"544":32,"613":33,"618":32,"691":33,"698":34,"777":34,"784":39,"865":35,"866":34,"872":38,"956":36,"1042":37,"1132":41,"1220":40,"1221":39,"1308":42,"1309":46,"1394":47,"1478":45,"1559":47,"1560":50,"2984":48,"3083":45,"3178":45,"3282":43,"3283":45,"3284":45,"3381":34,"3382":39,"3479":35,"3565":36,"3566":35,"3567":34,"3568":36,"3645":36,"3715":35,"3716":34,"3717":33,"3718":34,"3719":35,"3779":31,"3780":34,"3781":32,"3782":35,"3842":27,"3843":30,"3901":27,"3967":26,"4047":20,"4110":30,"4123":17,"4165":36,"4166":28,"4167":28,"4168":22,"4200":25,"4201":25,"4202":18},"fl":{"0":0,"152":31,"153":0,"154":0,"156":212,"200":498,"201":509,"206":38,"207":0,"252":484,"254":195,"257":28,"258":311,"259":0,"260":9,"317":475,"322":121,"323":14,"324":154,"325":14,"395":114,"477":405,"482":72,"483":89,"544":398,"613":367,"618":66,"691":308,"698":51,"777":206,"784":42,"865":194,"866":198,"872":38,"956":185,"1042":176,"1132":128,"1220":127,"1221":82,"1308":76,"1309":70,"1394":67,"1478":56,"1559":44,"1560":38,"2984":37,"3083":85,"3178":91,"3282":121,"3283":107,"3284":99,"3381":169,"3382":125,"3479":189,"3565":357,"3566":351,"3567":222,"3568":203,"3645":519,"3715":3,"3716":19,"3717":558,"3718":534,"3719":527,"3779":7,"3780":580,"3781":4,"3782":4,"3842":601,"3843":589,"3901":606,"3967":614,"4047":658,"4110":54,"4123":0,"4165":13,"4166":7,"4167":61,"4168":20,"4200":46,"4201":8,"4202":0},"r":{"0":0,"152":0,"153":0,"154":0,"156":100,"200":16,"201":16,"206":0,"207":0,"252":16,"254":100,"257":0,"258":49,"259":0,"260":0,"317":16,"322":49,"323":0,"324":90,"325":0,"395":49,"477":16,"482":49,"483":49,"544":16,"613":16,"618":49,"691":16,"698":49,"777":16,"784":49,"865":16,"866":16,"872":49,"956":16,"1042":16,"1132":16,"1220":16,"1221":16,"1308":16,"1309":16,"1394":16,"1478":16,"1559":16,"1560":16,"2984":19,"3083":19,"3178":19,"3282":19,"3283":19,"3284":19,"3381":19,"3382":19,"3479":19,"3565":19,"3566":19,"3567":19,"3568":19,"3645":15,"3715":0,"3716":0,"3717":19,"3718":19,"3719":19,"3779":0,"3780":19,"3781":0,"3782":0,"3842":19,"3843":19,"3901":19,"3967":19,"4047":19,"4110":105,"4123":0,"4165":0,"4166":0,"4167":105,"4168":0,"4200":156,"4201":0,"4202":0},"f":{"0":1,"152":3,"153":1,"154":1,"156":3,"200":3,"201":3,"206":3,"207":1,"252":3,"254":3,"257":3,"258":3,"259":1,"260":3,"317":3,"322":3,"323":3,"324":3,"325":3,"395":3,"477":3,"482":3,"483":3,"544":3,"613":3,"618":3,"691":3,"698":3,"777":3,"784":3,"865":3,"866":3,"872":3,"956":3,"1042":3,"1132":3,"1220":3,"1221":3,"1308":3,"1309":3,"1394":3,"1478":3,"1559":3,"1560":3,"2984":3,"3083":3,"3178":3,"3282":3,"3283":3,"3284":3,"3381":3,"3382":3,"3479":3,"3565":3,"3566":3,"3567":3,"3568":3,"3645":3,"3715":3,"3716":3,"3717":3,"3718":3,"3719":3,"3779":3,"3780":3,"3781":3,"3782":3,"3842":3,"3843":3,"3901":3,"3967":3,"4047":3,"4110":3,"4123":1,"4165":3,"4166":3,"4167":3,"4168":3,"4200":3,"4201":3,"4202":1},"haven":{"0":0,"152":153,"153":0,"154":0,"156":154,"200":0,"201":154,"206":207,"207":0,"252":0,"254":0,"257":0,"258":207,"259":0,"260":259,"317":0,"322":0,"323":0,"324":0,"325":0,"395":0,"477":0,"482":0,"483":0,"544":0,"613":0,"618":0,"691":0,"698":0,"777":0,"784":0,"865":0,"866":0,"872":0,"956":0,"1042":0,"1132":0,"1220":0,"1221":0,"1308":0,"1309":0,"1394":0,"1478":0,"1559":0,"1560":0,"2984":0,"3083":0,"3178":0,"3282":0,"3283":0,"3284":0,"3381":0,"3382":0,"3479":0,"3565":0,"3566":0,"3567":0,"3568":0,"3645":0,"3715":0,"3716":0,"3717":0,"3718":0,"3719":0,"3779":0,"3780":0,"3781":0,"3782":0,"3842":0,"3843":0,"3901":0,"3967":0,"4047":4123,"4110":0,"4123":0,"4165":0,"4166":0,"4167":4202,"4168":4202,"4200":0,"4201":4203,"4202":0},"harbor":{"0":0,"152":1,"153":0,"154":0,"156":2,"200":0,"201":2,"206":2,"207":0,"252":0,"254":0,"257":0,"258":2,"259":0,"260":1,"317":0,"322":0,"323":0,"324":0,"325":0,"395":0,"477":0,"482":0,"483":0,"544":0,"613":0,"618":0,"691":0,"698":0,"777":0,"784":0,"865":0,"866":0,"872":0,"956":0,"1042":0,"1132":0,"1220":0,"1221":0,"1308":0,"1309":0,"1394":0,"1478":0,"1559":0,"1560":0,"2984":0,"3083":0,"3178":0,"3282":0,"3283":0,"3284":0,"3381":0,"3382":0,"3479":0,"3565":0,"3566":0,"3567":0,"3568":0,"3645":0,"3715":0,"3716":0,"3717":0,"3718":0,"3719":0,"3779":0,"3780":0,"3781":0,"3782":0,"3842":0,"3843":0,"3901":0,"3967":0,"4047":1,"4110":0,"4123":0,"4165":0,"4166":0,"4167":1,"4168":2,"4200":0,"4201":2,"4202":0},"c":{"0":[16,14,13,1],"152":[200,199,150,151,153,201],"153":[201,152,151,109,110,154],"154":[156,201,153,110,111],"156":[254,201,154,111,155,202],"200":[253,252,199,152,201,254],"201":[254,200,152,153,154,156],"206":[258,257,204,159,207],"207":[259,258,206,159,163,162],"252":[319,318,317,251,199,200,253],"254":[320,253,200,201,156,202,203,255],"257":[322,256,205,204,206,258],"258":[324,323,322,257,206,207,259,260,325],"259":[260,258,207,162,208],"260":[261,325,258,259,208],"317":[477,389,316,250,251,252,318,390],"322":[395,321,255,256,257,258,323],"323":[396,395,322,258,324,397],"324":[398,397,323,258,325],"325":[398,324,258,260,261,326],"395":[484,483,394,321,322,323,396],"477":[478,544,476,389,317,390],"482":[550,618,549,481,483],"483":[550,482,481,394,395,484],"544":[614,613,543,476,477,478,545],"613":[692,691,612,611,543,544,614],"618":[698,697,617,549,482,550,619],"691":[777,776,690,612,613,692],"698":[784,783,697,618,619,699],"777":[866,864,776,691,692,778],"784":[873,872,783,698,699,785],"865":[956,955,954,864,866,867],"866":[867,865,864,777,778],"872":[962,961,871,783,784,873,963],"956":[867,1044,1043,1042,955,865],"1042":[1132,1131,1130,1041,955,956,1043],"1132":[1220,1219,1218,1131,1042,1043,1133],"1220":[1307,1306,1219,1132,1133,1134,1221],"1221":[1308,1307,1220,1134,1222],"1308":[1309,1392,1307,1221,1222,1223],"1309":[1394,1393,1392,1308,1223,1224,1310],"1394":[1477,1476,1393,1309,1310,1395,1478],"1478":[1558,1477,1394,1395,1396,1479,1559],"1559":[1560,1641,1640,1558,1478,1479],"1560":[1641,1559,1479,1561,1643,1642],"2984":[2888,2985,2986,3083,3082,3081,3080,2983,2887],"3083":[2984,2986,3084,3179,3178,3082],"3178":[3081,3082,3083,3179,3284,3177],"3282":[3176,3177,3283,3481,3382,3281,3175],"3283":[3282,3177,3284,3384,3383,3481],"3284":[3177,3178,3179,3285,3385,3384,3283],"3381":[3380,3281,3382,3480,3479,3478],"3382":[3281,3282,3481,3480,3381],"3479":[3568,3478,3381,3480,3570,3569],"3565":[3645,3564,3475,3566,3646],"3566":[3475,3476,3567,3647,3646,3565],"3567":[3476,3477,3568,3648,3647,3566],"3568":[3478,3479,3569,3649,3648,3567,3477],"3645":[3564,3565,3646,3721,3720,3719,3644,3563],"3715":[3640,3716,3780,3779,3778,3714],"3716":[3641,3717,3780,3715,3640],"3717":[3642,3643,3718,3782,3780,3716,3641],"3718":[3643,3644,3719,3782,3717],"3719":[3645,3720,3784,3783,3782,3718,3644],"3779":[3778,3715,3780,3843,3842,3841],"3780":[3717,3782,3781,3843,3779,3715,3716],"3781":[3780,3782,3844,3843],"3782":[3717,3718,3719,3783,3846,3845,3844,3781,3780],"3842":[3841,3779,3843,3902,3901],"3843":[3781,3844,3902,3842,3779,3780],"3901":[3842,3902,3967,3900,3841],"3967":[3902,3968,4047,4046,3966,3900,3901],"4047":[3967,3968,4049,4048,4123,4046],"4110":[4165,4109,4111,4166,4167],"4123":[4047,4048,4124,4125,4175,4122,4046],"4165":[4109,4110,4167,4200,4199,4164,4108],"4166":[4111,4113,4168,4167,4110],"4167":[4168,4202,4201,4200,4165,4110,4166],"4168":[4166,4113,4171,4202,4167],"4200":[4167,4201,4231,4229,4228,4199,4165],"4201":[4200,4167,4202,4203,4231],"4202":[4167,4168,4171,4203,4201]}},"rivers":[{"i":16,"cells":[1560,1559,1478,1394,1309,1308,1221,1220,1132,1042,956,865,866,777,691,613,544,477,317,252,200,201,154],"widthFactor":1.2,"sourceWidth":0.05},{"i":19,"cells":[2984,3083,3178,3284,3283,3282,3382,3381,3479,3568,3567,3566,3565,3645,3719,3718,3717,3780,3843,3842,3901,3967,4047,4123],"widthFactor":1.2,"sourceWidth":0.05},{"i":49,"cells":[872,784,698,618,482,483,395,322,258],"widthFactor":1,"sourceWidth":0.05},{"i":105,"cells":[4110,4167,4202],"widthFactor":1.2,"sourceWidth":0.07}],"features":{"1":{"i":1,"type":"ocean"},"9":{"i":9,"type":"lake"}}} \ No newline at end of file diff --git a/src/services/previews/fixtures/moncyia-kindum-waterfront.json b/src/services/previews/fixtures/moncyia-kindum-waterfront.json new file mode 100644 index 000000000..89bbf60d1 --- /dev/null +++ b/src/services/previews/fixtures/moncyia-kindum-waterfront.json @@ -0,0 +1 @@ +{"graph":{"width":1900,"height":939,"points":10000},"metresPerMapUnit":6437.376,"burg":{"i":11,"name":"Kindum","cell":390,"x":678.78,"y":231.25,"port":1},"cells":{"p":{"303":[669.2,227.6],"304":[679.54,225.28],"389":[664.55,236.45],"390":[674.88,235.14],"391":[690.16,228.57],"491":[664.6,245.59],"492":[677.86,244.47],"605":[664.23,264.44],"718":[648.29,273.29],"818":[649.18,287.38],"922":[649.59,302.59],"1034":[643.62,314.2],"1144":[651.21,332.83],"1257":[661.45,341.33],"1372":[665.79,348.58]},"h":{"303":21,"304":16,"389":23,"390":21,"391":18,"491":25,"492":22,"605":28,"718":28,"818":29,"922":29,"1034":29,"1144":28,"1257":28,"1372":32},"fl":{"303":63,"304":0,"389":27,"390":104,"391":0,"491":86,"492":36,"605":71,"718":67,"818":66,"922":63,"1034":53,"1144":51,"1257":38,"1372":36},"r":{"303":0,"304":0,"389":0,"390":36,"391":0,"491":36,"492":0,"605":36,"718":36,"818":36,"922":36,"1034":36,"1144":36,"1257":36,"1372":36},"f":{"303":3,"304":1,"389":3,"390":3,"391":1,"491":3,"492":3,"605":3,"718":3,"818":3,"922":3,"1034":3,"1144":3,"1257":3,"1372":3},"haven":{"303":304,"304":0,"389":0,"390":304,"391":0,"491":0,"492":391,"605":0,"718":0,"818":0,"922":0,"1034":0,"1144":0,"1257":0,"1372":0},"harbor":{"303":2,"304":0,"389":0,"390":2,"391":0,"491":0,"492":1,"605":0,"718":0,"818":0,"922":0,"1034":0,"1144":0,"1257":0,"1372":0},"c":{"303":[390,389,302,223,304],"304":[391,390,303,223,305],"389":[390,491,388,301,302,303],"390":[391,492,491,389,303,304],"391":[494,492,390,304,305,306,392,393],"491":[606,605,490,388,389,390,492],"492":[494,493,606,491,390,391],"605":[720,719,718,604,490,491,606],"718":[719,818,717,604,605],"818":[819,922,817,717,718,719],"922":[923,1035,1034,1033,921,817,818,819],"1034":[1144,1143,1033,922,1035,1145],"1144":[1257,1256,1255,1143,1034,1145],"1257":[1256,1144,1145,1146,1372],"1372":[1259,1373,1488,1256,1257,1146,1258]}},"rivers":[{"i":36,"cells":[1372,1257,1144,1034,922,818,718,605,491,390,304],"widthFactor":1.2,"sourceWidth":0.05}],"features":{"1":{"i":1,"type":"ocean"}}} \ No newline at end of file diff --git a/src/services/previews/lougy-waterfront.test.ts b/src/services/previews/lougy-waterfront.test.ts new file mode 100644 index 000000000..0ff0c8cb5 --- /dev/null +++ b/src/services/previews/lougy-waterfront.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Burg } from "@/generators/burgs-generator"; +import "@/generators/river-generator"; +import fixture from "./fixtures/lougy-waterfront.json"; +import { surveyBurgRivers } from "./river-water-survey"; +import { burgWaterfront } from "./settlemaker-burg"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("Lougy river associations", () => { + it.each([ + ["Hasingsbury", "16", "ocean", 8000], + ["Newcester", "49", "ocean", 20000], + ["Kinglewes", "105", "ocean", 8000], + ["Helster", "19", undefined, 14000] + ])("preserves %s's water relationship despite its cartographic marker offset", (name, riverId, shoreKind, radius) => { + vi.stubGlobal("pack", fixture); + vi.stubGlobal("options", { map: { graph: fixture.graph } }); + const burg = fixture.burgs.find(b => b.name === name)! as Burg; + const relation = burgWaterfront(burg); + expect(relation).toMatchObject({ riverId }); + expect(relation?.shoreKind).toBe(shoreKind); + expect(Boolean(burg.port)).toBe(name !== "Hasingsbury"); + const survey = surveyBurgRivers(burg, fixture.metresPerMapUnit, Number(radius), [], String(riverId)); + if (name !== "Kinglewes") expect(survey.omittedRiverIds).not.toContain(Number(riverId)); + const reaches = survey.rivers.filter(r => r.id === riverId); + expect(reaches.length).toBeGreaterThan(0); + expect(reaches.every(r => r.widthM > 0 && r.widthsM.every(width => width > 0))).toBe(true); + if (name === "Newcester") { + expect(reaches.every(r => r.centreline.every(point => Math.hypot(point.x, point.y) > 10000))).toBe(true); + } + }); +}); diff --git a/src/services/previews/moncyia-waterfront.test.ts b/src/services/previews/moncyia-waterfront.test.ts new file mode 100644 index 000000000..c1f9e4d11 --- /dev/null +++ b/src/services/previews/moncyia-waterfront.test.ts @@ -0,0 +1,24 @@ +// @vitest-environment node +import { afterEach, expect, it, vi } from "vitest"; +import type { Burg } from "@/generators/burgs-generator"; +import "@/generators/river-generator"; +import fixture from "./fixtures/moncyia-kindum-waterfront.json"; +import { surveyBurgRivers } from "./river-water-survey"; +import { burgWaterfront } from "./settlemaker-burg"; + +afterEach(() => vi.unstubAllGlobals()); + +it("preserves coastal Kindum's source river despite its marker lying beyond the old preview survey", () => { + vi.stubGlobal("pack", fixture); + vi.stubGlobal("options", { map: { graph: fixture.graph } }); + const burg = fixture.burg as Burg; + const waterfront = burgWaterfront(burg); + expect(waterfront).toEqual({ riverId: "36", shoreKind: "ocean", shoreFeatureId: "1" }); + expect(surveyBurgRivers(burg, fixture.metresPerMapUnit, 4000).rivers).toEqual([]); + const survey = surveyBurgRivers(burg, fixture.metresPerMapUnit, 16000, [], waterfront?.riverId); + expect(survey.omittedRiverIds).not.toContain(36); + expect(survey.rivers).toHaveLength(1); + expect(survey.rivers[0]).toMatchObject({ id: "36", courseSource: "fmg-catmull-rom-0.1" }); + expect(survey.rivers[0].centreline.length).toBeGreaterThan(2); + expect(survey.rivers[0].widthsM.every(width => width > 0)).toBe(true); +}); diff --git a/src/services/previews/river-water-survey.test.ts b/src/services/previews/river-water-survey.test.ts index 202f978d1..531cc2af8 100644 --- a/src/services/previews/river-water-survey.test.ts +++ b/src/services/previews/river-water-survey.test.ts @@ -226,6 +226,24 @@ describe("local model river surveys", () => { expect(result.omittedRiverIds).toEqual([99]); }); + it("retains an associated river beyond the burg survey without moving its measured coordinates", () => { + vi.stubGlobal("pack", { rivers: [river] }); + vi.spyOn(Rivers, "addMeandering").mockReturnValue([ + [-10000, 20000, 100], + [0, 20000, 200], + [10000, 20000, 300] + ]); + expect(surveyBurgRivers(burg, 1, 3000, [7]).rivers).toEqual([]); + const result = surveyBurgRivers(burg, 1, 3000, [7], "7"); + expect(result.omittedRiverIds).toEqual([]); + expect(result.rivers).toHaveLength(1); + const reach = result.rivers[0]; + expect(reach.id).toBe("7"); + expect(reach.centreline.every(point => point.y === 20000)).toBe(true); + expect(reach.centreline[0].x).toBeLessThan(-3000); + expect(reach.centreline.at(-1)!.x).toBeGreaterThan(3000); + }); + it("does not report unrelated malformed rivers as missing beside this burg", () => { vi.stubGlobal("pack", { rivers: [ diff --git a/src/services/previews/river-water-survey.ts b/src/services/previews/river-water-survey.ts index 672f050e5..c699edc7a 100644 --- a/src/services/previews/river-water-survey.ts +++ b/src/services/previews/river-water-survey.ts @@ -102,7 +102,13 @@ export function projectRiverSurvey( } /** 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) { +export function surveyRiver( + river: River, + burg: WaterPoint, + metresPerMapUnit: number, + radiusM: number, + retainAvailableWidths = false +): { rivers: RiverSurvey[]; omitted: boolean; unlocated?: boolean } { 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); @@ -127,14 +133,37 @@ export function surveyRiver(river: River, burg: WaterPoint, metresPerMapUnit: nu })), 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 + const runs: (typeof smooth)[] = []; + if (retainAvailableWidths) { + let run: typeof smooth = []; + for (const point of smooth) { + if (Number.isFinite(point.width) && point.width > 0) run.push(point); + else { + if (run.length > 1) runs.push(run); + run = []; + } + } + if (run.length > 1) runs.push(run); + } else runs.push(smooth); + const parts = runs.map(run => + projectRiverSurvey( + river.i, + run.map(p => [p.x, p.y, 0]), + run.map(p => p.width), + { x: 0, y: 0 }, + 1, + radiusM + ) ); + const projected = { + rivers: parts.flatMap(part => part.rivers), + omitted: + parts.some(part => part.omitted) || + (retainAvailableWidths && + smooth.some( + p => (!Number.isFinite(p.width) || p.width <= 0) && Math.abs(p.x) <= radiusM && Math.abs(p.y) <= 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 })); @@ -143,13 +172,59 @@ export function surveyRiver(river: River, burg: WaterPoint, metresPerMapUnit: nu return projected; } -export function surveyBurgRivers(burg: Burg, metresPerMapUnit: number, radiusM: number, expectedIds: number[] = []) { +/** Preserve the associated source reach even when the cartographic burg marker was shifted far away. */ +function surveyAssociatedRiver( + river: River, + burg: Burg, + metresPerMapUnit: number, + radiusM: number +): ReturnType { + if (!river.cells || river.cells.length < 2) return { rivers: [], omitted: true }; + const anchors = river.points?.length === river.cells.length ? river.points : undefined; + const course = Rivers.addMeandering(river.cells, anchors); + let nearest: WaterPoint | undefined; + let distance = Infinity; + for (let i = 1; i < course.length; i++) { + const [x, y] = course[i - 1], + dx = course[i][0] - x, + dy = course[i][1] - y; + const t = Math.max(0, Math.min(1, ((burg.x - x) * dx + (burg.y - y) * dy) / (dx * dx + dy * dy || 1))); + const point = { x: x + t * dx, y: y + t * dy }; + const d = Math.hypot(point.x - burg.x, point.y - burg.y); + if (d < distance) { + nearest = point; + distance = d; + } + } + if (!nearest) return { rivers: [], omitted: true }; + const survey = surveyRiver(river, nearest, metresPerMapUnit, radiusM, true); + const dx = (nearest.x - burg.x) * metresPerMapUnit, + dy = (nearest.y - burg.y) * metresPerMapUnit; + for (const reach of survey.rivers) + reach.centreline = reach.centreline.map(p => ({ + x: Math.round((p.x + dx) * 100) / 100, + y: Math.round((p.y + dy) * 100) / 100 + })); + return survey; +} + +export function surveyBurgRivers( + burg: Burg, + metresPerMapUnit: number, + radiusM: number, + expectedIds: number[] = [], + requiredRiverId?: string +) { const rivers: RiverSurvey[] = [], omitted = new Set(expectedIds); + if (requiredRiverId) omitted.add(Number(requiredRiverId)); for (const river of (pack.rivers ?? []).toSorted((a, b) => a.i - b.i)) { - const survey = surveyRiver(river, burg, metresPerMapUnit, radiusM); + const required = String(river.i) === requiredRiverId; + const survey = required + ? surveyAssociatedRiver(river, burg, metresPerMapUnit, radiusM) + : surveyRiver(river, burg, metresPerMapUnit, radiusM); if (survey.omitted) { - if (!survey.unlocated || expectedIds.includes(river.i)) omitted.add(river.i); + if (!survey.unlocated || expectedIds.includes(river.i) || required) omitted.add(river.i); } else omitted.delete(river.i); rivers.push(...survey.rivers); } diff --git a/src/services/previews/settlemaker-burg.test.ts b/src/services/previews/settlemaker-burg.test.ts index 29f05b722..e282496df 100644 --- a/src/services/previews/settlemaker-burg.test.ts +++ b/src/services/previews/settlemaker-burg.test.ts @@ -34,13 +34,13 @@ async function preview(population: number, endpoint?: string, surveyRadiusM?: nu describe("measured-water rollout", () => { it.each([ - [10, 3000], - [1000, 3000], - [16973, 4000], - [173976, 8000] + [10, 6000], + [1000, 6000], + [16973, 8000], + [173976, 16000] ])("exports measured water by default for population %s", async (population, radius) => { await preview(population); - expect(mocks.survey).toHaveBeenCalledWith(expect.anything(), [], radius); + expect(mocks.survey).toHaveBeenCalledWith(expect.anything(), [], radius, undefined, undefined); expect(mocks.url.mock.calls[0][1]).toMatchObject({ urbanDensity: 8, trade: true, @@ -65,26 +65,32 @@ describe("measured-water rollout", () => { }); it.each([ - [1001, 4000], - [16973, 4000], - [100000, 7000], - [500000, 13000] + [1001, 8000], + [16973, 8000], + [100000, 14000], + [500000, 26000] ])("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.survey).toHaveBeenCalledWith( + expect.objectContaining({ x: 10, y: 20 }), + [], + radius, + undefined, + undefined + ); 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); + expect(mocks.survey).toHaveBeenCalledWith(expect.anything(), [], 7000, undefined, undefined); }); 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); + expect(mocks.survey).toHaveBeenCalledWith(expect.anything(), [], 6000, undefined, undefined); }); it.each([0, 2999, NaN, Infinity])("rejects invalid radius %s without falling back", async radius => { @@ -138,3 +144,66 @@ describe("harbour water body", () => { expect(harbourWaterKind({ cell: 0, port: 0 } as Burg)).toBeUndefined(); }); }); + +describe("settlement water relations", () => { + it("keeps a non-port's own river and its shore independently", async () => { + vi.stubGlobal("pack", { + cells: { + haven: [1], + harbor: [1], + f: [0, 2], + r: [7, 0], + c: [[1]], + p: [ + [0, 0], + [1, 0] + ] + }, + features: [{}, {}, { type: "ocean" }] + }); + const { burgWaterfront } = await import("./settlemaker-burg"); + expect(burgWaterfront({ cell: 0, port: 0, x: 0, y: 0 } as Burg)).toEqual({ + riverId: "7", + shoreKind: "ocean", + shoreFeatureId: "2" + }); + }); + + it("chooses the nearest neighbouring river without requiring a port", async () => { + vi.stubGlobal("pack", { + cells: { + haven: [0], + harbor: [0], + f: [0], + r: [0, 8, 9], + c: [[1, 2]], + p: [ + [0, 0], + [10, 0], + [2, 0] + ] + }, + features: [] + }); + const { burgWaterfront } = await import("./settlemaker-burg"); + expect(burgWaterfront({ cell: 0, port: 0, x: 0, y: 0 } as Burg)).toEqual({ riverId: "9" }); + expect(burgWaterfront({ cell: 1, port: 0, x: 10, y: 0 } as Burg)).toEqual({ riverId: "8" }); + }); + + it("passes a coastal port's original river to the survey even beyond ordinary coverage", async () => { + await preview(237946); + vi.stubGlobal("pack", { + cells: { haven: [1], harbor: [1], f: [0, 2], r: [49], c: [[]] }, + features: [{}, {}, { type: "ocean" }] + }); + const { createSettlemakerPreview } = await import("./settlemaker-burg"); + await createSettlemakerPreview({ i: 1, cell: 0, port: 1, x: 481.83, y: 230.61 } as Burg); + expect(mocks.survey).toHaveBeenLastCalledWith(expect.anything(), [], 20000, "49", { + kind: "ocean", + featureId: "2" + }); + expect(mocks.url.mock.calls.at(-1)![1]).toMatchObject({ + waterfront: { riverId: "49", shoreKind: "ocean", shoreFeatureId: "2" } + }); + }); +}); diff --git a/src/services/previews/settlemaker-burg.ts b/src/services/previews/settlemaker-burg.ts index db8f5df56..a7cb4d416 100644 --- a/src/services/previews/settlemaker-burg.ts +++ b/src/services/previews/settlemaker-burg.ts @@ -9,21 +9,39 @@ export const MEASURED_WATER_RENDERER = export function waterSurveyRadius(population: number, override?: number): number { const configured = import.meta.env.VITE_SETTLEMAKER_WATER_SURVEY_RADIUS_M as string | undefined; - // Allow for the city's cultivated envelope, its diagonal and a navigation margin. + // Reserve coverage for waterfront alignment and development across both river banks. const cityRadius = Math.max(4000, Math.ceil((Math.sqrt(population * 240) + 1500) / 1000) * 1000); - const radius = override ?? (configured ? Number(configured) : population > 1000 ? cityRadius : 3000); + const radius = override ?? (configured ? Number(configured) : population > 1000 ? cityRadius * 2 : 6000); if (!Number.isFinite(radius) || radius < 3000) throw new Error("The measured water survey radius must be at least 3000 metres"); return radius; } +export function burgWaterfront(burg: Burg): AzgaarBurgInput["waterfront"] { + const { cells, features } = pack; + const neighbours = cells.c?.[burg.cell] ?? []; + const ownRiver = cells.r[burg.cell]; + const riverCell = ownRiver + ? burg.cell + : neighbours + .filter(cell => cells.r[cell]) + .sort((a, b) => { + const distance = (cell: number) => Math.hypot(cells.p[cell][0] - burg.x, cells.p[cell][1] - burg.y); + return distance(a) - distance(b) || a - b; + })[0]; + const shore = cells.harbor[burg.cell] > 0 ? features[cells.f[cells.haven[burg.cell]]] : undefined; + const shoreKind = shore?.type === "ocean" || shore?.type === "lake" ? shore.type : undefined; + if (riverCell === undefined && !shoreKind) return undefined; + return { + ...(riverCell !== undefined && { riverId: String(cells.r[riverCell]) }), + ...(shoreKind && { shoreKind, shoreFeatureId: String(cells.f[cells.haven[burg.cell]]) }) + }; +} + 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; + const waterfront = burgWaterfront(burg); + return waterfront?.shoreKind ?? (waterfront?.riverId ? "river" : undefined); } export async function createSettlemakerPreview( @@ -46,16 +64,20 @@ export async function createSettlemakerPreview( ) { throw new Error("The measured water preview renderer address is invalid"); } - const waterKind = harbourWaterKind(burg); + const waterfront = burgWaterfront(burg); + const waterKind = burg.port ? (waterfront?.shoreKind ?? (waterfront?.riverId ? "river" : undefined)) : undefined; return buildSettlemakerUrl(ctx, { ...opts, harbourWaterKind: waterKind, - ...(waterKind === "river" ? { harbourRiverId: String(pack.cells.r[burg.cell]) } : {}), + waterfront, + ...(waterKind === "river" ? { harbourRiverId: waterfront!.riverId } : {}), baseUrl: endpoint.toString(), waterSurvey: surveyBurgWater( burg, ctx.hydrology.rivers.map(river => river.id), - waterSurveyRadius(ctx.burg.population, surveyOptions.surveyRadiusM) + waterSurveyRadius(ctx.burg.population, surveyOptions.surveyRadiusM), + waterfront?.riverId, + waterfront?.shoreKind ? { kind: waterfront.shoreKind, featureId: waterfront.shoreFeatureId } : undefined ) }); } diff --git a/src/services/previews/settlemaker.test.ts b/src/services/previews/settlemaker.test.ts index 83fbd0a90..f1d8fd470 100644 --- a/src/services/previews/settlemaker.test.ts +++ b/src/services/previews/settlemaker.test.ts @@ -241,12 +241,22 @@ describe("toSettlemakerInput", () => { }); async function decodeIParam(url: string) { - const encoded = new URL(url).searchParams.get("i") as string; + const parsed = new URL(url); + const encoded = new URLSearchParams(parsed.hash.slice(1)).get("i") ?? (parsed.searchParams.get("i") as string); const b64 = encoded.replace(/-/g, "+").replace(/_/g, "/"); const bin = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4)); const bytes = Uint8Array.from(bin, c => c.charCodeAt(0)); const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw")); - return JSON.parse(await new Response(stream).text()); + const envelope = JSON.parse(await new Response(stream).text()); + if (envelope.geometryEncoding === "xy-tuples") { + const point = ([x, y]: [number, number]) => ({ x, y }); + if (envelope.burg.coastlineGeometry) + envelope.burg.coastlineGeometry = envelope.burg.coastlineGeometry.map((polygon: [number, number][]) => + polygon.map(point) + ); + for (const river of envelope.burg.rivers ?? []) river.centreline = river.centreline.map(point); + } + return envelope; } describe("buildSettlemakerUrl", () => { @@ -282,32 +292,34 @@ describe("buildSettlemakerUrl", () => { expect(burg.waterContext.omittedRivers).toBeUndefined(); }); - it("rejects an oversized river course without silently deleting the river", async () => { + it("carries an oversized river course in the fragment without deleting geometry", 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"); + const { link, preview } = await 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 + } + ] + } + }); + expect(link).toBe(preview); + expect(new URL(link).search).toBe(""); + expect(new URL(link).hash.startsWith("#i=")).toBe(true); + expect((await decodeIParam(link)).burg.rivers[0].centreline).toEqual(centreline); }); it("round-trips an authoritative empty water survey alongside the legacy bearing", async () => { @@ -383,26 +395,27 @@ describe("buildSettlemakerUrl", () => { ); it.each([13, 16973])( - "rejects oversized measured geometry for population %s without dropping water state", + "carries oversized measured geometry for population %s in a fragment 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"); + const { link } = await buildSettlemakerUrl(ctx({ burg: { ...ctx().burg, population } }), { + waterSurvey: { + waterContext: { + version: 1, + status: "measured", + coordinateSpace: "burg-local-metres", + surveyRadiusM: 3000, + geometryErrorM: 0.5, + bodies: [] + }, + coastlineGeometry + } + }); + expect(new URL(link).search).toBe(""); + expect(new URL(link).hash.startsWith("#i=")).toBe(true); + expect((await decodeIParam(link)).burg.coastlineGeometry).toEqual(coastlineGeometry); } ); @@ -481,7 +494,9 @@ describe("buildSettlemakerUrl", () => { }), { urbanDensity: 8, trade: true } ); - expect(new URL(link).searchParams.get("i")!.length).toBeGreaterThan(MAX_ENCODED_PAYLOAD_BYTES); + expect(new URLSearchParams(new URL(link).hash.slice(1)).get("i")!.length).toBeGreaterThan( + MAX_ENCODED_PAYLOAD_BYTES + ); const { burg } = await decodeIParam(link); expect(burg.culture).toBe(culture); expect(burg.roadBearings).toHaveLength(approaches.filter(a => a.group !== "traderoutes").length); diff --git a/src/services/previews/settlemaker.ts b/src/services/previews/settlemaker.ts index f68c1be83..395d4d4ab 100644 --- a/src/services/previews/settlemaker.ts +++ b/src/services/previews/settlemaker.ts @@ -33,6 +33,7 @@ export interface AzgaarBurgInput { harbourSize?: "large" | "small"; harbourWaterKind?: "ocean" | "lake" | "river"; harbourRiverId?: string; + waterfront?: { riverId?: string; shoreKind?: "ocean" | "lake"; shoreFeatureId?: string }; urbanDensity?: number; coreCapacity?: number; coastlineGeometry?: Array>; @@ -68,6 +69,7 @@ export function toSettlemakerInput( trade?: boolean; harbourWaterKind?: AzgaarBurgInput["harbourWaterKind"]; harbourRiverId?: string; + waterfront?: AzgaarBurgInput["waterfront"]; } ): AzgaarBurgInput { const { burg, hydrology, terrain, climate } = ctx; @@ -105,6 +107,7 @@ export function toSettlemakerInput( 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 (opts.waterfront) input.waterfront = opts.waterfront; if (climate.biome) input.biome = climate.biome; if (burg.culture) input.culture = burg.culture; input.elevation = terrain.elevationM; @@ -117,7 +120,7 @@ export function toSettlemakerInput( export const SETTLEMAKER_BASE_URL = "https://settlemaker.com/fmg"; export const URL_PAYLOAD_VERSION = 1; -/** url-api.md §3: keep the encoded i= value under ~8KB for margin against proxy limits. */ +/** Larger payloads use a fragment, which is not sent through HTTP proxies. */ export const MAX_ENCODED_PAYLOAD_BYTES = 8192; /** JSON → UTF-8 → deflate-raw → base64url, per url-api.md §3. */ @@ -137,6 +140,7 @@ export async function buildSettlemakerUrl( trade?: boolean; harbourWaterKind?: AzgaarBurgInput["harbourWaterKind"]; harbourRiverId?: string; + waterfront?: AzgaarBurgInput["waterfront"]; waterSurvey?: WaterSurvey; baseUrl?: string; } @@ -149,14 +153,28 @@ export async function buildSettlemakerUrl( } const seed = hashSeedToInt(ctx.burg.seedKey); - const encoded = await encodeJsonParam({ v: URL_PAYLOAD_VERSION, burg: input, seed }); - if (encoded.length > MAX_ENCODED_PAYLOAD_BYTES) { - if (input.waterContext) throw new Error("This measured water preview is too detailed to fit in a preview link."); - // The bound is advisory, not a renderer limit. Flat URLs lose route IDs and hints. - WARN && console.warn(`settlemaker payload ${encoded.length}B exceeds the recommended URL budget`); - } - - // /fmg is already chrome-free, so there is no separate preview variant. - const link = `${opts.baseUrl ?? SETTLEMAKER_BASE_URL}?i=${encoded}`; + const compact = Boolean(input.coastlineGeometry || input.rivers); + const burg = compact + ? { + ...input, + ...(input.coastlineGeometry && { + coastlineGeometry: input.coastlineGeometry.map(polygon => polygon.map(({ x, y }) => [x, y])) + }), + ...(input.rivers && { + rivers: input.rivers.map(river => ({ + ...river, + centreline: river.centreline.map(({ x, y }) => [x, y]) + })) + }) + } + : input; + const encoded = await encodeJsonParam({ + v: URL_PAYLOAD_VERSION, + ...(compact && { geometryEncoding: "xy-tuples" }), + burg, + seed + }); + const separator = encoded.length > MAX_ENCODED_PAYLOAD_BYTES ? "#" : "?"; + const link = `${opts.baseUrl ?? SETTLEMAKER_BASE_URL}${separator}i=${encoded}`; return { link, preview: link }; }