Compare commits

...

5 commits

Author SHA1 Message Date
1a3ba56d9a t 2026-08-08 17:41:30 +09:00
0990b98436 improoooved 2026-05-30 03:25:19 +09:00
440dba2f09 UI 2026-05-29 23:49:02 +09:00
6cef9a3abe not baad 2026-05-29 22:00:42 +09:00
4a8aff90b2 not bad 2026-05-29 18:50:54 +09:00
124 changed files with 19486 additions and 8107 deletions

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# Prefecture Map Generator
A browser-based procedural prefecture map generator.
## Project layout
- `src/` - application modules and web workers
- `styles/` - application styles
- `tests/` - reusable browser and Node.js tests
- `scripts/` - local development server helpers
- `archive/` - recoverable historical and legacy files, excluded from the active app
- `index.html` - application entry point
## Run locally
On Windows:
```bat
scripts\start_server.bat 8000
```
On macOS or Linux:
```sh
./scripts/start_server.sh 8000
```
Then open `http://127.0.0.1:8000/`.
## Tests
Run the complete Node.js test suite from the project root:
```sh
node tests/test-all.mjs
```
The browser test page is available at `http://127.0.0.1:8000/tests/test.html`.

1041
app.js

File diff suppressed because it is too large Load diff

8
archive/README.md Normal file
View file

@ -0,0 +1,8 @@
# Archive
This folder contains files removed from the active map project during cleanup.
- `generated-history/` contains old validation scripts, reports, probes, and notes.
- `legacy-project/` contains saved runtime state and unrelated files from the former colony app.
These files are retained only so the cleanup is reversible. Nothing in the active application references this folder.

View file

@ -0,0 +1,38 @@
# Final additional-generation stability fix
## Remaining failure reproduced
After the earlier large-selection work, a position-dependent rollback still existed. A large overlapping expansion could fail with `patch-large-final-seam-failed (road-portal-broken)` even though the road in question never crossed from established geography into newly generated geography.
The concrete false portal was an old `minorRoads` segment wholly inside the already-generated map. It crossed the synthetic write/blend band, so the diagnostic system treated it as a mandatory expansion seam portal. When that unrelated old road could not be rerouted through the patch gateway, the complete multi-tile operation was rolled back and the UI showed no additional-generation preview.
## Fix
Expansion transport portals are now contractual only when the pre-patch road/rail transition actually crosses **generated ↔ ungenerated** geography. A route whose two sides are both already generated (or both previously ungenerated) is not a user-visible expansion seam and is excluded from the hard portal contract.
The hard gate itself remains enabled. Genuine generated/ungenerated transport crossings are still audited; the change removes only false portals created by the implementation write band.
The previous large-selection stability changes are retained: bounded tiling, deferred internal-tile transport seam checks, final whole-selection repair/audit, single final administrative/terrain coherence passes, lightweight internal snapshots, and reduced duplicate per-tile work.
## Final verification
- 300×339 direct expansion: PASS, 4 tiles, seam clean.
- 500×350 direct expansion: PASS, 4 tiles, seam clean.
- 600×400 Worker expansion, deliberately bottom-right/off-center: PASS, 9 tiles, seam clean.
- selected cells: 240,000
- previously ungenerated selected cells: 192,786
- missing after generation: **0**
- Exact position that reproduced the false road portal: PASS.
- selected cells: 125,268
- previously ungenerated selected cells: 106,629
- missing after generation: **0**
- broken road portals: **0**
- Previously reported geography regressions: PASS.
- river points on sea: 0
- duplicate prefectural capitals: 0
- tiny newly-generated prefectures: 0
- administrative frontier breaks: 0
- max established-frontier elevation jump: 0.0268
- STEP15: PASS; expansion seam clean, no escaped footprint, unresolved road/rail portals 0.
- STEP17: PASS; alternatives still differ, explicit render revision and one-worker-per-patch behavior retained.
- Seam stress: 2 seeds × 4 directions = 8 cases, all seam clean; maximum observed frontier elevation jump 0.0364 vs hard limit 0.075.
## Island handling
Small disconnected components are not deleted solely because they are small. Sea-separated components without a valid adjacent land prefecture can represent legitimate islands and remain preserved.

View file

@ -0,0 +1,198 @@
{
"ok": true,
"verifiedAt": "2026-08-07",
"largeSelection": {
"thresholdCase300x339": {
"mode": "direct",
"size": "300x339",
"ms": 21695,
"ok": true,
"code": null,
"tileCount": 4,
"seam": "clean",
"hardPass": true
},
"midCase500x350": {
"mode": "direct",
"size": "500x350",
"ms": 23441,
"ok": true,
"code": null,
"tileCount": 4,
"seam": "clean",
"hardPass": true
},
"workerCoverage600x400BottomRight": {
"ok": true,
"size": "600x400",
"selectedCells": 240000,
"previouslyUngeneratedSelectedCells": 192786,
"missingPreviouslyUngeneratedCells": 0,
"tileCount": 9,
"seam": "clean",
"hardPass": true,
"ms": 42324
},
"overlapPortalRegression": {
"ok": true,
"rect": {
"x0": 345,
"y0": 257,
"x1": 774,
"y1": 549
},
"selectedCells": 125268,
"previouslyUngeneratedSelectedCells": 106629,
"missingPreviouslyUngeneratedCells": 0,
"tileCount": 4,
"roadPortalsBefore": 0,
"roadPortalsBroken": 0,
"seam": "clean",
"hardPass": true,
"ms": 19567
}
},
"reportedGeographyRegression": {
"ok": true,
"riverSeaPoints": 0,
"duplicateCapitalPrefs": [],
"tinyGenerated": [],
"maxEstablishedFrontierElevationJump": 0.0268,
"adminBreaksOnEstablishedFrontier": 0,
"prefectureBreaksOnEstablishedFrontier": 0,
"seamStatus": "clean"
},
"step15": {
"ok": true,
"expansion": {
"seconds": 14.65,
"seamStatus": "clean",
"footprintEscapedCells": 0,
"roadPortalsUnresolved": 0,
"railPortalsUnresolved": 0
},
"rollback": {
"code": "patch-quality-gate-failed",
"restored": true,
"sourceIdentityPreserved": true
}
},
"step17": {
"ok": true,
"alternativesDiffer": true,
"explicitRenderRevision": true,
"singlePatchWorkerLifetime": true
},
"seamStress": {
"cases": 8,
"maxObserved": 0.0364,
"hardLimit": 0.075,
"allClean": true,
"results": [
{
"baseSeed": 24681357,
"direction": "right",
"patchSeed": 305070147,
"pairs": 102,
"maxJump": 0.0253,
"meanJump": 0.0088,
"diagnosticMax": 0.0364,
"seamStatus": "clean",
"seamReasons": [],
"adjusted": 262,
"gradientAdjusted": 2063
},
{
"baseSeed": 24681357,
"direction": "left",
"patchSeed": 305070192,
"pairs": 84,
"maxJump": 0.0364,
"meanJump": 0.0237,
"diagnosticMax": 0.0364,
"seamStatus": "clean",
"seamReasons": [],
"adjusted": 144,
"gradientAdjusted": 1950
},
{
"baseSeed": 24681357,
"direction": "down",
"patchSeed": 305070177,
"pairs": 145,
"maxJump": 0.0364,
"meanJump": 0.0197,
"diagnosticMax": 0.0364,
"seamStatus": "clean",
"seamReasons": [],
"adjusted": 250,
"gradientAdjusted": 2689
},
{
"baseSeed": 24681357,
"direction": "up",
"patchSeed": 305070102,
"pairs": 145,
"maxJump": 0.0364,
"meanJump": 0.0258,
"diagnosticMax": 0.0364,
"seamStatus": "clean",
"seamReasons": [],
"adjusted": 236,
"gradientAdjusted": 2488
},
{
"baseSeed": 13579246,
"direction": "right",
"patchSeed": 328771616,
"pairs": 55,
"maxJump": 0.0221,
"meanJump": 0.0057,
"diagnosticMax": 0.0364,
"seamStatus": "clean",
"seamReasons": [],
"adjusted": 224,
"gradientAdjusted": 1913
},
{
"baseSeed": 13579246,
"direction": "left",
"patchSeed": 328771603,
"pairs": 102,
"maxJump": 0.0134,
"meanJump": 0.0054,
"diagnosticMax": 0.0364,
"seamStatus": "clean",
"seamReasons": [],
"adjusted": 240,
"gradientAdjusted": 2064
},
{
"baseSeed": 13579246,
"direction": "down",
"patchSeed": 328771586,
"pairs": 141,
"maxJump": 0.0286,
"meanJump": 0.0077,
"diagnosticMax": 0.0364,
"seamStatus": "clean",
"seamReasons": [],
"adjusted": 214,
"gradientAdjusted": 2491
},
{
"baseSeed": 13579246,
"direction": "up",
"patchSeed": 328771701,
"pairs": 83,
"maxJump": 0.0364,
"meanJump": 0.0105,
"diagnosticMax": 0.0259,
"seamStatus": "clean",
"seamReasons": [],
"adjusted": 141,
"gradientAdjusted": 2549
}
]
}
}

View file

@ -0,0 +1,30 @@
{
"ok": true,
"riverSeaPoints": 0,
"duplicateCapitalPrefs": [],
"missingCapitalPrefs": [],
"generatedSizes": [
[
0,
19776
],
[
3,
3083
],
[
4,
6129
]
],
"tinyGenerated": [],
"landLandPairs": 102,
"maxEstablishedFrontierElevationJump": 0.0268,
"adminBreaksOnEstablishedFrontier": 0,
"prefectureBreaksOnEstablishedFrontier": 0,
"frontierAdminCellsAligned": 568,
"establishedFrontierAdminCellsRestored": 601,
"frontierHarmonizedValues": 94004,
"seamStatus": "clean",
"seamGateReasons": []
}

View file

@ -0,0 +1,15 @@
{
"ok": true,
"capitalAudit": {
"active": [
0,
2,
3,
1
],
"zero": [],
"duplicate": []
},
"outsideRiverSeaPointsPreserved": 5,
"seam": "clean"
}

View file

@ -0,0 +1,13 @@
{
"ok": true,
"steps": [
{
"active": 5,
"seam": "clean"
},
{
"active": 6,
"seam": "clean"
}
]
}

View file

@ -0,0 +1,96 @@
{
"ok": true,
"scope": "fixes 3-7 only; previously identified issues 1-2 intentionally unchanged",
"adminRiver": {
"ok": true,
"capitalAudit": {
"active": [
0,
2,
3,
1
],
"zero": [],
"duplicate": []
},
"outsideRiverSeaPointsPreserved": 5,
"seam": "clean"
},
"capitalSequence": {
"ok": true,
"steps": [
{
"active": 5,
"seam": "clean"
},
{
"active": 6,
"seam": "clean"
}
]
},
"freeformTiling": {
"ok": true,
"tileCount": 4,
"ms": 27805,
"seam": "clean"
},
"uiState": {
"ok": true,
"cancelButton": true,
"watchdogSeconds": 60,
"escapeCancelsBusy": true,
"clearDiscardsPendingPreview": true
},
"reportedRegression": {
"ok": true,
"riverSeaPoints": 0,
"duplicateCapitalPrefs": [],
"missingCapitalPrefs": [],
"generatedSizes": [
[
0,
19776
],
[
3,
3083
],
[
4,
6129
]
],
"tinyGenerated": [],
"landLandPairs": 102,
"maxEstablishedFrontierElevationJump": 0.0268,
"adminBreaksOnEstablishedFrontier": 0,
"prefectureBreaksOnEstablishedFrontier": 0,
"frontierAdminCellsAligned": 568,
"establishedFrontierAdminCellsRestored": 601,
"frontierHarmonizedValues": 94004,
"seamStatus": "clean",
"seamGateReasons": []
},
"step15": {
"ok": true,
"expansion": {
"seconds": 14.99,
"seamStatus": "clean",
"footprintEscapedCells": 0,
"roadPortalsUnresolved": 0,
"railPortalsUnresolved": 0
},
"rollback": {
"code": "patch-quality-gate-failed",
"restored": true,
"sourceIdentityPreserved": true
}
},
"step17": {
"ok": true,
"alternativesDiffer": true,
"explicitRenderRevision": true,
"singlePatchWorkerLifetime": true
}
}

View file

@ -0,0 +1,47 @@
# Fixes 3-7: additional-generation integrity and UI controls
This revision intentionally addresses items 3-7 from the post-audit list. Items 1-2 (selection-size/mode discontinuity and derived-field seam continuity) are intentionally unchanged.
## 3. Prefectural capitals and prefecture metadata
- Every active prefecture raster ID is now required to have exactly one capital-like `modernCities` record.
- If a prefecture has no capital, the patch finalizer promotes, in order: an existing city, a market, a municipal center, then a generated fallback point.
- Duplicate capitals are still demoted.
- `prefectureRegions` metadata is rebuilt after capital normalization and now refreshes `worldX/worldY`, `capitalX/capitalY`, `capitalWorldX/capitalWorldY`, and `insidePrefecture` instead of carrying stale values across world expansion.
Validation: two sequential expansion patches increased active prefectures from 5 to 6 while maintaining exactly one capital per prefecture and valid capital metadata coordinates.
## 4. River sanitation locality
- River-vs-sea cleanup is now restricted to the patch `writeRect`.
- Existing river-mouth/ocean path points outside the patch are preserved byte-for-coordinate instead of being globally rewritten by an unrelated patch.
- River raster cells and path points over final sea are still removed inside the patch write region.
Validation: five pre-existing sea-mouth points outside the tested expansion remained unchanged; sea river points inside the generated write region were zero.
## 5. Freeform selection tiling
- Polygon/lasso expansion no longer uses the full bounding-box row × column Cartesian grid.
- The occupied polygon is recursively split along its overflowing axis, and only polygon-intersecting child regions are generated.
- Rectangular selections retain the existing regular tiling path.
Validation: a thin diagonal lasso whose old bounding-box grid implied up to 9 candidates now uses 4 tiles and completes with `seam=clean`.
## 6. Cancel and watchdog
- Added a `Cancel Generation` button.
- Escape cancels an active patch generation.
- Cancellation invalidates the request before terminating its worker so a late result cannot become `pendingPatch`.
- Added a 60-second *inactivity* watchdog. It resets on every worker progress event, so long generations are allowed as long as they continue reporting progress.
## 7. Clear Selection / pending preview state
- Clear Selection now discards any un-applied pending preview as well as the selection overlay.
- Escape outside generation does the same.
- `hideSelectionOverlay()` defaults to discarding a pending preview unless explicitly committing or opting to keep it, preventing a hidden `pendingPatch` from surviving after the dotted selection disappears.
## Regression status
- STEP15: PASS; expansion seam clean, no footprint escape, no unresolved road/rail portals, rollback preserved.
- STEP17: result `ok:true`; alternatives differ, render revision and single-patch-worker behavior preserved. The Node harness still remains alive after printing its result, so the outer timeout is a harness-exit issue rather than a failed assertion.
- Previously reported geography regression: PASS; no duplicate or missing capitals, no tiny generated prefectures, no patch-region sea river points, frontier admin breaks 0, maximum established-frontier elevation jump 0.0268, seam clean.

View file

@ -0,0 +1,6 @@
{
"ok": true,
"tileCount": 4,
"ms": 27805,
"seam": "clean"
}

View file

@ -0,0 +1,7 @@
{
"ok": true,
"cancelButton": true,
"watchdogSeconds": 60,
"escapeCancelsBusy": true,
"clearDiscardsPendingPreview": true
}

View file

@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
const appSource = readFileSync(new URL('./app.js', import.meta.url), 'utf8');
const patchSource = readFileSync(new URL('./mapPatch.js', import.meta.url), 'utf8');
assert.match(appSource, /acceptBestAvailableQuality:\s*true/);
assert.match(appSource, /!state\.patchBusy\s*&&\s*options\.allowWorldExpand/);
assert.match(patchSource, /if \(Number\.isFinite\(p\?\.x\)\) return p\.x \+ \(world\?\.originX \|\| 0\)/);
assert.match(patchSource, /if \(Number\.isFinite\(p\?\.y\)\) return p\.y \+ \(world\?\.originY \|\| 0\)/);
const initial = generateMap(1, { terrainType: 'auto', onProgress() {} });
const rect = { x0: 30, y0: 30, x1: 110, y1: 110 };
const strictWorld = createWorldMap(structuredClone(initial));
const strictResult = generatePatch(strictWorld, rect, {
patchMode: 'expansion', terrainType: 'oceanic_archipelago', seed: 456, variant: 0, maxQualityRetries: 0,
});
assert.equal(strictResult.ok, false);
assert.equal(strictResult.code, 'patch-quality-gate-failed');
const previewWorld = createWorldMap(structuredClone(initial));
const previewResult = generatePatch(previewWorld, rect, {
patchMode: 'expansion', terrainType: 'oceanic_archipelago', seed: 456, variant: 0,
maxQualityRetries: 0, acceptBestAvailableQuality: true,
});
assert.equal(previewResult.ok, true);
assert.equal(previewResult.qualityAcceptedAsBestAvailable, true);
assert.equal(previewResult.candidateQuality?.acceptedAsBestAvailable, true);
console.log(JSON.stringify({
ok: true,
strict: { ok: strictResult.ok, code: strictResult.code },
preview: { ok: previewResult.ok, acceptedAsBestAvailable: previewResult.qualityAcceptedAsBestAvailable, hardPass: previewResult.candidateQuality?.hardPass ?? null },
}, null, 2));

View file

@ -0,0 +1,28 @@
# Large additional-generation fix
## Symptoms
- A moderately larger patch selection could appear to stop during generation.
- A larger selection could return no visible preview or leave part of the selected area untouched.
## Root causes
1. `app.js` requested `qualityTerrainAttempts: 1`, but `generatePatchAttempt()` did not forward that option into the expansion candidate selector. An interactive click could therefore run extra terrain searches and a second complete production generation. On larger selections this could keep the patch worker busy long enough to look interrupted or be terminated under memory/CPU pressure.
2. Expansion candidates are production-sized (`258 x 183`) but patch selections had no corresponding upper bound. A selection larger than the candidate window caused source indices outside that fixed candidate to be skipped, so portions of a large selection could remain effectively ungenerated.
3. The first large-selection tiling prototype over-overlapped slightly oversized ranges, multiplying work. Tiling now evenly distributes the range with ~28-cell overlap.
4. Internal tile seams initially treated roads/rails created by an earlier tile as pre-existing portal contracts. A later tile could then hard-fail on a road that did not exist before the user's operation. Only transport present before the entire large patch is now protected by the hard portal contract.
## Changes
- Forward `qualityTerrainAttempts` to the expansion candidate selector. Interactive generation now actually performs one requested full candidate per click.
- Keep selections up to `258 x 183` on the existing single-candidate fast path.
- Automatically split larger Expansion selections into overlapping production-sized tiles, including freeform/lasso selections via polygon clipping.
- Process tiles outward from already-generated geography and merge all tile results into one preview world.
- Soft per-tile quality floors may use best-available results; hard seam failures still reject the operation.
- Preserve only the pre-operation road/rail set as a hard seam portal baseline across internal tiles.
- Worker-owned previews avoid an unnecessary second large field snapshot; synchronous/API calls retain atomic rollback snapshots.
## Validation
- Direct near-limit patch `250 x 180`: PASS, one full candidate, not tiled.
- Large patch `280 x 200`: PASS, 4 tiles, 56,000/56,000 selected cells covered by generated records, seam clean.
- Worker large patch `280 x 200`: PASS, outer worker result and inner patch result both successful, 4 tiles, seam clean.
- STEP17 validation: PASS for Alternative variants 0 and 1; variants remain distinct.
- STEP15 validation: PASS; Expansion seam clean, escaped footprint cells 0, unresolved road/rail portals 0.
- Previous reported-geography regression: PASS; river-on-sea 0, duplicate capitals 0, tiny generated prefectures 0, admin/prefecture frontier breaks 0, established-frontier max elevation jump 0.0268.

View file

@ -0,0 +1,27 @@
# Large additional-generation stability fix
## Reproduced failure
The previous build could roll back or appear to stop for larger selections. A concrete failure was reproduced at 300x339 and larger selections, with internal-tile transport seam failures and later with cumulative work/memory growth as tile count increased.
## Root causes fixed
1. **Internal tile transport seam contracts were treated as user-visible outer seams.** A transient road/rail portal inside the tile grid could roll back the entire selection. Internal tile transport seam checks are now deferred; only the real user-selection outer seam is repaired and hard-gated after all tiles are merged.
2. **Tile-count thresholds jumped unnecessarily.** The tiler now chooses the minimum grid that fits the fixed candidate window with a bounded overlap instead of adding a row/column too early.
3. **Full structural administration repair ran once per internal tile.** This repeatedly rescanned an ever-growing world. Internal tiles now do local coverage/ID bookkeeping only; topology cleanup, tiny-prefecture handling, frontier continuity and seam-shape repair run once over the complete selection after tile merge.
4. **Internal rollback/debug snapshots retained large world metadata repeatedly.** Internal tiles now use a lightweight local transaction while the outer tiled operation owns the atomic rollback snapshot. Generated tile history is stored as compact geometry/identity records.
5. **Patch-mode transport generation did redundant expensive guarantees for every internal tile.** Internal large-selection tiles use bounded road-flow/backbone work, while the final merged world performs the authoritative outer-network repair.
6. **Municipality connectivity work was needlessly repeated by municipality ID.** The strict connectivity pass was replaced with a whole-map component traversal that preserves genuine isolated islands while avoiding ID-count-proportional rescans.
7. **Terrain and administrative coherence are finalized once for the full selection.** This avoids repeatedly smoothing/reclassifying overlapping internal seams and keeps the final result governed by the actual user boundary.
## Verification performed on this build
- 300x339 direct: PASS, 4 tiles, seam clean.
- 500x350 direct: PASS, 4 tiles, seam clean.
- 600x400 direct: PASS, 9 tiles, seam clean, about 42.8 s in this environment.
- 600x400 Worker path: PASS, outer/inner result true, 9 tiles, seam clean, about 41.1 s.
- 280x200 direct and Worker regressions: PASS, selected-cell coverage 100% in the synchronous check.
- STEP15 validation: PASS; expansion seam clean, no escaped footprint, no unresolved road/rail portals.
- STEP17 validation: PASS; alternatives still differ and render-revision/Worker-lifetime behavior is retained.
- Previously reported geography regressions: PASS; no sea-river points, duplicate prefectural capitals, tiny newly generated prefectures, or administrative frontier breaks in the regression case.
- Seam stress directions were run in isolated processes to avoid Node test-process accumulation; observed maximum frontier elevation jump stayed <= 0.0364 versus the 0.075 hard limit.
## Island handling
Small disconnected components belonging to a larger prefecture are not automatically deleted. If they are separated by sea and lack an adjacent land prefecture to merge into, they can represent legitimate islands and are preserved.

View file

@ -0,0 +1,42 @@
# Additional-generation geography integrity fixes
## Fixed issues
1. **Multiple prefectural capitals in one prefecture**
- Re-normalizes capital flags against the final prefecture raster after patch merge.
- Keeps at most one capital-like city per prefecture, preferring established capitals.
2. **Selection dotted outline could not be cleared**
- Added a `Clear Selection` button.
- `Escape` also clears the active patch selection when generation is idle.
3. **Rivers appearing over sea**
- Final river raster is clipped against the final sea mask.
- River path layers are split/removed where path points fall on final sea cells.
4. **Extremely small generated prefectures**
- Newly generated prefectures are no longer immune to administrative cleanup.
- Tiny newly allocated prefectures are merged into the strongest adjacent prefecture.
- Established pre-existing prefectures are never collapsed by this cleanup.
5. **Unnatural geography along generation boundaries**
- Added old/new terrain-frontier harmonization while keeping the established side fixed.
- Added administrative-frontier continuation so municipality/prefecture borders do not terminate on the generation edge.
- Increased expansion overlap and corner taper to avoid rectangular/clipped coastlines and terrain.
- Added a final footprint restore so derived-field recomputation cannot leak outside the irregular generated footprint.
## Regression validation
`VALIDATE_REPORTED_PATCH_BUGS.mjs` checks the five reported failure classes directly.
Final observed result:
- river points on sea: `0`
- prefectures with duplicate capitals: `0`
- tiny generated prefectures in probe: `0`
- municipality breaks on established generation frontier: `0`
- prefecture breaks on established generation frontier: `0`
- maximum established-frontier elevation jump: `0.0383`
- seam status: `clean`
Existing `STEP17_VALIDATION.mjs` also passes with `ok: true`, preserving alternative-generation differentiation, explicit render revision, and single-patch Worker lifetime behavior.

View file

@ -0,0 +1,77 @@
# Additional-generation seam continuity fix
Date: 2026-08-07
## Scope
This revision addresses the remaining defect from the previous geography-integrity package: visible terrain discontinuities along the established-map / newly-generated-map frontier.
The previously reported disconnected prefecture component is **not** treated as an error merely because it is small. A sea-separated component can be a legitimate island. Existing component cleanup already preserves sea-isolated administrative components when there is no adjacent land region to merge into, and this revision does not add an island-erasing cleanup pass.
## Changes
1. **Literal old/new frontier constraint**
- The final terrain pass now measures the actual generated-coverage transition rather than relying only on the selection rectangle or alpha feather.
- Newly generated land cells directly touching established land are constrained to the established elevation neighborhood.
- The established side remains unchanged.
2. **Outward gradient propagation**
- The solved contact edge is propagated up to 12 cells into the new side using coverage distance.
- This avoids simply moving the visible seam one or two cells inward.
3. **Dedicated hard seam metric**
- `maxEstablishedFrontierElevationJump` measures only land/land edges where generated coverage changes from established to new.
- Hard limit: `0.075`.
- A candidate above this limit fails with `established-frontier-elevation-jump`.
4. **Best-available policy split**
- Interactive preview may still show a candidate that only misses a soft terrain/human-geography quality floor.
- A hard seam failure is never accepted as `best available`; it is rolled back.
5. **Diagnostic warning semantics**
- Natural cliffs inside the newly generated region no longer make the whole seam status `warning` merely because one cliff exists.
- Cliff density and literal frontier continuity are evaluated separately.
- Advanced diagnostics now show `Established frontier elevation` and the hard limit.
## Validation
### Reported-bug regression
`node VALIDATE_REPORTED_PATCH_BUGS.mjs`
- river path points on sea: `0`
- duplicate prefectural-capital prefectures: `0`
- tiny generated prefectures: `0`
- municipal breaks on established frontier: `0`
- prefecture breaks on established frontier: `0`
- maximum established-frontier elevation jump: `0.0268`
- seam status: `clean`
- seam gate reasons: none
### Direction / seed stress validation
`VALIDATE_SEAM_STRESS.mjs` was run in four separate two-case processes (to avoid cumulative process runtime limits): 2 base seeds × right/left/down/up.
- cases: `8`
- hard limit: `0.075`
- maximum independently measured frontier jump: `0.0364`
- all cases: seam hard gate PASS
See `SEAM_STRESS_RESULT.json` for per-case values.
### Existing regression suites
`STEP15_VALIDATION.mjs`: PASS
- expansion seam status: `clean`
- generated-footprint escaped cells: `0`
- unresolved road portals: `0`
- unresolved rail portals: `0`
`STEP17_VALIDATION.mjs`: PASS
- Alternative variants differ
- explicit render revision retained
- single-patch Worker lifetime retained
`FIX_ADDITIONAL_GENERATION_VALIDATION.mjs`: PASS
- strict quality caller still rolls back on quality failure
- interactive preview still accepts a soft-quality best-available candidate when seam continuity passes

View file

@ -0,0 +1,6 @@
{
"ok": true,
"tileCount": 5,
"ms": 31734,
"seam": "clean"
}

View file

@ -0,0 +1,51 @@
{
"ok": true,
"directNearLimit": {
"selection": "250x180",
"tiled": false,
"fullCandidateAttempts": 1
},
"largeSync": {
"selection": "280x200",
"tiled": true,
"tileCount": 4,
"selectedCells": 56000,
"generatedCoverageCells": 56000,
"missingCells": 0,
"seamStatus": "clean",
"measuredTotalMs": 20588.2
},
"largeWorker": {
"selection": "280x200",
"outerWorkerOk": true,
"patchOk": true,
"tiled": true,
"tileCount": 4,
"seamStatus": "clean",
"measuredTotalMs": 23139
},
"step17": {
"ok": true,
"variant0Seconds": 14.04,
"variant1Seconds": 15.22,
"alternativesDiffer": true
},
"step15": {
"ok": true,
"expansionSeconds": 14.88,
"seamStatus": "clean",
"footprintEscapedCells": 0,
"roadPortalsUnresolved": 0,
"railPortalsUnresolved": 0
},
"previousGeographyRegression": {
"ok": true,
"riverSeaPoints": 0,
"duplicateCapitalPrefectures": 0,
"tinyGeneratedPrefectures": 0,
"adminFrontierBreaks": 0,
"prefectureFrontierBreaks": 0,
"maxEstablishedFrontierElevationJump": 0.0268,
"seamStatus": "clean"
}
}

View file

@ -0,0 +1,18 @@
{
"ok": true,
"verifiedAt": "2026-08-07",
"largeSelectionCases": [
{"mode":"direct","size":"300x339","ms":24539,"ok":true,"tileCount":4,"seam":"clean","hardPass":true},
{"mode":"direct","size":"500x350","ms":29637,"ok":true,"tileCount":4,"seam":"clean","hardPass":true},
{"mode":"direct","size":"600x400","ms":42836,"ok":true,"tileCount":9,"seam":"clean","hardPass":true},
{"mode":"worker","size":"600x400","ms":41060,"outerOk":true,"ok":true,"tileCount":9,"seam":"clean","hardPass":true}
],
"existingRegressionChecks": {
"large280x200Sync": {"ok":true,"missingSelectedCells":0,"tileCount":4,"seam":"clean"},
"large280x200Worker": {"outerOk":true,"ok":true,"tileCount":4,"seam":"clean"},
"reportedPatchBugs": {"ok":true,"riverSeaPoints":0,"duplicateCapitalPrefs":0,"tinyGeneratedPrefectures":0,"adminFrontierBreaks":0,"prefectureFrontierBreaks":0,"maxEstablishedFrontierElevationJump":0.0268,"seam":"clean"},
"step17": {"ok":true,"alternativesDiffer":true,"explicitRenderRevision":true,"singlePatchWorkerLifetime":true},
"step15": {"ok":true,"expansionSeam":"clean","footprintEscapedCells":0,"roadPortalsUnresolved":0,"railPortalsUnresolved":0},
"seamStress": {"checkedIndependently":true,"maxObserved":0.0364,"hardLimit":0.075,"status":"clean"}
}
}

View file

@ -0,0 +1,9 @@
{
"ms": 23764,
"outer": true,
"inner": true,
"tiled": true,
"tileCount": 6,
"seam": "clean",
"reason": null
}

View file

@ -0,0 +1,26 @@
{
"ok": true,
"riverSeaPoints": 0,
"duplicateCapitalPrefs": [],
"missingCapitalPrefs": [],
"generatedSizes": [
[
3,
1333
],
[
4,
4239
]
],
"tinyGenerated": [],
"landLandPairs": 102,
"maxEstablishedFrontierElevationJump": 0.0364,
"adminBreaksOnEstablishedFrontier": 0,
"prefectureBreaksOnEstablishedFrontier": 0,
"frontierAdminCellsAligned": 0,
"establishedFrontierAdminCellsRestored": 0,
"frontierHarmonizedValues": 0,
"seamStatus": "clean",
"seamGateReasons": []
}

View file

@ -0,0 +1,17 @@
{
"ok": true,
"note": "Measured in four separate two-case runs to avoid cumulative validation-process runtime limits.",
"hardLimit": 0.075,
"cases": 8,
"maxObserved": 0.0364,
"results": [
{"baseSeed":24681357,"direction":"right","pairs":102,"maxJump":0.0253,"meanJump":0.0088,"diagnosticMax":0.0364,"seamReasons":[]},
{"baseSeed":24681357,"direction":"left","pairs":84,"maxJump":0.0364,"meanJump":0.0244,"diagnosticMax":0.0364,"seamReasons":[]},
{"baseSeed":24681357,"direction":"down","pairs":145,"maxJump":0.0364,"meanJump":0.0202,"diagnosticMax":0.0364,"seamReasons":[]},
{"baseSeed":24681357,"direction":"up","pairs":145,"maxJump":0.0364,"meanJump":0.0258,"diagnosticMax":0.0364,"seamReasons":[]},
{"baseSeed":13579246,"direction":"right","pairs":55,"maxJump":0.0221,"meanJump":0.0057,"diagnosticMax":0.0364,"seamReasons":[]},
{"baseSeed":13579246,"direction":"left","pairs":102,"maxJump":0.0134,"meanJump":0.0054,"diagnosticMax":0.0364,"seamReasons":[]},
{"baseSeed":13579246,"direction":"down","pairs":141,"maxJump":0.0285,"meanJump":0.0073,"diagnosticMax":0.0364,"seamReasons":[]},
{"baseSeed":13579246,"direction":"up","pairs":83,"maxJump":0.0364,"meanJump":0.0105,"diagnosticMax":0.0259,"seamReasons":[]}
]
}

View file

@ -0,0 +1,36 @@
# Step 0 / Step 1 implementation
## Step 0 — Seam diagnostics
Patch preview/application now records and displays:
- inspected seam-band cell count
- land→sea and sea→land flips
- existing transport cells converted to sea
- pre-existing road/rail seam portals and post-patch connection status
- new municipal/prefecture boundary edges created in the seam band
- near-parallel/overlapping boundary pairs
- abrupt elevation edges
- map markers and the seam outline
The overlay is enabled by default and can be toggled with **Layer → Seam diagnostics**.
Detailed metrics are shown under **Advanced / Debug Data → Seam Diagnostics** and included in **Copy Important Data**.
Marker colors:
- magenta dashed line: patch seam
- red: disconnected/critical issue
- orange: topology or boundary warning
- green: retained road/rail crossing
## Step 1 — Variable rectangle candidate suspended
`PATCH_VARIABLE_CANDIDATE_ENABLED` is set to `false` in `mapPatch.js`.
Patch generation therefore uses `legacy-full-pipeline`, while the variable-size implementation remains in the source for later re-enablement.
The UI and diagnostic output explicitly report `variableCandidateSuspended: true` during this comparison phase.
## Validation performed
- Syntax check passed for every JavaScript file.
- Two end-to-end Node runs completed: initial map generation, world creation, full-pipeline patch generation, seam analysis, and viewport diagnostic transformation.
- Both runs reported `patchGenerationMode: legacy-full-pipeline` and `variableCandidateSuspended: true`.

View file

@ -0,0 +1,126 @@
# Step 15 — residual cleanup and final verification
## Scope
This step completes the cleanup work identified after Step 14. The goal is to remove dead/duplicated/compatibility-only code and avoid pointless transport repair work without changing the generator's intended map quality. The earlier Step 14 transport lifecycle remains conceptually intact: base transport -> pre-admin finalization -> post-admin transport -> output topology finalization.
## Cleanup completed
### Dead and compatibility-only code
- Removed the remaining unused `nearestNetworkPoint` import.
- Removed unused local bindings such as the old renderer history flag and unused satellite-classification return binding.
- Removed test-only legacy name compatibility exports (`NAME_PARTS`, `generateTemplateName`).
- Removed retired name-generation fallback debug fields.
- Removed retired administration debug fields that were permanently zero after the older seed/snap pipeline was deleted.
- Removed stale comments referring to the deleted patch candidate cache.
- Reduced exports that were only used inside their own module.
### Shared utilities / duplication
- Centralized time measurement, world indexing, finite field access, nearest-point distance, and grid-path walking in `mapUtils.js`.
- Centralized administrative border-field averaging in `mapAdminShared.js`.
- Centralized transport path rasterization and occupancy connected-component labeling in `mapTransportUtils.js`.
- Reused the shared raster/component utilities from `mapTransport.js` and `mapOutput.js` instead of keeping separate implementations.
- Static audit now finds no unreachable runtime module, no unused runtime import/export/local top-level declaration, and no exact duplicate named function body among 773 scanned function bodies.
### Pre-admin road connectivity
`connectPreAdminRoadComponents()` previously could repeat a failed topology state up to 4446 times. It now:
- builds candidates only between different connected components,
- reuses the influence field while the path topology is unchanged,
- tries each candidate in the current topology state at most once,
- stops a round immediately when no candidate succeeds,
- rebuilds components only after an accepted connector changes topology.
Eight-seed probing shows failed attempts are now bounded to 02 in the common cases and no-success states stop after one round rather than 46 rounds.
### Gap-stitch candidate work
The former gap-stitch pass enumerated tens of thousands of same-component near pairs only to discard them. Candidate indexing is now component-aware so same-component pairs are excluded before route construction.
### Redundant road passes
- Removed the effectively dead `nationalPrune` pass after national-road downgrade.
- Made the final minor-road dedupe conditional on an actual connector having been added.
- Removed obsolete constant transport debug fields associated with deleted passes.
### Output road finalization
The output lifecycle now performs required additions before the final prune:
1. municipal-center local access,
2. required center stubs,
3. nearby endpoint connectors,
4. isolated-component pruning,
5. final component measurement.
This removes the previous normal pattern of pruning first and then re-adding required roads. The final diagnostic is now `finalOutputRoadTopology`, measured after all output topology mutations.
### Patch runtime regression found during cleanup
Static refactoring exposed one missed call site where `createPatchContext()` was still passed the deleted local `worldIndex` identifier. Normal map-generation tests did not execute that path. A real Expansion probe raised `ReferenceError: worldIndex is not defined`; the call site was corrected to the shared `worldIndexOf` helper. Expansion and rollback were rerun after the fix.
## Verification
### Runtime validation
`STEP15_VALIDATION.mjs` passes on the final source:
- seeds 1 / 3 / 5: every municipal center has road access,
- road-cell coverage remains above the regression floor,
- pre-admin connectivity attempts are bounded and no-success rounds terminate,
- Expansion succeeds with a clean seam,
- footprint write escapes: 0,
- unresolved road portals: 0,
- unresolved rail portals: 0,
- forced quality failure restores fields, source map, patch history state, serial, and source-map object identity.
Latest measured Expansion in this validation: 7.55 s in this container.
### Eight-seed transport probe
Seeds `1, 3, 5, 101, 777, 999, 2026, 12345` retain 100% municipal-center road coverage. The former 4446 failed pre-admin connector retries are gone; observed attempts are 03 per seed.
### Test shards
All eight test shards pass individually on the final source:
- core: 0 failures, 17.46 s
- terrain: 0 failures, 24.23 s
- admin: 0 failures, 14.01 s
- determinism-114514: 0 failures, 8.46 s
- determinism-12345: 0 failures, 8.00 s
- determinism-54321: 0 failures, 8.38 s
- determinism-777: 0 failures, 7.80 s
- determinism-999: 0 failures, 9.92 s
Total measured shard wall time when run as separate invocations: 98.26 s. Every shard is below three minutes.
The container tool used for this work applies a cumulative execution throttle/termination to long single tool invocations; consequently `test-all.mjs` could not be observed to finish as one monolithic invocation here even though each child suite completes independently. `STEP15_TEST_RESULT.json` records the authoritative per-shard verification rather than claiming a monolithic pass.
### Static audit
Final runtime source:
- runtime modules: 35
- reachable runtime modules: 35 / 35
- runtime source size: about 1.21 MB
- unused imports found: 0
- unused exported declarations found: 0
- unused local top-level declarations found: 0
- exact duplicate named function bodies found: 0 / 773 scanned
- retired compatibility/debug symbols remaining: 0
- all JS/MJS files pass `node --check`
## Packaging
Two packages are produced:
- `map_step15.zip`: clean development project with source, tests, Step 15 validation/report files, and patch.
- `map_step15_runtime.zip`: runtime-only package containing `index.html`, `styles.css`, and the 35 reachable runtime JavaScript modules. Tests, validation scripts, patches, notes, and old Step artifacts are excluded.
## Remaining caution
This cleanup deliberately avoids another redesign of the four lifecycle phases of the transport generator. Some output pruning may still legitimately remove isolated roads, and some isolated same-landmass municipal components may require terrain-aware connectors during final output. Those operations have functional purposes and were retained rather than treated as dead code.

View file

@ -0,0 +1,21 @@
{
"ok": true,
"runtimeFiles": 35,
"runtimeLines": 27567,
"runtimeBytes": 1209414,
"runtimeReachable": 35,
"runtimeUnreachable": [],
"unusedImports": [],
"unusedExports": [],
"unusedLocalCandidates": [],
"exactDuplicateFunctionBodies": [],
"functionBodiesScanned": 773,
"retiredSymbolsRemaining": [],
"sharedUtilitiesConfirmed": {
"pathRasterization": true,
"occupancyComponents": true,
"fieldSchema": true,
"commonTimingAndIndex": true
},
"note": "Regex/static reachability audit is conservative; runtime regression tests are the authoritative behavior check."
}

View file

@ -0,0 +1,83 @@
{
"ok": true,
"verificationMode": "individual-shards",
"reasonMonolithicNotUsed": "The container execution harness throttles/terminates long cumulative CPU runs; test-all.mjs did not complete in one tool invocation although each child suite completes independently.",
"suiteCount": 8,
"failures": 0,
"sumWallSeconds": 98.26,
"allSuitesUnderThreeMinutes": true,
"suites": [
{
"suite": "core",
"status": 0,
"failedAssertions": 0,
"fullMapGenerations": 4,
"elapsedMsReported": 17403,
"wallSeconds": 17.46,
"allTestsPassedMarker": true
},
{
"suite": "terrain",
"status": 0,
"failedAssertions": 0,
"fullMapGenerations": 6,
"elapsedMsReported": 24171,
"wallSeconds": 24.23,
"allTestsPassedMarker": true
},
{
"suite": "admin",
"status": 0,
"failedAssertions": 0,
"fullMapGenerations": 3,
"elapsedMsReported": 13950,
"wallSeconds": 14.01,
"allTestsPassedMarker": true
},
{
"suite": "determinism-114514",
"status": 0,
"failedAssertions": 0,
"fullMapGenerations": 2,
"elapsedMsReported": 8407,
"wallSeconds": 8.46,
"allTestsPassedMarker": true
},
{
"suite": "determinism-12345",
"status": 0,
"failedAssertions": 0,
"fullMapGenerations": 2,
"elapsedMsReported": 7931,
"wallSeconds": 8.0,
"allTestsPassedMarker": true
},
{
"suite": "determinism-54321",
"status": 0,
"failedAssertions": 0,
"fullMapGenerations": 2,
"elapsedMsReported": 8313,
"wallSeconds": 8.38,
"allTestsPassedMarker": true
},
{
"suite": "determinism-777",
"status": 0,
"failedAssertions": 0,
"fullMapGenerations": 2,
"elapsedMsReported": 7745,
"wallSeconds": 7.8,
"allTestsPassedMarker": true
},
{
"suite": "determinism-999",
"status": 0,
"failedAssertions": 0,
"fullMapGenerations": 2,
"elapsedMsReported": 9862,
"wallSeconds": 9.92,
"allTestsPassedMarker": true
}
]
}

View file

@ -0,0 +1,8 @@
{"seed":1,"ms":4760,"roadCells":2122,"centers":45,"covered":45,"minor":155,"national":27,"nc":{"beforeComponents":3,"afterComponents":3,"added":0,"failed":1,"attempted":1,"rounds":1},"gap":{"expressway":0,"national":1,"localToNational":16,"local":3,"localMeshClosures":41},"out":{"components":6,"requiredStubsAdded":6,"endpointConnectorsAdded":3,"prune":{"beforeComponents":12,"afterComponents":6,"pruned":{"minor":11,"national":1,"external":0,"expressway":0,"externalExpressway":0},"prunePasses":2,"mountainAdminConnections":{"attempted":6,"added":6,"skippedIsland":3,"failed":0}}}}
{"seed":3,"ms":4894,"roadCells":2598,"centers":43,"covered":43,"minor":181,"national":26,"nc":{"beforeComponents":3,"afterComponents":1,"added":2,"failed":0,"attempted":2,"rounds":2},"gap":{"expressway":0,"national":2,"localToNational":9,"local":4,"localMeshClosures":35},"out":{"components":2,"requiredStubsAdded":10,"endpointConnectorsAdded":8,"prune":{"beforeComponents":19,"afterComponents":2,"pruned":{"minor":5,"national":0,"external":0,"expressway":0,"externalExpressway":0},"prunePasses":2,"mountainAdminConnections":{"attempted":14,"added":14,"skippedIsland":1,"failed":0}}}}
{"seed":5,"ms":3471,"roadCells":2044,"centers":45,"covered":45,"minor":139,"national":19,"nc":{"beforeComponents":5,"afterComponents":4,"added":1,"failed":2,"attempted":3,"rounds":2},"gap":{"expressway":0,"national":1,"localToNational":9,"local":4,"localMeshClosures":40},"out":{"components":9,"requiredStubsAdded":10,"endpointConnectorsAdded":3,"prune":{"beforeComponents":22,"afterComponents":9,"pruned":{"minor":41,"national":6,"external":0,"expressway":3,"externalExpressway":0},"prunePasses":2,"mountainAdminConnections":{"attempted":12,"added":12,"skippedIsland":4,"failed":0}}}}
{"seed":101,"ms":4410,"roadCells":2148,"centers":47,"covered":47,"minor":144,"national":27,"nc":{"beforeComponents":2,"afterComponents":1,"added":1,"failed":0,"attempted":1,"rounds":1},"gap":{"expressway":0,"national":0,"localToNational":5,"local":3,"localMeshClosures":29},"out":{"components":1,"requiredStubsAdded":6,"endpointConnectorsAdded":4,"prune":{"beforeComponents":10,"afterComponents":1,"pruned":{"minor":2,"national":0,"external":0,"expressway":0,"externalExpressway":0},"prunePasses":1,"mountainAdminConnections":{"attempted":7,"added":7,"skippedIsland":0,"failed":0}}}}
{"seed":777,"ms":3587,"roadCells":1906,"centers":44,"covered":44,"minor":131,"national":24,"nc":{"beforeComponents":1,"afterComponents":1,"added":0,"failed":0,"attempted":0,"rounds":0},"gap":{"expressway":0,"national":2,"localToNational":13,"local":1,"localMeshClosures":31},"out":{"components":5,"requiredStubsAdded":4,"endpointConnectorsAdded":3,"prune":{"beforeComponents":10,"afterComponents":5,"pruned":{"minor":4,"national":0,"external":0,"expressway":0,"externalExpressway":0},"prunePasses":2,"mountainAdminConnections":{"attempted":9,"added":7,"skippedIsland":0,"failed":2}}}}
{"seed":999,"ms":4244,"roadCells":1719,"centers":43,"covered":43,"minor":116,"national":25,"nc":{"beforeComponents":11,"afterComponents":11,"added":0,"failed":2,"attempted":2,"rounds":1},"gap":{"expressway":0,"national":0,"localToNational":15,"local":0,"localMeshClosures":38},"out":{"components":13,"requiredStubsAdded":7,"endpointConnectorsAdded":6,"prune":{"beforeComponents":12,"afterComponents":13,"pruned":{"minor":41,"national":1,"external":0,"expressway":3,"externalExpressway":0},"prunePasses":2,"mountainAdminConnections":{"attempted":11,"added":5,"skippedIsland":0,"failed":6}}}}
{"seed":2026,"ms":4037,"roadCells":2439,"centers":50,"covered":50,"minor":177,"national":28,"nc":{"beforeComponents":2,"afterComponents":2,"added":0,"failed":0,"attempted":0,"rounds":1},"gap":{"expressway":0,"national":1,"localToNational":6,"local":7,"localMeshClosures":44},"out":{"components":3,"requiredStubsAdded":9,"endpointConnectorsAdded":8,"prune":{"beforeComponents":15,"afterComponents":3,"pruned":{"minor":4,"national":0,"external":0,"expressway":0,"externalExpressway":0},"prunePasses":2,"mountainAdminConnections":{"attempted":11,"added":10,"skippedIsland":1,"failed":1}}}}
{"seed":12345,"ms":3222,"roadCells":2140,"centers":50,"covered":50,"minor":151,"national":31,"nc":{"beforeComponents":6,"afterComponents":4,"added":2,"failed":1,"attempted":3,"rounds":3},"gap":{"expressway":0,"national":2,"localToNational":10,"local":4,"localMeshClosures":37},"out":{"components":9,"requiredStubsAdded":6,"endpointConnectorsAdded":3,"prune":{"beforeComponents":7,"afterComponents":9,"pruned":{"minor":9,"national":3,"external":0,"expressway":0,"externalExpressway":0},"prunePasses":2,"mountainAdminConnections":{"attempted":1,"added":0,"skippedIsland":5,"failed":1}}}}

View file

@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { generateMap } from './mapPipeline.js';
import { createWorldMap } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
import { MAP_H, MAP_W, SIZE, indexOf } from './mapUtils.js';
function hashObject(value) {
const hash = createHash('sha256');
const seen = new WeakSet();
function visit(v, path = '') {
if (v == null || typeof v !== 'object') { hash.update(`${path}:${typeof v}:${String(v)}\n`); return; }
if (ArrayBuffer.isView(v)) {
hash.update(`${path}:${v.constructor.name}:${v.length}:`);
hash.update(Buffer.from(v.buffer, v.byteOffset, v.byteLength));
return;
}
if (seen.has(v)) return;
seen.add(v);
if (Array.isArray(v)) {
hash.update(`${path}:array:${v.length}\n`);
for (let i = 0; i < v.length; i++) visit(v[i], `${path}[${i}]`);
return;
}
const keys = Object.keys(v).sort();
hash.update(`${path}:object:${keys.join(',')}\n`);
for (const key of keys) visit(v[key], `${path}.${key}`);
}
visit(value);
return hash.digest('hex');
}
function rasterize(paths) {
const occ = new Uint8Array(SIZE);
for (const path of paths || []) for (let k = 1; k < (path?.length || 0); k++) {
const [x0, y0] = path[k - 1];
const [x1, y1] = path[k];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(x0 + (x1 - x0) * t);
const y = Math.round(y0 + (y1 - y0) * t);
if (x >= 0 && y >= 0 && x < MAP_W && y < MAP_H) occ[indexOf(x, y)] = 1;
}
}
return occ;
}
function countCells(occ) { let n = 0; for (const v of occ) n += v ? 1 : 0; return n; }
function nearPath(paths, point, radius = 0.75) {
for (const path of paths || []) for (const [x, y] of path || []) {
if (Math.hypot(x - point.x, y - point.y) <= radius) return true;
}
return false;
}
function westSelection(world) {
const ox = world.originX, oy = world.originY;
return { kind: 'lasso', polygon: [
{ x: ox - 145, y: oy - 14 }, { x: ox + 8, y: oy - 14 },
{ x: ox + 8, y: oy + MAP_H + 14 }, { x: ox - 145, y: oy + MAP_H + 14 },
] };
}
const source = {
transport: readFileSync(new URL('./mapTransport.js', import.meta.url), 'utf8'),
features: readFileSync(new URL('./mapFeatures.js', import.meta.url), 'utf8'),
output: readFileSync(new URL('./mapOutput.js', import.meta.url), 'utf8'),
names: readFileSync(new URL('./names.js', import.meta.url), 'utf8'),
admin: readFileSync(new URL('./mapAdminStage.js', import.meta.url), 'utf8'),
patch: readFileSync(new URL('./mapPatch.js', import.meta.url), 'utf8'),
utils: readFileSync(new URL('./mapTransportUtils.js', import.meta.url), 'utf8'),
};
for (const retired of ['NAME_PARTS', 'oneKanjiAppendFallbackUsed', 'legacyFallbackUsed', 'changedAfterSnap', 'pendingSeedsUsedForLowlandSplit', 'seedLifecycle', 'seedCellRevivalCount', 'pendingSeedCount']) {
assert.equal(Object.values(source).some((text) => text.includes(retired)), false, `retired compatibility/debug symbol remains: ${retired}`);
}
for (const retired of ['nationalPrune', 'skippedSameComponent', 'finalOutputRoadConnectivity', 'all-road-connect:${pass}', 'Cached candidates']) {
assert.equal(Object.values(source).some((text) => text.includes(retired)), false, `retired transport/cache pattern remains: ${retired}`);
}
assert.equal(source.transport.includes('nearestNetworkPoint'), false, 'unused nearestNetworkPoint remains');
assert.equal(source.utils.includes('occupancyComponentsFromPathGroups'), true, 'shared occupancy component helper missing');
assert.equal(source.utils.includes('rasterizePathCells'), true, 'shared path rasterization helper missing');
assert.equal(source.features.includes('debug.networkConnectivity.added > 0'), true, 'minor final dedupe is not conditional');
const outputStub = source.output.indexOf('const requiredStubsAdded = ensureAdminCenterRoadStubs();');
const outputEndpoint = source.output.indexOf('const endpointConnectorsAdded = connectNearbyRoadEndpoints();');
const outputPrune = source.output.indexOf('const prune = pruneIsolatedFinalRoadComponents();');
assert.ok(outputStub >= 0 && outputEndpoint > outputStub && outputPrune > outputEndpoint, 'output topology mutations must precede final prune');
assert.equal(source.names.includes('export function generateTemplateName'), false, 'test-only generateTemplateName API remains');
assert.equal(source.names.includes('export const NAME_PARTS'), false, 'test-only NAME_PARTS API remains');
assert.equal(source.features.includes('aStarRoutes: 0'), false, 'obsolete constant transport debug remains');
assert.equal(source.features.includes('fieldCorridorTransport: false'), false, 'obsolete constant transport debug remains');
assert.equal(source.features.includes('nationalRoadPopulationCoverage: 0'), false, 'obsolete constant transport debug remains');
const seeds = [1, 3, 5];
const transport = [];
let initial = null;
for (const seed of seeds) {
const t0 = performance.now();
const map = generateMap(seed, { terrainType: 'auto', onProgress() {} });
if (seed === 1) initial = map;
const roads = [...(map.minorRoads || []), ...(map.nationalRoads || []), ...(map.externalRoads || []), ...(map.ringRoads || [])];
const roadCells = countCells(rasterize(roads));
const centers = map.adminCenters || map.adminCentersRaw || [];
const covered = centers.filter((center) => nearPath(roads, center)).length;
const nc = map.transportDebug?.layers?.preAdminRoadFinalization?.networkConnectivity || {};
const output = map.transportDebug?.layers?.finalOutputRoadTopology || {};
assert.equal(covered, centers.length, `seed ${seed}: municipal center lost road access`);
assert.ok(roadCells >= 1200, `seed ${seed}: road coverage collapsed (${roadCells})`);
assert.ok((nc.attempted || 0) <= 18 * Math.max(1, (nc.rounds || 0)), `seed ${seed}: connectivity candidate loop expanded unexpectedly`);
assert.ok((nc.failed || 0) <= (nc.attempted || 0), `seed ${seed}: failed attempt accounting invalid`);
if ((nc.added || 0) === 0) assert.ok((nc.rounds || 0) <= 1, `seed ${seed}: no-success connectivity loop repeated rounds`);
assert.ok(Number.isFinite(output.components), `seed ${seed}: final output component count missing`);
transport.push({
seed,
seconds: Math.round((performance.now() - t0) / 10) / 100,
roadCells,
adminCenters: { total: centers.length, covered },
connectivity: nc,
finalOutput: output,
});
}
const expansionWorld = createWorldMap(structuredClone(initial));
const expStart = performance.now();
const expansion = generatePatch(expansionWorld, westSelection(expansionWorld), {
patchMode: 'expansion', terrainType: 'auto', seed: 0x15151515, variant: 0, maxQualityRetries: 1,
});
const expansionSeconds = (performance.now() - expStart) / 1000;
assert.equal(expansion.ok, true, `expansion failed: ${expansion.code || 'unknown'}`);
assert.notEqual(expansion.candidateQuality?.hardPass, false, 'committed failed quality candidate');
assert.equal(expansion.seamDiagnostics?.expansionFootprintEscapedCells || 0, 0, 'footprint write escaped');
assert.equal(expansion.seamDiagnostics?.roadPortalsUnresolved || 0, 0, 'road portal disconnected');
assert.equal(expansion.seamDiagnostics?.railPortalsUnresolved || 0, 0, 'rail portal disconnected');
const rollbackWorld = createWorldMap(structuredClone(initial));
const before = {
fields: hashObject(rollbackWorld.fields),
sourceMap: hashObject(rollbackWorld.sourceMap),
generatedRects: structuredClone(rollbackWorld.generatedRects),
invalidatedRects: structuredClone(rollbackWorld.invalidatedRects),
serial: rollbackWorld.patchGenerationSerial || 0,
sourceIdentity: rollbackWorld.sourceMap,
};
const rejected = generatePatch(rollbackWorld, { x0: 30, y0: 30, x1: 110, y1: 110 }, {
patchMode: 'expansion', terrainType: 'oceanic_archipelago', seed: 456, variant: 0, maxQualityRetries: 0,
});
assert.equal(rejected.ok, false, 'rollback probe unexpectedly committed');
assert.equal(rejected.code, 'patch-quality-gate-failed');
assert.equal(hashObject(rollbackWorld.fields), before.fields, 'field rollback mismatch');
assert.equal(hashObject(rollbackWorld.sourceMap), before.sourceMap, 'sourceMap rollback mismatch');
assert.deepEqual(rollbackWorld.generatedRects, before.generatedRects, 'generatedRects rollback mismatch');
assert.deepEqual(rollbackWorld.invalidatedRects, before.invalidatedRects, 'invalidatedRects rollback mismatch');
assert.equal(rollbackWorld.patchGenerationSerial || 0, before.serial, 'serial rollback mismatch');
assert.equal(rollbackWorld.sourceMap, before.sourceIdentity, 'sourceMap identity rollback mismatch');
console.log(JSON.stringify({
ok: true,
transport,
expansion: {
seconds: Math.round(expansionSeconds * 100) / 100,
seamStatus: expansion.seamDiagnostics?.status || null,
footprintEscapedCells: expansion.seamDiagnostics?.expansionFootprintEscapedCells || 0,
roadPortalsUnresolved: expansion.seamDiagnostics?.roadPortalsUnresolved || 0,
railPortalsUnresolved: expansion.seamDiagnostics?.railPortalsUnresolved || 0,
},
rollback: { code: rejected.code, restored: true, sourceIdentityPreserved: true },
}, null, 2));

View file

@ -0,0 +1,137 @@
{
"ok": true,
"transport": [
{
"seed": 1,
"seconds": 4.67,
"roadCells": 2122,
"adminCenters": {
"total": 45,
"covered": 45
},
"connectivity": {
"beforeComponents": 3,
"afterComponents": 3,
"added": 0,
"failed": 1,
"attempted": 1,
"rounds": 1
},
"finalOutput": {
"components": 6,
"requiredStubsAdded": 6,
"endpointConnectorsAdded": 3,
"prune": {
"beforeComponents": 12,
"afterComponents": 6,
"pruned": {
"minor": 11,
"national": 1,
"external": 0,
"expressway": 0,
"externalExpressway": 0
},
"prunePasses": 2,
"mountainAdminConnections": {
"attempted": 6,
"added": 6,
"skippedIsland": 3,
"failed": 0
}
}
}
},
{
"seed": 3,
"seconds": 4.81,
"roadCells": 2598,
"adminCenters": {
"total": 43,
"covered": 43
},
"connectivity": {
"beforeComponents": 3,
"afterComponents": 1,
"added": 2,
"failed": 0,
"attempted": 2,
"rounds": 2
},
"finalOutput": {
"components": 2,
"requiredStubsAdded": 10,
"endpointConnectorsAdded": 8,
"prune": {
"beforeComponents": 19,
"afterComponents": 2,
"pruned": {
"minor": 5,
"national": 0,
"external": 0,
"expressway": 0,
"externalExpressway": 0
},
"prunePasses": 2,
"mountainAdminConnections": {
"attempted": 14,
"added": 14,
"skippedIsland": 1,
"failed": 0
}
}
}
},
{
"seed": 5,
"seconds": 3.57,
"roadCells": 2044,
"adminCenters": {
"total": 45,
"covered": 45
},
"connectivity": {
"beforeComponents": 5,
"afterComponents": 4,
"added": 1,
"failed": 2,
"attempted": 3,
"rounds": 2
},
"finalOutput": {
"components": 9,
"requiredStubsAdded": 10,
"endpointConnectorsAdded": 3,
"prune": {
"beforeComponents": 22,
"afterComponents": 9,
"pruned": {
"minor": 41,
"national": 6,
"external": 0,
"expressway": 3,
"externalExpressway": 0
},
"prunePasses": 2,
"mountainAdminConnections": {
"attempted": 12,
"added": 12,
"skippedIsland": 4,
"failed": 0
}
}
}
}
],
"expansion": {
"seconds": 7.55,
"seamStatus": "clean",
"footprintEscapedCells": 0,
"roadPortalsUnresolved": 0,
"railPortalsUnresolved": 0
},
"rollback": {
"code": "patch-quality-gate-failed",
"restored": true,
"sourceIdentityPreserved": true
}
}

View file

@ -0,0 +1,137 @@
{
"ok": true,
"transport": [
{
"seed": 1,
"seconds": 4.89,
"roadCells": 2122,
"adminCenters": {
"total": 45,
"covered": 45
},
"connectivity": {
"beforeComponents": 3,
"afterComponents": 3,
"added": 0,
"failed": 1,
"attempted": 1,
"rounds": 1
},
"finalOutput": {
"components": 6,
"requiredStubsAdded": 6,
"endpointConnectorsAdded": 3,
"prune": {
"beforeComponents": 12,
"afterComponents": 6,
"pruned": {
"minor": 11,
"national": 1,
"external": 0,
"expressway": 0,
"externalExpressway": 0
},
"prunePasses": 2,
"mountainAdminConnections": {
"attempted": 6,
"added": 6,
"skippedIsland": 3,
"failed": 0
}
}
}
},
{
"seed": 3,
"seconds": 5.02,
"roadCells": 2598,
"adminCenters": {
"total": 43,
"covered": 43
},
"connectivity": {
"beforeComponents": 3,
"afterComponents": 1,
"added": 2,
"failed": 0,
"attempted": 2,
"rounds": 2
},
"finalOutput": {
"components": 2,
"requiredStubsAdded": 10,
"endpointConnectorsAdded": 8,
"prune": {
"beforeComponents": 19,
"afterComponents": 2,
"pruned": {
"minor": 5,
"national": 0,
"external": 0,
"expressway": 0,
"externalExpressway": 0
},
"prunePasses": 2,
"mountainAdminConnections": {
"attempted": 14,
"added": 14,
"skippedIsland": 1,
"failed": 0
}
}
}
},
{
"seed": 5,
"seconds": 3.44,
"roadCells": 2044,
"adminCenters": {
"total": 45,
"covered": 45
},
"connectivity": {
"beforeComponents": 5,
"afterComponents": 4,
"added": 1,
"failed": 2,
"attempted": 3,
"rounds": 2
},
"finalOutput": {
"components": 9,
"requiredStubsAdded": 10,
"endpointConnectorsAdded": 3,
"prune": {
"beforeComponents": 22,
"afterComponents": 9,
"pruned": {
"minor": 41,
"national": 6,
"external": 0,
"expressway": 3,
"externalExpressway": 0
},
"prunePasses": 2,
"mountainAdminConnections": {
"attempted": 12,
"added": 12,
"skippedIsland": 4,
"failed": 0
}
}
}
}
],
"expansion": {
"seconds": 24.47,
"seamStatus": "clean",
"footprintEscapedCells": 0,
"roadPortalsUnresolved": 0,
"railPortalsUnresolved": 0
},
"rollback": {
"code": "patch-quality-gate-failed",
"restored": true,
"sourceIdentityPreserved": true
}
}

View file

@ -0,0 +1,86 @@
# Step 17 — Alternative preview / apparent interruption fix
## Diagnosis
The Step 16 worker move removed the explicit UI-thread fallback, but it did not fix two UI semantics that could make a completed Alternative request look as if nothing had happened.
1. Expansion quality selection internally searched consecutive terrain variants. A request for variant `N` could evaluate `N`, `N+1` (and sometimes `N+2`), while the next Alternative request for `N+1` evaluated an overlapping set. Both clicks could therefore select the same internal terrain variant.
2. `generatePatch()` could silently rerun the complete patch once after a quality/seam rejection. This made one Alternative click take roughly one or two complete patch attempts depending on the result. On a slow machine that produces the observed large wall-time variation and can cross an external/browser execution limit.
3. If an Alternative failed, the previous pending preview stayed on screen, but the failure message was transient. This looked like a successful generation that was not reflected.
4. Renderer raster caches inferred freshness from map metadata. There was no explicit preview revision in the key.
5. Patch controls stayed usable while a worker job was active, so repeated clicks could enqueue work and make completion ownership ambiguous.
6. The patch worker was reused after large jobs, allowing a high-water heap to survive across repeated Alternatives.
## Changes
### One Alternative click now means one exact variant
Interactive patch generation passes:
- `qualityTerrainAttempts: 1`
- `maxQualityRetries: 0`
`mapPatch.js` now supports an explicit terrain-quality attempt count. The default non-interactive behavior remains unchanged; only the UI Alternative path uses the one-variant policy.
This removes overlapping internal candidate sets. If variant `N` fails the quality gate, it is rejected and the user can request `N+1`; the UI no longer silently spends another whole-patch attempt on a different hidden variant.
### Preview freshness is explicit
Every successful preview receives a monotonically increasing `world.renderRevision` and its requested `previewVariant`.
`worldViewport.js` propagates those values and `renderer.js` includes them in the stable raster cache prefix. A newly returned preview therefore cannot reuse a terrain/urban/prefecture-fill cache entry from an older preview merely because its dimensions and field types are the same.
### Verify that a preview actually differs
Before displaying a successful worker result, `app.js` compares the committed and preview worlds over the patch write area and counts:
- changed cells
- terrain changed cells (`elevation`, `sea`, `landuse`, `populationDensity`)
- administration changed cells (`adminId`, `municipalityId`, `prefectureRegionId`)
The persistent patch status reports those counts. If a result is genuinely identical, the UI says so explicitly instead of implying that a visual update was lost.
### Unambiguous rendering
After a successful patch worker result, the app performs one immediate full redraw of the new pending world. The previous fast-redraw + delayed-full-redraw pair was removed from this path.
The progress text now distinguishes generation from rendering and reports both generation and render wall time.
### Failure remains visible
If an Alternative is rejected or the worker fails, the patch status now says either:
- no preview was applied, or
- the previous preview variant is still being shown.
This message remains in the patch controls instead of disappearing with the temporary progress overlay.
### One active patch job
Patch/Alternative/Apply/Discard/variant controls are disabled while a patch job is active. A generation request also invalidates any stale patch request.
### One-shot patch workers
A patch worker is terminated after every completed job. This releases the worker heap between Alternative clicks and prevents stale heavy-generation state from accumulating across previews.
## Validation
`STEP17_VALIDATION.mjs` runs two consecutive interactive-style variants from the same committed world using the browser worker module.
Observed:
- variant 0: 8.42 s, selected terrain variant 0
- variant 1: 8.50 s, selected terrain variant 1
- quality retry count: 0 for both
- seam status: clean for both
- elevation hashes: different
- sea-mask hashes: different
- administration hashes: different
Therefore the two Alternative requests produce materially different world data and exact requested variants.
All 8 existing test shards pass independently on the final Step 17 source with zero `NG:` assertions. All JavaScript/MJS files pass `node --check`.
## Browser E2E note
The available container Chromium is subject to a localhost navigation policy/interstitial, so a reliable in-browser pointer/canvas E2E could not be completed here. Worker execution, variant data differences, cache-revision wiring, and all generator regressions are tested directly.

View file

@ -0,0 +1,63 @@
{
"ok": true,
"suites": [
{
"suite": "core",
"failedAssertions": 0,
"fullMapGenerations": 4,
"elapsedMs": 16970,
"status": 0
},
{
"suite": "terrain",
"failedAssertions": 0,
"fullMapGenerations": 6,
"elapsedMs": 25008,
"status": 0
},
{
"suite": "admin",
"failedAssertions": 0,
"fullMapGenerations": 3,
"elapsedMs": 14863,
"status": 0
},
{
"suite": "determinism-114514",
"failedAssertions": 0,
"fullMapGenerations": 2,
"elapsedMs": 9447,
"status": 0
},
{
"suite": "determinism-12345",
"failedAssertions": 0,
"fullMapGenerations": 2,
"elapsedMs": 9000,
"status": 0
},
{
"suite": "determinism-54321",
"failedAssertions": 0,
"fullMapGenerations": 2,
"elapsedMs": 8428,
"status": 0
},
{
"suite": "determinism-777",
"failedAssertions": 0,
"fullMapGenerations": 2,
"elapsedMs": 7999,
"status": 0
},
{
"suite": "determinism-999",
"failedAssertions": 0,
"fullMapGenerations": 2,
"elapsedMs": 29472,
"status": 0
}
],
"totalElapsedMsReported": 121187,
"note": "Each shard executed independently on the final Step 17 source."
}

View file

@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { Worker } from 'node:worker_threads';
import { performance } from 'node:perf_hooks';
import { generateMap } from './mapPipeline.js';
import { createWorldMap } from './worldMap.js';
import { MAP_H } from './mapUtils.js';
import { collectTransferableBuffers } from './transferUtils.js';
function hashView(view) {
return createHash('sha256').update(Buffer.from(view.buffer, view.byteOffset, view.byteLength)).digest('hex');
}
function derivePatchSeed(baseSeed, rect, terrainType, variant = 0) {
let h = (baseSeed >>> 0) ^ 0x9e3779b9;
h = Math.imul(h ^ (rect.x0 | 0), 1664525) >>> 0;
h = Math.imul(h ^ (rect.y0 | 0), 1013904223) >>> 0;
h = Math.imul(h ^ (rect.x1 | 0), 2246822519) >>> 0;
h = Math.imul(h ^ (rect.y1 | 0), 3266489917) >>> 0;
h = Math.imul(h ^ (variant >>> 0), 668265263) >>> 0;
for (const ch of String(terrainType || 'auto')) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
return h >>> 0;
}
function browserWorkerAdapter(moduleUrl) {
const target = moduleUrl.href;
const code = `
import { parentPort } from 'node:worker_threads';
globalThis.self = { onmessage: null, postMessage(message, transfer) { parentPort.postMessage(message, transfer); } };
await import(${JSON.stringify(target)});
parentPort.on('message', (data) => self.onmessage?.({ data }));
`;
return new Worker(new URL(`data:text/javascript,${encodeURIComponent(code)}`), { type: 'module', execArgv: [] });
}
async function runPatchWorker(baseWorld, rect, baseSeed, variant) {
const worker = browserWorkerAdapter(new URL('./mapPatchWorker.js', import.meta.url));
const preview = structuredClone(baseWorld);
const transfer = Array.from(collectTransferableBuffers(preview));
const started = performance.now();
const message = await new Promise((resolve, reject) => {
worker.on('message', (m) => { if (m?.type !== 'progress') resolve(m); });
worker.on('error', reject);
worker.postMessage({
id: variant + 1,
world: preview,
rect,
options: {
patchMode: 'expansion', terrainType: 'auto',
seed: derivePatchSeed(baseSeed, rect, 'auto', variant),
variant, maxQualityRetries: 0, qualityTerrainAttempts: 1,
},
}, transfer);
});
await worker.terminate();
assert.equal(message.ok, true, `variant ${variant} worker failed: ${message.error || 'unknown'}`);
assert.equal(message.result?.ok, true, `variant ${variant} patch failed: ${message.result?.code || message.result?.reason || 'unknown'}`);
return { message, seconds: (performance.now() - started) / 1000 };
}
const appSource = readFileSync(new URL('./app.js', import.meta.url), 'utf8');
const viewportSource = readFileSync(new URL('./worldViewport.js', import.meta.url), 'utf8');
const rendererSource = readFileSync(new URL('./renderer.js', import.meta.url), 'utf8');
assert.match(appSource, /maxQualityRetries:\s*0/);
assert.match(appSource, /patchBusy/);
assert.match(appSource, /previewPatchDelta/);
assert.match(appSource, /renderRevision/);
assert.match(appSource, /worker\.terminate\?\.\(\)/);
assert.match(viewportSource, /viewport\.renderRevision/);
assert.match(rendererSource, /map\?\.renderRevision/);
const baseSeed = 1;
const baseWorld = createWorldMap(generateMap(baseSeed, { terrainType: 'auto', onProgress() {} }));
const ox = baseWorld.originX;
const oy = baseWorld.originY;
const rect = { kind: 'lasso', polygon: [
{ x: ox - 145, y: oy - 14 },
{ x: ox + 8, y: oy - 14 },
{ x: ox + 8, y: oy + MAP_H + 14 },
{ x: ox - 145, y: oy + MAP_H + 14 },
] };
rect.x0 = ox - 145; rect.y0 = oy - 14; rect.x1 = ox + 8; rect.y1 = oy + MAP_H + 14;
const a = await runPatchWorker(baseWorld, rect, baseSeed, 0);
const b = await runPatchWorker(baseWorld, rect, baseSeed, 1);
const aWorld = a.message.world;
const bWorld = b.message.world;
const hashesA = {
elevation: hashView(aWorld.fields.elevation), sea: hashView(aWorld.fields.sea), admin: hashView(aWorld.fields.adminId),
};
const hashesB = {
elevation: hashView(bWorld.fields.elevation), sea: hashView(bWorld.fields.sea), admin: hashView(bWorld.fields.adminId),
};
assert.notEqual(hashesA.elevation, hashesB.elevation, 'Alternative variants generated identical elevation');
assert.notEqual(hashesA.sea, hashesB.sea, 'Alternative variants generated identical sea mask');
assert.notEqual(hashesA.admin, hashesB.admin, 'Alternative variants generated identical administration');
assert.equal(a.message.result.qualityRetryCount || 0, 0);
assert.equal(b.message.result.qualityRetryCount || 0, 0);
assert.equal(a.message.result.candidateQuality?.selectedVariant, 0, 'variant 0 did not render its exact requested terrain variant');
assert.equal(b.message.result.candidateQuality?.selectedVariant, 1, 'variant 1 did not render its exact requested terrain variant');
assert.equal(a.message.result.seamDiagnostics?.status, 'clean');
assert.equal(b.message.result.seamDiagnostics?.status, 'clean');
console.log(JSON.stringify({
ok: true,
interactiveRetryPolicy: 'one requested variant per click; no hidden whole-patch retry',
variants: [
{ variant: 0, seconds: Math.round(a.seconds * 100) / 100, selectedVariant: a.message.result.candidateQuality?.selectedVariant, hashes: hashesA },
{ variant: 1, seconds: Math.round(b.seconds * 100) / 100, selectedVariant: b.message.result.candidateQuality?.selectedVariant, hashes: hashesB },
],
alternativesDiffer: true,
explicitRenderRevision: true,
singlePatchWorkerLifetime: true,
}, null, 2));

View file

@ -0,0 +1,29 @@
{
"ok": true,
"interactiveRetryPolicy": "one requested variant per click; no hidden whole-patch retry",
"variants": [
{
"variant": 0,
"seconds": 8.42,
"selectedVariant": 0,
"hashes": {
"elevation": "1fbbef64867850a580ecf152d1d36d23ea0b895c15762e43d6d3ba9d5e974d8a",
"sea": "d22babc88d3474f034288b3c8351029b5cd2c9510c1c4e8cc090fd2efed70f6f",
"admin": "f0dc92888460571478b9369413724dd1001b84fb94863dd1416ce52dab65bc07"
}
},
{
"variant": 1,
"seconds": 8.5,
"selectedVariant": 1,
"hashes": {
"elevation": "e0671a7b011126360a7a0e6f296d5504488970448b5138c3b605fbd0a1fe3774",
"sea": "d756d0738617ca056e6c985244e097ea4c14e5c284ef1628ddca97fb5b7dd22a",
"admin": "a1605a14d7ac208f58158cda575594bdc0e5b5b22a7702dd913a1878be27a99b"
}
}
],
"alternativesDiffer": true,
"explicitRenderRevision": true,
"singlePatchWorkerLifetime": true
}

View file

@ -0,0 +1,29 @@
{
"ok": true,
"interactiveRetryPolicy": "one requested variant per click; no hidden whole-patch retry",
"variants": [
{
"variant": 0,
"seconds": 36.31,
"selectedVariant": 0,
"hashes": {
"elevation": "528ea16e05aeada539dbdea076af9dd9ea7088189f10fdb0cf414e37e33902ee",
"sea": "c53befa66f7a2ff958c7db3465c827a481274a4e9c67d6a52ef31eb3197eb362",
"admin": "bdf9aa6e2e0d0526026a6e69fe3de949a626d00835347a30361b5d1a709deb70"
}
},
{
"variant": 1,
"seconds": 27.07,
"selectedVariant": 1,
"hashes": {
"elevation": "ad3a08864ca413ddebe466880032a8b3d33b48b166c5e5f10debe30c44eed4c8",
"sea": "295684a63f5243883c89cf9be595393cd5ce4b3714290e7efb13bae0b162f02c",
"admin": "1139fa28c1605224b20d99c84b06dbecdcd7d637bef10640c19a9884326c4491"
}
}
],
"alternativesDiffer": true,
"explicitRenderRevision": true,
"singlePatchWorkerLifetime": true
}

View file

@ -0,0 +1,128 @@
# Step 4 / Step 5 実装記録
## Step 4 — 道路・鉄道ポータルの必須再接続
追加生成前の交通線から、パッチ境界を横断する地点を **transport portal contract** として保存するようにした。
各ポータルには以下を保持する。
- 境界外側の既存中心線セル
- 境界内側の中心線セル
- 外側から内側へ向かう進行方向
- 元の道路・鉄道路線レイヤー
- 近傍の旧中心線ガイド
- 路線階層の優先度
候補交通網を統合した後、ポータルごとに次の処理を行う。
1. 描画中心線をセルへ高密度ラスタライズする。
2. 外側既存ネットワークと内側生成ネットワークが、8近傍で実際に連続しているか検査する。
3. 未接続の場合、ポータル内側の固定ゲートを通る二段階経路探索を行う。
4. 元路線と同じレイヤーへコネクターを追加する。
5. 追加後に再ラスタライズし、見た目上の接続が成立しなければロールバックする。
6. 一般グラフ修復後にもう一度必須ポータルを検査する。
候補内交通網が検索範囲外にある場合だけ、旧中心線の短い内向きガイドを復元する。この使用件数は `roadPortalLegacyGuideFallbacks` / `railPortalLegacyGuideFallbacks` で確認できる。
### グラフ接続判定の修正
従来は最大2セル離れた交通線も同一グラフ成分としていたため、内部判定では接続済みでも描画上は隙間が残ることがあった。
現在は、上下左右と斜めの **8近傍で接触するセルだけ**を接続済みとして扱う。
### 追加された診断値
- `roadPortalsRequired`
- `roadPortalsConnected`
- `roadPortalsUnresolved`
- `roadPortalConnectorsAdded`
- `roadPortalLegacyGuideFallbacks`
- `railPortalsRequired`
- `railPortalsConnected`
- `railPortalsUnresolved`
- `railPortalConnectorsAdded`
- `railPortalLegacyGuideFallbacks`
- `portalPathAttempts`
## Step 5 — 最終IDラスターから行政境界を単一再構築
行政境界ベクトルの生成元を次の最終フィールドに限定した。
- 市町村境界: `world.fields.adminId`
- 都道府県境界: `world.fields.prefectureRegionId`
- 外周境界: `world.fields.prefectureMask`
パッチ処理後に世界全体を再走査し、以下のベクトルを再構築する。
- `sourceMap.adminBorders`
- `sourceMap.regionalPrefectureBorders`
- `sourceMap.prefectureBorder`
既存ベクトル、候補生成器の境界ベクトル、距離ベースの補完境界は統合しない。これにより、候補境界とラスター再構築境界が並行して残る経路を廃止した。
### 境界階層
市町村境界は、隣接セルの `adminId` が異なり、かつ `prefectureRegionId` が同じ場合だけ生成する。
したがって都道府県境界上では、市町村境界を後処理で近接除去するのではなく、生成段階から出力しない。
### 半セル位置ずれの修正
パッチ側のラスター境界座標が初期生成より0.5セルずれていた。
レンダラーが境界描画時に `-0.5` セルのオフセットを適用するため、再構築座標を初期生成と同じ `x + 1` / `y + 1` の共有セル辺座標へ統一した。
### 重複境界診断
連続する同一チェーンの隣接単位セグメントを「二重境界」と誤判定しないようにした。現在は次を満たす平行線だけを重複候補とする。
- セグメント方向が近い
- 投影区間が十分に重なる
- 同一チェーンの共有端点ではない
- 線間距離が許容範囲内
市町村境界と都道府県境界の完全一致も検出対象にした。
## 検証
実行コマンド:
```bash
node STEP4_STEP5_VALIDATION.mjs
```
検証結果:
```text
Expansion
mandatory road portals: 1
broken road portals: 0
broken rail portals: 0
duplicate boundary pairs: 0
Regeneration
mandatory road portals: 5
mandatory rail portals: 1
broken road portals: 0
broken rail portals: 0
duplicate boundary pairs: 0
```
検証スクリプトは、各境界セグメントについて隣接する最終IDセルを逆算し、以下を全件確認する。
- 市町村境界の両側で `adminId` が異なる
- 市町村境界の両側で `prefectureRegionId` が同じ
- 都道府県境界の両側で `prefectureRegionId` が異なる
- 市町村境界と都道府県境界に完全一致セグメントがない
- 外周境界が最終 `prefectureMask` の変化位置と一致する
- 各レイヤー内に完全重複セグメントがない
Step 2 / Step 3 の検証スクリプトも再実行し、次を維持していることを確認した。
- 既存生成部分の海陸反転: 0
- 異なる生成窓での重複座標の最大標高差: 0
- 異なる生成窓での海陸不一致: 0
全JavaScript/MJSファイルは `node --check` を通過した。
既存の総合 `test.js` はこの環境で長時間実行が継続し、完了確認まで行えていない。今回の合否判定には、交通ポータルと最終ID境界を直接検証する専用回帰テストを使用した。

View file

@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import { generateMap } from "./mapPipeline.js";
import { MAP_H } from "./mapUtils.js";
import { generatePatch } from "./mapPatch.js";
import { createWorldMap } from "./worldMap.js";
function stage(patch, key) {
return patch?.humanGeography?.patchStages?.[key];
}
function segmentKey(segment) {
const a = `${segment[0][0]},${segment[0][1]}`;
const b = `${segment[1][0]},${segment[1][1]}`;
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
function validateUniqueSegments(segments, label) {
const seen = new Set();
for (const segment of segments || []) {
const key = segmentKey(segment);
assert(!seen.has(key), `${label} contains a duplicate segment ${key}`);
seen.add(key);
}
return seen;
}
function adjacentCellsForSegment(world, segment) {
const ax = segment[0][0] + world.originX;
const ay = segment[0][1] + world.originY;
const bx = segment[1][0] + world.originX;
const by = segment[1][1] + world.originY;
if (Math.abs(ax - bx) < 1e-6) {
const lineX = Math.round(ax);
const y = Math.floor((ay + by) * 0.5);
return [[lineX - 1, y], [lineX, y]];
}
if (Math.abs(ay - by) < 1e-6) {
const x = Math.floor((ax + bx) * 0.5);
const lineY = Math.round(ay);
return [[x, lineY - 1], [x, lineY]];
}
throw new Error(`non-axis-aligned raster boundary ${JSON.stringify(segment)}`);
}
function worldIndex(world, x, y) {
assert(x >= 0 && y >= 0 && x < world.width && y < world.height, `boundary cell out of world: ${x},${y}`);
return y * world.width + x;
}
function validateFinalIdBoundaries(world) {
const sourceMap = world.sourceMap;
const admin = world.fields.adminId;
const pref = world.fields.prefectureRegionId;
const sea = world.fields.sea;
const mask = world.fields.prefectureMask;
const adminKeys = validateUniqueSegments(sourceMap.adminBorders, "adminBorders");
const prefKeys = validateUniqueSegments(sourceMap.regionalPrefectureBorders, "regionalPrefectureBorders");
validateUniqueSegments(sourceMap.prefectureBorder, "prefectureBorder");
for (const segment of sourceMap.adminBorders || []) {
const [[ax, ay], [bx, by]] = adjacentCellsForSegment(world, segment);
const ai = worldIndex(world, ax, ay);
const bi = worldIndex(world, bx, by);
assert(!sea[ai] && !sea[bi], "municipal border must separate two land cells");
assert(mask[ai] && mask[bi], "municipal border must remain inside the final prefecture mask");
assert(admin[ai] >= 0 && admin[bi] >= 0 && admin[ai] !== admin[bi], "municipal border must match differing final admin IDs");
assert(pref[ai] >= 0 && pref[ai] === pref[bi], "municipal border must not duplicate a prefecture border");
}
for (const segment of sourceMap.regionalPrefectureBorders || []) {
const [[ax, ay], [bx, by]] = adjacentCellsForSegment(world, segment);
const ai = worldIndex(world, ax, ay);
const bi = worldIndex(world, bx, by);
assert(!sea[ai] && !sea[bi], "prefecture border must separate two land cells");
assert(mask[ai] && mask[bi], "prefecture border must remain inside the final prefecture mask");
assert(pref[ai] >= 0 && pref[bi] >= 0 && pref[ai] !== pref[bi], "prefecture border must match differing final prefecture IDs");
}
for (const key of adminKeys) assert(!prefKeys.has(key), `municipal and prefecture layers overlap at ${key}`);
for (const segment of sourceMap.prefectureBorder || []) {
const [[ax, ay], [bx, by]] = adjacentCellsForSegment(world, segment);
const ai = worldIndex(world, ax, ay);
const bi = worldIndex(world, bx, by);
assert(!sea[ai] && !sea[bi], "outer prefecture mask border must not be drawn through water");
assert(Boolean(mask[ai]) !== Boolean(mask[bi]), "outer prefecture border must match the final mask edge");
}
return {
adminSegments: sourceMap.adminBorders?.length || 0,
prefectureSegments: sourceMap.regionalPrefectureBorders?.length || 0,
outerMaskSegments: sourceMap.prefectureBorder?.length || 0,
};
}
const initial = generateMap(114514, { terrainType: "setouchi_inland_sea", onProgress() {} });
const expansionWorld = createWorldMap(structuredClone(initial));
const ox = expansionWorld.originX;
const oy = expansionWorld.originY;
const expansionSelection = {
kind: "lasso",
polygon: [
{ x: ox - 92, y: oy + 28 },
{ x: ox + 34, y: oy + 28 },
{ x: ox + 34, y: oy + MAP_H - 28 },
{ x: ox - 92, y: oy + MAP_H - 28 },
],
};
const expansionPatch = generatePatch(expansionWorld, expansionSelection, {
patchMode: "auto",
terrainType: "setouchi_inland_sea",
seed: 0x1234abcd,
variant: 0,
});
assert.equal(expansionPatch.ok, true);
assert.equal(expansionPatch.patchMode, "expansion");
const expansionTransport = stage(expansionPatch, "transport");
const expansionSegments = stage(expansionPatch, "segments");
assert(expansionTransport.roadPortalsRequired > 0, "expansion fixture must contain a mandatory road portal");
assert.equal(expansionTransport.roadPortalsUnresolved, 0, "every expansion road portal must reconnect");
assert.equal(expansionPatch.seamDiagnostics.roadPortalsBroken, 0, "rendered expansion road portals must be connected");
assert.equal(expansionPatch.seamDiagnostics.railPortalsBroken, 0, "rendered expansion rail portals must be connected");
assert.equal(expansionSegments.boundarySource, "final-id-rasters");
assert.equal(expansionSegments.globalBoundaryRebuild, true);
assert.equal(expansionSegments.candidateAdminBordersMerged, 0);
assert.equal(expansionSegments.candidatePrefectureBordersMerged, 0);
assert.equal(expansionPatch.seamDiagnostics.duplicateBoundaryPairs, 0, "final-raster boundary rebuild must not leave seam twins");
const expansionBoundaryCounts = validateFinalIdBoundaries(expansionWorld);
const regenerationWorld = createWorldMap(structuredClone(initial));
const regenerationSelection = {
x0: regenerationWorld.originX + 70,
y0: regenerationWorld.originY + 40,
x1: regenerationWorld.originX + 190,
y1: regenerationWorld.originY + 150,
};
const regenerationPatch = generatePatch(regenerationWorld, regenerationSelection, {
patchMode: "regeneration",
terrainType: "setouchi_inland_sea",
seed: 0x7412abce,
variant: 1,
});
assert.equal(regenerationPatch.ok, true);
assert.equal(regenerationPatch.patchMode, "regeneration");
const regenerationTransport = stage(regenerationPatch, "transport");
const regenerationSegments = stage(regenerationPatch, "segments");
assert(regenerationTransport.roadPortalsRequired > 0, "regeneration fixture must contain road portals");
assert(regenerationTransport.railPortalsRequired > 0, "regeneration fixture must contain a rail portal");
assert.equal(regenerationTransport.roadPortalsUnresolved, 0, "every regeneration road portal must reconnect");
assert.equal(regenerationTransport.railPortalsUnresolved, 0, "every regeneration rail portal must reconnect");
assert.equal(regenerationPatch.seamDiagnostics.roadPortalsBroken, 0);
assert.equal(regenerationPatch.seamDiagnostics.railPortalsBroken, 0);
assert.equal(regenerationPatch.seamDiagnostics.duplicateBoundaryPairs, 0);
assert.equal(regenerationSegments.boundarySource, "final-id-rasters");
assert.equal(regenerationSegments.candidateAdminBordersMerged, 0);
assert.equal(regenerationSegments.candidatePrefectureBordersMerged, 0);
const regenerationBoundaryCounts = validateFinalIdBoundaries(regenerationWorld);
console.log(JSON.stringify({
ok: true,
expansion: {
roadPortals: expansionTransport.roadPortalsRequired,
roadPortalConnectors: expansionTransport.roadPortalConnectorsAdded,
railPortals: expansionTransport.railPortalsRequired,
brokenRoadPortals: expansionPatch.seamDiagnostics.roadPortalsBroken,
brokenRailPortals: expansionPatch.seamDiagnostics.railPortalsBroken,
duplicateBoundaryPairs: expansionPatch.seamDiagnostics.duplicateBoundaryPairs,
boundaries: expansionBoundaryCounts,
},
regeneration: {
roadPortals: regenerationTransport.roadPortalsRequired,
roadPortalConnectors: regenerationTransport.roadPortalConnectorsAdded,
railPortals: regenerationTransport.railPortalsRequired,
railPortalConnectors: regenerationTransport.railPortalConnectorsAdded,
brokenRoadPortals: regenerationPatch.seamDiagnostics.roadPortalsBroken,
brokenRailPortals: regenerationPatch.seamDiagnostics.railPortalsBroken,
duplicateBoundaryPairs: regenerationPatch.seamDiagnostics.duplicateBoundaryPairs,
boundaries: regenerationBoundaryCounts,
},
}, null, 2));

View file

@ -0,0 +1,207 @@
# Step 6 実装・検証記録
## 目的
通常の領域拡張で、初期生成とできるだけ同等の地形・集落・行政・交通品質を得る。特に、次の失敗を候補確定前に排除する。
- 追加範囲のほぼ全域が意図せず海になる
- 陸地はあるが、連結性や開発可能地が乏しい
- 地名・町村・行政中心・交通網の密度が初期生成に比べて著しく低い
- 海岸シーム補正によって、良好な候補地形が結合時に水没する
## 実装概要
### 1. 通常Expansionを初期生成と同じ本番パイプラインへ統合
通常のExpansionは、Step 3の簡易な絶対座標矩形地形を最終候補として使わず、初期生成と同じ本番地形生成器を使用する。
選定済み地形は `terrainOverride` として以下の既存本番工程へ渡される。
1. 地理・水系
2. 集落・都市・土地利用
3. 市町村・都道府県
4. 道路・鉄道
5. 出力・後処理
診断上の生成モードは次の値になる。
```text
expansion-production-quality-selected
```
`productionPipelineParity: true` は、選定された候補が初期生成と同じ人文・行政・交通パイプラインを通過したことを示す。
### 2. 二段階の品質選択
Expansionごとに、指定variantから連続する6候補の本番地形を生成し、まず地形だけを高速評価する。
地形評価項目:
- 陸地率
- 開発可能地率
- 最大連結陸地率
- 既存陸地とのフロンティア接続率
- 海岸線複雑度
- 地形テンプレートごとの許容陸地率
地形上位2候補について本番フルパイプラインを実行し、次を追加評価する。
- 町村・市場・都市・港・行政中心の数
- 初期生成に対する地名密度・集落密度
- 道路・鉄道路線の存在
- 地形品質と人文品質の総合点
上位2候補がどちらも人文品質基準を満たさない場合だけ、第3候補のフル生成を実行する。
### 3. 地形テンプレート別の品質基準
地形タイプに応じて陸地率等の基準を変える。
- `mixed_archipelago`
- `setouchi_inland_sea`
- `kanto_alluvial`
- `chubu_mountain`
- `tohoku_spine`
- `oceanic_archipelago`
明示的な `oceanic_archipelago` だけは、大部分が海である候補を仕様として許可する。通常の `auto` では、海洋専用テンプレートを自動選択対象にしない既存仕様を維持する。
### 4. 結合後の再検査
候補単体で合格しても、シーム結合後に品質が崩れる可能性があるため、最終ワールド上でも再検査する。
- 高所有率の新規内部セルにおける陸地率
- 最終的に残った地名数
- 最終的に残った集落数
- 人文密度の最低値
候補単体と結合後の双方が合格した場合だけ、`candidateQuality.hardPass` が真になる。
### 5. 海岸シーム補正の局所化
従来の標高アフィン補正は、既存側に地形を合わせるためのオフセットと傾きを追加範囲全体へ適用していた。このため、候補段階で十分な陸地があっても、結合時に広域が海面下へ落ちる場合があった。
Step 6では次のように変更した。
- 世界海面高への基準オフセットは全域へ適用
- 既存地形へ合わせる追加オフセット・傾きは、生成済み領域とのフロンティア近傍だけへ適用
- 新規領域の内部へ進むほど補正を減衰
- Expansionの補正上限をRegenerationより小さく制限
これにより、シームの連続性を保ちながら、新規内部では本番地形の陸地統計を維持する。
## UI・診断
Seam diagnosticsに以下を追加した。
- Step 6品質ゲートの合否
- 総合品質スコア
- 選定された内部variant
- 候補陸地率
- 開発可能地率
- 最大連結陸地率
- 候補地名・集落密度
- 結合後の高所有率内部陸地率
- 結合後の地名・集落数
品質基準を完全には満たさない候補しか得られなかった場合は、最良候補を使用しつつ警告を記録する。
## 専用回帰テスト
実行:
```bash
node STEP6_VALIDATION.mjs
```
### 瀬戸内型Expansion
```text
terrain type: setouchi_inland_sea
terrain attempts: 6
full production attempts: 2
selected variant: 4
candidate land ratio: 0.5121
candidate developable ratio: 0.8038
candidate largest component ratio: 0.5153
candidate labels: 82
candidate settlements: 38
final owned-interior land ratio: 0.4626
final labels: 77
final settlements: 40
quality score: 0.9513
quality gate: PASS
```
### Auto Expansion
```text
selected terrain type: mixed_archipelago
terrain attempts: 6
full production attempts: 2
selected variant: 0
candidate land ratio: 0.7398
candidate developable ratio: 0.7126
candidate largest component ratio: 0.7084
candidate labels: 67
candidate settlements: 27
final owned-interior land ratio: 0.6907
final labels: 68
final settlements: 27
quality score: 0.9667
quality gate: PASS
```
両ケースで以下も確認した。
- 道路ポータル切断: 0
- 鉄道ポータル切断: 0
- 二重行政境界候補: 0
## Step 4 / Step 5回帰
既存の交通ポータル・最終ID境界テストを再実行した。
```text
Expansion
mandatory road portals: 1
broken road portals: 0
broken rail portals: 0
duplicate boundary pairs: 0
Regeneration
mandatory road portals: 5
mandatory rail portals: 1
broken road portals: 0
broken rail portals: 0
duplicate boundary pairs: 0
```
全JavaScript/MJSファイルは `node --check` を通過した。
## 制約と設計上の変更
Step 3で導入した絶対座標矩形地形は、同じ絶対座標に対する窓サイズ非依存性を持っていた。Step 6の通常Expansionは品質を優先し、初期生成と同じ本番地形候補を選ぶ方式へ変更したため、候補の地形そのものについて厳密な窓サイズ不変性は保証しない。
代わりに、次で既存世界との整合性を維持する。
- 世界共通海面高
- 双方向オーバーラップ
- 海陸・標高の境界契約
- 交通ポータル
- 最終IDからの行政境界再構築
- 結合後品質検査
また、品質選択のため通常Expansionの処理時間は増える。標準ケースでは地形候補6回とフル生成2回を行い、候補不良時だけフル生成3回目を実行する。
## 既存総合テストとの比較
`node test.js` をStep 4 / Step 5基準版とStep 6版の双方で実行した。
```text
Step 4 / Step 5基準版: 47 failures
Step 6版: 47 failures
差分: 0
```
Step 6導入時に、旧モード名 `expansion-full-pipeline-stable-terrain` を直接検索していたソース検査1件だけを、新しい `expansion-production-quality-selected``prepareProductionTerrain` を確認する検査へ更新した。それ以外の失敗項目は基準版と完全に一致しており、Step 6による総合テスト上の追加失敗はない。

View file

@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { generateMap } from "./mapPipeline.js";
import { generatePatch } from "./mapPatch.js";
import { createWorldMap } from "./worldMap.js";
import { MAP_H } from "./mapUtils.js";
function westExpansion(world, leftReach, oldSideReach, topPad, bottomPad) {
const ox = world.originX;
const oy = world.originY;
return {
kind: "lasso",
polygon: [
{ x: ox - leftReach, y: oy - topPad },
{ x: ox + oldSideReach, y: oy - topPad },
{ x: ox + oldSideReach, y: oy + MAP_H + bottomPad },
{ x: ox - leftReach, y: oy + MAP_H + bottomPad },
],
};
}
function validateQualityPatch(patch, label) {
assert.equal(patch.ok, true, `${label}: patch must succeed`);
assert.equal(patch.patchMode, "expansion", `${label}: fixture must resolve to expansion`);
assert.equal(patch.patchGenerationMode, "expansion-production-quality-selected", `${label}: Step 6 production mode must be active`);
assert.equal(patch.productionPipelineParity, true, `${label}: production pipeline parity flag must be set`);
const quality = patch.candidateQuality;
assert(quality, `${label}: quality diagnostics must exist`);
assert.equal(quality.policyVersion, "step6-production-parity-v1");
assert.equal(quality.productionPipelineParity, true);
assert(quality.terrainAttempts.length >= 6, `${label}: terrain search must inspect all configured attempts`);
assert(quality.fullAttempts.length >= 2, `${label}: at least two complete production candidates must be compared`);
assert.equal(quality.preMergeHardPass, true, `${label}: selected production candidate must pass before merge`);
assert.equal(quality.final?.hardPass, true, `${label}: merged patch must pass final quality verification`);
assert.equal(quality.hardPass, true, `${label}: combined Step 6 quality gate must pass`);
assert(quality.final.ownedLandRatio >= quality.final.landFloor, `${label}: owned interior must retain enough land`);
assert(quality.final.labelCount >= quality.final.minFinalLabels, `${label}: final label floor must be met`);
assert(quality.final.settlementCount >= quality.final.minFinalSettlements, `${label}: final settlement floor must be met`);
assert.equal(patch.seamDiagnostics.roadPortalsBroken, 0, `${label}: road portals must remain connected`);
assert.equal(patch.seamDiagnostics.railPortalsBroken, 0, `${label}: rail portals must remain connected`);
assert.equal(patch.seamDiagnostics.duplicateBoundaryPairs, 0, `${label}: no duplicate administrative boundary pairs`);
return {
terrainType: quality.terrain.terrainType,
selectedVariant: quality.selectedVariant,
terrainAttempts: quality.terrainAttempts.length,
fullAttempts: quality.fullAttempts.length,
candidateLandRatio: quality.terrain.landRatio,
candidateDevelopableRatio: quality.terrain.developableRatio,
candidateLargestComponentRatio: quality.terrain.largestComponentRatio,
candidateLabels: quality.human.labelCount,
candidateSettlements: quality.human.settlementCount,
finalOwnedLandRatio: quality.final.ownedLandRatio,
finalLabels: quality.final.labelCount,
finalSettlements: quality.final.settlementCount,
score: quality.score,
seaRatio: patch.seaRatio,
};
}
function runSetouchi() {
const initial = generateMap(114514, { terrainType: "setouchi_inland_sea", onProgress() {} });
const world = createWorldMap(structuredClone(initial));
const patch = generatePatch(world, westExpansion(world, 150, 10, 20, 20), {
patchMode: "expansion",
terrainType: "setouchi_inland_sea",
seed: 0x1234abcd,
variant: 0,
});
const result = validateQualityPatch(patch, "setouchi");
assert(result.candidateLandRatio >= 0.22 && result.candidateLandRatio <= 0.82, "setouchi: candidate must satisfy template land interval");
assert(result.finalOwnedLandRatio >= 0.20, "setouchi: generated interior must not collapse into open ocean");
return result;
}
function runAuto() {
const initial = generateMap(24681357, { terrainType: "auto", onProgress() {} });
const world = createWorldMap(structuredClone(initial));
const patch = generatePatch(world, westExpansion(world, 170, 5, 15, 18), {
patchMode: "expansion",
terrainType: "auto",
seed: 0x11111111,
variant: 0,
});
const result = validateQualityPatch(patch, "auto");
assert.notEqual(result.terrainType, "oceanic_archipelago", "auto: manual ocean-only template must not be selected");
assert(result.finalOwnedLandRatio >= 0.32, "auto: owned interior must contain a substantial landmass");
assert(result.finalLabels >= 12, "auto: final generated area must not be label-sparse");
assert(result.finalSettlements >= 6, "auto: final generated area must not be settlement-sparse");
return result;
}
const scenario = process.argv[2] || "all";
if (scenario === "setouchi") {
console.log(JSON.stringify(runSetouchi()));
} else if (scenario === "auto") {
console.log(JSON.stringify(runAuto()));
} else {
// Isolate the two large generation fixtures in child processes. Keeping both
// complete worlds in one process can exceed browser-like memory budgets and
// is unrelated to the behavior under test.
const script = fileURLToPath(import.meta.url);
const runChild = (name) => {
const child = spawnSync(process.execPath, [script, name], {
cwd: process.cwd(),
encoding: "utf8",
maxBuffer: 4 * 1024 * 1024,
});
if (child.status !== 0) {
process.stderr.write(child.stderr || child.stdout || `${name} validation failed\n`);
process.exit(child.status || 1);
}
return JSON.parse(child.stdout.trim());
};
const setouchi = runChild("setouchi");
const auto = runChild("auto");
console.log(JSON.stringify({
ok: true,
policy: "step6-production-parity-v1",
setouchi,
auto,
}, null, 2));
}

View file

@ -0,0 +1,145 @@
# Step 7 実装・検証記録
## 対象
Step 6 の生成結果で確認された次の問題を修正した。
1. 追加生成が 2030 秒以上かかる
2. 海岸・道路・行政形状がラッソ外周に追従する
3. 既存の都道府県名が `県域N` に置き換わる、または既存領域の行政境界が広く消える
## 1. 追加生成の高速化
Step 6 は地形候補を 6 件作り、完全な初期生成パイプラインを通常 2 件、条件次第で 3 件実行していた。人文地理・行政・交通の全段階を複数回実行することが主要な遅延原因だった。
Step 7 では次の構成へ変更した。
- 軽量な本番地形候補: 2 件を基本、両方が不合格の場合のみ 3 件目
- 完全な初期生成パイプライン: 最良地形に対して 1 回だけ
- 最終的な海陸率、地名密度、集落密度、交通・境界品質の検査は維持
- 候補キャッシュは従来どおり使用
生成モードは次に変更した。
```text
expansion-production-fast-natural
```
品質ポリシーは次の識別子を使用する。
```text
step7-fast-natural-expansion-v1
```
### 計測結果
`STEP7_VALIDATION.mjs` による追加生成部分だけの実測値:
| ケース | 地形候補 | 完全候補 | 追加生成時間 |
|---|---:|---:|---:|
| 瀬戸内海型 | 2 | 1 | 6.08 秒 |
| Auto | 2 | 1 | 8.19 秒 |
実行環境の負荷により変動するが、検証ケースでは両方とも 10 秒以内だった。初期マップ生成時間は上記に含まない。
## 2. ラッソ外周への追従を抑制
### 仮想拡大フレーム
初期生成器はマップ外周を海へ落とす設計を含む。Step 6 では候補マップの外周が追加選択範囲の外周付近へ写像されるため、海岸がラッソ形状に沿いやすかった。
Step 7 では追加生成候補を、1.55 倍の仮想本番マップから切り出した中央クロップとして生成する。これにより、初期生成器の意図的な「マップ端の海」がラッソ端へ直接現れない。
- 地形ノイズは従来どおり絶対ワールド座標を使用
- 海岸・山系に使用する正規化座標だけを仮想拡大フレームへ変換
- 候補ごとに小さな決定論的フレームオフセットを使用
### 外周フェザー
ラッソ外周のアルファを、数セル幅の均一な内向きフェザーから、広域・中域・詳細のワールド座標ノイズを合成した不規則な深度へ変更した。これにより、海岸や土地利用境界が選択線と平行に続く傾向を弱める。
### 外部交通の抑制
初期生成向けの次の候補レイヤーは、追加生成では取り込まない。
- `externalGateways`
- `externalRoads`
- `externalRailways`
- `externalExpressways`
これらは本来、初期マップの画面外接続を表すため、追加領域へ移植すると道路・鉄道がラッソ上端・下端へ直進する原因になる。既存マップとの接続は Step 4 の交通ポータルで保証する。
検証では、生成された交通線の端点が旧世界との実シームではない外周 4 セル以内に現れた数は、瀬戸内海型で 1、Auto で 0 だった。
## 3. 行政名と既存境界の保護
### 既存行政フィールドの復元
Expansion でも修復領域内の次のフィールドだけを軽量スナップショットするよう変更した。
- `adminId`
- `municipalityId`
- `prefectureRegionId`
- `prefectureMask`
パッチアルファが 0 の旧領域は行政トポロジー整理後に復元する。これにより、修復矩形が既存領域へ広がっても、選択外の県・市町村IDが統合・消去されない。
### 行政名のID固定
パッチ前の `adminCenters``prefectureRegions` をID別に保存し、整合化後も同じIDには元の名称を戻す。ラベル位置が再計算されても名称は維持される。
新規候補については、最終IDラスター内に存在する行政IDに対応する候補メタデータを、候補ラベル点が選択範囲外にあっても補完する。これにより `県域N` へのフォールバックを防止する。
### 境界再構築範囲
Step 5 の境界再構築は `prefectureMask` 内だけを対象にしていたため、周辺県の境界が消えていた。Step 7 では次の正本から世界全体の陸上境界を再構築する。
- 市町村境界: 最終 `adminId` の差、かつ同一 `prefectureRegionId`
- 都道府県境界: 最終 `prefectureRegionId` の差
- 対象範囲: 海以外の全セル
`prefectureMask` は注目県の外周レイヤーにだけ引き続き使用する。
## 検証
実行:
```bash
node STEP7_VALIDATION.mjs
```
最終確認結果:
```text
setouchi
expansion: 6.08 s
terrain attempts: 2
full attempts: 1
old prefecture names preserved: 3
generic prefecture names: 0
outer-edge transport endpoints: 1
auto
expansion: 8.19 s
terrain attempts: 2
full attempts: 1
old prefecture names preserved: 3
generic prefecture names: 0
outer-edge transport endpoints: 0
```
両ケースで以下を確認した。
- 最終品質ゲート: PASS
- 道路ポータル切断: 0
- 鉄道ポータル切断: 0
- 二重行政境界: 0
- 既存都道府県名の変更・消失: 0
- `県域N` フォールバック: 0
- 市町村境界ベクトルが全陸上の最終ID差分と完全一致
- 都道府県境界ベクトルが全陸上の最終ID差分と完全一致
- 追加候補由来の外部道路・外部鉄道・外部高速道路・外部ゲートウェイ: 0
全 JavaScript / MJS ファイルは `node --check` を通過した。
既存の総合 `test.js` はこの実行環境で 180 秒以内に完了しなかったため、総合スイート完走は確認できていない。Step 7 専用回帰テストは完走している。

View file

@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import { performance } from "node:perf_hooks";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { generateMap } from "./mapPipeline.js";
import { generatePatch } from "./mapPatch.js";
import { createWorldMap } from "./worldMap.js";
import { MAP_H } from "./mapUtils.js";
function westExpansion(world, leftReach, oldSideReach, topPad, bottomPad) {
const ox = world.originX;
const oy = world.originY;
return {
kind: "lasso",
polygon: [
{ x: ox - leftReach, y: oy - topPad },
{ x: ox + oldSideReach, y: oy - topPad },
{ x: ox + oldSideReach, y: oy + MAP_H + bottomPad },
{ x: ox - leftReach, y: oy + MAP_H + bottomPad },
],
};
}
function pointId(point) {
if (Number.isFinite(point?.prefectureRegionId)) return Math.floor(point.prefectureRegionId);
if (Number.isFinite(point?.id)) return Math.floor(point.id);
return -1;
}
function pointName(point) {
return point?.name || point?.labelName || point?.prefectureName || point?.prefectureRegionName || point?.regionName || "";
}
function segmentKey(x0, y0, x1, y1) {
const a = `${x0},${y0}`;
const b = `${x1},${y1}`;
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
function vectorSegmentSet(world, segments) {
const out = new Set();
for (const seg of segments || []) {
if (!Array.isArray(seg) || seg.length < 2) continue;
const x0 = Math.round(seg[0][0] + world.originX);
const y0 = Math.round(seg[0][1] + world.originY);
const x1 = Math.round(seg[1][0] + world.originX);
const y1 = Math.round(seg[1][1] + world.originY);
out.add(segmentKey(x0, y0, x1, y1));
}
return out;
}
function expectedBoundarySets(world) {
const sea = world.fields.sea;
const admin = world.fields.adminId;
const pref = world.fields.prefectureRegionId;
const municipal = new Set();
const prefecture = new Set();
for (let y = 0; y < world.height; y++) {
for (let x = 0; x < world.width; x++) {
const i = y * world.width + x;
if (sea?.[i]) continue;
if (x + 1 < world.width) {
const j = i + 1;
if (!sea?.[j]) {
if (pref?.[i] >= 0 && pref?.[j] >= 0 && pref[i] !== pref[j]) prefecture.add(segmentKey(x + 1, y, x + 1, y + 1));
if (admin?.[i] >= 0 && admin?.[j] >= 0 && admin[i] !== admin[j] && pref?.[i] >= 0 && pref[i] === pref[j]) municipal.add(segmentKey(x + 1, y, x + 1, y + 1));
}
}
if (y + 1 < world.height) {
const j = i + world.width;
if (!sea?.[j]) {
if (pref?.[i] >= 0 && pref?.[j] >= 0 && pref[i] !== pref[j]) prefecture.add(segmentKey(x, y + 1, x + 1, y + 1));
if (admin?.[i] >= 0 && admin?.[j] >= 0 && admin[i] !== admin[j] && pref?.[i] >= 0 && pref[i] === pref[j]) municipal.add(segmentKey(x, y + 1, x + 1, y + 1));
}
}
}
}
return { municipal, prefecture };
}
function generatedOuterEdgeEndpoints(world, patch) {
const r = patch.rects.coreRect;
const keys = ["premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "expressways", "icAccessRoads", "railways", "branchRailways"];
let count = 0;
for (const key of keys) {
for (const path of world.sourceMap[key] || []) {
if (!path?.patchGenerated || path.length < 2) continue;
for (const tuple of [path[0], path[path.length - 1]]) {
const x = tuple[0] + world.originX;
const y = tuple[1] + world.originY;
// Right is the real old/new seam. Only top, bottom, and outer-left are
// synthetic selection edges and should not attract transport gateways.
const d = Math.min(Math.abs(x - r.x0), Math.abs(y - r.y0), Math.abs(y - (r.y1 - 1)));
if (d <= 4) count++;
}
}
}
return count;
}
function validateScenario({ initialSeed, terrainType, patchSeed, leftReach, oldSideReach, topPad, bottomPad }) {
const initial = generateMap(initialSeed, { terrainType, onProgress() {} });
const oldNames = new Map((initial.prefectureRegions || []).map((p) => [pointId(p), pointName(p)]).filter(([id]) => id >= 0));
const world = createWorldMap(structuredClone(initial));
const selection = westExpansion(world, leftReach, oldSideReach, topPad, bottomPad);
const t0 = performance.now();
const patch = generatePatch(world, selection, {
patchMode: "expansion",
terrainType,
seed: patchSeed,
variant: 0,
});
const seconds = (performance.now() - t0) / 1000;
assert.equal(patch.ok, true);
assert.equal(patch.patchGenerationMode, "expansion-production-fast-natural");
assert(seconds < 12, `expansion took ${seconds.toFixed(2)} s; expected browser-scale completion near 10 s`);
assert.equal(patch.candidateQuality?.policyVersion, "step7-fast-natural-expansion-v1");
assert.equal(patch.candidateQuality?.fastPath, true);
assert(patch.candidateQuality.terrainAttempts.length >= 2 && patch.candidateQuality.terrainAttempts.length <= 3);
assert.equal(patch.candidateQuality.fullAttempts.length, 1);
assert(patch.candidateQuality.terrainFrameScale >= 1.4);
assert.equal(patch.candidateQuality.final?.hardPass, true);
assert.equal(patch.seamDiagnostics.roadPortalsBroken, 0);
assert.equal(patch.seamDiagnostics.railPortalsBroken, 0);
assert.equal(patch.seamDiagnostics.duplicateBoundaryPairs, 0);
const currentNames = new Map((world.sourceMap.prefectureRegions || []).map((p) => [pointId(p), pointName(p)]).filter(([id]) => id >= 0));
for (const [id, name] of oldNames) assert.equal(currentNames.get(id), name, `old prefecture name ${id} changed or disappeared`);
const genericNames = [...currentNames.values()].filter((name) => /^県域\d+$/.test(String(name)));
assert.deepEqual(genericNames, [], "candidate prefecture metadata should prevent generic 県域 fallback names");
const expected = expectedBoundarySets(world);
const actualMunicipal = vectorSegmentSet(world, world.sourceMap.adminBorders);
const actualPrefecture = vectorSegmentSet(world, world.sourceMap.regionalPrefectureBorders);
assert.deepEqual(actualMunicipal, expected.municipal, "municipal vectors must cover all final land IDs, not only prefectureMask");
assert.deepEqual(actualPrefecture, expected.prefecture, "prefecture vectors must cover all final land IDs, including old map areas outside the patch");
for (const key of ["externalRoads", "externalRailways", "externalExpressways", "externalGateways"]) {
assert.equal((world.sourceMap[key] || []).filter((item) => item?.patchGenerated).length, 0, `${key} must not be imported from the synthetic candidate perimeter`);
}
const outerEdgeEndpoints = generatedOuterEdgeEndpoints(world, patch);
assert(outerEdgeEndpoints <= 4, `too many generated transport endpoints follow the synthetic lasso edge: ${outerEdgeEndpoints}`);
return {
terrainType,
seconds: Math.round(seconds * 100) / 100,
terrainAttempts: patch.candidateQuality.terrainAttempts.length,
fullAttempts: patch.candidateQuality.fullAttempts.length,
selectedVariant: patch.candidateQuality.selectedVariant,
finalOwnedLandRatio: patch.candidateQuality.final.ownedLandRatio,
finalLabels: patch.candidateQuality.final.labelCount,
oldPrefectureNamesPreserved: oldNames.size,
genericPrefectureNames: genericNames.length,
municipalBoundarySegments: actualMunicipal.size,
prefectureBoundarySegments: actualPrefecture.size,
outerEdgeTransportEndpoints: outerEdgeEndpoints,
};
}
const scenario = process.argv[2] || "all";
const cases = {
setouchi: { initialSeed: 114514, terrainType: "setouchi_inland_sea", patchSeed: 0x1234abcd, leftReach: 150, oldSideReach: 10, topPad: 20, bottomPad: 20 },
auto: { initialSeed: 24681357, terrainType: "auto", patchSeed: 0x11111111, leftReach: 170, oldSideReach: 5, topPad: 15, bottomPad: 18 },
};
if (scenario !== "all") {
console.log(JSON.stringify(validateScenario(cases[scenario]), null, 2));
} else {
const script = fileURLToPath(import.meta.url);
const runChild = (name) => {
const child = spawnSync(process.execPath, [script, name], { cwd: process.cwd(), encoding: "utf8", maxBuffer: 8 * 1024 * 1024 });
if (child.status !== 0) {
process.stderr.write(child.stderr || child.stdout || `${name} failed\n`);
process.exit(child.status || 1);
}
return JSON.parse(child.stdout.trim());
};
console.log(JSON.stringify({ ok: true, policy: "step7-fast-natural-expansion-v1", setouchi: runChild("setouchi"), auto: runChild("auto") }, null, 2));
}

View file

@ -0,0 +1,32 @@
{
"ok": true,
"policy": "step7-fast-natural-expansion-v1",
"setouchi": {
"terrainType": "setouchi_inland_sea",
"seconds": 6.08,
"terrainAttempts": 2,
"fullAttempts": 1,
"selectedVariant": 1,
"finalOwnedLandRatio": 0.1904959144481681,
"finalLabels": 47,
"oldPrefectureNamesPreserved": 3,
"genericPrefectureNames": 0,
"municipalBoundarySegments": 2767,
"prefectureBoundarySegments": 67,
"outerEdgeTransportEndpoints": 1
},
"auto": {
"terrainType": "auto",
"seconds": 8.19,
"terrainAttempts": 2,
"fullAttempts": 1,
"selectedVariant": 0,
"finalOwnedLandRatio": 0.6075206069005763,
"finalLabels": 69,
"oldPrefectureNamesPreserved": 3,
"genericPrefectureNames": 0,
"municipalBoundarySegments": 5048,
"prefectureBoundarySegments": 1307,
"outerEdgeTransportEndpoints": 0
}
}

View file

@ -0,0 +1,7 @@
{
"ok": true,
"cancelButton": true,
"watchdogSeconds": 60,
"escapeCancelsBusy": true,
"clearDiscardsPendingPreview": true
}

View file

@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
import { MAP_W, MAP_H } from './mapUtils.js';
function worldPoint(world, p) {
return { x: Math.round((p?.x || 0) + (world.originX || 0)), y: Math.round((p?.y || 0) + (world.originY || 0)) };
}
function idx(world,x,y){ return x>=0&&y>=0&&x<world.width&&y<world.height ? y*world.width+x : -1; }
function capitalAudit(world) {
const pref=world.fields.prefectureRegionId, sea=world.fields.sea;
const active=new Set();
for(let i=0;i<pref.length;i++) if(!sea[i]&&pref[i]>=0) active.add(Math.floor(pref[i]));
const counts=new Map([...active].map(id=>[id,0]));
for(const city of world.sourceMap.modernCities||[]) {
const cap=city.isPrefecturalCapital||city.isRegionalCapital||/Capital/i.test(String(city.rank||''))||/Capital/i.test(String(city.kind||''));
if(!cap) continue;
const p=worldPoint(world,city), i=idx(world,p.x,p.y); if(i<0||sea[i]||pref[i]<0) continue;
const id=Math.floor(pref[i]); counts.set(id,(counts.get(id)||0)+1);
}
return { active:[...active], zero:[...counts].filter(([,n])=>n===0), duplicate:[...counts].filter(([,n])=>n>1) };
}
function riverSeaPointsOutsideRect(world, rect) {
const sea=world.fields.sea; const out=[];
for(const key of ['rivers','riverPaths','majorRivers','minorRivers']) for(const path of world.sourceMap?.[key]||[]) for(const t of path||[]) {
if(!Array.isArray(t)||t.length<2) continue;
const x=Math.round((t[0]||0)+world.originX), y=Math.round((t[1]||0)+world.originY), i=idx(world,x,y);
if(i>=0 && sea[i] && !(x>=rect.x0&&x<rect.x1&&y>=rect.y0&&y<rect.y1)) out.push(`${key}:${x},${y}`);
}
return out.sort();
}
const seed=24681357;
const initial=generateMap(seed,{terrainType:'auto',onProgress(){}});
const world=createWorldMap(structuredClone(initial));
const rect={x0:world.originX+MAP_W-25,y0:world.originY+35,x1:world.originX+MAP_W+120,y1:world.originY+150};
const beforeRiver=riverSeaPointsOutsideRect(world,rect);
const result=generatePatch(world,rect,{patchMode:'expansion',terrainType:'auto',seed:777001,variant:0,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true});
assert.equal(result.ok,true, result.reason||result.code);
const capitals=capitalAudit(world);
assert.equal(capitals.zero.length,0,`prefectures with no capital: ${JSON.stringify(capitals.zero)}`);
assert.equal(capitals.duplicate.length,0,`prefectures with duplicate capitals: ${JSON.stringify(capitals.duplicate)}`);
const afterRiver=riverSeaPointsOutsideRect(world,rect);
assert.deepEqual(afterRiver,beforeRiver,'river sanitation changed sea-mouth points outside patch write region');
for(const r of world.sourceMap.prefectureRegions||[]) {
assert.equal(r.insidePrefecture,true);
assert.ok(Number.isFinite(r.worldX)&&Number.isFinite(r.worldY));
assert.ok(Number.isFinite(r.capitalX)&&Number.isFinite(r.capitalY));
assert.ok(Number.isFinite(r.capitalWorldX)&&Number.isFinite(r.capitalWorldY));
}
const app=readFileSync(new URL('./app.js', import.meta.url),'utf8');
const html=readFileSync(new URL('./index.html', import.meta.url),'utf8');
assert.match(app,/PATCH_WORKER_INACTIVITY_WATCHDOG_MS\s*=\s*60_000/);
assert.match(app,/function cancelPatchGeneration/);
assert.match(app,/hideSelectionOverlay\(\{ discardPreview: true \}\)/);
assert.match(html,/id="cancelPatchGeneration"/);
console.log(JSON.stringify({ok:true,capitalAudit:capitals,outsideRiverSeaPointsPreserved:beforeRiver.length,seam:result.seamDiagnostics?.status||null},null,2));

View file

@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
import { MAP_W, MAP_H } from './mapUtils.js';
const initial=generateMap(42424242,{terrainType:'auto',onProgress(){}});
const world=createWorldMap(structuredClone(initial));
const rects=[
{x0:world.originX+MAP_W-25,y0:world.originY+30,x1:world.originX+MAP_W+115,y1:world.originY+150},
{x0:world.originX+35,y0:world.originY+MAP_H-25,x1:world.originX+175,y1:world.originY+MAP_H+105},
];
function audit(){
const pref=world.fields.prefectureRegionId, sea=world.fields.sea; const active=new Set();
for(let i=0;i<pref.length;i++) if(!sea[i]&&pref[i]>=0) active.add(Math.floor(pref[i]));
const counts=new Map([...active].map(id=>[id,0]));
for(const c of world.sourceMap.modernCities||[]){
const cap=c.isPrefecturalCapital||c.isRegionalCapital||/Capital/i.test(String(c.rank||''))||/Capital/i.test(String(c.kind||'')); if(!cap)continue;
const x=Math.round((c.x||0)+world.originX),y=Math.round((c.y||0)+world.originY); if(x<0||y<0||x>=world.width||y>=world.height)continue;
const i=y*world.width+x; if(sea[i]||pref[i]<0)continue; const id=Math.floor(pref[i]); counts.set(id,(counts.get(id)||0)+1);
}
const zeros=[...counts].filter(([,n])=>n===0), dup=[...counts].filter(([,n])=>n!==1);
assert.equal(zeros.length,0,`zero capital ${JSON.stringify(zeros)}`); assert.equal(dup.length,0,`not exactly one ${JSON.stringify(dup)}`);
for(const r of world.sourceMap.prefectureRegions||[]){
const id=Math.floor(r.prefectureRegionId??r.id); assert.ok(active.has(id)); assert.equal(r.insidePrefecture,true);
const cx=Math.round(r.capitalWorldX), cy=Math.round(r.capitalWorldY); assert.ok(cx>=0&&cy>=0&&cx<world.width&&cy<world.height);
const i=cy*world.width+cx; assert.equal(Math.floor(pref[i]),id,`capital metadata outside prefecture ${id}`); assert.equal(!!sea[i],false);
assert.equal(Math.round((r.capitalX||0)+world.originX),cx); assert.equal(Math.round((r.capitalY||0)+world.originY),cy);
assert.equal(Math.round((r.x||0)+world.originX),Math.round(r.worldX)); assert.equal(Math.round((r.y||0)+world.originY),Math.round(r.worldY));
}
return {active:active.size};
}
const steps=[];
for(let k=0;k<rects.length;k++){
const r=generatePatch(world,rects[k],{patchMode:'expansion',terrainType:'auto',seed:91000+k,variant:0,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true});
assert.equal(r.ok,true,r.reason||r.code); steps.push({...audit(),seam:r.seamDiagnostics?.status||null});
}
console.log(JSON.stringify({ok:true,steps},null,2)); process.exit(0);

View file

@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { Worker } from 'node:worker_threads';
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { collectTransferableBuffers } from './transferUtils.js';
const BASE_SEED = 24681357;
const PATCH_SEED = 0x4a35b921;
const WIDTH = 600;
const HEIGHT = 400;
function footprintContains(fp, x, y) {
if (!fp || x < fp.x0 || y < fp.y0 || x >= fp.x1 || y >= fp.y1) return false;
const row = fp.rowRuns?.[y - fp.y0];
if (!Array.isArray(row)) return false;
for (let i = 0; i + 1 < row.length; i += 2) if (x >= row[i] && x < row[i + 1]) return true;
return false;
}
function recordContains(record, x, y) {
if (record?.generatedFootprint) return footprintContains(record.generatedFootprint, x, y);
const r = record?.coreRect || record;
return !!r && x >= r.x0 && y >= r.y0 && x < r.x1 && y < r.y1;
}
function generatedAt(world, x, y) {
return (world.generatedRects || []).some(record => recordContains(record, x, y));
}
function centeredRect(world, width, height) {
// Use a deliberately off-center but fully valid selection so this exercises
// a different outer seam than the existing centered stability test.
const x0 = Math.max(0, world.width - width);
const y0 = Math.max(0, world.height - height);
return { x0, y0, x1: x0 + width, y1: y0 + height };
}
function workerAdapter(url) {
const code = `import { parentPort } from 'node:worker_threads';\n`
+ `globalThis.self = { onmessage: null, postMessage(message, transfer) { parentPort.postMessage(message, transfer); } };\n`
+ `await import(${JSON.stringify(url.href)});\n`
+ `parentPort.on('message', data => self.onmessage?.({ data }));\n`;
return new Worker(new URL(`data:text/javascript,${encodeURIComponent(code)}`), { type: 'module', execArgv: [] });
}
const initial = generateMap(BASE_SEED, { terrainType: 'auto', onProgress() {} });
const baseWorld = createWorldMap(initial);
const rect = centeredRect(baseWorld, WIDTH, HEIGHT);
const needed = [];
for (let y = rect.y0; y < rect.y1; y++) {
for (let x = rect.x0; x < rect.x1; x++) {
if (!generatedAt(baseWorld, x, y)) needed.push([x, y]);
}
}
assert.ok(needed.length > 0, 'test selection must contain ungenerated cells');
const preview = structuredClone(baseWorld);
const transfer = Array.from(collectTransferableBuffers(preview));
const worker = workerAdapter(new URL('./mapPatchWorker.js', import.meta.url));
const startedAt = Date.now();
const message = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('600x400 worker timeout')), 120000);
worker.on('message', message => {
if (message?.type === 'progress') return;
clearTimeout(timer);
resolve(message);
});
worker.on('error', error => { clearTimeout(timer); reject(error); });
worker.postMessage({
id: 1,
world: preview,
rect,
options: {
patchMode: 'expansion', terrainType: 'auto', seed: PATCH_SEED, variant: 1,
maxQualityRetries: 0, qualityTerrainAttempts: 1, acceptBestAvailableQuality: true,
},
}, transfer);
});
assert.equal(message?.ok, true, message?.error || 'worker outer failure');
assert.equal(message?.result?.ok, true, message?.result?.reason || message?.result?.code || 'patch failure');
assert.equal(message?.result?.seamDiagnostics?.hardPass, true, 'seam hard gate failed');
const returnedWorld = message.world;
let missing = 0;
for (const [x, y] of needed) if (!generatedAt(returnedWorld, x, y)) missing++;
assert.equal(missing, 0, `missing ${missing} previously-ungenerated selected cells`);
const out = {
ok: true,
size: `${WIDTH}x${HEIGHT}`,
selectedCells: WIDTH * HEIGHT,
previouslyUngeneratedSelectedCells: needed.length,
missingPreviouslyUngeneratedCells: missing,
tileCount: message.result?.tileCount || 1,
seam: message.result?.seamDiagnostics?.status || null,
hardPass: message.result?.seamDiagnostics?.hardPass ?? null,
ms: Date.now() - startedAt,
};
console.log(JSON.stringify(out, null, 2));
await worker.terminate();
process.exit(0);

View file

@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
const initial=generateMap(24681357,{terrainType:'auto',onProgress(){}});
const world=createWorldMap(structuredClone(initial));
const ox=world.originX, oy=world.originY;
const lasso={kind:'lasso',polygon:[
{x:ox-120,y:oy-120},{x:ox+410,y:oy+215},{x:ox+425,y:oy+240},{x:ox-105,y:oy-95}
]};
const t=Date.now();
const result=generatePatch(world,lasso,{patchMode:'expansion',terrainType:'auto',seed:888002,variant:0,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true,onProgress(e){if(e?.key?.startsWith('large-tile-')&&e.status==='start') console.error(e.label)}});
const ms=Date.now()-t;
assert.equal(result.ok,true,result.reason||result.code);
assert.ok((result.tileCount||1)<9,`thin lasso still uses ${result.tileCount} tiles`);
console.log(JSON.stringify({ok:true,tileCount:result.tileCount||1,ms,seam:result.seamDiagnostics?.status||null},null,2));
process.exit(0);

View file

@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
import { MAP_W, MAP_H } from './mapUtils.js';
function fpContains(fp,x,y){if(!fp||x<fp.x0||y<fp.y0||x>=fp.x1||y>=fp.y1)return false;const row=fp.rowRuns?.[y-fp.y0];if(!Array.isArray(row))return false;for(let i=0;i+1<row.length;i+=2)if(x>=row[i]&&x<row[i+1])return true;return false;}
function recContains(r,x,y){if(r?.generatedFootprint)return fpContains(r.generatedFootprint,x,y);const q=r?.coreRect||r;return !!q&&x>=q.x0&&y>=q.y0&&x<q.x1&&y<q.y1;}
function coverage(world,rect){let selected=0,generated=0;for(let y=rect.y0;y<rect.y1;y++)for(let x=rect.x0;x<rect.x1;x++){selected++;if((world.generatedRects||[]).some(r=>recContains(r,x,y)))generated++;}return{selected,generated,missing:selected-generated,ratio:generated/selected};}
function makeRect(world,w,h){const edge=world.originX+MAP_W-1,cy=world.originY+Math.floor(MAP_H/2);return{x0:edge-40,y0:cy-Math.floor(h/2),x1:edge-40+w,y1:cy-Math.floor(h/2)+h};}
const initial=generateMap(24681357,{terrainType:'auto',onProgress(){}});
const directWorld=createWorldMap(structuredClone(initial)); const directRect=makeRect(directWorld,250,180); const direct=generatePatch(directWorld,directRect,{patchMode:'expansion',terrainType:'auto',seed:0x12345678,variant:1,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true}); assert.equal(direct.ok,true,direct.reason||direct.code); assert.notEqual(direct.tiledExpansion,true); assert.equal(direct.candidateQuality?.requestedTerrainAttempts,1); assert.equal(direct.candidateQuality?.fullAttempts?.length,1);
const world=createWorldMap(structuredClone(initial)); const rect=makeRect(world,280,200); const r=generatePatch(world,rect,{patchMode:'expansion',terrainType:'auto',seed:0x4a35b921,variant:1,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true}); assert.equal(r.ok,true,r.reason||r.code); assert.equal(r.tiledExpansion,true); assert.equal(r.tileCount,4); assert.equal(r.seamDiagnostics?.hardPass,true); const c=coverage(world,rect); assert.equal(c.missing,0); console.log(JSON.stringify({ok:true,direct:{size:'250x180',fullAttempts:direct.candidateQuality.fullAttempts.length,tiled:false},large:{size:'280x200',tileCount:r.tileCount,seam:r.seamDiagnostics.status,coverage:c,totalMs:r.patchTimings.find(x=>x.key==='tiled-total')?.ms}},null,2)); process.exit(0);

View file

@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import { Worker } from 'node:worker_threads';
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { MAP_W,MAP_H } from './mapUtils.js';
import { collectTransferableBuffers } from './transferUtils.js';
function adapter(url){const code=`import {parentPort} from 'node:worker_threads'; globalThis.self={onmessage:null,postMessage(m,t){parentPort.postMessage(m,t)}}; await import(${JSON.stringify(url.href)}); parentPort.on('message',d=>self.onmessage?.({data:d}));`;return new Worker(new URL(`data:text/javascript,${encodeURIComponent(code)}`),{type:'module',execArgv:[]});}
const initial=generateMap(24681357,{terrainType:'auto',onProgress(){}}); const world=createWorldMap(initial); const edge=world.originX+MAP_W-1,cy=world.originY+Math.floor(MAP_H/2); const rect={x0:edge-40,y0:cy-100,x1:edge-40+280,y1:cy+100};
const worker=adapter(new URL('./mapPatchWorker.js',import.meta.url)); const preview=structuredClone(world); const transfer=Array.from(collectTransferableBuffers(preview)); const t=Date.now(); let last=0;
const message=await new Promise((resolve,reject)=>{const timer=setTimeout(()=>reject(new Error('timeout')),80000);worker.on('message',m=>{if(m?.type==='progress'){if(Date.now()-last>4000){last=Date.now(); console.error(Date.now()-t,m.progress?.label||m.progress?.key);}return;}clearTimeout(timer);resolve(m)});worker.on('error',e=>{clearTimeout(timer);reject(e)});worker.postMessage({id:1,world:preview,rect,options:{patchMode:'expansion',terrainType:'auto',seed:0x4a35b921,variant:1,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true}},transfer);});
console.log(JSON.stringify({ms:Date.now()-t,outer:message.ok,inner:message.result?.ok,tiled:message.result?.tiledExpansion,tileCount:message.result?.tileCount,seam:message.result?.seamDiagnostics?.status,reason:message.result?.reason||message.error||null},null,2)); await worker.terminate(); process.exit(0);

View file

@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { Worker } from 'node:worker_threads';
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
import { collectTransferableBuffers } from './transferUtils.js';
const BASE_SEED = 24681357;
const PATCH_SEED = 0x4a35b921;
function centeredRect(world, width, height) {
return {
x0: Math.floor((world.width - width) / 2),
y0: Math.floor((world.height - height) / 2),
x1: Math.floor((world.width - width) / 2) + width,
y1: Math.floor((world.height - height) / 2) + height,
};
}
function workerAdapter(url) {
const code = `import { parentPort } from 'node:worker_threads';\n`
+ `globalThis.self = { onmessage: null, postMessage(message, transfer) { parentPort.postMessage(message, transfer); } };\n`
+ `await import(${JSON.stringify(url.href)});\n`
+ `parentPort.on('message', data => self.onmessage?.({ data }));\n`;
return new Worker(new URL(`data:text/javascript,${encodeURIComponent(code)}`), { type: 'module', execArgv: [] });
}
async function runDirect(width, height) {
const initial = generateMap(BASE_SEED, { terrainType: 'auto', onProgress() {} });
const world = createWorldMap(initial);
const rect = centeredRect(world, width, height);
const startedAt = Date.now();
const result = generatePatch(world, rect, {
patchMode: 'expansion', terrainType: 'auto', seed: PATCH_SEED, variant: 1,
maxQualityRetries: 0, qualityTerrainAttempts: 1, acceptBestAvailableQuality: true,
onProgress() {},
});
const row = {
mode: 'direct', size: `${width}x${height}`, ms: Date.now() - startedAt,
ok: result?.ok === true, code: result?.code || null,
tileCount: result?.tileCount || 1,
seam: result?.seamDiagnostics?.status || null,
hardPass: result?.seamDiagnostics?.hardPass ?? null,
};
assert.equal(row.ok, true, `${row.size}: ${result?.reason || result?.code}`);
assert.equal(row.hardPass, true, `${row.size}: seam hard gate failed`);
return row;
}
async function runWorker(width, height) {
const initial = generateMap(BASE_SEED, { terrainType: 'auto', onProgress() {} });
const world = createWorldMap(initial);
const rect = centeredRect(world, width, height);
const preview = structuredClone(world);
const transfer = Array.from(collectTransferableBuffers(preview));
const worker = workerAdapter(new URL('./mapPatchWorker.js', import.meta.url));
const startedAt = Date.now();
const message = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${width}x${height}: worker timeout`)), 120000);
worker.on('message', message => {
if (message?.type === 'progress') return;
clearTimeout(timer);
resolve(message);
});
worker.on('error', error => { clearTimeout(timer); reject(error); });
worker.postMessage({
id: 1, world: preview, rect,
options: {
patchMode: 'expansion', terrainType: 'auto', seed: PATCH_SEED, variant: 1,
maxQualityRetries: 0, qualityTerrainAttempts: 1, acceptBestAvailableQuality: true,
},
}, transfer);
});
const row = {
mode: 'worker', size: `${width}x${height}`, ms: Date.now() - startedAt,
outerOk: message?.ok === true, ok: message?.result?.ok === true,
code: message?.result?.code || null, tileCount: message?.result?.tileCount || 1,
seam: message?.result?.seamDiagnostics?.status || null,
hardPass: message?.result?.seamDiagnostics?.hardPass ?? null,
};
worker.unref();
void worker.terminate();
assert.equal(row.outerOk, true, `${row.size}: worker outer failure ${message?.error || ''}`);
assert.equal(row.ok, true, `${row.size}: ${message?.result?.reason || message?.result?.code}`);
assert.equal(row.hardPass, true, `${row.size}: seam hard gate failed`);
return row;
}
const args = process.argv.slice(2);
if (args[0] === '--direct') {
console.log(JSON.stringify(await runDirect(Number(args[1]), Number(args[2]))));
process.exit(0);
}
if (args[0] === '--worker') {
console.log(JSON.stringify(await runWorker(Number(args[1]), Number(args[2]))));
process.exit(0);
}
const specs = [
['--direct', 300, 339],
['--direct', 500, 350],
['--direct', 600, 400],
['--worker', 600, 400],
];
const results = [];
for (const spec of specs) {
const child = spawnSync(process.execPath, [new URL(import.meta.url).pathname, ...spec.map(String)], {
cwd: process.cwd(), encoding: 'utf8', timeout: 130000, maxBuffer: 4 * 1024 * 1024,
});
assert.equal(child.status, 0, `${spec.slice(1).join('x')} ${spec[0]} failed:\n${child.stderr || child.stdout}`);
const lines = child.stdout.trim().split(/\r?\n/).filter(Boolean);
results.push(JSON.parse(lines.at(-1)));
}
console.log(JSON.stringify({ ok: true, results }, null, 2));

View file

@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
function footprintContains(fp, x, y) {
if (!fp || x < fp.x0 || y < fp.y0 || x >= fp.x1 || y >= fp.y1) return false;
const row = fp.rowRuns?.[y - fp.y0];
if (!Array.isArray(row)) return false;
for (let i = 0; i + 1 < row.length; i += 2) if (x >= row[i] && x < row[i + 1]) return true;
return false;
}
function recordContains(record, x, y) {
if (record?.generatedFootprint) return footprintContains(record.generatedFootprint, x, y);
const r = record?.coreRect || record;
return !!r && x >= r.x0 && y >= r.y0 && x < r.x1 && y < r.y1;
}
function generatedAt(world, x, y) {
return (world.generatedRects || []).some(record => recordContains(record, x, y));
}
const world = createWorldMap(generateMap(24681357, { terrainType: 'auto', onProgress() {} }));
// This is the exact in-world rectangle produced by the previously failing
// bottom-right overlap case after validation/clipping. Before the real-frontier
// portal fix, a minor road wholly inside established geography was mistaken for
// an expansion seam portal and the whole operation rolled back.
const rect = { x0: 345, y0: 257, x1: 774, y1: 549 };
const needed = [];
for (let y = rect.y0; y < rect.y1; y++) {
for (let x = rect.x0; x < rect.x1; x++) if (!generatedAt(world, x, y)) needed.push([x, y]);
}
const startedAt = Date.now();
const result = generatePatch(world, rect, {
patchMode: 'expansion', terrainType: 'auto', seed: 0x4a35b921, variant: 1,
maxQualityRetries: 0, qualityTerrainAttempts: 1, acceptBestAvailableQuality: true,
onProgress() {},
});
assert.equal(result?.ok, true, result?.reason || result?.code || 'patch failed');
assert.equal(result?.seamDiagnostics?.hardPass, true, 'seam hard gate failed');
assert.equal(result?.seamDiagnostics?.roadPortalsBroken || 0, 0, 'false road portal remained');
let missing = 0;
for (const [x, y] of needed) if (!generatedAt(world, x, y)) missing++;
assert.equal(missing, 0, `missing ${missing} previously-ungenerated selected cells`);
console.log(JSON.stringify({
ok: true,
rect,
selectedCells: (rect.x1 - rect.x0) * (rect.y1 - rect.y0),
previouslyUngeneratedSelectedCells: needed.length,
missingPreviouslyUngeneratedCells: missing,
tileCount: result?.tileCount || 1,
roadPortalsBefore: result?.seamDiagnostics?.roadPortalsBefore || 0,
roadPortalsBroken: result?.seamDiagnostics?.roadPortalsBroken || 0,
seam: result?.seamDiagnostics?.status || null,
hardPass: result?.seamDiagnostics?.hardPass ?? null,
ms: Date.now() - startedAt,
}, null, 2));
process.exit(0);

View file

@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const app=readFileSync(new URL('./app.js',import.meta.url),'utf8');
const html=readFileSync(new URL('./index.html',import.meta.url),'utf8');
assert.match(html,/id="cancelPatchGeneration"/);
assert.match(app,/const PATCH_WORKER_INACTIVITY_WATCHDOG_MS = 60_000/);
assert.match(app,/function cancelPatchGeneration\(/);
assert.match(app,/activePatchCancel/);
assert.match(app,/worker\.terminate\?\.\(\)/);
assert.match(app,/if \(state\.patchBusy\) \{\s*cancelPatchGeneration\(\)/s);
assert.match(app,/clearPatchSelectionButton\?\.addEventListener\("click", \(\) => hideSelectionOverlay\(\{ discardPreview: true \}\)\)/);
assert.match(app,/const discardPreview = options\.discardPreview === true \|\| \(!commitPreview && options\.keepPreview !== true && !!state\.pendingPatch\)/);
console.log(JSON.stringify({ok:true,cancelButton:true,watchdogSeconds:60,escapeCancelsBusy:true,clearDiscardsPendingPreview:true},null,2));

View file

@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
import { MAP_W, MAP_H, worldIndexOf } from './mapUtils.js';
const appSource = readFileSync(new URL('./app.js', import.meta.url), 'utf8');
const htmlSource = readFileSync(new URL('./index.html', import.meta.url), 'utf8');
assert.match(htmlSource, /id="clearPatchSelection"/);
assert.match(appSource, /clearPatchSelectionButton\?\.addEventListener\("click", \(\) => hideSelectionOverlay\(\{ discardPreview: true \}\)\)/);
assert.match(appSource, /function cancelPatchGeneration/);
assert.match(appSource, /PATCH_WORKER_INACTIVITY_WATCHDOG_MS/);
const initial = generateMap(24681357, { terrainType: 'auto', onProgress() {} });
const world = createWorldMap(structuredClone(initial));
const establishedEdgeX = world.originX + MAP_W - 1;
const y0 = world.originY + Math.floor(MAP_H * 0.22);
const y1 = world.originY + Math.floor(MAP_H * 0.78);
const rect = {
x0: establishedEdgeX - 33,
y0,
x1: establishedEdgeX + 93,
y1,
};
const result = generatePatch(world, rect, {
patchMode: 'expansion', terrainType: 'auto', seed: 97531, variant: 1,
maxQualityRetries: 0, qualityTerrainAttempts: 1, acceptBestAvailableQuality: true,
});
assert.equal(result.ok, true, result.reason || result.code || 'patch failed');
const source = world.sourceMap;
const sea = world.fields.sea;
let riverSeaPoints = 0;
const writeRect = result.rects?.writeRect || rect;
for (const key of ['mainRivers','tributaryRivers','smallStreams','riverPaths']) {
for (const path of source[key] || []) for (const t of path || []) {
const x = Math.round((t?.[0] || 0) + world.originX);
const y = Math.round((t?.[1] || 0) + world.originY);
const i = worldIndexOf(world,x,y);
if (i >= 0 && sea[i] && x >= writeRect.x0 && x < writeRect.x1 && y >= writeRect.y0 && y < writeRect.y1) riverSeaPoints++;
}
}
assert.equal(riverSeaPoints, 0, `river path points over sea inside patch writeRect: ${riverSeaPoints}`);
const byPref = new Map();
for (const city of source.modernCities || []) {
const capital = city.isPrefecturalCapital || city.isRegionalCapital || /Capital/i.test(String(city.rank || '')) || /Capital/i.test(String(city.kind || ''));
if (!capital) continue;
const x = Math.round((city.x || 0) + world.originX);
const y = Math.round((city.y || 0) + world.originY);
const i = worldIndexOf(world,x,y);
if (i < 0 || sea[i] || world.fields.prefectureRegionId[i] < 0) continue;
const id = world.fields.prefectureRegionId[i];
byPref.set(id, (byPref.get(id) || 0) + 1);
}
const duplicateCapitalPrefs = [...byPref.entries()].filter(([,n]) => n > 1);
assert.equal(duplicateCapitalPrefs.length, 0, `duplicate capital prefectures: ${JSON.stringify(duplicateCapitalPrefs)}`);
const activePrefIds = new Set();
for (let i=0;i<world.fields.prefectureRegionId.length;i++) if (!sea[i] && world.fields.prefectureRegionId[i] >= 0) activePrefIds.add(Math.floor(world.fields.prefectureRegionId[i]));
const missingCapitalPrefs = [...activePrefIds].filter((id) => (byPref.get(id) || 0) === 0);
assert.equal(missingCapitalPrefs.length, 0, `missing capital prefectures: ${JSON.stringify(missingCapitalPrefs)}`);
const prefCounts = new Map();
for (let i=0;i<world.fields.prefectureRegionId.length;i++) if (!sea[i] && world.fields.prefectureRegionId[i] >= 0) {
const id=world.fields.prefectureRegionId[i];
prefCounts.set(id,(prefCounts.get(id)||0)+1);
}
const generatedRegions = (source.prefectureRegions || []).filter(p => p.patchGenerated).map(p => p.prefectureRegionId ?? p.id).filter(Number.isFinite);
const generatedSizes = [...new Set(generatedRegions)].map(id => [id,prefCounts.get(id)||0]);
const tinyGenerated = generatedSizes.filter(([,n]) => n > 0 && n < 650);
assert.equal(tinyGenerated.length, 0, `tiny generated prefectures survived: ${JSON.stringify(tinyGenerated)}`);
let landLandPairs = 0;
let maxEstablishedFrontierElevationJump = 0;
let adminBreaksOnEstablishedFrontier = 0;
let prefectureBreaksOnEstablishedFrontier = 0;
for (let y=y0; y<y1; y++) {
const a = worldIndexOf(world, establishedEdgeX, y);
const b = worldIndexOf(world, establishedEdgeX + 1, y);
if (a < 0 || b < 0 || sea[a] || sea[b]) continue;
landLandPairs++;
maxEstablishedFrontierElevationJump = Math.max(maxEstablishedFrontierElevationJump, Math.abs(world.fields.elevation[a] - world.fields.elevation[b]));
if (world.fields.adminId[a] !== world.fields.adminId[b]) adminBreaksOnEstablishedFrontier++;
if (world.fields.prefectureRegionId[a] !== world.fields.prefectureRegionId[b]) prefectureBreaksOnEstablishedFrontier++;
}
assert.ok(landLandPairs > 20, 'frontier probe did not include enough land pairs');
assert.ok(maxEstablishedFrontierElevationJump < 0.08, `frontier elevation jump too large: ${maxEstablishedFrontierElevationJump}`);
assert.equal(adminBreaksOnEstablishedFrontier, 0, 'municipal boundary still follows generation frontier');
assert.equal(prefectureBreaksOnEstablishedFrontier, 0, 'prefecture boundary still follows generation frontier');
assert.equal(result.seamDiagnostics?.hardPass, true, `seam gate failed: ${JSON.stringify(result.seamDiagnostics?.gateReasons || [])}`);
console.log(JSON.stringify({
ok: true,
riverSeaPoints,
duplicateCapitalPrefs,
missingCapitalPrefs,
generatedSizes,
tinyGenerated,
landLandPairs,
maxEstablishedFrontierElevationJump: Math.round(maxEstablishedFrontierElevationJump * 10000) / 10000,
adminBreaksOnEstablishedFrontier,
prefectureBreaksOnEstablishedFrontier,
frontierAdminCellsAligned: result.humanGeography?.frontierAdminCellsAligned || 0,
establishedFrontierAdminCellsRestored: result.humanGeography?.establishedFrontierAdminCellsRestored || 0,
frontierHarmonizedValues: result.humanGeography?.frontierHarmonizedValues || 0,
seamStatus: result.seamDiagnostics?.status,
seamGateReasons: result.seamDiagnostics?.gateReasons || [],
}, null, 2));

View file

@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
import { MAP_W, MAP_H, worldIndexOf } from './mapUtils.js';
const baseSeeds = (process.env.BASE_SEEDS || '24681357,13579246').split(',').map(Number).filter(Number.isFinite);
const requestedDirections = new Set((process.env.DIRECTIONS || 'right,left,down,up').split(',').map(s => s.trim()));
const results = [];
const quiet = process.env.QUIET === '1';
function probeAxis(world, direction, baseOriginX, baseOriginY) {
const edgeX = baseOriginX + MAP_W - 1;
const edgeY = baseOriginY + MAP_H - 1;
if (direction === 'right') return { horizontal: false, fixed: edgeX, start: baseOriginY + Math.floor(MAP_H*.22), end: baseOriginY + Math.floor(MAP_H*.78), oldOffset: 0, newOffset: 1 };
if (direction === 'left') return { horizontal: false, fixed: baseOriginX, start: baseOriginY + Math.floor(MAP_H*.22), end: baseOriginY + Math.floor(MAP_H*.78), oldOffset: 0, newOffset: -1 };
if (direction === 'down') return { horizontal: true, fixed: edgeY, start: baseOriginX + Math.floor(MAP_W*.22), end: baseOriginX + Math.floor(MAP_W*.78), oldOffset: 0, newOffset: 1 };
return { horizontal: true, fixed: baseOriginY, start: baseOriginX + Math.floor(MAP_W*.22), end: baseOriginX + Math.floor(MAP_W*.78), oldOffset: 0, newOffset: -1 };
}
function makeRect(direction, ox, oy) {
const edgeX = ox + MAP_W - 1, edgeY = oy + MAP_H - 1;
const x0 = ox + Math.floor(MAP_W*.22), x1 = ox + Math.floor(MAP_W*.78);
const y0 = oy + Math.floor(MAP_H*.22), y1 = oy + Math.floor(MAP_H*.78);
if (direction === 'right') return { x0: edgeX - 33, y0, x1: edgeX + 93, y1 };
if (direction === 'left') return { x0: ox - 93, y0, x1: ox + 33, y1 };
if (direction === 'down') return { x0, y0: edgeY - 33, x1, y1: edgeY + 93 };
return { x0, y0: oy - 93, x1, y1: oy + 33 };
}
function measureFrontier(world, probe) {
let pairs = 0, maxJump = 0, sum = 0;
for (let t = probe.start; t < probe.end; t++) {
let ax, ay, bx, by;
if (!probe.horizontal) {
ax = probe.fixed + probe.oldOffset; ay = t;
bx = probe.fixed + probe.newOffset; by = t;
} else {
ax = t; ay = probe.fixed + probe.oldOffset;
bx = t; by = probe.fixed + probe.newOffset;
}
const a = worldIndexOf(world,ax,ay), b = worldIndexOf(world,bx,by);
if (a < 0 || b < 0 || world.fields.sea[a] || world.fields.sea[b]) continue;
const jump = Math.abs(world.fields.elevation[a] - world.fields.elevation[b]);
pairs++; sum += jump; maxJump = Math.max(maxJump,jump);
}
return { pairs, maxJump, meanJump: pairs ? sum/pairs : 0 };
}
for (const baseSeed of baseSeeds) {
const initial = generateMap(baseSeed, { terrainType:'auto', onProgress(){} });
for (const direction of ['right','left','down','up']) {
if (!requestedDirections.has(direction)) continue;
const world = createWorldMap(structuredClone(initial));
const ox = world.originX, oy = world.originY;
const rect = makeRect(direction,ox,oy);
const patchSeed = (0x13579bdf ^ baseSeed ^ ({right:0x11,left:0x22,down:0x33,up:0x44}[direction])) >>> 0;
const result = generatePatch(world,rect,{patchMode:'expansion',terrainType:'auto',seed:patchSeed,variant:1,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true});
assert.equal(result.ok,true,`${baseSeed}/${direction}: ${result.reason || result.code}`);
const probe = measureFrontier(world,probeAxis(world,direction,ox,oy));
assert.ok(probe.pairs >= 12,`${baseSeed}/${direction}: insufficient land frontier pairs (${probe.pairs})`);
assert.ok(probe.maxJump <= 0.075,`${baseSeed}/${direction}: frontier max jump ${probe.maxJump}`);
assert.equal(result.seamDiagnostics?.hardPass,true,`${baseSeed}/${direction}: seam gate ${JSON.stringify(result.seamDiagnostics?.gateReasons || [])}`);
assert.ok((result.seamDiagnostics?.maxEstablishedFrontierElevationJump || 0) <= 0.075,`${baseSeed}/${direction}: diagnostic frontier jump ${result.seamDiagnostics?.maxEstablishedFrontierElevationJump}`);
const row={baseSeed,direction,patchSeed,pairs:probe.pairs,maxJump:+probe.maxJump.toFixed(4),meanJump:+probe.meanJump.toFixed(4),diagnosticMax:+(result.seamDiagnostics?.maxEstablishedFrontierElevationJump||0).toFixed(4),seamStatus:result.seamDiagnostics?.status,seamReasons:result.seamDiagnostics?.gateReasons||[],adjusted:result.humanGeography?.establishedFrontierElevationCellsAdjusted||0,gradientAdjusted:result.humanGeography?.establishedFrontierGradientCellsAdjusted||0};
results.push(row);
if (!quiet) console.log(JSON.stringify(row));
}
}
console.log(JSON.stringify({ok:true,cases:results.length,maxObserved:Math.max(...results.map(r=>r.maxJump)),results},null,2));

View file

@ -0,0 +1,17 @@
import { generateMap } from './mapGenerator.js';
import { createWorldMap } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
import { MAP_W, MAP_H } from './mapUtils.js';
const init=generateMap(24681357,{terrainType:'auto',onProgress(){}});
function rectFor(world,w,h){const edge=world.originX+MAP_W-1,cy=world.originY+Math.floor(MAP_H/2);return{x0:edge-38,y0:cy-Math.floor(h/2),x1:edge-38+w,y1:cy-Math.floor(h/2)+h};}
const wa=createWorldMap(structuredClone(init)), wb=createWorldMap(structuredClone(init));
const a=rectFor(wa,258,183), b=rectFor(wb,259,184);
const opts={patchMode:'expansion',terrainType:'auto',seed:0x4a35b921,variant:1,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true};
const ra=generatePatch(wa,a,opts); const rb=generatePatch(wb,b,opts);
if(!ra.ok||!rb.ok){console.log(JSON.stringify({ra:{ok:ra.ok,code:ra.code,reason:ra.reason},rb:{ok:rb.ok,code:rb.code,reason:rb.reason}},null,2));process.exit(2)}
const fields=['elevation','sea','plain','agriculture','populationDensity','prefectureRegionId','adminId'];
const out={a:{tiled:!!ra.tiledExpansion,tileCount:ra.tileCount||1},b:{tiled:!!rb.tiledExpansion,tileCount:rb.tileCount||1},common:{}};
for(const name of fields){const A=wa.fields[name],B=wb.fields[name];let n=0,diff=0,sum=0,max=0;for(let y=a.y0;y<a.y1;y++)for(let x=a.x0;x<a.x1;x++){const i=y*wa.width+x;const av=A[i],bv=B[i];const d=Math.abs(Number(av)-Number(bv));n++;if(d>1e-9)diff++;sum+=d;max=Math.max(max,d)}out.common[name]={n,diff,rate:diff/n,meanAbs:sum/n,maxAbs:max};}
function boundarySet(world,field,rect){const f=world.fields[field],s=new Set();for(let y=rect.y0;y<rect.y1-1;y++)for(let x=rect.x0;x<rect.x1-1;x++){const i=y*world.width+x;if(f[i]!==f[i+1]||f[i]!==f[i+world.width])s.add(`${x},${y}`)}return s}
for(const field of ['prefectureRegionId','adminId']){const A=boundarySet(wa,field,a),B=boundarySet(wb,field,a);let xor=0;for(const k of A)if(!B.has(k))xor++;for(const k of B)if(!A.has(k))xor++;out.common[field+'Boundary']={a:A.size,b:B.size,xor,normalized:xor/Math.max(1,A.size+B.size)};}
console.log(JSON.stringify(out,null,2));

View file

@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { generateMap } from './mapGenerator.js';
import { createWorldMap, ensureWorldPaddingForCamera } from './worldMap.js';
import { generatePatch } from './mapPatch.js';
import { MAP_W, MAP_H } from './mapUtils.js';
const seed=24681357;
const init=generateMap(seed,{terrainType:'auto',onProgress(){}});
function opts(){return {patchMode:'expansion',terrainType:'auto',seed:0x4a35b921,variant:1,maxQualityRetries:0,qualityTerrainAttempts:1,acceptBestAvailableQuality:true};}
function worldRect(w){return {x0:w.originX+MAP_W-34,y0:w.originY+34,x1:w.originX+MAP_W+106,y1:w.originY+154};}
const a=createWorldMap(structuredClone(init));
const ra=worldRect(a); const wa0={x0:ra.x0-a.originX,y0:ra.y0-a.originY,x1:ra.x1-a.originX,y1:ra.y1-a.originY};
const ar=generatePatch(a,ra,opts()); assert.equal(ar.ok,true,ar.reason||ar.code);
const b=createWorldMap(structuredClone(init)); ensureWorldPaddingForCamera(b,{x:0,y:0},MAP_W,MAP_H,82);
const rb={x0:b.originX+wa0.x0,y0:b.originY+wa0.y0,x1:b.originX+wa0.x1,y1:b.originY+wa0.y1};
const br=generatePatch(b,rb,opts()); assert.equal(br.ok,true,br.reason||br.code);
const fields=['elevation','sea','plain','agriculture','populationDensity','prefectureRegionId','adminId'];
const padding={};
for(const name of fields){let diff=0,max=0,n=0; const A=a.fields[name],B=b.fields[name]; for(let wy=wa0.y0;wy<wa0.y1;wy++)for(let wx=wa0.x0;wx<wa0.x1;wx++){const ai=(wy+a.originY)*a.width+(wx+a.originX),bi=(wy+b.originY)*b.width+(wx+b.originX); const d=Math.abs(Number(A[ai])-Number(B[bi])); if(d>1e-9)diff++; if(d>max)max=d;n++;} padding[name]={n,diff,max}; assert.equal(diff,0,`${name} changed after world padding`);}
// Both patch modes must report the same unified generator mode. Use modest rectangles to keep the validation fast.
const c=createWorldMap(structuredClone(init)); const cr={x0:c.originX+50,y0:c.originY+45,x1:c.originX+150,y1:c.originY+145};
const regen=generatePatch(c,cr,{...opts(),patchMode:'regeneration'}); assert.equal(regen.ok,true,regen.reason||regen.code);
const d=createWorldMap(structuredClone(init)); const dr={x0:d.originX+MAP_W-30,y0:d.originY+40,x1:d.originX+MAP_W+90,y1:d.originY+150};
const exp=generatePatch(d,dr,opts()); assert.equal(exp.ok,true,exp.reason||exp.code);
assert.match(String(regen.patchGenerationMode||regen.humanGeography?.patchGenerationMode||''),/unified-world-native/);
assert.match(String(exp.patchGenerationMode||exp.humanGeography?.patchGenerationMode||''),/unified-world-native/);
console.log(JSON.stringify({ok:true,padding,regenerationMode:regen.patchGenerationMode,expansionMode:exp.patchGenerationMode},null,2));
process.exit(0);

View file

@ -0,0 +1,20 @@
# World-native patch generator final notes
## Implemented
- Regeneration and expansion now call the same `generateUnifiedWorldNativePatchCandidate()` path.
- World-native seed construction excludes candidate origin, candidate size, and selection/context rectangles.
- Padding-invariant geographic coordinates are `arrayX - world.originX` and `arrayY - world.originY`.
- Terrain, settlement jitter, land-use/human geography inputs and patch-noise paths use world-coordinate context.
- Expansion is split on a canonical world-coordinate grid; internal tiles share the pre-operation generated-coverage snapshot.
- Large/tiled expansion keeps final terrain/admin/transport coherence as one aggregate pass.
## Verified
- Left/top world padding: elevation, sea, plain, agriculture, populationDensity, prefectureRegionId and adminId all match exactly over 16,800 compared cells.
- Regeneration reports `unified-world-native-patch`; tiled expansion reports `unified-world-native-patch-tiled`.
- Large Worker expansion completes with seam clean.
- STEP15 and STEP17 pass.
- Existing river/capital/tiny-prefecture/frontier regressions pass.
- Freeform lasso completes with seam clean.
## Remaining contextual edge behavior
World-native *base generation* is invariant, but the outer seam is not frozen forever. When a selection is enlarged, cells that used to be the outer seam become interior and are recomputed by final seam/admin coherence. In the 258x183 -> 259x184 stress comparison, sea and adminId are identical in the common area; prefecture IDs differ in ~5.1% and elevation differs in ~3.9% (mean absolute difference ~0.00518). These differences are predominantly the final outer-boundary coherence layer, not a change of the underlying world-coordinate generator.

View file

@ -0,0 +1,351 @@
{
"ok": true,
"architecture": {
"regenerationGenerator": "unified-world-native-patch",
"expansionGenerator": "unified-world-native-patch-tiled",
"worldCoordinate": "array coordinate minus world.originX/originY",
"selectionGeometryExcludedFromWorldNativeSeed": true,
"canonicalExpansionGrid": true
},
"worldNativeUnification": {
"ok": true,
"padding": {
"elevation": {
"n": 16800,
"diff": 0,
"max": 0
},
"sea": {
"n": 16800,
"diff": 0,
"max": 0
},
"plain": {
"n": 16800,
"diff": 0,
"max": 0
},
"agriculture": {
"n": 16800,
"diff": 0,
"max": 0
},
"populationDensity": {
"n": 16800,
"diff": 0,
"max": 0
},
"prefectureRegionId": {
"n": 16800,
"diff": 0,
"max": 0
},
"adminId": {
"n": 16800,
"diff": 0,
"max": 0
}
},
"regenerationMode": "unified-world-native-patch",
"expansionMode": "unified-world-native-patch-tiled"
},
"selectionThresholdComparison": {
"a": {
"tiled": true,
"tileCount": 2
},
"b": {
"tiled": true,
"tileCount": 4
},
"common": {
"elevation": {
"n": 47214,
"diff": 1831,
"rate": 0.038780870080908206,
"meanAbs": 0.005177739778349026,
"maxAbs": 0.4071335792541504
},
"sea": {
"n": 47214,
"diff": 0,
"rate": 0,
"meanAbs": 0,
"maxAbs": 0
},
"plain": {
"n": 47214,
"diff": 2214,
"rate": 0.04689287075867327,
"meanAbs": 0.002550405116033862,
"maxAbs": 0.20580322295427322
},
"agriculture": {
"n": 47214,
"diff": 2238,
"rate": 0.04740119456093531,
"meanAbs": 0.0010068689713980234,
"maxAbs": 0.08625703305006027
},
"populationDensity": {
"n": 47214,
"diff": 18,
"rate": 0.0003812428516965307,
"meanAbs": 8.295775460319255e-08,
"maxAbs": 0.00030538812279701233
},
"prefectureRegionId": {
"n": 47214,
"diff": 2408,
"rate": 0.051001821493624776,
"meanAbs": 0.12121404668106918,
"maxAbs": 3
},
"adminId": {
"n": 47214,
"diff": 0,
"rate": 0,
"meanAbs": 0,
"maxAbs": 0
},
"prefectureRegionIdBoundary": {
"a": 633,
"b": 937,
"xor": 304,
"normalized": 0.19363057324840766
},
"adminIdBoundary": {
"a": 3570,
"b": 3570,
"xor": 0,
"normalized": 0
}
}
},
"largeWorker": {
"ms": 23764,
"outer": true,
"inner": true,
"tiled": true,
"tileCount": 6,
"seam": "clean",
"reason": null
},
"reportedBugRegression": {
"ok": true,
"riverSeaPoints": 0,
"duplicateCapitalPrefs": [],
"missingCapitalPrefs": [],
"generatedSizes": [
[
3,
1333
],
[
4,
4239
]
],
"tinyGenerated": [],
"landLandPairs": 102,
"maxEstablishedFrontierElevationJump": 0.0364,
"adminBreaksOnEstablishedFrontier": 0,
"prefectureBreaksOnEstablishedFrontier": 0,
"frontierAdminCellsAligned": 0,
"establishedFrontierAdminCellsRestored": 0,
"frontierHarmonizedValues": 0,
"seamStatus": "clean",
"seamGateReasons": []
},
"uiState": {
"ok": true,
"cancelButton": true,
"watchdogSeconds": 60,
"escapeCancelsBusy": true,
"clearDiscardsPendingPreview": true
},
"freeform": {
"ok": true,
"tileCount": 5,
"ms": 31734,
"seam": "clean"
},
"step15": {
"ok": true,
"transport": [
{
"seed": 1,
"seconds": 4.89,
"roadCells": 2122,
"adminCenters": {
"total": 45,
"covered": 45
},
"connectivity": {
"beforeComponents": 3,
"afterComponents": 3,
"added": 0,
"failed": 1,
"attempted": 1,
"rounds": 1
},
"finalOutput": {
"components": 6,
"requiredStubsAdded": 6,
"endpointConnectorsAdded": 3,
"prune": {
"beforeComponents": 12,
"afterComponents": 6,
"pruned": {
"minor": 11,
"national": 1,
"external": 0,
"expressway": 0,
"externalExpressway": 0
},
"prunePasses": 2,
"mountainAdminConnections": {
"attempted": 6,
"added": 6,
"skippedIsland": 3,
"failed": 0
}
}
}
},
{
"seed": 3,
"seconds": 5.02,
"roadCells": 2598,
"adminCenters": {
"total": 43,
"covered": 43
},
"connectivity": {
"beforeComponents": 3,
"afterComponents": 1,
"added": 2,
"failed": 0,
"attempted": 2,
"rounds": 2
},
"finalOutput": {
"components": 2,
"requiredStubsAdded": 10,
"endpointConnectorsAdded": 8,
"prune": {
"beforeComponents": 19,
"afterComponents": 2,
"pruned": {
"minor": 5,
"national": 0,
"external": 0,
"expressway": 0,
"externalExpressway": 0
},
"prunePasses": 2,
"mountainAdminConnections": {
"attempted": 14,
"added": 14,
"skippedIsland": 1,
"failed": 0
}
}
}
},
{
"seed": 5,
"seconds": 3.44,
"roadCells": 2044,
"adminCenters": {
"total": 45,
"covered": 45
},
"connectivity": {
"beforeComponents": 5,
"afterComponents": 4,
"added": 1,
"failed": 2,
"attempted": 3,
"rounds": 2
},
"finalOutput": {
"components": 9,
"requiredStubsAdded": 10,
"endpointConnectorsAdded": 3,
"prune": {
"beforeComponents": 22,
"afterComponents": 9,
"pruned": {
"minor": 41,
"national": 6,
"external": 0,
"expressway": 3,
"externalExpressway": 0
},
"prunePasses": 2,
"mountainAdminConnections": {
"attempted": 12,
"added": 12,
"skippedIsland": 4,
"failed": 0
}
}
}
}
],
"expansion": {
"seconds": 24.47,
"seamStatus": "clean",
"footprintEscapedCells": 0,
"roadPortalsUnresolved": 0,
"railPortalsUnresolved": 0
},
"rollback": {
"code": "patch-quality-gate-failed",
"restored": true,
"sourceIdentityPreserved": true
}
},
"step17": {
"ok": true,
"interactiveRetryPolicy": "one requested variant per click; no hidden whole-patch retry",
"variants": [
{
"variant": 0,
"seconds": 36.31,
"selectedVariant": 0,
"hashes": {
"elevation": "528ea16e05aeada539dbdea076af9dd9ea7088189f10fdb0cf414e37e33902ee",
"sea": "c53befa66f7a2ff958c7db3465c827a481274a4e9c67d6a52ef31eb3197eb362",
"admin": "bdf9aa6e2e0d0526026a6e69fe3de949a626d00835347a30361b5d1a709deb70"
}
},
{
"variant": 1,
"seconds": 27.07,
"selectedVariant": 1,
"hashes": {
"elevation": "ad3a08864ca413ddebe466880032a8b3d33b48b166c5e5f10debe30c44eed4c8",
"sea": "295684a63f5243883c89cf9be595393cd5ce4b3714290e7efb13bae0b162f02c",
"admin": "1139fa28c1605224b20d99c84b06dbecdcd7d637bef10640c19a9884326c4491"
}
}
],
"alternativesDiffer": true,
"explicitRenderRevision": true,
"singlePatchWorkerLifetime": true
},
"knownResidual": {
"description": "World-native base generation is invariant, but the user-selection outer seam is intentionally recomputed when the selection grows. Cells near a former outer boundary can therefore change when that boundary becomes interior.",
"threshold258to259": {
"elevationDiffRate": 0.038780870080908206,
"elevationMeanAbs": 0.005177739778349026,
"elevationMaxAbs": 0.4071335792541504,
"seaDiffRate": 0,
"populationDiffRate": 0.0003812428516965307,
"prefectureIdDiffRate": 0.051001821493624776,
"adminIdDiffRate": 0
}
}
}

View file

@ -0,0 +1,73 @@
{
"a": {
"tiled": true,
"tileCount": 2
},
"b": {
"tiled": true,
"tileCount": 4
},
"common": {
"elevation": {
"n": 47214,
"diff": 1831,
"rate": 0.038780870080908206,
"meanAbs": 0.005177739778349026,
"maxAbs": 0.4071335792541504
},
"sea": {
"n": 47214,
"diff": 0,
"rate": 0,
"meanAbs": 0,
"maxAbs": 0
},
"plain": {
"n": 47214,
"diff": 2214,
"rate": 0.04689287075867327,
"meanAbs": 0.002550405116033862,
"maxAbs": 0.20580322295427322
},
"agriculture": {
"n": 47214,
"diff": 2238,
"rate": 0.04740119456093531,
"meanAbs": 0.0010068689713980234,
"maxAbs": 0.08625703305006027
},
"populationDensity": {
"n": 47214,
"diff": 18,
"rate": 0.0003812428516965307,
"meanAbs": 8.295775460319255e-8,
"maxAbs": 0.00030538812279701233
},
"prefectureRegionId": {
"n": 47214,
"diff": 2408,
"rate": 0.051001821493624776,
"meanAbs": 0.12121404668106918,
"maxAbs": 3
},
"adminId": {
"n": 47214,
"diff": 0,
"rate": 0,
"meanAbs": 0,
"maxAbs": 0
},
"prefectureRegionIdBoundary": {
"a": 633,
"b": 937,
"xor": 304,
"normalized": 0.19363057324840766
},
"adminIdBoundary": {
"a": 3570,
"b": 3570,
"xor": 0,
"normalized": 0
}
}
}

View file

@ -0,0 +1,42 @@
{
"ok": true,
"padding": {
"elevation": {
"n": 16800,
"diff": 0,
"max": 0
},
"sea": {
"n": 16800,
"diff": 0,
"max": 0
},
"plain": {
"n": 16800,
"diff": 0,
"max": 0
},
"agriculture": {
"n": 16800,
"diff": 0,
"max": 0
},
"populationDensity": {
"n": 16800,
"diff": 0,
"max": 0
},
"prefectureRegionId": {
"n": 16800,
"diff": 0,
"max": 0
},
"adminId": {
"n": 16800,
"diff": 0,
"max": 0
}
},
"regenerationMode": "unified-world-native-patch",
"expansionMode": "unified-world-native-patch-tiled"
}

View file

@ -0,0 +1,6 @@
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule mod_access_compat.c>
Deny from all
</IfModule>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,44 @@
AddType application/manifest+json .webmanifest
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/html "access plus 0 seconds"
ExpiresByType text/css "access plus 30 days"
ExpiresByType application/javascript "access plus 30 days"
ExpiresByType application/json "access plus 30 days"
ExpiresByType image/png "access plus 30 days"
ExpiresByType image/webp "access plus 30 days"
ExpiresByType audio/mpeg "access plus 30 days"
ExpiresByType audio/wav "access plus 30 days"
</IfModule>
<IfModule mod_headers.c>
<FilesMatch "achievement_api\.php$">
Header set Cache-Control "no-store, no-cache, must-revalidate, max-age=0"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
<FilesMatch "\.html?$">
Header set Cache-Control "no-cache, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
<FilesMatch "\.(css|js|json|webmanifest|ico|png|webp|mp3|wav)$">
Header set Cache-Control "public, max-age=2592000, immutable"
</FilesMatch>
<FilesMatch "service-worker\.js$">
Header set Cache-Control "no-cache, no-store, must-revalidate"
Header set Pragma "no-cache"
Header set Expires "0"
</FilesMatch>
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json text/plain
</IfModule>
# Never serve hidden server-data paths.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule (^|/)\. - [R=404,L]
</IfModule>

View file

@ -0,0 +1,183 @@
{
"version": "39.18.11",
"css": [
"css/base.css",
"css/layout.css",
"css/panel.css",
"css/components.css",
"css/mobile.css"
],
"js": [
"js/version.js",
"js/event_bus.js",
"js/domain_ids.js",
"js/registry_base.js",
"js/disease_registry.js",
"js/sound_pack.js",
"js/deterministic_helpers.js",
"js/item_tool_metadata.js",
"js/item_tool_definitions.js",
"js/item_visual_definitions.js",
"js/item_food_definitions.js",
"js/item_effect_definitions.js",
"js/item_registry.js",
"js/data.js",
"js/ground_types.js",
"js/input_mode_manager.js",
"js/math.js",
"js/geometry_helpers.js",
"js/collision_footprint_system.js",
"js/physics_helpers.js",
"js/placement_preview_system.js",
"js/pin_attachment_system.js",
"js/physics_shape_editor_system.js",
"js/mechanical_system.js",
"js/mechanical_shape_bridge.js",
"js/constraint_system.js",
"js/physics_world_system.js",
"js/physics_projection_system.js",
"js/circuit_board_system.js",
"js/signal_system.js",
"js/assets.js",
"js/audio.js",
"js/perf_profiler.js",
"js/display_helpers.js",
"js/render.js",
"js/sim_core.js",
"js/tarinai_seed_factory.js",
"js/structures.js",
"js/item_type_catalog.js",
"js/items.js",
"js/item_type_initializers.js",
"js/item_lifecycle_support.js",
"js/item_update_policy.js",
"js/fire_runtime_system.js",
"js/robot_cleaner_system.js",
"js/item_dynamic_tool_system.js",
"js/item_dynamic_ball_system.js",
"js/item_dynamic_duplicator_system.js",
"js/item_dynamic_pin_system.js",
"js/item_dynamic_zunchi_system.js",
"js/item_environment_hazard_system.js",
"js/item_dynamic_system.js",
"js/item_lifecycle_decay_system.js",
"js/item_lifecycle_growth_system.js",
"js/item_lifecycle_step_frame.js",
"js/item_lifecycle_step_decay.js",
"js/item_lifecycle_step_dynamic.js",
"js/item_lifecycle_step_growth.js",
"js/update_step_pipeline_runner.js",
"js/item_lifecycle_pipeline.js",
"js/item_runtime.js",
"js/structure_lifecycle.js",
"js/memorial_bell_system.js",
"js/item_render_helpers.js",
"js/item_render_runtime.js",
"js/burn_motion_util.js",
"js/ants.js",
"js/health.js",
"js/tarinai.js",
"js/tarinai_action_spec.js",
"js/tarinai_behavior_state.js",
"js/tarinai_identity_social.js",
"js/tarinai_action_state.js",
"js/tarinai_disease_nest.js",
"js/tarinai_item_effects.js",
"js/tarinai_needs_core.js",
"js/tarinai_behavior_text.js",
"js/tarinai_forced_behavior.js",
"js/tarinai_nest_sleep_system.js",
"js/tarinai_item_targeting.js",
"js/tarinai_consumable_behavior.js",
"js/tarinai_social_action_runtime.js",
"js/tarinai_building_behavior.js",
"js/seesaw_system.js",
"js/tarinai_action_definitions.js",
"js/tarinai_needs_items.js",
"js/tarinai_food_prototype_mixin.js",
"js/tarinai_direct_feeding_system.js",
"js/tarinai_need_planner_system.js",
"js/tarinai_item_interaction_context.js",
"js/tarinai_food_interaction_system.js",
"js/tarinai_contact_item_system.js",
"js/tarinai_item_interaction_system.js",
"js/tarinai_sunbath_system.js",
"js/tarinai_cursor_care_system.js",
"js/tarinai_local_environment_system.js",
"js/tarinai_social_move_life.js",
"js/tarinai_update_step_frame.js",
"js/tarinai_update_step_ai.js",
"js/tarinai_update_step_environment.js",
"js/tarinai_update_step_movement.js",
"js/tarinai_update_step_health.js",
"js/tarinai_update_pipeline.js",
"js/tarinai_runtime.js",
"js/tarinai_render.js",
"js/world.js",
"js/world_view.js",
"js/family_graph.js",
"js/world_reset_presets.js",
"js/world_family_social.js",
"js/impact_core_system.js",
"js/impact_response_system.js",
"js/collision_response_system.js",
"js/world_combat_effects.js",
"js/world_environment.js",
"js/world_temperature_system.js",
"js/world_pathfinding_system.js",
"js/world_grass_placement_system.js",
"js/world_spatial_budget.js",
"js/world_ants_system.js",
"js/weather_system.js",
"js/item_update_scheduler.js",
"js/simulation_runtime_helpers.js",
"js/tarinai_update_policy.js",
"js/simulation_environment_system.js",
"js/simulation_item_ant_system.js",
"js/simulation_effects_system.js",
"js/simulation_creature_system.js",
"js/simulation_maintenance_system.js",
"js/simulation_ambient_system.js",
"js/simulation_systems.js",
"js/system_order.js",
"js/simulation.js",
"js/colony_situation_system.js",
"js/world_update.js",
"js/world_tool_actions.js",
"js/world_placement_log.js",
"js/world_event_effects.js",
"js/command_dispatcher.js",
"js/text_catalog.js",
"js/ui.js",
"js/game_dialogs.js",
"js/achievement_catalog.js",
"js/achievements.js",
"js/ui_helpers.js",
"js/ui_log.js",
"js/ui_selected.js",
"js/save_schema.js",
"js/snapshot_system.js",
"js/restore_coordinator.js",
"js/history_system.js",
"js/save_codec.js",
"js/save_storage.js",
"js/save_system.js",
"js/ui_tooltips.js",
"js/ui_layout_dialogs.js",
"js/ui_ground.js",
"js/ui_tools.js",
"js/ui_input_shared.js",
"js/ui_pointer_action_system.js",
"js/ui_input_touch.js",
"js/ui_input_mouse.js",
"js/ui_bind.js",
"js/ui_charts.js",
"js/ui_family_data.js",
"js/ui_family_async.js",
"js/ui_family_layout.js",
"js/ui_family_paths.js",
"js/ui_family_render.js",
"js/main.js",
"js/debug_tools.js"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,27 @@
{
"id": "./",
"name": "たりない観察",
"short_name": "たりない観察",
"description": "たりないたちの暮らしを観察・操作する実験ゲーム",
"lang": "ja",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "any",
"background_color": "#dfe9df",
"theme_color": "#244d3a",
"icons": [
{
"src": "assets/ui/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/ui/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}

View file

@ -0,0 +1,198 @@
"use strict";
const APP_VERSION = "39.18.11";
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const v = `v=${APP_VERSION}`;
// Static runtime list synchronized with app_manifest.json for this release.
const coreScriptNames = [
"version", "event_bus", "domain_ids", "registry_base", "disease_registry", "sound_pack", "deterministic_helpers", "item_tool_metadata", "item_tool_definitions", "item_visual_definitions", "item_food_definitions", "item_effect_definitions", "item_registry", "data", "ground_types", "input_mode_manager", "math", "geometry_helpers", "collision_footprint_system", "physics_helpers", "placement_preview_system", "pin_attachment_system", "physics_shape_editor_system", "mechanical_system", "mechanical_shape_bridge", "constraint_system", "physics_world_system", "physics_projection_system", "circuit_board_system", "signal_system", "assets", "audio", "perf_profiler", "display_helpers", "render", "sim_core", "tarinai_seed_factory", "structures", "item_type_catalog", "items", "item_type_initializers", "item_lifecycle_support", "item_update_policy", "fire_runtime_system", "robot_cleaner_system", "item_dynamic_tool_system", "item_dynamic_ball_system", "item_dynamic_duplicator_system", "item_dynamic_pin_system", "item_dynamic_zunchi_system", "item_environment_hazard_system", "item_dynamic_system", "item_lifecycle_decay_system", "item_lifecycle_growth_system", "item_lifecycle_step_frame", "item_lifecycle_step_decay", "item_lifecycle_step_dynamic", "item_lifecycle_step_growth", "update_step_pipeline_runner", "item_lifecycle_pipeline", "item_runtime", "structure_lifecycle", "memorial_bell_system", "item_render_helpers", "item_render_runtime", "burn_motion_util", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_core", "tarinai_behavior_text", "tarinai_forced_behavior", "tarinai_nest_sleep_system", "tarinai_item_targeting", "tarinai_consumable_behavior", "tarinai_social_action_runtime", "tarinai_building_behavior", "seesaw_system", "tarinai_action_definitions", "tarinai_needs_items", "tarinai_food_prototype_mixin", "tarinai_direct_feeding_system", "tarinai_need_planner_system", "tarinai_item_interaction_context", "tarinai_food_interaction_system", "tarinai_contact_item_system", "tarinai_item_interaction_system", "tarinai_sunbath_system", "tarinai_cursor_care_system", "tarinai_local_environment_system", "tarinai_social_move_life", "tarinai_update_step_frame", "tarinai_update_step_ai", "tarinai_update_step_environment", "tarinai_update_step_movement", "tarinai_update_step_health", "tarinai_update_pipeline", "tarinai_runtime", "tarinai_render", "world", "world_view", "family_graph", "world_reset_presets", "world_family_social", "impact_core_system", "impact_response_system", "collision_response_system", "world_combat_effects", "world_environment", "world_temperature_system", "world_pathfinding_system", "world_grass_placement_system", "world_spatial_budget", "world_ants_system", "weather_system", "item_update_scheduler", "simulation_runtime_helpers", "tarinai_update_policy", "simulation_environment_system", "simulation_item_ant_system", "simulation_effects_system", "simulation_creature_system", "simulation_maintenance_system", "simulation_ambient_system", "simulation_systems", "system_order", "simulation", "colony_situation_system", "world_update", "world_tool_actions", "world_placement_log", "world_event_effects", "command_dispatcher", "text_catalog", "ui", "game_dialogs", "achievement_catalog", "achievements", "ui_helpers", "ui_log", "ui_selected", "save_schema", "snapshot_system", "restore_coordinator", "history_system", "save_codec", "save_storage", "save_system", "ui_tooltips", "ui_layout_dialogs", "ui_ground", "ui_tools", "ui_input_shared", "ui_pointer_action_system", "ui_input_touch", "ui_input_mouse", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
];
const mediaAssetNames = [
"assets/objects/ant_queen.webp",
"assets/objects/ant_worker.webp",
"assets/objects/genkotsu.webp",
"assets/objects/oshibyo.webp",
"assets/objects/oshibyo_stuck.webp",
"assets/objects/plushie_bear.png",
"assets/objects/pushpin.webp",
"assets/objects/pushpin_stuck.webp",
"assets/objects/zunchi.webp",
"assets/objects/zunchi_02.webp",
"assets/sounds/achievement_unlock.mp3",
"assets/sounds/acquired_stress_001.wav",
"assets/sounds/acquired_stress_002.wav",
"assets/sounds/acquired_stress_003.wav",
"assets/sounds/acquired_stress_004.wav",
"assets/sounds/acquired_stress_005.wav",
"assets/sounds/acquired_stress_006.wav",
"assets/sounds/bicycle_bell.mp3",
"assets/sounds/firecracker_explosion.mp3",
"assets/sounds/major_damage_01.wav",
"assets/sounds/major_damage_02.wav",
"assets/sounds/major_damage_03.wav",
"assets/sounds/major_damage_04.wav",
"assets/sounds/shoot_bolt_action.mp3",
"assets/sounds/shoot_pistol.mp3",
"assets/sounds/voice_001_hau.wav",
"assets/sounds/voice_002_flee.wav",
"assets/sounds/voice_003_po.wav",
"assets/sounds/voice_004_eat.wav",
"assets/sounds/voice_005_stress.wav",
"assets/sounds/voice_006_sleep.wav",
"assets/sounds/voice_007_sunbath_pokapoka.wav",
"assets/sounds/voice_008_temperature_buruburu.wav",
"assets/sounds/voice_009_temperature_achui.wav",
"assets/sprites/tarinai_01_smile.webp",
"assets/sprites/tarinai_02_angry.webp",
"assets/sprites/tarinai_03_teary.webp",
"assets/sprites/tarinai_04_jito.webp",
"assets/sprites/tarinai_05_drool.webp",
"assets/sprites/tarinai_06_cry.webp",
"assets/sprites/tarinai_07_sleep.webp",
"assets/sprites/tarinai_08_pokan.webp",
"assets/sprites/tarinai_09_stretch.webp",
"assets/sprites/tarinai_10_hurt.webp",
"assets/sprites/tarinai_11_weak.webp",
"assets/sprites/tarinai_12_back.webp",
"assets/sprites/tarinai_13_flee.webp",
"assets/sprites/tarinai_14_zunda_eat.webp",
"assets/sprites/tarinai_15_hurt2.webp",
"assets/sprites/tarinai_16_normal_smirk.webp",
"assets/sprites/tarinai_17_normal_tongue.webp",
"assets/sprites/tarinai_18_fear.webp",
"assets/sprites/tarinai_19_flee_fear2.webp",
"assets/sprites/tarinai_20_sleep2.webp",
"assets/sprites/tarinai_21_normal_happy.webp",
"assets/sprites/tarinai_22_fear_blue.webp",
"assets/sprites/tarinai_23_fear_cry.webp",
"assets/sprites/tarinai_24_stress_dizzy.webp",
"assets/sprites/tarinai_25_stress_sweat.webp",
"assets/sprites/tarinai_26_intimidate.webp",
"assets/sprites/tarinai_27_birth_ritual.webp",
"assets/sprites/tarinai_28_zunchi_slave.webp",
"assets/sprites/tarinai_29_hungry_70.webp",
"assets/sprites/tarinai_30_zunchi_slave_alt.webp",
"assets/sprites/tarinai_31_sunbath_01.webp",
"assets/sprites/tarinai_32_sunbath_02.webp",
"assets/sprites/tarinai_33_sunbath_03.webp",
"assets/sprites/tarinai_34_hot_01.webp",
"assets/sprites/tarinai_35_hot_02.webp",
"assets/sprites/tarinai_36_hot_03.webp",
"assets/sprites/tarinai_37_cold_01.webp",
"assets/sprites/tarinai_38_cold_02.webp",
"assets/sprites/tarinai_39_cold_03.webp",
"assets/sprites/tarinai_acquired_overlay.png",
"assets/ui/apple-touch-icon.png",
"assets/ui/ecology_hp_stress.webp",
"assets/ui/favicon.png",
"assets/ui/tool_acquired_tarinai.webp",
"assets/ui/tool_ammo.webp",
"assets/ui/tool_ant_nest.webp",
"assets/ui/tool_ball.webp",
"assets/ui/tool_bed.webp",
"assets/ui/tool_delete.webp",
"assets/ui/tool_duplicator.webp",
"assets/ui/tool_dwarf_drug.webp",
"assets/ui/tool_fence_h.webp",
"assets/ui/tool_fence_v.webp",
"assets/ui/tool_fight_mochi.webp",
"assets/ui/tool_firecracker.webp",
"assets/ui/tool_genkotsu.webp",
"assets/ui/tool_giant_drug.webp",
"assets/ui/tool_grass.webp",
"assets/ui/tool_laxative.webp",
"assets/ui/tool_love_mochi.webp",
"assets/ui/tool_mercury.webp",
"assets/ui/tool_mystery_drug.webp",
"assets/ui/tool_nest_box.webp",
"assets/ui/tool_new.webp",
"assets/ui/tool_niteropu.webp",
"assets/ui/tool_observe.webp",
"assets/ui/tool_oshibyo.webp",
"assets/ui/tool_protein.webp",
"assets/ui/tool_pushpin.webp",
"assets/ui/tool_shoot.png",
"assets/ui/tool_signboard.webp",
"assets/ui/tool_sleep_drug.webp",
"assets/ui/tool_stone.webp",
"assets/ui/tool_sweet.webp",
"assets/ui/tool_zunchi.webp",
"assets/ui/tool_zunda_juice.webp"
];
const CORE_ASSETS = [
"./",
"./index.html",
`./css/base.css?${v}`,
`./css/layout.css?${v}`,
`./css/panel.css?${v}`,
`./css/components.css?${v}`,
`./css/mobile.css?${v}`,
...coreScriptNames.map(name => `./js/${name}.js?${v}`),
"./app_manifest.json",
"./manifest.webmanifest",
"./favicon.ico",
"./assets/ui/pwa-icon-192.png",
"./assets/ui/pwa-icon-512.png",
...mediaAssetNames.map(name => `./${name}`),
];
self.addEventListener("install", event => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(CORE_ASSETS);
await self.skipWaiting();
})());
});
self.addEventListener("activate", event => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys
.filter(key => key.startsWith("tarinai-colony-") && key !== CACHE_NAME)
.map(key => caches.delete(key)));
await self.clients.claim();
})());
});
function isStaticAsset(request) {
return /\.(?:js|css|json|webmanifest|ico|webp|png|jpg|jpeg|gif|svg|mp3|wav|woff2?)$/i.test(new URL(request.url).pathname);
}
function cacheableResponse(response) {
return response && response.status === 200 && response.type !== "opaque";
}
function putCacheSafely(request, response) {
if (!cacheableResponse(response)) return Promise.resolve(false);
return caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone())).then(() => true).catch(() => false);
}
self.addEventListener("fetch", event => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
// PWA-04 intentionally remains unchanged: cross-origin Google Fonts are not cached.
if (url.origin !== self.location.origin || /service-worker\.js$/i.test(url.pathname)) return;
if (request.mode === "navigate" || /index\.html$/i.test(url.pathname)) {
const networkResponse = fetch(request);
const cacheWrite = networkResponse.then(response => putCacheSafely(request, response)).catch(() => false);
event.waitUntil(cacheWrite.then(() => undefined));
event.respondWith(networkResponse.catch(() => caches.match(request).then(cached => cached || caches.match("./index.html"))));
return;
}
if (isStaticAsset(request)) {
const responsePromise = caches.match(request).then(async cached => {
if (cached) return cached;
// Media are precached at their canonical URL. JS/CSS query versions must not
// resolve to an older cached file, otherwise UI fixes can remain stale.
const isCodeAsset = /\.(?:js|css)$/i.test(url.pathname);
if (!isCodeAsset) {
const canonical = await caches.match(request, { ignoreSearch: true });
if (canonical) return canonical;
}
const response = await fetch(request);
await putCacheSafely(request, response);
return response;
});
event.waitUntil(responsePromise.then(() => undefined, () => undefined));
event.respondWith(responsePromise);
}
});

View file

@ -4,23 +4,67 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prefecture Map Generator</title>
<link rel="stylesheet" href="./styles.css" />
<link rel="stylesheet" href="./styles/styles.css" />
</head>
<body>
<div class="app">
<main class="layout">
<section class="main-panel">
<header class="header">
<div>
<h1>Prefecture Map Generator v17</h1>
<p>Terrain, municipalities, transport, viewport panning, patch terrain generation, and hover inspection in one generated map.</p>
</div>
</header>
<div class="canvas-shell">
<main class="page-shell">
<section class="top-stage">
<section class="map-section" aria-label="Map area">
<div class="canvas-shell" id="canvasShell">
<div class="canvas-stage">
<canvas id="mapCanvas" class="map-canvas"></canvas>
</div>
<svg id="mapSelectionSvg" class="map-selection-svg" aria-hidden="true"></svg>
<div id="mapSelection" class="map-selection" aria-hidden="true"></div>
<div id="toolHint" class="map-hint hidden">
Patch Area mode: right-drag to select an expansion or regeneration area.
</div>
<div class="zoom-control" aria-label="Map zoom controls">
<button id="zoomIn" type="button" aria-label="Zoom in">+</button>
<button id="zoomOut" type="button" aria-label="Zoom out"></button>
<button id="zoomReset" type="button">100%</button>
<button id="centerMap" type="button">Center</button>
</div>
<aside class="map-dock" aria-label="Layer and legend controls">
<details class="dock-card" open>
<summary>
<span>Layer</span>
<span class="summary-button" aria-hidden="true">Toggle</span>
</summary>
<div class="dock-body">
<div class="field-label">Display mode</div>
<div id="modeGrid" class="mode-grid"></div>
<div class="divider"></div>
<label class="checkbox-row">
<input id="showFeatures" type="checkbox" checked />
<span>Map features</span>
</label>
<label class="checkbox-row">
<input id="showLabels" type="checkbox" checked />
<span>Labels</span>
</label>
<label class="checkbox-row">
<input id="showSeamDiagnostics" type="checkbox" checked />
<span>Seam diagnostics</span>
</label>
</div>
</details>
<details class="dock-card">
<summary>
<span>Legend</span>
<span class="summary-button" aria-hidden="true">Toggle</span>
</summary>
<div id="mainLegendGrid" class="legend-grid dock-body"></div>
</details>
</aside>
<div id="generationProgress" class="generation-progress hidden" role="status" aria-live="polite">
<div class="progress-title">Generating map...</div>
<div id="generationProgressStage" class="progress-stage">Preparing</div>
@ -30,11 +74,24 @@
</div>
</section>
<aside class="sidebar">
<section class="card">
<label class="label" for="seed">Seed</label>
<aside class="generation-section" aria-label="Generation settings">
<section class="settings-card settings-header-card">
<div class="card-heading tight">
<div>
<div class="eyebrow">Controls</div>
<h2>Generation Settings</h2>
</div>
</div>
</section>
<section class="settings-card">
<label class="field">
<span class="field-label">Seed</span>
<input id="seed" class="input" value="114514" />
<label class="label inline-label" for="generationType">Generation Type</label>
</label>
<label class="field">
<span class="field-label">Terrain type</span>
<select id="generationType" class="input">
<option value="auto">Auto</option>
<option value="tohoku_spine">Tohoku spine</option>
@ -44,13 +101,37 @@
<option value="kanto_alluvial">Kanto alluvial plain</option>
<option value="mixed_archipelago">Mixed archipelago</option>
</select>
<button id="randomSeed" type="button" class="primary-button">Generate Random Seed</button>
</label>
<div class="button-stack two-col">
<button id="generateMap" type="button" class="primary-button">Generate</button>
<button id="randomSeed" type="button" class="secondary-button">Random</button>
</div>
</section>
<section class="settings-card">
<div class="card-heading">
<div>
<div class="eyebrow">Interaction</div>
<h3>Mode</h3>
</div>
</div>
<div class="segmented-group" aria-label="Interaction mode">
<button id="toolPan" type="button" class="segmented-button active" data-tool="pan">Pan</button>
<button id="toolPatch" type="button" class="segmented-button" data-tool="patch">Patch Area</button>
</div>
</section>
<section class="card">
<div class="card-title">Patch Generation</div>
<label class="label" for="patchTerrainType">Patch Terrain Type</label>
<section class="settings-card patch-card">
<div class="card-heading">
<div>
<div class="eyebrow">Right-drag selection</div>
<h3>Patch Generation</h3>
</div>
</div>
<label class="field">
<span class="field-label">Patch terrain</span>
<select id="patchTerrainType" class="input">
<option value="auto">Auto</option>
<option value="tohoku_spine">Tohoku spine</option>
@ -60,66 +141,177 @@
<option value="kanto_alluvial">Kanto alluvial plain</option>
<option value="mixed_archipelago">Mixed archipelago</option>
</select>
<div class="patch-variant-row">
<label class="label patch-variant-label" for="patchVariant">Patch Variant</label>
<input id="patchVariant" class="input patch-variant-input" type="number" min="0" step="1" value="0" />
</div>
<div class="patch-button-row">
<button id="generatePatch" type="button" class="primary-button" disabled>Generate Selected Area</button>
</label>
<label class="field">
<span class="field-label">Patch mode</span>
<select id="patchMode" class="input">
<option value="auto">Auto (detect expansion)</option>
<option value="expansion">Expand generated world</option>
<option value="regeneration">Regenerate existing area</option>
</select>
</label>
<label class="field">
<span class="field-label">Variant</span>
<input id="patchVariant" class="input" type="number" min="0" step="1" value="0" />
</label>
<div class="button-stack two-col">
<button id="generatePatch" type="button" class="primary-button" disabled>Preview Area</button>
<button id="alternativePatch" type="button" class="secondary-button" disabled>Alternative</button>
</div>
<p id="patchStatus" class="patch-status">Right-drag to lasso a freeform patch area.</p>
</section>
<section class="card">
<div class="card-title">Display Layers</div>
<div id="modeGrid" class="mode-grid"></div>
<label class="checkbox-row">
<input id="showFeatures" type="checkbox" checked />
Show features
</label>
<label class="checkbox-row">
<input id="showLabels" type="checkbox" checked />
Show labels
</label>
</section>
<section class="card">
<div class="card-title">Generated Features</div>
<div id="stats" class="stats"></div>
</section>
<section class="card legend">
<div class="card-title">Legend</div>
<div class="legend-grid" aria-label="Map legend">
<div class="legend-row"><span class="legend-swatch border-swatch"></span><span>Prefecture region / municipal border</span></div>
<div class="legend-row"><span class="legend-line river-major"></span><span>Main river / tributary</span></div>
<div class="legend-row"><span class="legend-line rail-line"></span><span>Railway / solid ring railway</span></div>
<div class="legend-row"><span class="legend-line road-line"></span><span>National road / trunk ring road</span></div>
<div class="legend-row"><span class="legend-line express-line"></span><span>Expressway / environmental ring segment</span></div>
<div class="legend-row"><span class="legend-line old-road-line"></span><span>Premodern / minor road</span></div>
<div class="legend-row"><span class="legend-icon city-icon"></span><span>Prefectural capital / city</span></div>
<div class="legend-row"><span class="legend-icon port-icon"></span><span>Major / regional / fishing / lake port</span></div>
<div class="legend-row"><span class="legend-icon castle-icon"></span><span>Castle / ruins</span></div>
<div class="legend-row"><span class="legend-swatch cbd-swatch"></span><span>CBD / central city cells</span></div>
<div class="legend-row"><span class="legend-icon satellite-icon"></span><span>Satellite city</span></div>
<div class="legend-row"><span class="legend-icon station-icon"></span><span>Station</span></div>
<div class="legend-row"><span class="legend-icon industry-icon"></span><span>Industry / logistics</span></div>
<div class="legend-row"><span class="legend-icon newtown-icon"></span><span>New town</span></div>
<div class="button-stack two-col">
<button id="applyPatch" type="button" class="secondary-button" disabled>Apply Preview</button>
<button id="discardPatch" type="button" class="ghost-button" disabled>Discard</button>
</div>
<div class="button-stack two-col">
<button id="clearPatchSelection" type="button" class="ghost-button" disabled>Clear Selection</button>
<button id="cancelPatchGeneration" type="button" class="ghost-button" disabled>Cancel Generation</button>
</div>
</section>
<section class="card legend">
<div class="card-title">Notes</div>
<p>Open <code>index.html</code> with Live Server. Left-drag pans the viewport; right-drag draws a freeform regeneration area; use Patch Generation to write terrain into that area.</p>
<p>Add preferred reusable place names in <code>CUSTOM_NAME_LIST</code> inside <code>names.js</code>.</p>
<p id="patchStatus" class="patch-status">Switch to Patch Area, then right-drag to draw a freeform area.</p>
</section>
</aside>
</section>
<section class="summary-section" aria-label="Summary">
<div class="section-title-row compact">
<div>
<div class="eyebrow">Current map</div>
<h2>Summary</h2>
</div>
</div>
<div id="stats" class="stats summary-stats"></div>
</section>
<section class="advanced-section" aria-label="Advanced and debug data">
<details class="advanced-details">
<summary>
<span>Advanced / Debug Data</span>
<span class="advanced-summary-actions">
<button id="copyImportantData" type="button" class="copy-debug-button">Copy Important Data</button>
<span id="copyDebugStatus" class="copy-debug-status" aria-live="polite"></span>
<span class="advanced-toggle" aria-hidden="true">Open</span>
</span>
</summary>
<div class="advanced-body">
<section class="debug-panel wide">
<header class="debug-panel-header">
<div>
<div class="eyebrow">Last 10</div>
<h3>Full Generation Performance</h3>
</div>
<p>Average, max, P95, breakdown, terrain type, area, and seconds per 1,000 cells.</p>
</header>
<div id="advancedGenerationStats" class="metric-grid"></div>
<div id="advancedGenerationHistory" class="perf-history"></div>
</section>
<section class="debug-panel wide">
<header class="debug-panel-header">
<div>
<div class="eyebrow">Last 10</div>
<h3>Patch / Additional Generation Performance</h3>
</div>
<p>Preview and alternative patch generation, including selected area and normalized cost.</p>
</header>
<div id="advancedPatchStats" class="metric-grid"></div>
<div id="advancedPatchHistory" class="perf-history"></div>
</section>
<section class="debug-panel wide">
<header class="debug-panel-header">
<div>
<div class="eyebrow">Last 10</div>
<h3>Viewport Interaction Latency</h3>
</div>
<p>Pan and zoom are aggregated separately for fast redraw and full redraw phases.</p>
</header>
<div class="aggregate-header" aria-hidden="true">
<span>Group</span><small>Count</small><strong>Avg</strong><strong>Max</strong><strong>P95</strong>
</div>
<div id="advancedInteractionStats" class="perf-history compact"></div>
<div id="advancedInteractionHistory" class="perf-history compact"></div>
</section>
<section class="debug-panel wide">
<header class="debug-panel-header">
<div>
<div class="eyebrow">Current render</div>
<h3>Viewport / Feature Diagnostics</h3>
</div>
<p>Drawn cell count, viewport size, canvas size, and feature counts used to separate generation cost from rendering cost.</p>
</header>
<div id="advancedViewportDiagnostics" class="diagnostic-grid"></div>
<div id="advancedFeatureCounts" class="diagnostic-table"></div>
</section>
<section class="debug-panel">
<header class="debug-panel-header">
<div>
<div class="eyebrow">Patch area</div>
<h3>Selection / Write Ratio</h3>
</div>
</header>
<div id="advancedPatchDiagnostics" class="diagnostic-table"></div>
</section>
<section class="debug-panel">
<header class="debug-panel-header">
<div>
<div class="eyebrow">Patch seam</div>
<h3>Seam Diagnostics</h3>
</div>
<p>Magenta: seam outline. Red: disconnected or critical. Orange: topology/boundary warning. Green: retained transport crossing.</p>
</header>
<div id="advancedSeamDiagnostics" class="diagnostic-table"></div>
</section>
<section class="debug-panel">
<header class="debug-panel-header">
<div>
<div class="eyebrow">Runtime</div>
<h3>Worker / World Diagnostics</h3>
</div>
</header>
<div id="advancedWorkerDiagnostics" class="diagnostic-table"></div>
<div id="advancedWorldDiagnostics" class="diagnostic-table stacked"></div>
</section>
<section class="debug-panel wide">
<header class="debug-panel-header">
<div>
<div class="eyebrow">Last 10</div>
<h3>Warnings / Errors</h3>
</div>
<p>Recent invalid selections, worker fallbacks, failed generations, and other UI-visible diagnostics.</p>
</header>
<div id="advancedWarningHistory" class="diagnostic-log"></div>
</section>
<section class="debug-panel">
<header class="debug-panel-header">
<div>
<div class="eyebrow">Notes</div>
<h3>Debug Policy</h3>
</div>
</header>
<div class="debug-note flat">
<strong>Patch workflow</strong>
<p>Preview Area inspects a candidate patch. Apply Preview commits it; Discard resets it.</p>
</div>
<div class="debug-note flat">
<strong>Readable default UI</strong>
<p>Verbose debug layers and unused legend entries stay outside the default controls.</p>
</div>
</section>
</div>
</details>
</section>
</main>
</div>
<script type="module" src="./app.js"></script>
<script type="module" src="./src/app.js"></script>
</body>
</html>

View file

@ -1,80 +0,0 @@
import { SIZE } from "./mapUtils.js";
export function changedCellsSince(before, after, prefectureMask, sea) {
let changed = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== after[i]) changed++;
return changed;
}
export function municipalityAreaById(adminId, prefectureMask, sea) {
const area = new Map();
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
area.set(adminId[i], (area.get(adminId[i]) || 0) + 1);
}
return area;
}
export function maskLandArea(mask, sea) {
let area = 0;
for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++;
return area;
}
export function isProtectedAdminSeed(seed) {
if (!seed) return false;
if (seed.seedKind === "capital" || seed.protectedCity?.isPrefecturalCapital) return true;
if (seed.seedKind === "modernCity" && (seed.protectedCity?.population || seed.population || 0) >= 180000) return true;
if (seed.seedKind === "port" && seed.portClass === "major") return true;
if (seed.seedKind === "satelliteCity" && (seed.protectedSatellite?.population || seed.population || 0) >= 60000) return true;
return false;
}
export function buildSeedLifecycle(adminCenters, adminId, prefectureMask, sea, minArea = 80) {
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const lifecycle = adminCenters.map((center, id) => {
const protectedSeed = isProtectedAdminSeed(center);
const area = areaById.get(id) || 0;
const enoughArea = area >= (protectedSeed ? 28 : minArea);
return {
id,
protected: protectedSeed,
area,
state: enoughArea || protectedSeed ? "survived" : "pending",
};
});
return lifecycle;
}
export function activeSeedIds(seedLifecycle) {
return new Set(seedLifecycle.filter((seed) => seed.state === "survived" || seed.protected).map((seed) => seed.id));
}
export function dominantCompartmentOwners(compartments, adminId) {
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const counts = new Map();
for (const i of unit.cells) {
const id = adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
let bestId = -1, best = -1;
for (const [id, count] of counts) if (count > best) { best = count; bestId = id; }
owner[unit.id] = bestId;
}
return owner;
}
export function applyCompartmentOwners(adminId, compartments, owner) {
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
for (const i of unit.cells) adminId[i] = id;
}
}

View file

@ -1,503 +0,0 @@
import {
applyLandscapeUnitAdminPartition,
assignAdminRegionsFromNaturalCompartments,
lockSmallUrbanComponentsToMunicipality,
mergeTinyMunicipalities,
removeMunicipalExclaves,
enforceMunicipalityConnectivityStrict,
smoothAdminRegionsTerrainAware,
snapAdminBoundariesToTerrain,
} from "./adminRegions.js";
import { INF, clamp, indexOf, inside, rand } from "./mapUtils.js";
import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js";
import {
changedCellsSince,
municipalityAreaById,
maskLandArea,
isProtectedAdminSeed,
buildSeedLifecycle,
activeSeedIds,
} from "./mapAdminShared.js";
import {
compactWholeCompartmentMunicipalities,
enforceCompartmentMunicipalityOwnership,
enforceSimpleAdministrativeHierarchy,
lockCompactUrbanAreasToDominantAdmin,
repairAdminSingleOwnerEnclaves,
splitOversizedCompartmentMunicipalities,
} from "./mapAdminCompartmentRepair.js";
import { generatePrefecturesFromMunicipalities } from "./mapPrefectureStage.js";
import {
absorbSeedCompartments,
splitOversizedLowlandsWithPendingSeeds,
promotePendingSeedsForMunicipalityCount,
restoreSurvivedSeedsByCompartment,
} from "./mapAdminSeedLifecycle.js";
import {
computeTargetMunicipalityCount,
buildLowlandAdminSeeds,
} from "./mapAdminTargets.js";
import {
classifySatelliteMunicipalities,
expandSatelliteMunicipalityCatchment,
enforceCityMunicipalityCatchments,
} from "./mapAdminUrbanCatchments.js";
function generateAdminLayoutForMask({
seed,
prefectureMask,
sea,
elevation,
slope,
river,
ridgeField,
naturalBarrierScore,
valleyField,
basinField,
coastalLowland,
flowAccum,
plain,
agriculture,
settlementScore,
populationDensity,
stationInfluence,
roadInfluence,
railInfluence2,
villageInfluence,
landuse,
modernCities,
satelliteCities,
newTowns,
markets,
villages,
ports,
stations,
industrialZones,
logisticsParks,
naturalCompartmentId,
naturalCompartments,
geography = null,
habitability = null,
accessibility = null,
centrality = null,
geographicBarrier = null,
geographicBarrierCost = null,
adminBoundaryPreference = null,
boundaryAvoidance = null,
adminRegionMeta = {},
adminProgress = null,
}) {
const unifiedBoundaryPreference = adminBoundaryPreference || geography?.adminBoundaryPreference || null;
const unifiedBoundaryAvoidance = boundaryAvoidance || geography?.boundaryAvoidance || null;
const unifiedGeographicBarrier = geographicBarrier || geography?.geographicBarrier || null;
const boundaryRidgeField = naturalBarrierScore
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
: ridgeField;
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "classify satellites" });
const satelliteClassificationDebug = classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, habitability: habitability || geography?.habitability, centrality: centrality || geography?.centrality, accessibility: accessibility || geography?.accessibility, geographicBarrier: unifiedGeographicBarrier, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta });
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
const regionLandArea = adminRegionMeta.landArea || maskLandArea(prefectureMask, sea);
const minCompartmentTarget = clamp(Math.round(Math.max(targetMunicipalityCount * 3.4, regionLandArea / 78)), 22, 150);
const maxCompartmentTarget = clamp(Math.round(Math.max(targetMunicipalityCount * 6.0, regionLandArea / 36)), minCompartmentTarget, 320);
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), minCompartmentTarget, maxCompartmentTarget);
let adminCentersRaw = buildLowlandAdminSeeds({
seed,
targetMunicipalityCount,
prefectureMask,
sea,
elevation,
slope,
ridgeField: boundaryRidgeField,
plain,
basinField,
coastalLowland,
settlementScore,
populationDensity,
roadInfluence,
railInfluence2,
stationInfluence,
landuse,
habitability: habitability || geography?.habitability,
accessibility: accessibility || geography?.accessibility,
centrality: centrality || geography?.centrality,
boundaryAvoidance: unifiedBoundaryAvoidance,
adminBoundaryPreference: unifiedBoundaryPreference,
geographicBarrier: unifiedGeographicBarrier,
modernCities,
satelliteCities,
markets,
ports,
newTowns,
stations,
});
if (adminCentersRaw.length < targetMunicipalityCount) targetCompartmentCount = Math.max(targetCompartmentCount, minCompartmentTarget);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "natural compartments", targetMunicipalityCount, targetCompartmentCount, seedCount: adminCentersRaw.length });
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
seed,
targetMunicipalityCount,
targetCompartmentCount,
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
naturalCompartmentId,
naturalCompartments,
naturalBarrierScore,
geography,
habitability: habitability || geography?.habitability,
accessibility: accessibility || geography?.accessibility,
centrality: centrality || geography?.centrality,
boundaryAvoidance: unifiedBoundaryAvoidance,
adminBoundaryPreference: unifiedBoundaryPreference,
geographicBarrier: unifiedGeographicBarrier,
progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }),
});
const adminId = compartmentAssignment.adminId;
if (naturalCompartmentId && naturalCompartments) {
const oversizedCompartmentSplit = splitOversizedCompartmentMunicipalities(adminId, adminCentersRaw, compartmentAssignment.compartments, prefectureMask, sea, {
elevation, slope, ridgeField: boundaryRidgeField, plain, agriculture, basinField, coastalLowland, populationDensity,
}, seed + 21900);
const changedAfterInitialCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
const hierarchyRepair = enforceSimpleAdministrativeHierarchy(adminId, compartmentAssignment.compartments, prefectureMask, sea, {
minCompartmentsPerMunicipality: 2,
maxUrbanClusterCells: 1600,
modernCities,
});
const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
const compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope });
const strictConnectivityChangedCells = enforceMunicipalityConnectivityStrict(compacted.adminId, prefectureMask, sea, compacted.adminCentersRaw, [...modernCities, ...(compacted.adminCentersRaw || [])], 8);
const strictEnclaveRepairChangedCells = repairAdminSingleOwnerEnclaves(compacted.adminId, prefectureMask, sea, 4);
const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask);
const actualMunicipalityCount = new Set([...compacted.adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size;
const naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length;
const adminDebug = {
...compartmentAssignment.debug,
simpleHierarchyPrototype: true,
administrativeHierarchySpec: "unified-geography->natural-compartments->living-sphere-municipalities->prefectures",
unifiedGeographyAdministrativeBasis: true,
naturalCompartmentsImmutable: true,
municipalitiesAreCompartmentGroups: true,
prefecturesAreMunicipalityGroups: true,
cellLevelAdminSmoothingDisabled: true,
sharedNaturalCompartmentLayer: true,
skippedLegacyCellCleanupForHierarchy: true,
targetMunicipalityCount,
actualMunicipalityCount,
finalMunicipalityCount: actualMunicipalityCount,
changedAfterStrictMunicipalityConnectivity: strictConnectivityChangedCells,
changedAfterStrictMunicipalityEnclaveRepair: strictEnclaveRepairChangedCells,
candidateSeedCount: adminCentersRaw.length,
municipalOfficePointCount: compacted.adminCentersRaw.length,
seedCellRevivalCount: 0,
survivedSeedCount: compacted.activeMunicipalityCount,
pendingSeedCount: 0,
absorbedSeedCount: Math.max(0, adminCentersRaw.length - compacted.activeMunicipalityCount),
targetNaturalCompartmentCount: targetCompartmentCount,
naturalCompartmentCount,
compartmentCount: naturalCompartmentCount,
changedAfterCompartmentAssignment: naturalCompartmentCount,
changedAfterInitialCompartmentOwnership,
changedAfterFinalCompartmentOwnership,
changedAfterUrbanUnification: hierarchyRepair.changedAfterUrbanUnification,
urbanComponentsUnified: hierarchyRepair.urbanComponentsUnified,
changedAfterCityMetroMunicipalityUnification: hierarchyRepair.changedAfterCityMetroMunicipalityUnification,
cityMetroMunicipalitiesUnified: hierarchyRepair.cityMetroMunicipalitiesUnified,
changedAfterCompartmentConnectivity: hierarchyRepair.changedAfterCompartmentConnectivity,
disconnectedCompartmentComponentsMerged: hierarchyRepair.disconnectedCompartmentComponentsMerged,
changedAfterAdminEnclaveRepair: hierarchyRepair.changedAfterCompartmentEnclaveRepair,
compartmentEnclaveComponentsMerged: hierarchyRepair.compartmentEnclaveComponentsMerged,
changedAfterSingleCompartmentMunicipalityMerge: hierarchyRepair.changedAfterSingleCompartmentMunicipalityMerge,
singleCompartmentMunicipalitiesMerged: hierarchyRepair.singleCompartmentMunicipalitiesMerged,
remainingSingleCompartmentMunicipalities: hierarchyRepair.remainingSingleCompartmentMunicipalities,
changedAfterPostMergeCompartmentOwnership: changedAfterFinalCompartmentOwnership,
changedAfterOversizedCompartmentSplit: oversizedCompartmentSplit.changedCells,
oversizedCompartmentMunicipalitiesSplit: oversizedCompartmentSplit.splitMunicipalities,
oversizedCompartmentSplitAddedCenters: oversizedCompartmentSplit.addedCenters,
oversizedCompartmentSplitMaxArea: oversizedCompartmentSplit.maxArea || 0,
finalTinyMunicipalityCount: [...municipalityAreaById(compacted.adminId, prefectureMask, sea).values()].filter((area) => area > 0 && area < 8).length,
compartmentBorders: compartmentAssignment.debug?.compartmentBorders || [],
borderNaturalBarrierAverage: compartmentAssignment.debug?.finalBorderNaturalBarrierAverage || 0,
voronoiLikeRate: compartmentAssignment.debug?.voronoiLikeRateAfter || 0,
};
return {
adminCentersRaw: compacted.adminCentersRaw,
adminId: compacted.adminId,
adminBorders,
adminDebug,
naturalCompartmentId: compartmentAssignment.compartmentId,
naturalCompartments: compartmentAssignment.compartments,
};
}
let previousSnapshot = new Int16Array(adminId);
const adminDebug = {
changedAfterSmooth: 0,
changedAfterUrbanLock: 0,
changedAfterSmallUrbanLock: 0,
changedAfterInitialMerge: 0,
changedAfterInitialExclaveRemoval: 0,
changedAfterLandscapePartition: 0,
changedAfterSnap: 0,
changedAfterOversizedRuralSplit: 0,
changedAfterFinalExclaveRemoval: 0,
changedAfterFinalMerge: 0,
targetMunicipalityCount,
actualMunicipalityCount: 0,
municipalityCountReason: "unified habitability/accessibility, settlement hierarchy, coastline complexity, basin/lowland bonus, and natural-barrier adjustment",
unifiedGeographyAdministrativeBasis: true,
administrativeHierarchySpec: "unified-geography->natural-compartments->living-sphere-municipalities->prefectures",
changedAfterCompartmentAssignment: compartmentAssignment.debug?.naturalCompartmentCount || 0,
oversizedRuralSplits: 0,
oversizedLowlandSplits: 0,
ruralSplitsAccepted: 0,
ruralSplitsRejected: 0,
targetNaturalCompartmentCount: targetCompartmentCount,
compartmentMultiplier,
lowlandAdminSeedCount: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).length,
lowlandAdminSeeds: adminCentersRaw.filter((p) => p.invisibleLowlandAdminSeed).map((p) => ({ x: p.x, y: p.y })),
realAdminSeedCount: adminCentersRaw.filter((p) => !p.invisibleLowlandAdminSeed).length,
highMountainAdminSeedCount: adminCentersRaw.filter((p) => {
const i = indexOf(p.x, p.y);
return elevation[i] > 0.70 || slope[i] > 0.52 || boundaryRidgeField[i] > 0.62;
}).length,
candidateSeedCount: adminCentersRaw.length,
protectedSeedCount: adminCentersRaw.filter(isProtectedAdminSeed).length,
survivedSeedCount: 0,
pendingSeedCount: 0,
absorbedSeedCount: 0,
pendingSeedsUsedForLowlandSplit: 0,
finalMunicipalityCount: 0,
finalTinyMunicipalityCount: 0,
seedCellRevivalCount: 0,
satelliteMunicipalitiesCreated: adminCentersRaw.filter((p) => p.protectedSatellite).length,
satelliteMunicipalitiesMerged: 0,
satelliteMunicipalitiesExpanded: 0,
satelliteMunicipalitiesTooSmall: 0,
averageSatelliteMunicipalityArea: 0,
minSatelliteMunicipalityArea: 0,
satelliteMunicipalityAreaByNameOrIndex: {},
independentSatelliteMunicipalities: satelliteClassificationDebug.independent,
attachedSatelliteDistricts: satelliteClassificationDebug.attached,
satelliteMunicipalityStats: satelliteClassificationDebug,
...compartmentAssignment.debug,
};
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "seed lifecycle" });
const seedLifecycle = buildSeedLifecycle(adminCentersRaw, adminId, prefectureMask, sea, 35);
const pendingSplitDebug = splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, {
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, [...(satelliteCities || []), ...newTowns, ...markets, ...villages, ...ports]);
adminDebug.changedAfterPendingSeedLowlandSplit = pendingSplitDebug.changedCells;
adminDebug.pendingSeedsUsedForLowlandSplit = pendingSplitDebug.pendingSeedsUsed;
adminDebug.oversizedLowlandSplits += pendingSplitDebug.splitMunicipalities;
const pendingPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, {
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(24, targetMunicipalityCount));
adminDebug.changedAfterPendingSeedCountRepair = pendingPromotionDebug.changedCells;
adminDebug.pendingSeedsPromotedForCount = pendingPromotionDebug.promotedSeeds;
let areaAfterPendingSplit = municipalityAreaById(adminId, prefectureMask, sea);
for (const seedState of seedLifecycle) {
if (seedState.state !== "pending") continue;
seedState.area = areaAfterPendingSplit.get(seedState.id) || 0;
if (seedState.area >= 35) seedState.state = "survived";
else seedState.state = "absorbed";
}
adminDebug.changedAfterAbsorbingSeeds = absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle);
let activeAdminIds = activeSeedIds(seedLifecycle);
function markChanged(field) {
adminDebug[field] = changedCellsSince(previousSnapshot, adminId, prefectureMask, sea);
previousSnapshot = new Int16Array(adminId);
}
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "smooth boundaries" });
smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, populationDensity, landuse, 2);
markChanged("changedAfterSmooth");
function lockUrbanClusterToMunicipality(city, radius, allowSuburban = true) {
if (!city || !prefectureMask[indexOf(city.x, city.y)]) return;
let bestAdmin = -1;
let bestD = INF;
adminCentersRaw.forEach((center, id) => {
if (!activeAdminIds.has(id)) return;
const d = Math.hypot(center.x - city.x, center.y - city.y);
if (d < bestD) { bestD = d; bestAdmin = id; }
});
if (bestAdmin < 0) return;
const r = Math.ceil(radius);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const x = city.x + dx;
const y = city.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const d = Math.hypot(dx, dy);
if (d > radius) continue;
const urban = landuse[i] === 2 || landuse[i] === 3 || (allowSuburban && (landuse[i] === 4 || landuse[i] === 7 || landuse[i] === 8));
if (urban || populationDensity[i] > 0.22) adminId[i] = bestAdmin;
}
}
}
for (const city of modernCities) {
const radius = (city.population || 0) >= 500000
? clamp(17 + Math.sqrt(city.population) / 120, 20, 38)
: clamp(5 + Math.sqrt(city.population || 70000) / 210, 6, 11);
lockUrbanClusterToMunicipality(city, radius, true);
}
for (const sat of satelliteCities || []) {
if (!prefectureMask[indexOf(sat.x, sat.y)]) continue;
let bestAdmin = -1;
if (sat.municipalityClass === "independentSatelliteMunicipality") {
let bestD = INF;
adminCentersRaw.forEach((center, id) => {
if (!activeAdminIds.has(id)) return;
const d = Math.hypot(center.x - sat.x, center.y - sat.y);
if (d < bestD) { bestD = d; bestAdmin = id; }
});
} else if (inside(sat.parentX ?? -1, sat.parentY ?? -1)) {
bestAdmin = adminId[indexOf(sat.parentX, sat.parentY)];
}
if (bestAdmin < 0) continue;
sat.parentAdminHint = bestAdmin;
const changed = expandSatelliteMunicipalityCatchment(adminId, sat, bestAdmin, {
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
if (changed > 0 && sat.municipalityClass === "independentSatelliteMunicipality") adminDebug.satelliteMunicipalitiesExpanded++;
}
markChanged("changedAfterUrbanLock");
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 520);
lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, 620);
markChanged("changedAfterSmallUrbanLock");
const activeAdminCenters = () => adminCentersRaw.filter((_, id) => activeAdminIds.has(id));
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 120, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 100, protectedPoints: activeAdminCenters() });
markChanged("changedAfterInitialMerge");
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "topology cleanup" });
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, modernCities, 180);
markChanged("changedAfterInitialExclaveRemoval");
// The initial compartment graph assignment is now the primary natural partition.
// Re-running the older raw landscape-unit pass here collapses lowland seeds into a few broad owners.
markChanged("changedAfterLandscapePartition");
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "split oversized municipalities" });
// The older oversized-lowland pass rebuilds natural compartments a second time.
// The current pipeline already performs pending-seed lowland splitting on the active
// compartment graph above, so keep the full admin layout while avoiding the duplicate
// high-cost recomputation.
const oversizedSplitDebug = {
changedCells: 0,
splitMunicipalities: 0,
rejectedMunicipalities: 0,
skippedDuplicateCompartmentRebuild: true,
skippedForVisibleFragment: false,
};
adminDebug.changedAfterOversizedRuralSplit = oversizedSplitDebug.changedCells;
adminDebug.oversizedRuralMunicipalitiesSplit = oversizedSplitDebug.splitMunicipalities;
adminDebug.oversizedRuralSplits = oversizedSplitDebug.splitMunicipalities;
adminDebug.oversizedLowlandSplits = oversizedSplitDebug.splitMunicipalities;
adminDebug.ruralSplitsAccepted = oversizedSplitDebug.splitMunicipalities;
adminDebug.ruralSplitsRejected = oversizedSplitDebug.rejectedMunicipalities || 0;
adminDebug.oversizedSplitSkippedForVisibleFragment = Boolean(oversizedSplitDebug.skippedForVisibleFragment);
adminDebug.oversizedSplitSkippedDuplicateCompartmentRebuild = Boolean(oversizedSplitDebug.skippedDuplicateCompartmentRebuild);
previousSnapshot = new Int16Array(adminId);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "terrain snap" });
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...satelliteCities, ...ports, ...industrialZones, ...logisticsParks], 2);
markChanged("changedAfterSnap");
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 360);
markChanged("changedAfterFinalExclaveRemoval");
mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities, 80, { satelliteCities, satelliteStats: adminDebug, satelliteMinArea: 90, protectedPoints: activeAdminCenters() });
markChanged("changedAfterFinalMerge");
for (const sat of satelliteCities || []) {
if (sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) continue;
const targetAdmin = adminId[indexOf(sat.x, sat.y)];
if (targetAdmin < 0) continue;
expandSatelliteMunicipalityCatchment(adminId, sat, targetAdmin, {
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities,
});
}
const cityCatchmentDebug = enforceCityMunicipalityCatchments(adminId, modernCities, {
prefectureMask, sea, elevation, slope, river, ridgeField: boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum,
landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence,
});
adminDebug.changedAfterCityMunicipalityCatchment = cityCatchmentDebug.changed;
adminDebug.protectedCityMunicipalityCount = cityCatchmentDebug.protectedCities;
adminDebug.tooSmallCityMunicipalityCountBeforeRepair = cityCatchmentDebug.tooSmall;
snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, flowAccum, populationDensity, landuse, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 2);
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 260);
const finalPromotionDebug = promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, {
plain, agriculture, basinField, coastalLowland, valleyField, ridgeField: boundaryRidgeField, slope, elevation,
}, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount));
adminDebug.changedAfterFinalPendingSeedCountRepair = finalPromotionDebug.changedCells;
adminDebug.pendingSeedsPromotedForCount += finalPromotionDebug.promotedSeeds;
const restoredSeedDebug = restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartmentAssignment.compartments, adminCentersRaw, seedLifecycle, Math.min(22, targetMunicipalityCount));
adminDebug.changedAfterSurvivedSeedCompartmentRestore = restoredSeedDebug.changedCells;
adminDebug.survivedSeedsRestoredByCompartment = restoredSeedDebug.restoredSeeds;
let finalAreaBySeed = municipalityAreaById(adminId, prefectureMask, sea);
for (const seedState of seedLifecycle) {
seedState.area = finalAreaBySeed.get(seedState.id) || 0;
if (!seedState.protected && seedState.state === "pending" && seedState.area < 25) seedState.state = "absorbed";
}
absorbSeedCompartments(adminId, compartmentAssignment.compartments, seedLifecycle);
activeAdminIds = activeSeedIds(seedLifecycle);
adminDebug.changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
removeMunicipalExclaves(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 220);
adminDebug.changedAfterPostCompartmentExclaveRemoval = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
adminDebug.changedAfterUrbanUnification = lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, 980);
adminDebug.changedAfterAdminEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 6);
adminDebug.changedAfterFinalCompartmentOwnership += enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
adminDebug.changedAfterStrictMunicipalityConnectivity = enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCentersRaw, [...modernCities, ...activeAdminCenters()], 8);
adminDebug.changedAfterStrictMunicipalityEnclaveRepair = repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, 4);
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const satelliteAreas = [];
(satelliteCities || []).forEach((sat, index) => {
if (!inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)]) return;
const id = adminId[indexOf(sat.x, sat.y)];
const area = areaById.get(id) || 0;
const key = sat.name || `satellite-${index}`;
adminDebug.satelliteMunicipalityAreaByNameOrIndex[key] = area;
if (sat.municipalityClass === "independentSatelliteMunicipality" && (area < Math.max(120, sat.satelliteMinArea || 0) || ((sat.population || 0) >= 60000 && area < 150))) {
sat.municipalityClass = "smallTownAttachedToRuralMunicipality";
adminDebug.satelliteMunicipalitiesTooSmall++;
return;
}
if (sat.municipalityClass === "independentSatelliteMunicipality") {
satelliteAreas.push(area);
if (area < 80) adminDebug.satelliteMunicipalitiesTooSmall++;
}
});
adminDebug.averageSatelliteMunicipalityArea = satelliteAreas.length ? satelliteAreas.reduce((sum, value) => sum + value, 0) / satelliteAreas.length : 0;
adminDebug.minSatelliteMunicipalityArea = satelliteAreas.length ? Math.min(...satelliteAreas) : 0;
adminDebug.satelliteMunicipalitiesIndependent = satelliteAreas.length;
const landscapeDebug = applyLandscapeUnitAdminPartition.lastDebug || {};
Object.assign(adminDebug, landscapeDebug);
adminDebug.targetNaturalCompartmentCount = compartmentAssignment.debug?.targetNaturalCompartmentCount || targetCompartmentCount;
adminDebug.naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || adminDebug.naturalCompartmentCount || adminDebug.compartmentCount || 0;
adminDebug.compartmentCount = adminDebug.naturalCompartmentCount;
adminDebug.averageCompartmentsPerMunicipality = compartmentAssignment.debug?.averageCompartmentsPerMunicipality || adminDebug.averageCompartmentsPerMunicipality || 0;
adminDebug.singleCompartmentMunicipalityRatio = compartmentAssignment.debug?.singleCompartmentMunicipalityRatio ?? adminDebug.singleCompartmentMunicipalityRatio ?? 0;
adminDebug.actualMunicipalityCount = new Set([...adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size;
adminDebug.averageCompartmentsPerMunicipality = adminDebug.actualMunicipalityCount ? adminDebug.naturalCompartmentCount / adminDebug.actualMunicipalityCount : 0;
adminDebug.survivedSeedCount = seedLifecycle.filter((seed) => seed.state === "survived").length;
adminDebug.pendingSeedCount = seedLifecycle.filter((seed) => seed.state === "pending").length;
adminDebug.absorbedSeedCount = seedLifecycle.filter((seed) => seed.state === "absorbed").length;
adminDebug.finalMunicipalityCount = adminDebug.actualMunicipalityCount;
adminDebug.finalTinyMunicipalityCount = [...areaById.values()].filter((area) => area > 0 && area < 8).length;
adminDebug.seedLifecycle = seedLifecycle.map((seed) => ({ id: seed.id, state: seed.state, protected: seed.protected, area: seed.area }));
adminDebug.borderNaturalBarrierAverage = adminDebug.finalBorderNaturalBarrierAverage ?? 0;
adminDebug.voronoiLikeRate = adminDebug.voronoiLikeRateAfter ?? 0;
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "extract borders" });
const adminBorders = extractAdminBorderSegments(adminId, prefectureMask);
return {
adminCentersRaw,
adminId,
adminBorders,
adminDebug,
naturalCompartmentId: compartmentAssignment.compartmentId,
naturalCompartments: compartmentAssignment.compartments,
};
}
export function generateAdminLayout(context) {
const layout = generateAdminLayoutForMask(context);
return { ...layout, ...generatePrefecturesFromMunicipalities(context, layout) };
}

View file

@ -1,239 +0,0 @@
import { INF, SIZE, MinHeap, clamp, indexOf, inside, xyOf } from "./mapUtils.js";
import { municipalityAreaById } from "./mapAdminShared.js";
export function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) {
if (!city || !inside(city.x, city.y)) return 0;
const start = indexOf(city.x, city.y);
if (!prefectureMask[start] || sea[start]) return 0;
const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7));
const seen = new Uint8Array(SIZE);
const queue = [start];
seen[start] = 1;
let area = 0;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
const d = Math.hypot(x - city.x, y - city.y);
if (d > radius) continue;
const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18;
if (!urban) continue;
area++;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
return area;
}
export function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) {
if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 };
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
let maxBarrier = 0;
let lowUrbanRun = 0;
let bestLowUrbanRun = 0;
let densitySum = 0;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42);
maxBarrier = Math.max(maxBarrier, barrier);
densitySum += populationDensity[i];
const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20;
if (urban) lowUrbanRun = 0;
else {
lowUrbanRun++;
bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun);
}
}
return {
separatedByBarrier: maxBarrier > 0.56,
ruralGap: bestLowUrbanRun >= 4,
averageDensity: densitySum / (steps + 1),
maxBarrier,
};
}
export function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) {
let independent = 0;
let attached = 0;
for (const sat of satelliteCities || []) {
if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue;
const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0];
const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99;
const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse);
const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity);
const i = indexOf(sat.x, sat.y);
const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier;
const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000);
let municipalityClass = "independentSatelliteMunicipality";
if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent";
else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict";
else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality";
else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality";
sat.municipalityClass = municipalityClass;
sat.parentX = parent?.x;
sat.parentY = parent?.y;
sat.parentAdminHint = -1;
sat.distinctUrbanComponentArea = urbanArea;
sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap;
sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360);
if (municipalityClass === "independentSatelliteMunicipality") independent++;
else attached++;
}
return { independent, attached };
}
export function expandSatelliteMunicipalityCatchment(adminId, satellite, targetAdmin, context) {
const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence, modernCities } = context;
if (!satellite || targetAdmin < 0 || !inside(satellite.x, satellite.y)) return 0;
const start = indexOf(satellite.x, satellite.y);
if (!prefectureMask[start] || sea[start]) return 0;
const targetAreaBase = clamp(90 + Math.sqrt(satellite.population || 24000) * 0.8 + (satellite.urbanRadius || 5) * 18, 120, 520);
const targetArea = satellite.municipalityClass === "smallTownAttachedToRuralMunicipality"
? Math.min(130, targetAreaBase * 0.55)
: satellite.municipalityClass === "suburbanDistrictMergedWithParent" || satellite.municipalityClass === "newTownDistrict"
? Math.min(190, targetAreaBase * 0.62)
: targetAreaBase;
const maxCost = satellite.municipalityClass === "independentSatelliteMunicipality" ? 46 : 32;
const heap = new MinHeap();
const best = new Float32Array(SIZE);
best.fill(INF);
heap.push({ i: start, f: 0 });
best[start] = 0;
const claimed = [];
while (heap.length > 0 && claimed.length < targetArea) {
const cur = heap.pop();
if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue;
const [x, y] = xyOf(cur.i);
const d = Math.hypot(x - satellite.x, y - satellite.y);
if (!prefectureMask[cur.i] || sea[cur.i]) continue;
let invadesOtherCore = false;
for (const city of modernCities || []) {
if (!city || (city.population || 0) < 140000) continue;
if (Math.hypot(city.x - satellite.x, city.y - satellite.y) < 4) continue;
if (Math.hypot(city.x - x, city.y - y) <= Math.max(3.5, (city.coreRadius || 4) * 1.25)) {
invadesOtherCore = true;
break;
}
}
if (invadesOtherCore) continue;
const compatible = d <= (satellite.urbanRadius || 5) * 1.25 ||
[2, 3, 4, 7, 8].includes(landuse[cur.i]) ||
populationDensity[cur.i] > 0.12 ||
roadInfluence[cur.i] > 0.12 ||
railInfluence2[cur.i] > 0.10 ||
stationInfluence?.[cur.i] > 0.10 ||
basinField[cur.i] > 0.22 ||
valleyField[cur.i] > 0.24 ||
coastalLowland[cur.i] > 0.20;
if (!compatible && claimed.length > targetArea * 0.55) continue;
claimed.push(cur.i);
for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const barrier = ridgeField[ni] * 5.2 + Math.max(0, elevation[ni] - 0.58) * 4.0 + slope[ni] * 2.2 + (river[ni] > 0.55 || flowAccum[ni] > 0.70 ? 7.5 : river[ni] > 0.30 ? 2.8 : 0);
const living = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.2 : 0) + populationDensity[ni] * 2.0 + roadInfluence[ni] * 0.85 + railInfluence2[ni] * 0.95 + (stationInfluence?.[ni] || 0) * 1.2 + basinField[ni] * 0.42 + valleyField[ni] * 0.48 + coastalLowland[ni] * 0.32;
const distanceCost = Math.hypot(nx - satellite.x, ny - satellite.y) / Math.max(7, (satellite.urbanRadius || 5) * 1.9);
const nd = cur.f + Math.max(0.28, 1.05 + barrier - living + distanceCost) * step;
if (nd < best[ni]) {
best[ni] = nd;
heap.push({ i: ni, f: nd });
}
}
}
let changed = 0;
for (const i of claimed) {
if (adminId[i] !== targetAdmin) changed++;
adminId[i] = targetAdmin;
}
return changed;
}
export function cityMinimumMunicipalityArea(city) {
const populationArea = Math.sqrt(city.population || 0) * 0.72;
const footprintArea = (city.urbanFootprintCells || 0) * 0.42;
return clamp(95 + populationArea + footprintArea, 130, (city.population || 0) >= 450000 ? 780 : 520);
}
export function enforceCityMunicipalityCatchments(adminId, cities, context) {
const { prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity, roadInfluence, railInfluence2, stationInfluence } = context;
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
let changed = 0;
let protectedCities = 0;
let tooSmall = 0;
for (const city of cities || []) {
if (!city || (city.population || 0) < 95000 || !inside(city.x, city.y)) continue;
const start = indexOf(city.x, city.y);
if (!prefectureMask[start] || sea[start]) continue;
const targetAdmin = adminId[start];
if (targetAdmin < 0) continue;
protectedCities++;
const minArea = cityMinimumMunicipalityArea(city);
if ((areaById.get(targetAdmin) || 0) >= minArea) continue;
tooSmall++;
const heap = new MinHeap();
const best = new Float32Array(SIZE);
best.fill(INF);
heap.push({ i: start, f: 0 });
best[start] = 0;
const claimed = [];
const maxCost = (city.population || 0) >= 450000 ? 78 : 56;
let projectedArea = areaById.get(targetAdmin) || 0;
while (heap.length > 0 && projectedArea < minArea) {
const cur = heap.pop();
if (!cur || cur.f > best[cur.i] + 1e-5 || cur.f > maxCost) continue;
const [x, y] = xyOf(cur.i);
if (!prefectureMask[cur.i] || sea[cur.i]) continue;
const d = Math.hypot(x - city.x, y - city.y);
const compatible = d <= Math.max(5, (city.coreRadius || 3) * 2.0) ||
[2, 3, 4, 7, 8].includes(landuse[cur.i]) ||
populationDensity[cur.i] > 0.10 ||
roadInfluence[cur.i] > 0.10 ||
railInfluence2[cur.i] > 0.10 ||
(stationInfluence?.[cur.i] || 0) > 0.10 ||
valleyField[cur.i] > 0.22 ||
basinField[cur.i] > 0.20 ||
coastalLowland[cur.i] > 0.18;
if (!compatible && claimed.length > minArea * 0.55) continue;
claimed.push(cur.i);
if (adminId[cur.i] !== targetAdmin) projectedArea++;
for (const [dx, dy, step] of [[1, 0, 1], [-1, 0, 1], [0, 1, 1], [0, -1, 1], [1, 1, 1.41], [-1, 1, 1.41], [1, -1, 1.41], [-1, -1, 1.41]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const majorBarrier = river[ni] > 0.62 || flowAccum[ni] > 0.74 || ridgeField[ni] > 0.70;
const barrier = ridgeField[ni] * 5.4 + Math.max(0, elevation[ni] - 0.60) * 4.4 + slope[ni] * 2.6 + (majorBarrier ? 8.5 : river[ni] > 0.34 ? 2.6 : 0);
const fit = ([2, 3, 4, 7, 8].includes(landuse[ni]) ? 2.4 : 0) + populationDensity[ni] * 2.2 + roadInfluence[ni] * 0.88 + railInfluence2[ni] * 0.92 + (stationInfluence?.[ni] || 0) * 1.15 + valleyField[ni] * 0.56 + basinField[ni] * 0.42 + coastalLowland[ni] * 0.34;
const nd = cur.f + Math.max(0.30, 1.05 + barrier - fit + Math.hypot(nx - city.x, ny - city.y) / Math.max(8, (city.urbanRadius || 7) * 2.1)) * step;
if (nd < best[ni]) {
best[ni] = nd;
heap.push({ i: ni, f: nd });
}
}
}
for (const i of claimed) {
const old = adminId[i];
if (old === targetAdmin) continue;
if (old >= 0) areaById.set(old, Math.max(0, (areaById.get(old) || 0) - 1));
adminId[i] = targetAdmin;
areaById.set(targetAdmin, (areaById.get(targetAdmin) || 0) + 1);
changed++;
}
city.municipalityMinArea = minArea;
}
return { changed, protectedCities, tooSmall };
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

25
scripts/router.php Normal file
View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
// Router for PHP's built-in development server. Apache protects the legacy
// directory with .htaccess, but the built-in server ignores it completely.
$uri = rawurldecode((string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/'));
$segments = array_values(array_filter(explode('/', str_replace('\\', '/', $uri)), static fn(string $part): bool => $part !== ''));
foreach ($segments as $segment) {
if (str_starts_with($segment, '.')) {
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo "Not found\n";
return true;
}
}
$projectRoot = dirname(__DIR__);
$relative = ltrim($uri, '/');
$candidate = $projectRoot . ($relative === '' ? DIRECTORY_SEPARATOR . 'index.html' : DIRECTORY_SEPARATOR . $relative);
if (is_file($candidate)) return false;
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo "Not found\n";
return true;

7
scripts/start_server.bat Normal file
View file

@ -0,0 +1,7 @@
@echo off
setlocal
set PORT=%~1
if "%PORT%"=="" set PORT=8000
for %%I in ("%~dp0..") do set "ROOT=%%~fI"
cd /d "%ROOT%"
php -S 127.0.0.1:%PORT% -t "%ROOT%" "%~dp0router.php"

6
scripts/start_server.sh Normal file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
PORT="${1:-8000}"
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
exec php -S "127.0.0.1:${PORT}" -t "$PROJECT_ROOT" "$SCRIPT_DIR/router.php"

View file

@ -1,316 +1,13 @@
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, indexOf, inside, weightedScore, xyOf } from "./mapUtils.js";
function neighbors8(x, y) {
const out = [];
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]);
}
}
return out;
}
function neighbors4(x, y) {
const out = [];
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx;
const ny = y + dy;
if (inside(nx, ny)) out.push([nx, ny, 1]);
}
return out;
}
export function generateAdminRegions(centers, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse) {
const adminId = new Int16Array(SIZE);
adminId.fill(-1);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
centers.forEach((center, regionId) => {
const i = indexOf(center.x, center.y);
dist[i] = 0;
adminId[i] = regionId;
heap.push({ i, f: 0, regionId });
});
let guard = 0;
while (heap.length > 0 && guard++ < SIZE * 12) {
const current = heap.pop();
if (!current) continue;
const curIndex = current.i;
const curRegion = adminId[curIndex];
if (curRegion < 0 || current.f > dist[curIndex] + 1e-5) continue;
const [cx, cy] = xyOf(curIndex);
for (const [nx, ny, step] of neighbors8(cx, cy)) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const ridgeBarrier = Math.max(ridgeField[ni], ridgeField[curIndex]);
const riverBarrier = Math.max(river[ni], river[curIndex]);
const highDivide = Math.max(elevation[ni], elevation[curIndex]);
const watershedBarrier = ridgeBarrier * (27.0 + Math.max(0, highDivide - 0.46) * 46.0);
const ridgePenalty = Math.max(0, highDivide - 0.36) * 16.0 + Math.abs(elevation[ni] - elevation[curIndex]) * 12.4 + watershedBarrier;
const slopePenalty = slope[ni] * 10.6;
const valleyBarrier = valleyField[ni] > 0.50 ? valleyField[ni] * (riverBarrier > 0.16 ? 7.2 : 2.6) : 0;
const riverPenalty = riverBarrier > 0.7 ? 22.0 : riverBarrier > 0.42 ? 14.8 : riverBarrier > 0.22 ? 7.4 : riverBarrier > 0.12 ? 2.2 : 0;
const urbanContinuityBonus = (landuse[ni] >= 2 && landuse[ni] <= 4 && populationDensity[ni] > 0.20) ? 1.65 : 0;
const valleyLocalityBonus = valleyField[ni] * 0.16;
const stepCost = Math.max(0.25, 0.72 + ridgePenalty + slopePenalty + riverPenalty + valleyBarrier - valleyLocalityBonus - urbanContinuityBonus) * step;
const nextDist = dist[curIndex] + stepCost;
if (nextDist < dist[ni]) {
dist[ni] = nextDist;
adminId[ni] = curRegion;
heap.push({ i: ni, f: nextDist, regionId: curRegion });
}
}
}
return adminId;
}
function terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField) {
return clamp(weightedScore([
[ridgeField[i], 3.15],
[river[i], 2.45],
[valleyField[i], 0.62],
[slope[i], 1.06],
[Math.max(0, elevation[i] - 0.5), 1.18],
]));
}
export function smoothAdminRegionsTerrainAware(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, populationDensity, landuse, passes = 5) {
let current = new Int16Array(adminId);
for (let pass = 0; pass < passes; pass++) {
const next = new Int16Array(current);
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
const own = current[i];
if (!prefectureMask[i] || sea[i] || own < 0) continue;
const urbanCell = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.24;
const barrier = terrainBoundaryStrength(i, elevation, slope, river, ridgeField, valleyField);
if (barrier > 0.62 || urbanCell) continue;
const counts = new Map();
let ownCount = 0;
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const id = current[ni];
if (id < 0) continue;
const weight = terrainBoundaryStrength(ni, elevation, slope, river, ridgeField, valleyField) > 0.72 ? 0.45 : 1;
counts.set(id, (counts.get(id) || 0) + weight);
if (id === own) ownCount += weight;
}
let bestId = own;
let best = ownCount;
for (const [id, score] of counts) if (score > best) { best = score; bestId = id; }
if (bestId !== own && (best >= 4.2 || ownCount <= 2.1)) next[i] = bestId;
}
}
current = next;
}
adminId.set(current);
}
export function lockSmallUrbanComponentsToMunicipality(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 360) {
const seen = new Uint8Array(SIZE);
const queue = [];
for (let i = 0; i < SIZE; i++) {
if (seen[i] || !prefectureMask[i] || sea[i]) continue;
const isUrbanStart = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || landuse[i] === 8 || populationDensity[i] > 0.20;
if (!isUrbanStart) continue;
const component = [];
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
component.push(cur);
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
const isUrban = (landuse[ni] >= 2 && landuse[ni] <= 4) || landuse[ni] === 7 || landuse[ni] === 8 || populationDensity[ni] > 0.20;
if (!isUrban) continue;
seen[ni] = 1;
queue.push(ni);
}
}
if (component.length === 0 || component.length > maxCells) continue;
const counts = new Map();
for (const ci of component) {
const id = adminId[ci];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + populationDensity[ci]);
}
let bestId = -1;
let best = -1;
for (const [id, score] of counts) if (score > best) { best = score; bestId = id; }
if (bestId >= 0) for (const ci of component) adminId[ci] = bestId;
}
}
export function mergeTinyMunicipalities(adminId, prefectureMask, sea, populationDensity, modernCities = [], minArea = 320, options = {}) {
const area = new Map();
const pop = new Map();
const adjacency = new Map();
const cityMunicipalities = new Set();
for (const city of modernCities || []) {
if (!inside(city.x, city.y)) continue;
const id = adminId[indexOf(city.x, city.y)];
if (id < 0) continue;
if (options.protectAllModernCities !== false || city.isPrefecturalCapital || (city.population || 0) >= (options.majorCityPopulationThreshold || 120000)) cityMunicipalities.add(id);
}
for (const point of options.protectedPoints || []) {
if (!point || !inside(point.x, point.y)) continue;
const id = adminId[indexOf(point.x, point.y)];
if (id >= 0) cityMunicipalities.add(id);
}
const satelliteByAdmin = new Map();
for (const sat of options.satelliteCities || []) {
if (!sat || !inside(sat.x, sat.y)) continue;
const id = adminId[indexOf(sat.x, sat.y)];
if (id < 0) continue;
if (!satelliteByAdmin.has(id)) satelliteByAdmin.set(id, []);
satelliteByAdmin.get(id).push(sat);
}
const satelliteStats = options.satelliteStats || null;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const id = adminId[i];
if (id < 0) continue;
area.set(id, (area.get(id) || 0) + 1);
pop.set(id, (pop.get(id) || 0) + populationDensity[i]);
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const other = adminId[ni];
if (other < 0 || other === id) continue;
const key = id < other ? `${id}:${other}` : `${other}:${id}`;
adjacency.set(key, (adjacency.get(key) || 0) + 1);
}
}
}
const mergeTarget = new Map();
for (const [id, cells] of area) {
const score = cells + (pop.get(id) || 0) * 16;
if (cells >= minArea || cityMunicipalities.has(id)) continue;
const satellites = satelliteByAdmin.get(id) || [];
const protectedSatellite = satellites.some((sat) => {
const minSatelliteArea = sat.satelliteMinArea || options.satelliteMinArea || 110;
return sat.municipalityClass === "independentSatelliteMunicipality" && (
cells >= minSatelliteArea ||
(sat.population || 0) >= (options.satelliteIndependentPopulationThreshold || 60000) ||
(sat.distinctUrbanComponentArea || 0) >= 80 ||
sat.separatedByBarrier
);
});
if (protectedSatellite) continue;
let bestNeighbor = -1;
let bestScore = -1;
for (const [key, border] of adjacency) {
const [a, b] = key.split(":").map(Number);
if (a !== id && b !== id) continue;
const other = a === id ? b : a;
const parentBias = satellites.some((sat) => inside(sat.parentX ?? -1, sat.parentY ?? -1) && adminId[indexOf(sat.parentX, sat.parentY)] === other) ? 26 : 0;
const ruralBias = satellites.some((sat) => sat.municipalityClass === "smallTownAttachedToRuralMunicipality") ? Math.min(12, (area.get(other) || 0) * 0.01) : 0;
const candidate = border * 3 + (area.get(other) || 0) * 0.012 + (pop.get(other) || 0) * 0.24 + parentBias + ruralBias;
if (candidate > bestScore) { bestScore = candidate; bestNeighbor = other; }
}
if (bestNeighbor >= 0 && (area.get(bestNeighbor) || 0) >= score * 0.35) {
mergeTarget.set(id, bestNeighbor);
if (satelliteStats && satellites.length) {
satelliteStats.satelliteMunicipalitiesMerged += satellites.length;
for (const sat of satellites) sat.mergedMunicipalityTarget = bestNeighbor;
}
}
}
if (mergeTarget.size === 0) return;
for (let i = 0; i < SIZE; i++) if (mergeTarget.has(adminId[i])) adminId[i] = mergeTarget.get(adminId[i]);
}
export function removeMunicipalExclaves(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxIslandCells = 220) {
const protectedByAdmin = new Map();
for (const p of [...adminCenters, ...protectedPoints]) {
if (!p || !inside(p.x, p.y)) continue;
const id = adminId[indexOf(p.x, p.y)];
if (id < 0) continue;
if (!protectedByAdmin.has(id)) protectedByAdmin.set(id, new Set());
protectedByAdmin.get(id).add(indexOf(p.x, p.y));
}
const ids = new Set();
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]);
const globalSeen = new Uint8Array(SIZE);
const queue = [];
for (const id of ids) {
const components = [];
for (let i = 0; i < SIZE; i++) {
if (globalSeen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue;
const comp = [];
let hasProtected = protectedByAdmin.get(id)?.has(i) || false;
queue.length = 0;
queue.push(i);
globalSeen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
if (protectedByAdmin.get(id)?.has(cur)) hasProtected = true;
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (globalSeen[ni] || adminId[ni] !== id || !prefectureMask[ni] || sea[ni]) continue;
globalSeen[ni] = 1;
queue.push(ni);
}
}
components.push({ cells: comp, hasProtected });
}
if (components.length <= 1) continue;
components.sort((a, b) => (b.hasProtected ? 1000000 : 0) + b.cells.length - ((a.hasProtected ? 1000000 : 0) + a.cells.length));
for (const component of components.slice(1)) {
const mainSize = components[0].cells.length;
if (component.hasProtected && component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.42) continue;
if (component.cells.length > maxIslandCells && component.cells.length > mainSize * 0.36) continue;
const counts = new Map();
for (const ci of component.cells) {
const [x, y] = xyOf(ci);
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) continue;
const other = adminId[ni];
if (other >= 0 && other !== id) counts.set(other, (counts.get(other) || 0) + 1);
}
}
let target = -1;
let best = -1;
for (const [other, count] of counts) if (count > best) { best = count; target = other; }
if (target >= 0) for (const ci of component.cells) adminId[ci] = target;
}
}
}
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, indexOf, inside, xyOf } from "./mapUtils.js";
import { neighbors4, neighbors8 } from "./mapGeneratorHelpers.js";
import { averageFieldAcrossOwnerBorders } from "./mapAdminShared.js";
export function enforceMunicipalityConnectivityStrict(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], maxPasses = 6) {
// Final cell-level invariant: each municipality should be one contiguous land
// component. Earlier stages are allowed to leave sizeable satellite pieces
// while boundaries are still being snapped; this pass removes the remaining
// visual exclaves by attaching every non-primary component to the neighboring
// municipality with the largest shared boundary. A component that contains a
// protected point may become the primary component, but it no longer protects
// additional detached pieces.
// component. Build every municipality's components in one map scan per pass.
// The previous implementation rescanned all SIZE cells separately for every
// municipality ID, so a candidate with many small municipalities could turn a
// normally sub-second cleanup into minutes during large tiled expansion.
const protectedByAdmin = new Map();
for (const p of [...(adminCenters || []), ...(protectedPoints || [])]) {
if (!p || !inside(p.x, p.y)) continue;
@ -324,22 +21,22 @@ export function enforceMunicipalityConnectivityStrict(adminId, prefectureMask, s
let changed = 0;
const queue = [];
for (let pass = 0; pass < maxPasses; pass++) {
let passChanged = 0;
const ids = new Set();
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]);
for (const id of ids) {
const seen = new Uint8Array(SIZE);
const components = [];
const componentsByAdmin = new Map();
// One connected-component traversal over the complete land mask. Each cell
// is visited at most once in this phase, regardless of municipality count.
for (let i = 0; i < SIZE; i++) {
if (seen[i] || adminId[i] !== id || !prefectureMask[i] || sea[i]) continue;
const comp = [];
if (seen[i] || !prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const id = adminId[i];
const cells = [];
let protectedHits = 0;
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
cells.push(cur);
if (protectedByAdmin.get(id)?.has(cur)) protectedHits++;
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors4(x, y)) {
@ -349,8 +46,13 @@ export function enforceMunicipalityConnectivityStrict(adminId, prefectureMask, s
queue.push(ni);
}
}
components.push({ cells: comp, protectedHits });
const list = componentsByAdmin.get(id) || [];
list.push({ cells, protectedHits });
componentsByAdmin.set(id, list);
}
let passChanged = 0;
for (const [id, components] of componentsByAdmin) {
if (components.length <= 1) continue;
components.sort((a, b) =>
(b.protectedHits ? 1_000_000 : 0) + b.cells.length -
@ -376,18 +78,16 @@ export function enforceMunicipalityConnectivityStrict(adminId, prefectureMask, s
if (score > best || (score === best && other < target)) { best = score; target = other; }
}
if (target < 0) {
// Very rare: a detached island component has no labeled neighbor.
// Keep the largest/protected primary and merge the component into it
// only if it is directly adjacent after previous changes; otherwise
// leave it for the next pass rather than inventing over-sea ownership.
// A detached component with no land neighbor may be a real island.
// Keep it with its municipality rather than inventing an over-sea merge.
target = id;
}
if (target >= 0 && target !== id) {
for (const ci of component.cells) adminId[ci] = target;
passChanged += component.cells.length;
} else if (component !== primary) {
// If no external target exists, still mark it as handled by keeping it;
// another pass may expose a target after surrounding cells change.
// Keep isolated island components; a later pass may expose a land
// neighbor after surrounding ownership changes.
}
}
}
@ -397,7 +97,7 @@ export function enforceMunicipalityConnectivityStrict(adminId, prefectureMask, s
return changed;
}
export function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) {
function terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse) {
const urbanPenalty = urbanBoundaryPenalty(i, populationDensity, landuse);
const majorRiver = clamp(Math.max(river[i] - 0.32, 0) * 1.9 + Math.max(flowAccum[i] - 0.38, 0) * 0.75);
const minorStream = clamp(river[i] * 0.34 + flowAccum[i] * 0.18);
@ -414,123 +114,6 @@ function urbanBoundaryPenalty(i, populationDensity, landuse) {
return clamp(core + populationDensity[i] * 1.35);
}
function isAdminBoundaryCell(labels, prefectureMask, sea, x, y, useEight = true) {
const i = indexOf(x, y);
const own = labels[i];
if (!prefectureMask[i] || sea[i] || own < 0) return false;
const neighbors = useEight ? neighbors8(x, y) : neighbors4(x, y);
for (const [nx, ny] of neighbors) {
const ni = indexOf(nx, ny);
if (prefectureMask[ni] && !sea[ni] && labels[ni] >= 0 && labels[ni] !== own) return true;
}
return false;
}
function buildBoundaryBand(labels, prefectureMask, sea, radius = 5) {
const band = new Uint8Array(SIZE);
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
if (!isAdminBoundaryCell(labels, prefectureMask, sea, x, y, true)) continue;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (Math.hypot(dx, dy) > radius) continue;
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (prefectureMask[ni] && !sea[ni]) band[ni] = 1;
}
}
}
}
return band;
}
function buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters = [], protectedPoints = [], populationDensity, landuse) {
const protectedMask = new Uint8Array(SIZE);
function protectDisk(p, radius) {
if (!p || !inside(p.x, p.y)) return;
const owner = adminId[indexOf(p.x, p.y)];
if (owner < 0) return;
const r = Math.ceil(radius);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
if (Math.hypot(dx, dy) > radius) continue;
const x = p.x + dx;
const y = p.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (prefectureMask[i] && !sea[i] && adminId[i] === owner) protectedMask[i] = 1;
}
}
}
for (const center of adminCenters) protectDisk(center, 2.2);
for (const p of protectedPoints || []) protectDisk(p, p.population ? clamp(1.6 + Math.sqrt(p.population) / 520, 2.1, 6.0) : p.portClass ? 2.0 : 1.7);
for (let i = 0; i < SIZE; i++) {
if (prefectureMask[i] && !sea[i] && (landuse[i] === 3 || populationDensity[i] > 0.72)) protectedMask[i] = 1;
}
return protectedMask;
}
function localBoundaryEnergy(labels, i, candidateId, targetScore, centerDist, populationDensity, landuse, river, valleyField) {
const [x, y] = xyOf(i);
const oldId = labels[i];
let energy = centerDist[candidateId]?.[i] ?? 0;
let same4 = 0;
let diff4 = 0;
let diagDiff = 0;
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
const neighborId = labels[ni];
if (neighborId < 0) continue;
const isCardinal = nx === x || ny === y;
const differs = neighborId !== candidateId;
if (isCardinal) {
if (differs) {
diff4++;
const boundaryTarget = (targetScore[i] + targetScore[ni]) * 0.5;
const urbanCut = (urbanBoundaryPenalty(i, populationDensity, landuse) + urbanBoundaryPenalty(ni, populationDensity, landuse)) * 0.5;
const minorValley = (valleyField[i] + valleyField[ni]) * 0.5 > 0.34 && Math.max(river[i], river[ni]) < 0.30 ? 0.72 : 0;
const dHere = centerDist[candidateId]?.[i] ?? 99;
const dThere = centerDist[neighborId]?.[i] ?? 99;
const weakBisectorPenalty = Math.abs(dHere - dThere) < 4.0 && boundaryTarget < 0.42 ? 0.62 : 0;
energy += 2.15 - boundaryTarget * 1.55 + urbanCut * 3.0 + minorValley + weakBisectorPenalty;
} else same4++;
} else if (differs) diagDiff++;
}
if (same4 === 0) energy += 5.2;
if (same4 === 1) energy += 1.7;
if (diff4 >= 3 && targetScore[i] < 0.42) energy += 1.25;
if (diagDiff >= 3 && diff4 >= 2 && targetScore[i] < 0.50) energy += 0.42;
if (candidateId !== oldId) {
const candidateDistance = centerDistanceAt(centerDist, candidateId, i);
const oldDistance = centerDistanceAt(centerDist, oldId, i);
if (Number.isFinite(candidateDistance) && Number.isFinite(oldDistance)) {
const drift = candidateDistance - oldDistance;
if (drift > 0) energy += Math.min(0.9, drift * 0.012);
}
}
return energy;
}
function centerDistanceAt(centerDist, id, i) {
const field = centerDist?.fields?.[id] || centerDist?.[id];
if (field) return field[i];
const center = centerDist?.centers?.[id];
if (!center || !inside(center.x, center.y)) return 24;
const [x, y] = xyOf(i);
return Math.hypot(x - center.x, y - center.y);
}
function buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea) {
// Older versions materialized one full SIZE Float32Array per municipality.
// In multi-prefecture generation this can create heavy transient memory use.
// Keep the same interface conceptually, but compute distances on demand.
return { ids: adminIds, centers: adminCenters, prefectureMask, sea };
}
function repairAdminTopology(adminId, prefectureMask, sea, adminCenters = [], targetScore = null, populationDensity = null, landuse = null) {
const ids = new Set();
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && adminId[i] >= 0) ids.add(adminId[i]);
@ -604,7 +187,7 @@ function classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyFie
return 11;
}
export function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) {
function buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore = null, plain = null, agriculture = null, populationDensity = null, landuse = null) {
const score = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i]) continue;
@ -653,20 +236,6 @@ function mountainCompartmentFitness(i, elevation, slope, ridgeField, populationD
return clamp(elevation[i] * 0.38 + slope[i] * 0.32 + ridgeField[i] * 0.42 - settled);
}
function canShareNaturalCompartment(a, b, classA, classB, barrier, river, flowAccum, valleyField, populationDensity, landuse) {
if (classA !== classB) {
const bothUrban = classA <= 3 && classB <= 3;
const bothLivingCorridor = [5, 6, 7, 10].includes(classA) && [5, 6, 7, 10].includes(classB);
if (!bothUrban && !bothLivingCorridor) return false;
}
const majorRiverEdge = Math.max(river[a], river[b]) > 0.56 || Math.max(flowAccum[a], flowAccum[b]) > 0.68;
const urbanEdge = ((landuse[a] >= 2 && landuse[a] <= 4) || landuse[a] === 7 || populationDensity[a] > 0.34) &&
((landuse[b] >= 2 && landuse[b] <= 4) || landuse[b] === 7 || populationDensity[b] > 0.34);
const valleyContinuity = (valleyField[a] + valleyField[b]) * 0.5 > 0.48 && !majorRiverEdge;
const threshold = urbanEdge ? 0.74 : valleyContinuity ? 0.56 : classA === 8 || classB === 8 ? 0.28 : 0.43;
return barrier < threshold && (!majorRiverEdge || urbanEdge);
}
function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse) {
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = unit.riverExposure || 0;
let coastalExposure = 0, basinIdentity = 0, valleyIdentity = 0, lowlandFitness = 0, mountainFitness = 0;
@ -705,89 +274,6 @@ function refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField
unit.mountainFitness = mountainFitness / Math.max(1, area);
}
function splitOneNaturalCompartment(unit, newId, compartmentId, fields, seed) {
if (!unit || unit.area < 24) return null;
const { elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse, naturalBarrierScore, river, flowAccum } = fields;
const minPart = Math.max(8, Math.min(28, Math.floor(unit.area * 0.20)));
let first = -1;
let second = -1;
let bestA = -INF;
let bestB = -INF;
const width = unit.width || (unit.maxX - unit.minX + 1) || 1;
const height = unit.height || (unit.maxY - unit.minY + 1) || 1;
const horizontal = width >= height;
const elongated = Math.max(width, height) / Math.max(1, Math.min(width, height)) > 1.65;
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
const settled = populationDensity[i] * 0.28 + ([2, 3, 4, 7, 8].includes(landuse[i]) ? 0.34 : 0);
const axis = elongated ? (horizontal ? (unit.maxX - x) / Math.max(1, width) : (unit.maxY - y) / Math.max(1, height)) : 0.0;
const score = axis * 1.7 + low * 0.42 + settled + hashSeededTie(x, y, seed) * 0.05 - ridgeField[i] * 0.10;
if (score > bestA) { bestA = score; first = i; }
}
if (first < 0) return null;
const [fx, fy] = xyOf(first);
for (const i of unit.cells) {
const [x, y] = xyOf(i);
const low = lowlandCompartmentFitness(i, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
const axis = elongated ? (horizontal ? (x - unit.minX) / Math.max(1, width) : (y - unit.minY) / Math.max(1, height)) : 0.0;
const d = Math.hypot(x - fx, y - fy);
const score = axis * 1.9 + d * (0.18 + low * 0.22) + hashSeededTie(x, y, seed + 17) * 0.08 - ridgeField[i] * 0.08;
if (score > bestB) { bestB = score; second = i; }
}
if (second < 0 || second === first) return null;
const cellSet = new Set(unit.cells);
const owner = new Int8Array(SIZE);
owner.fill(-1);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
for (const [source, sourceOwner] of [[first, 0], [second, 1]]) {
owner[source] = sourceOwner;
dist[source] = 0;
heap.push({ i: source, f: 0, owner: sourceOwner });
}
while (heap.length > 0) {
const cur = heap.pop();
if (!cur || cur.f > dist[cur.i] + 1e-5) continue;
const [x, y] = xyOf(cur.i);
for (const [nx, ny, step] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (!cellSet.has(ni)) continue;
const barrier = ((naturalBarrierScore?.[cur.i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5;
const riverBarrier = Math.max(river?.[cur.i] || 0, river?.[ni] || 0) + Math.max(flowAccum?.[cur.i] || 0, flowAccum?.[ni] || 0) * 0.32;
const ridgeStep = Math.max(ridgeField[cur.i], ridgeField[ni]) * 1.18 + Math.abs(elevation[cur.i] - elevation[ni]) * 1.48;
const corridorBonus = Math.min(0.42, ((valleyField[cur.i] + valleyField[ni]) * 0.5 + (plain?.[ni] || 0) * 0.15 + (coastalLowland?.[ni] || 0) * 0.10));
const stepCost = Math.max(0.18, 0.78 + barrier * 3.75 + riverBarrier * 1.10 + ridgeStep + slope[ni] * 0.48 - corridorBonus) * step;
const nd = cur.f + stepCost;
if (nd < dist[ni]) {
dist[ni] = nd;
owner[ni] = cur.owner;
heap.push({ i: ni, f: nd, owner: cur.owner });
}
}
}
const aCells = [];
const bCells = [];
for (const ci of unit.cells) {
if (owner[ci] === 1) bCells.push(ci);
else aCells.push(ci);
}
if (aCells.length < minPart || bCells.length < minPart) return null;
unit.cells = aCells;
const newUnit = { ...unit, id: newId, cells: bCells, centerIds: [], adjacent: new Map() };
for (const ci of bCells) compartmentId[ci] = newId;
refreshCompartmentStats(unit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
refreshCompartmentStats(newUnit, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, populationDensity, landuse);
return newUnit;
}
function hashSeededTie(x, y, seed) {
let h = Math.imul((x | 0) ^ (seed | 0), 1597334677) ^ Math.imul((y | 0) ^ ((seed >>> 1) | 0), 3812015801);
h = (h ^ (h >>> 15)) >>> 0;
@ -1308,50 +794,6 @@ export function buildNaturalCompartments(prefectureMask, sea, elevation, slope,
return buildSeededNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, crestCrossingScore, plain, agriculture, populationDensity, landuse, options);
}
function buildLandscapeUnits(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse) {
const unitId = new Int32Array(SIZE);
unitId.fill(-1);
const cellClass = new Int16Array(SIZE);
cellClass.fill(-1);
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) cellClass[i] = classifyLandscapeCell(i, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse);
const units = [];
const queue = [];
for (let i = 0; i < SIZE; i++) {
if (cellClass[i] < 0 || unitId[i] >= 0) continue;
const id = units.length;
const klass = cellClass[i];
const cells = [];
let sx = 0, sy = 0, pop = 0, urbanWeight = 0, ridgeExposure = 0, riverExposure = 0;
queue.length = 0;
queue.push(i);
unitId[i] = id;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
cells.push(cur);
sx += x; sy += y; pop += populationDensity[cur];
urbanWeight += urbanBoundaryPenalty(cur, populationDensity, landuse);
ridgeExposure += ridgeField[cur];
riverExposure += river[cur] + flowAccum[cur] * 0.45;
for (const [nx, ny] of neighbors4(x, y)) {
const ni = indexOf(nx, ny);
if (unitId[ni] >= 0 || cellClass[ni] !== klass) continue;
unitId[ni] = id;
queue.push(ni);
}
}
units.push({ id, classId: klass, cells, area: cells.length, x: sx / cells.length, y: sy / cells.length, population: pop, urbanWeight: urbanWeight / cells.length, ridgeExposure: ridgeExposure / cells.length, riverExposure: riverExposure / cells.length, adjacent: new Map(), centerIds: [], owner: -1 });
}
const targetScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) if (cellClass[i] >= 0) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse);
rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea);
mergeTinyLandscapeUnits(unitId, units, 10);
rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea);
return { unitId, units, targetScore };
}
function rebuildLandscapeUnitAdjacency(unitId, units, targetScore, prefectureMask, sea) {
for (const unit of units) unit.adjacent = new Map();
for (let y = 0; y < MAP_H; y++) {
@ -1607,103 +1049,7 @@ function compartmentMunicipalityMetrics(compartments, owner, targetMunicipalityC
};
}
function averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore) {
let sum = 0;
let count = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue;
sum += (naturalBarrierScore[i] + naturalBarrierScore[ni]) * 0.5;
count++;
}
}
}
return count ? sum / count : 0;
}
function averageFinalBorderField(adminId, prefectureMask, sea, field) {
if (!field) return 0;
let sum = 0;
let count = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue;
sum += ((field[i] || 0) + (field[ni] || 0)) * 0.5;
count++;
}
}
}
return count ? sum / count : 0;
}
function assignCompartmentsToAdminOwners(compartments, compartmentId, adminCenters, naturalBarrierScore, prefectureMask, sea) {
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
const compIndex = compartmentId[indexOf(center.x, center.y)];
if (compIndex >= 0 && compartments[compIndex]?.area > 0) {
const unit = compartments[compIndex];
unit.centerIds.push(id);
owner[compIndex] = id;
}
}
for (let pass = 0; pass < compartments.length + 8; pass++) {
let changed = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestOwner = -1;
let bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent) {
const neighborOwner = owner[neighborId];
if (neighborOwner < 0) continue;
const neighbor = compartments[neighborId];
if (!neighbor || neighbor.area === 0) continue;
const center = adminCenters[neighborOwner];
const d = center ? Math.hypot(unit.x - center.x, unit.y - center.y) : 0;
const score = naturalOwnershipAffinity(unit, neighbor, edge) - d * 0.006 + Math.min(0.9, Math.sqrt(Math.max(1, neighbor.area)) * 0.020);
if (score > bestScore) { bestScore = score; bestOwner = neighborOwner; }
}
const accept = unit.classId <= 3 ? bestScore > -0.35 : unit.classId === 8 || unit.classId === 9 ? bestScore > -1.05 : bestScore > -0.70;
if (bestOwner >= 0 && accept) {
owner[unit.id] = bestOwner;
changed++;
}
}
if (changed === 0) break;
}
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] >= 0) continue;
let bestId = -1;
let bestScore = -INF;
for (let id = 0; id < adminCenters.length; id++) {
const center = adminCenters[id];
if (!center || !inside(center.x, center.y)) continue;
const centerComp = compartments[compartmentId[indexOf(center.x, center.y)]];
const sameGroup = centerComp && naturalGroupKey(centerComp) === naturalGroupKey(unit) ? 2.3 : 0;
const sameClass = centerComp && centerComp.classId === unit.classId ? 0.8 : 0;
const urbanFit = unit.urbanWeight > 0.55 && centerComp?.urbanWeight > 0.55 ? 1.3 : 0;
const d = Math.hypot(unit.x - center.x, unit.y - center.y);
const score = sameGroup + sameClass + urbanFit - d * 0.020 - unit.ridgeExposure * 0.16;
if (score > bestScore) { bestScore = score; bestId = id; }
}
owner[unit.id] = bestId >= 0 ? bestId : 0;
}
return owner;
}
export function extractCompartmentBorders(compartmentId, prefectureMask, sea) {
function extractCompartmentBorders(compartmentId, prefectureMask, sea) {
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
@ -1765,11 +1111,11 @@ export function assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, e
.map((unit) => ({ id: unit.id, area: unit.area || 0, width: unit.width || 0, height: unit.height || 0, elongation: unit.elongation || 1, classId: unit.classId, x: Math.round(unit.x || 0), y: Math.round(unit.y || 0) }))
.sort((a, b) => (b.elongation * Math.sqrt(Math.max(1, b.area))) - (a.elongation * Math.sqrt(Math.max(1, a.area))))
.slice(0, 8),
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
finalBorderNaturalBarrierAverage: averageFieldAcrossOwnerBorders((i) => adminId[i], prefectureMask, sea, naturalBarrierScore),
unifiedGeographyAppliedToAdminCompartments: unifiedGeographyApplied,
finalBorderUnifiedBoundaryPreferenceAverage: averageFinalBorderField(adminId, prefectureMask, sea, options.adminBoundaryPreference || options.geography?.adminBoundaryPreference),
finalBorderUnifiedBoundaryAvoidanceAverage: averageFinalBorderField(adminId, prefectureMask, sea, options.boundaryAvoidance || options.geography?.boundaryAvoidance),
finalBorderUnifiedCentralityAverage: averageFinalBorderField(adminId, prefectureMask, sea, options.centrality || options.geography?.centrality),
finalBorderUnifiedBoundaryPreferenceAverage: averageFieldAcrossOwnerBorders((i) => adminId[i], prefectureMask, sea, options.adminBoundaryPreference || options.geography?.adminBoundaryPreference),
finalBorderUnifiedBoundaryAvoidanceAverage: averageFieldAcrossOwnerBorders((i) => adminId[i], prefectureMask, sea, options.boundaryAvoidance || options.geography?.boundaryAvoidance),
finalBorderUnifiedCentralityAverage: averageFieldAcrossOwnerBorders((i) => adminId[i], prefectureMask, sea, options.centrality || options.geography?.centrality),
voronoiLikeRateBefore: 0,
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
},
@ -1799,7 +1145,7 @@ function weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, natural
return total ? weak / total : 0;
}
export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = []) {
const before = new Int16Array(adminId);
const initialNaturalBarrierScore = buildNaturalBarrierScore(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
const beforeVoronoiLikeRate = weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, initialNaturalBarrierScore);
@ -1884,140 +1230,9 @@ export function applyLandscapeUnitAdminPartition(adminId, prefectureMask, sea, e
compartmentCount: activeCompartments.length,
averageCompartmentArea: activeCompartments.length ? activeCompartments.reduce((sum, unit) => sum + unit.area, 0) / activeCompartments.length : 0,
changedAfterNaturalCompartmentPartition: changedCells,
finalBorderNaturalBarrierAverage: averageFinalBorderBarrier(adminId, prefectureMask, sea, naturalBarrierScore),
finalBorderNaturalBarrierAverage: averageFieldAcrossOwnerBorders((i) => adminId[i], prefectureMask, sea, naturalBarrierScore),
voronoiLikeRateBefore: beforeVoronoiLikeRate,
voronoiLikeRateAfter: weakVoronoiLikeRate(adminId, adminCenters, prefectureMask, sea, naturalBarrierScore),
};
}
export function snapAdminBoundariesToTerrain(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse, adminCenters = [], protectedPoints = [], passes = 6) {
const targetScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i]) targetScore[i] = terrainBoundaryTargetScore(i, elevation, slope, river, ridgeField, valleyField, flowAccum, populationDensity, landuse);
const protectedMask = buildAdminProtectedMask(adminId, prefectureMask, sea, adminCenters, protectedPoints, populationDensity, landuse);
const band = buildBoundaryBand(adminId, prefectureMask, sea, 5);
const adminIds = [...new Set([...adminId].filter((id) => id >= 0))];
const centerDist = buildCenterDistanceFields(adminIds, adminCenters, prefectureMask, sea);
let current = new Int16Array(adminId);
for (let pass = 0; pass < passes; pass++) {
const next = new Int16Array(current);
let changed = 0;
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
const own = current[i];
if (!band[i] || protectedMask[i] || !prefectureMask[i] || sea[i] || own < 0) continue;
if (!isAdminBoundaryCell(current, prefectureMask, sea, x, y, true)) continue;
const candidates = new Set();
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
if (prefectureMask[ni] && !sea[ni] && current[ni] >= 0 && current[ni] !== own) candidates.add(current[ni]);
}
if (candidates.size === 0) continue;
const currentEnergy = localBoundaryEnergy(current, i, own, targetScore, centerDist, populationDensity, landuse, river, valleyField);
let bestId = own, bestEnergy = currentEnergy;
for (const candidate of candidates) {
const candidateEnergy = localBoundaryEnergy(current, i, candidate, targetScore, centerDist, populationDensity, landuse, river, valleyField);
const threshold = 0.18 + (targetScore[i] < 0.36 ? 0.16 : 0) + urbanBoundaryPenalty(i, populationDensity, landuse) * 0.25;
if (bestEnergy - candidateEnergy > threshold) { bestEnergy = candidateEnergy; bestId = candidate; }
}
if (bestId !== own) { next[i] = bestId; changed++; }
}
}
current = next;
if (changed === 0) break;
}
adminId.set(current);
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, targetScore, populationDensity, landuse);
}
export function splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) {
const before = new Int16Array(adminId);
const area = new Map();
const lowland = new Map();
const mountain = new Map();
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const id = adminId[i];
area.set(id, (area.get(id) || 0) + 1);
const living = (plain[i] || 0) * 0.42 + (agriculture[i] || 0) * 0.28 + basinField[i] * 0.20 + coastalLowland[i] * 0.20 + valleyField[i] * 0.12;
const rough = ridgeField[i] * 0.54 + slope[i] * 0.36 + Math.max(0, elevation[i] - 0.58) * 0.38;
lowland.set(id, (lowland.get(id) || 0) + living);
mountain.set(id, (mountain.get(id) || 0) + rough);
}
const areas = [...area.values()].sort((a, b) => a - b);
const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
if (!median) return { changedCells: 0, splitMunicipalities: 0 };
const { compartmentId, compartments, naturalBarrierScore } = buildNaturalCompartments(prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, null, plain, agriculture, populationDensity, landuse);
const unitOwner = new Int16Array(compartments.length);
unitOwner.fill(-1);
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const counts = new Map();
for (const i of unit.cells) {
const id = adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
let bestId = -1, best = -1;
for (const [id, count] of counts) if (count > best) { best = count; bestId = id; }
unitOwner[unit.id] = bestId;
}
const adminCenterIndex = new Map();
for (let id = 0; id < adminCenters.length; id++) {
const c = adminCenters[id];
if (c && inside(c.x, c.y)) adminCenterIndex.set(id, indexOf(c.x, c.y));
}
let splitMunicipalities = 0;
let rejectedMunicipalities = 0;
for (const [id, cells] of area) {
const averageLowland = (lowland.get(id) || 0) / cells;
const averageMountain = (mountain.get(id) || 0) / cells;
if (cells < median * 1.85 || averageLowland < 0.24 || averageMountain > 0.48) {
if (cells >= median * 1.85) rejectedMunicipalities++;
continue;
}
const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id);
const meaningfulNodes = localSettlements.filter((p) => p.kind === "Satellite City" || p.kind === "New Town" || p.kind === "Market Town" || (p.population || 0) >= 30000);
if (meaningfulNodes.length < 2) {
rejectedMunicipalities++;
continue;
}
let changedHere = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || unitOwner[unit.id] !== id) continue;
const centerIndex = adminCenterIndex.get(id);
if (centerIndex >= 0 && unit.cells.includes(centerIndex)) continue;
if (unit.classId === 8 || unit.classId === 9) continue;
let bestNeighbor = -1;
let bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent) {
const neighborOwner = unitOwner[neighborId];
if (neighborOwner < 0 || neighborOwner === id) continue;
const boundaryTarget = edge.target / Math.max(1, edge.count);
const neighbor = compartments[neighborId];
const nodePull = meaningfulNodes.reduce((best, p) => Math.max(best, 1 / (1 + Math.hypot(p.x - unit.x, p.y - unit.y) / 6)), 0);
const score = edge.count * 0.7 + boundaryTarget * 1.4 + nodePull * 1.2 - Math.max(0, (neighbor?.ridgeExposure || 0) - unit.ridgeExposure) * 0.35;
if (score > bestScore) { bestScore = score; bestNeighbor = neighborOwner; }
}
if (bestNeighbor < 0 || bestScore < 2.2) continue;
for (const ci of unit.cells) {
if (adminId[ci] === id) {
adminId[ci] = bestNeighbor;
changedHere++;
}
}
}
if (changedHere > Math.max(28, cells * 0.035)) splitMunicipalities++;
}
repairAdminTopology(adminId, prefectureMask, sea, adminCenters, naturalBarrierScore, populationDensity, landuse);
let changedCells = 0;
for (let i = 0; i < SIZE; i++) if (prefectureMask[i] && !sea[i] && before[i] !== adminId[i]) changedCells++;
return { changedCells, splitMunicipalities, rejectedMunicipalities };
}
export function splitOversizedLowlandMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters = [], settlements = []) {
return splitOversizedRuralMunicipalities(adminId, prefectureMask, sea, elevation, slope, river, ridgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCenters, settlements);
}

2755
src/app.js Normal file

File diff suppressed because it is too large Load diff

27
src/fieldSchema.js Normal file
View file

@ -0,0 +1,27 @@
const NEGATIVE_ONE_FIELD_NAMES = new Set([
"adminId",
"prefectureRegionId",
"regionId",
"municipalityId",
"naturalCompartmentId",
"watershedId",
]);
export function isTypedCellField(value, expectedSize) {
return ArrayBuffer.isView(value)
&& typeof value.length === "number"
&& Number.isFinite(expectedSize)
&& value.length === expectedSize;
}
export function defaultCellFieldValue(name, Constructor) {
if (name === "sea" || name === "ocean") return 1;
if (name === "elevation") return 0.08;
if (NEGATIVE_ONE_FIELD_NAMES.has(name)) return -1;
if (Constructor === Float32Array || Constructor === Float64Array) return 0;
return 0;
}
export function worldFieldConstructor(name, sourceConstructor) {
return NEGATIVE_ONE_FIELD_NAMES.has(name) ? Int32Array : sourceConstructor;
}

25
src/generationWorker.js Normal file
View file

@ -0,0 +1,25 @@
import { generateMap } from "./mapPipeline.js";
import { collectTransferableBuffers } from "./transferUtils.js";
self.onmessage = (message) => {
const { id, seed, options } = message.data || {};
if (!Number.isFinite(id)) return;
try {
const map = generateMap(seed, {
...(options || {}),
onProgress: (event) => {
self.postMessage({ type: "progress", id, event });
},
});
const transfer = Array.from(collectTransferableBuffers(map));
self.postMessage({ type: "result", id, ok: true, map }, transfer);
} catch (error) {
self.postMessage({
type: "result",
id,
ok: false,
error: error?.message || String(error),
stack: error?.stack || "",
});
}
};

View file

@ -11,7 +11,7 @@ export const LANDUSE = Object.freeze({
FOREST: 9,
});
export const LANDUSE_LABELS = Object.freeze({
const LANDUSE_LABELS = Object.freeze({
[LANDUSE.RURAL]: "Rural / natural land",
[LANDUSE.FARMLAND]: "Farmland",
[LANDUSE.OLD_URBAN]: "Old urban area",
@ -28,10 +28,3 @@ export function landuseLabel(value) {
return LANDUSE_LABELS[value] || "Land";
}
export function isBuiltLanduse(value) {
return value >= LANDUSE.OLD_URBAN && value <= LANDUSE.ROADSIDE;
}
export function isUrbanResidentialLanduse(value) {
return value === LANDUSE.OLD_URBAN || value === LANDUSE.CBD || value === LANDUSE.SUBURB || value === LANDUSE.NEW_TOWN || value === LANDUSE.ROADSIDE;
}

View file

@ -69,92 +69,7 @@ export function enforceCompartmentMunicipalityOwnership(adminId, compartmentId,
}
export function mergeSingleCompartmentMunicipalities(adminId, compartments, prefectureMask, sea, minCompartments = 2, maxPasses = 8) {
if (!compartments?.length) return { changedCells: 0, mergedMunicipalities: 0, remainingSingleCompartmentMunicipalities: 0 };
let totalChangedCells = 0;
let mergedMunicipalities = 0;
let remainingSingles = 0;
for (let pass = 0; pass < maxPasses; pass++) {
const owner = dominantCompartmentOwners(compartments, adminId);
const byOwner = new Map();
const areaByOwner = new Map();
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
if (!byOwner.has(id)) byOwner.set(id, []);
byOwner.get(id).push(unit);
areaByOwner.set(id, (areaByOwner.get(id) || 0) + unit.area);
}
const singles = [...byOwner.entries()]
.filter(([, units]) => units.length > 0 && units.length < minCompartments)
.sort((a, b) => (areaByOwner.get(a[0]) || 0) - (areaByOwner.get(b[0]) || 0) || a[0] - b[0]);
remainingSingles = singles.length;
if (!singles.length) break;
let passChanged = 0;
for (const [id, units] of singles) {
// The old rule allowed one natural compartment to become one municipality.
// That produces many tiny office-only municipalities and makes the hierarchy
// hard to read. Merge such municipalities into the strongest adjacent owner.
const neighborScores = new Map();
for (const unit of units) {
for (const [neighborId, edge] of unit.adjacent || []) {
const candidate = owner[neighborId];
if (candidate < 0 || candidate === id) continue;
const neighborUnit = compartments[neighborId];
const shared = edge.count || 1;
const barrier = edge.target ? edge.target / Math.max(1, shared) : 0;
const sameLandscape = neighborUnit?.classId === unit.classId ? 0.7 : 0;
const score = shared * (2.2 - Math.min(1.6, barrier) + sameLandscape) + Math.sqrt(areaByOwner.get(candidate) || 1) * 0.05;
neighborScores.set(candidate, (neighborScores.get(candidate) || 0) + score);
}
}
let best = -1, bestScore = -INF;
for (const [candidate, score] of neighborScores) {
if (score > bestScore || (score === bestScore && candidate < best)) { best = candidate; bestScore = score; }
}
if (best < 0) {
// One-cell islets have no land adjacency. Attach them to the nearest
// existing municipality instead of leaving a one-compartment municipality.
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
let bestDist = INF;
for (const [candidate, candidateUnits] of byOwner) {
if (candidate === id || candidateUnits.length < minCompartments) continue;
for (const unit of candidateUnits) {
const d = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
if (d < bestDist || (d === bestDist && candidate < best)) { bestDist = d; best = candidate; }
}
}
if (bestDist > 28) best = -1;
}
if (best < 0) continue;
for (const unit of units) {
for (const i of unit.cells || []) {
if (!prefectureMask[i] || sea[i]) continue;
if (adminId[i] !== best) {
adminId[i] = best;
passChanged++;
}
}
}
mergedMunicipalities++;
}
totalChangedCells += passChanged;
if (!passChanged) break;
}
const finalOwner = dominantCompartmentOwners(compartments, adminId);
const finalCounts = new Map();
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const id = finalOwner[unit.id];
if (id >= 0) finalCounts.set(id, (finalCounts.get(id) || 0) + 1);
}
remainingSingles = [...finalCounts.values()].filter((count) => count > 0 && count < minCompartments).length;
return { changedCells: totalChangedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities: remainingSingles };
}
export function ownerAreaByCompartment(owner, compartments) {
function ownerAreaByCompartment(owner, compartments) {
const area = new Map();
const count = new Map();
for (const unit of compartments || []) {
@ -167,7 +82,7 @@ export function ownerAreaByCompartment(owner, compartments) {
return { area, count };
}
export function compartmentTouchesOutside(unit, prefectureMask, sea) {
function compartmentTouchesOutside(unit, prefectureMask, sea) {
if (!unit?.cells?.length) return true;
for (const i of unit.cells) {
const [x, y] = xyOf(i);
@ -182,7 +97,7 @@ export function compartmentTouchesOutside(unit, prefectureMask, sea) {
return false;
}
export function bestNeighborOwnerForUnits(units, owner, compartments, allowNearestFallback = false) {
function bestNeighborOwnerForUnits(units, owner, compartments, allowNearestFallback = false) {
const { area } = ownerAreaByCompartment(owner, compartments);
const scores = new Map();
const blocked = new Set(units.map((unit) => owner[unit.id]));
@ -217,7 +132,7 @@ export function bestNeighborOwnerForUnits(units, owner, compartments, allowNeare
return bestDist <= 32 ? best : -1;
}
export function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, minCompartments = 2, maxPasses = 8) {
function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments, minCompartments = 2, maxPasses = 8) {
let changedCells = 0;
let mergedMunicipalities = 0;
let remainingSingleCompartmentMunicipalities = 0;
@ -254,7 +169,7 @@ export function mergeSmallCompartmentMunicipalitiesByOwner(owner, compartments,
return { changedCells, mergedMunicipalities, remainingSingleCompartmentMunicipalities };
}
export function repairCompartmentOwnerConnectivity(owner, compartments, maxPasses = 8) {
function repairCompartmentOwnerConnectivity(owner, compartments, maxPasses = 8) {
let changedCells = 0;
let changedComponents = 0;
for (let pass = 0; pass < maxPasses; pass++) {
@ -300,7 +215,7 @@ export function repairCompartmentOwnerConnectivity(owner, compartments, maxPasse
return { changedCells, changedComponents };
}
export function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, maxPasses = 6) {
function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, maxPasses = 6) {
let changedCells = 0;
let changedComponents = 0;
const outsideCache = new Map();
@ -354,7 +269,7 @@ export function repairCompartmentOwnerEnclaves(owner, compartments, prefectureMa
return { changedCells, changedComponents };
}
export function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, maxCells = 1100) {
function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, maxCells = 1100) {
const isUrbanUnit = (unit) => unit && unit.area > 0 && (
unit.classId <= 3 ||
(unit.urbanWeight || 0) >= 0.34 ||
@ -406,7 +321,7 @@ export function lockCompactUrbanCompartmentsToDominantOwner(owner, compartments,
}
export function lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields = {}) {
function lockCityMetroCompartmentsToSingleMunicipality(owner, compartments, fields = {}) {
const { modernCities = [] } = fields;
if (!owner || !compartments?.length || !modernCities?.length) return { changedCells: 0, unifiedCities: 0 };
let changedCells = 0;
@ -466,6 +381,145 @@ export function lockCityMetroCompartmentsToSingleMunicipality(owner, compartment
return { changedCells, unifiedCities };
}
export function reduceMunicipalityCountByCompartment(adminId, compartments, prefectureMask, sea, maxMunicipalities, modernCities = []) {
if (!compartments?.length || !Number.isFinite(maxMunicipalities) || maxMunicipalities < 1) return { changedCells: 0, mergedMunicipalities: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
const protectedOwners = new Set();
for (const city of modernCities || []) {
if (!city || !inside(city.x, city.y) || (city.population || 0) < 95000) continue;
const id = adminId[indexOf(city.x, city.y)];
if (id >= 0) protectedOwners.add(id);
}
let changedCells = 0;
let mergedMunicipalities = 0;
for (let pass = 0; pass < 80; pass++) {
const { area, count } = ownerAreaByCompartment(owner, compartments);
const active = [...area.keys()].filter((id) => (area.get(id) || 0) > 0);
if (active.length <= maxMunicipalities) break;
const candidates = active
.filter((id) => !protectedOwners.has(id))
.map((id) => ({ id, area: area.get(id) || 0, units: count.get(id) || 0 }))
.sort((a, b) => a.area - b.area || a.units - b.units || a.id - b.id);
let merged = false;
for (const candidate of candidates) {
const units = compartments.filter((unit) => unit && unit.area > 0 && owner[unit.id] === candidate.id);
let target = bestNeighborOwnerForUnits(units, owner, compartments, true);
if (target < 0 || target === candidate.id) {
const ux = units.reduce((sum, unit) => sum + (unit.x || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
const uy = units.reduce((sum, unit) => sum + (unit.y || 0) * (unit.area || 1), 0) / Math.max(1, units.reduce((sum, unit) => sum + (unit.area || 1), 0));
let bestDistance = INF;
for (const unit of compartments) {
const other = owner[unit?.id];
if (!unit || unit.area <= 0 || other < 0 || other === candidate.id) continue;
const distance = Math.hypot((unit.x || 0) - ux, (unit.y || 0) - uy);
if (distance < bestDistance || (distance === bestDistance && other < target)) {
bestDistance = distance;
target = other;
}
}
}
if (target < 0 || target === candidate.id) continue;
for (const unit of units) {
owner[unit.id] = target;
changedCells += unit.area || 0;
}
mergedMunicipalities++;
merged = true;
break;
}
if (!merged) break;
}
repairCompartmentOwnerConnectivity(owner, compartments, 6);
repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 4);
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, mergedMunicipalities, finalMunicipalityCount: new Set([...owner].filter((id) => id >= 0)).size };
}
export function expandMajorCityMunicipalitiesByCompartment(adminId, compartmentId, compartments, prefectureMask, sea, modernCities = []) {
if (!compartmentId || !compartments?.length || !modernCities?.length) return { changedCells: 0, expandedCities: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
const majorCityUnit = new Map();
for (const city of modernCities) {
if (!city || !inside(city.x, city.y) || (city.population || 0) < 95000) continue;
const i = indexOf(city.x, city.y);
if (!prefectureMask[i] || sea[i]) continue;
const unitId = compartmentId[i];
if (unitId >= 0) majorCityUnit.set(unitId, city);
}
const connectedWithout = (ownerId, removedUnitId) => {
const members = compartments.filter((unit) => unit && unit.area > 0 && owner[unit.id] === ownerId && unit.id !== removedUnitId);
if (members.length <= 1) return members.length === 1;
const memberIds = new Set(members.map((unit) => unit.id));
const seen = new Set([members[0].id]);
const queue = [members[0].id];
for (let q = 0; q < queue.length; q++) {
for (const next of compartments[queue[q]]?.adjacent?.keys?.() || []) {
if (!memberIds.has(next) || seen.has(next)) continue;
seen.add(next);
queue.push(next);
}
}
return seen.size === members.length;
};
let changedCells = 0;
let expandedCities = 0;
const cities = modernCities
.filter((city) => city && inside(city.x, city.y) && (city.population || 0) >= 95000)
.sort((a, b) => (b.population || 0) - (a.population || 0));
for (const city of cities) {
const cell = indexOf(city.x, city.y);
if (!prefectureMask[cell] || sea[cell]) continue;
const homeUnitId = compartmentId[cell];
if (homeUnitId < 0) continue;
const cityOwner = owner[homeUnitId];
if (cityOwner < 0) continue;
const population = city.population || 0;
const requestedArea = Math.min(population >= 450000 ? 780 : 520, Math.max(130, 95 + Math.sqrt(population) * 0.72 + (city.urbanFootprintCells || 0) * 0.42));
const targetArea = requestedArea * 0.86;
let rows = ownerAreaByCompartment(owner, compartments);
let currentArea = rows.area.get(cityOwner) || 0;
let localChanged = 0;
for (let pass = 0; pass < 24 && currentArea + 1e-6 < targetArea; pass++) {
const candidates = new Map();
for (const unit of compartments) {
if (!unit || unit.area <= 0 || owner[unit.id] !== cityOwner) continue;
for (const [nextId, edge] of unit.adjacent || []) {
const next = compartments[nextId];
const donor = owner[nextId];
if (!next || donor < 0 || donor === cityOwner) continue;
const donorUnits = rows.count.get(donor) || 0;
if (donorUnits <= 2 || majorCityUnit.has(nextId)) continue;
if (!connectedWithout(donor, nextId)) continue;
const distance = Math.hypot((next.x || 0) - city.x, (next.y || 0) - city.y);
const shared = edge.count || 1;
const barrier = edge.target ? edge.target / Math.max(1, shared) : (edge.barrier || 0);
const score = shared * 2.8 + (next.lowlandFitness || 0) * 3.0 + (next.urbanWeight || 0) * 2.2 - barrier * 2.5 - distance * 0.13 - Math.max(0, (next.area || 0) - 240) * 0.006;
const previous = candidates.get(nextId);
if (!previous || score > previous.score) candidates.set(nextId, { unit: next, donor, score });
}
}
const best = [...candidates.values()].sort((a, b) => b.score - a.score || a.unit.id - b.unit.id)[0];
if (!best) break;
owner[best.unit.id] = cityOwner;
localChanged += best.unit.area || 0;
changedCells += best.unit.area || 0;
rows = ownerAreaByCompartment(owner, compartments);
currentArea = rows.area.get(cityOwner) || 0;
}
if (localChanged > 0) expandedCities++;
}
const connectivity = repairCompartmentOwnerConnectivity(owner, compartments, 6);
const enclaves = repairCompartmentOwnerEnclaves(owner, compartments, prefectureMask, sea, 4);
applyCompartmentOwners(adminId, compartments, owner);
return {
changedCells: changedCells + connectivity.changedCells + enclaves.changedCells,
expandedCities,
connectivityChangedCells: connectivity.changedCells,
enclaveChangedCells: enclaves.changedCells,
};
}
export function enforceSimpleAdministrativeHierarchy(adminId, compartments, prefectureMask, sea, fields = {}) {
const owner = dominantCompartmentOwners(compartments, adminId);
const urban = lockCompactUrbanCompartmentsToDominantOwner(owner, compartments, fields.maxUrbanClusterCells || 1100);
@ -496,60 +550,6 @@ export function enforceSimpleAdministrativeHierarchy(adminId, compartments, pref
}
export function repairPrefectureCellEnclavesFromGrid(adminId, owner, prefectureMask, sea, maxPasses = 5) {
let changed = 0;
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
for (let pass = 0; pass < maxPasses; pass++) {
const prefId = new Int16Array(SIZE);
prefId.fill(-1);
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
prefId[i] = owner.get(adminId[i]) ?? -1;
}
const seen = new Uint8Array(SIZE);
let passChanged = 0;
for (let i = 0; i < SIZE; i++) {
if (seen[i] || prefId[i] < 0) continue;
const id = prefId[i];
const queue = [i];
const comp = [];
seen[i] = 1;
let touchesOutside = false;
const boundaryCounts = new Map();
const adminCounts = new Map();
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
const aid = adminId[cur];
if (aid >= 0) adminCounts.set(aid, (adminCounts.get(aid) || 0) + 1);
const [x, y] = xyOf(cur);
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) touchesOutside = true;
for (const [dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) { touchesOutside = true; continue; }
const ni = indexOf(nx, ny);
if (!prefectureMask[ni] || sea[ni]) { touchesOutside = true; continue; }
const nid = prefId[ni];
if (nid === id) {
if (!seen[ni]) { seen[ni] = 1; queue.push(ni); }
} else if (nid >= 0) {
boundaryCounts.set(nid, (boundaryCounts.get(nid) || 0) + 1);
}
}
}
if (touchesOutside || boundaryCounts.size !== 1) continue;
const [targetPref] = boundaryCounts.keys();
if (targetPref < 0 || targetPref === id) continue;
for (const aid of adminCounts.keys()) {
if (owner.get(aid) !== targetPref) { owner.set(aid, targetPref); passChanged++; }
}
}
changed += passChanged;
if (!passChanged) break;
}
return changed;
}
export function repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, maxPasses = 5) {
let changed = 0;
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
@ -594,49 +594,6 @@ export function repairAdminSingleOwnerEnclaves(adminId, prefectureMask, sea, max
return changed;
}
export function lockCompactUrbanAreasToDominantAdmin(adminId, prefectureMask, sea, landuse, populationDensity, maxCells = 950) {
if (!landuse || !populationDensity) return 0;
const seen = new Uint8Array(SIZE);
const dirs = [[1,0],[-1,0],[0,1],[0,-1]];
let changed = 0;
const isUrban = (i) => !sea[i] && prefectureMask[i] && (populationDensity[i] > 0.34 || [2, 3, 4, 7, 8].includes(landuse[i]));
for (let i = 0; i < SIZE; i++) {
if (seen[i] || !isUrban(i) || adminId[i] < 0) continue;
const queue = [i];
const comp = [];
seen[i] = 1;
const counts = new Map();
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
comp.push(cur);
const id = adminId[cur];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1 + (populationDensity[cur] || 0) * 2.0);
const [x, y] = xyOf(cur);
for (const [dx, dy] of dirs) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (seen[ni] || !isUrban(ni)) continue;
seen[ni] = 1;
queue.push(ni);
}
}
if (comp.length < 8 || comp.length > maxCells || counts.size <= 1 || counts.size > 7) continue;
let best = -1, bestScore = -INF;
let total = 0;
for (const [id, score] of counts) {
total += score;
if (score > bestScore || (score === bestScore && id < best)) { best = id; bestScore = score; }
}
if (best < 0 || bestScore / Math.max(1, total) < 0.38) continue;
for (const ci of comp) {
if (adminId[ci] !== best) { adminId[ci] = best; changed++; }
}
}
return changed;
}
export function splitOversizedCompartmentMunicipalities(adminId, centers, compartments, prefectureMask, sea, fields = {}, seed = 0) {
if (!compartments?.length) return { changedCells: 0, splitMunicipalities: 0, addedCenters: 0 };
const compOwner = new Int16Array(compartments.length);

69
src/mapAdminShared.js Normal file
View file

@ -0,0 +1,69 @@
import { MAP_H, MAP_W, SIZE } from "./mapUtils.js";
export function municipalityAreaById(adminId, prefectureMask, sea) {
const area = new Map();
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
area.set(adminId[i], (area.get(adminId[i]) || 0) + 1);
}
return area;
}
export function maskLandArea(mask, sea) {
let area = 0;
for (let i = 0; i < SIZE; i++) if (mask[i] && !sea[i]) area++;
return area;
}
export function dominantCompartmentOwners(compartments, adminId) {
const owner = new Int16Array(compartments.length);
owner.fill(-1);
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const counts = new Map();
for (const i of unit.cells) {
const id = adminId[i];
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
}
let bestId = -1, best = -1;
for (const [id, count] of counts) if (count > best) { best = count; bestId = id; }
owner[unit.id] = bestId;
}
return owner;
}
export function applyCompartmentOwners(adminId, compartments, owner) {
for (const unit of compartments) {
if (!unit || unit.area === 0) continue;
const id = owner[unit.id];
if (id < 0) continue;
for (const i of unit.cells) adminId[i] = id;
}
}
export function averageFieldAcrossOwnerBorders(ownerAt, mask, sea, field) {
if (typeof ownerAt !== "function" || !field) return 0;
const width = MAP_W;
const height = MAP_H;
let sum = 0;
let count = 0;
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const i = y * width + x;
if (!mask[i] || sea[i]) continue;
const a = ownerAt(i);
if (!Number.isFinite(a) || a < 0) continue;
for (const ni of [i + 1, i + width]) {
if (!mask[ni] || sea[ni]) continue;
const b = ownerAt(ni);
if (!Number.isFinite(b) || b < 0 || a === b) continue;
sum += ((field[i] || 0) + (field[ni] || 0)) * 0.5;
count++;
}
}
}
return count ? sum / count : 0;
}

321
src/mapAdminStage.js Normal file
View file

@ -0,0 +1,321 @@
import {
assignAdminRegionsFromNaturalCompartments,
enforceMunicipalityConnectivityStrict
} from "./adminRegionsCore.js";
import { INF, MAP_H, MAP_W, clamp, indexOf, inside, rand } from "./mapUtils.js";
import { extractAdminBorderSegments } from "./mapGeneratorHelpers.js";
import {
municipalityAreaById,
maskLandArea
} from "./mapAdminShared.js";
import {
compactWholeCompartmentMunicipalities,
enforceCompartmentMunicipalityOwnership,
enforceSimpleAdministrativeHierarchy,
expandMajorCityMunicipalitiesByCompartment,
reduceMunicipalityCountByCompartment,
repairAdminSingleOwnerEnclaves,
splitOversizedCompartmentMunicipalities
} from "./mapAdminCompartmentRepair.js";
import { generatePrefecturesFromMunicipalities } from "./mapPrefectureStage.js";
import {
computeTargetMunicipalityCount,
buildLowlandAdminSeeds,
} from "./mapAdminTargets.js";
import {
classifySatelliteMunicipalities
} from "./mapAdminUrbanCatchments.js";
function capCompactedMunicipalityCount(compacted, prefectureMask, sea, targetCount, modernCities, fields) {
if (!compacted?.adminId || !Number.isFinite(targetCount)) return { compacted, changedCells: 0, mergedMunicipalities: 0 };
const adminId = compacted.adminId;
let changedCells = 0;
let mergedMunicipalities = 0;
for (let pass = 0; pass < 24; pass++) {
const area = municipalityAreaById(adminId, prefectureMask, sea);
if (area.size <= targetCount) break;
const protectedIds = new Set();
for (const city of modernCities || []) {
if (!city || !inside(city.x, city.y) || (city.population || 0) < 95000) continue;
const id = adminId[indexOf(city.x, city.y)];
if (id >= 0) protectedIds.add(id);
}
const candidates = [...area.entries()]
.filter(([id]) => !protectedIds.has(id))
.sort((a, b) => a[1] - b[1] || a[0] - b[0]);
if (!candidates.length) break;
let merged = false;
for (const [source] of candidates) {
const neighbors = new Map();
let sx = 0, sy = 0, n = 0;
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i] || adminId[i] !== source) continue;
sx += x; sy += y; n++;
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
const target = adminId[ni];
if (!prefectureMask[ni] || sea[ni] || target < 0 || target === source) continue;
neighbors.set(target, (neighbors.get(target) || 0) + 1);
}
}
let target = [...neighbors.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]?.[0] ?? -1;
if (target < 0 && n > 0) {
const cx = sx / n, cy = sy / n;
let bestDistance = INF;
for (const [centerIndex, center] of (compacted.adminCentersRaw || []).entries()) {
const id = center?.adminId ?? center?.municipalityId ?? center?.adminNumericId ?? centerIndex;
if (!Number.isFinite(id) || id < 0 || id === source) continue;
const d = Math.hypot((center.x || 0) - cx, (center.y || 0) - cy);
if (d < bestDistance) { bestDistance = d; target = id; }
}
}
if (target < 0 || target === source) continue;
for (let i = 0; i < adminId.length; i++) {
if (adminId[i] === source) { adminId[i] = target; changedCells++; }
}
mergedMunicipalities++;
merged = true;
break;
}
if (!merged) break;
}
if (!mergedMunicipalities) return { compacted, changedCells, mergedMunicipalities };
return {
compacted: compactWholeCompartmentMunicipalities(adminId, compacted.adminCentersRaw, prefectureMask, sea, fields),
changedCells,
mergedMunicipalities,
};
}
function generateAdminLayoutForMask({
seed,
prefectureMask,
sea,
elevation,
slope,
river,
ridgeField,
naturalBarrierScore,
valleyField,
basinField,
coastalLowland,
flowAccum,
plain,
agriculture,
settlementScore,
populationDensity,
stationInfluence,
roadInfluence,
railInfluence2,
villageInfluence,
landuse,
modernCities,
satelliteCities,
newTowns,
markets,
villages,
ports,
stations,
industrialZones,
logisticsParks,
naturalCompartmentId,
naturalCompartments,
geography = null,
habitability = null,
accessibility = null,
centrality = null,
geographicBarrier = null,
geographicBarrierCost = null,
adminBoundaryPreference = null,
boundaryAvoidance = null,
adminRegionMeta = {},
adminProgress = null,
}) {
const unifiedBoundaryPreference = adminBoundaryPreference || geography?.adminBoundaryPreference || null;
const unifiedBoundaryAvoidance = boundaryAvoidance || geography?.boundaryAvoidance || null;
const unifiedGeographicBarrier = geographicBarrier || geography?.geographicBarrier || null;
const boundaryRidgeField = naturalBarrierScore
? Float32Array.from(ridgeField, (value, i) => clamp(value * 0.72 + naturalBarrierScore[i] * 0.46))
: ridgeField;
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "classify satellites" });
classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, boundaryRidgeField, river, flowAccum);
const targetMunicipalityCount = computeTargetMunicipalityCount({ prefectureMask, sea, elevation, slope, ridgeField: boundaryRidgeField, coastalLowland, basinField, habitability: habitability || geography?.habitability, centrality: centrality || geography?.centrality, accessibility: accessibility || geography?.accessibility, geographicBarrier: unifiedGeographicBarrier, modernCities, markets, ports, satelliteCities, villages, adminRegionMeta });
const compartmentMultiplier = clamp(4.6 + rand(seed, 1320) * 2.4, 4.6, 7.0);
const regionLandArea = adminRegionMeta.landArea || maskLandArea(prefectureMask, sea);
const minCompartmentTarget = clamp(Math.round(Math.max(targetMunicipalityCount * 3.4, regionLandArea / 78)), 22, 150);
const maxCompartmentTarget = clamp(Math.round(Math.max(targetMunicipalityCount * 6.0, regionLandArea / 36)), minCompartmentTarget, 320);
let targetCompartmentCount = clamp(Math.round(targetMunicipalityCount * compartmentMultiplier), minCompartmentTarget, maxCompartmentTarget);
let adminCentersRaw = buildLowlandAdminSeeds({
seed,
targetMunicipalityCount,
prefectureMask,
sea,
elevation,
slope,
ridgeField: boundaryRidgeField,
plain,
basinField,
coastalLowland,
settlementScore,
populationDensity,
roadInfluence,
railInfluence2,
stationInfluence,
landuse,
habitability: habitability || geography?.habitability,
accessibility: accessibility || geography?.accessibility,
centrality: centrality || geography?.centrality,
boundaryAvoidance: unifiedBoundaryAvoidance,
adminBoundaryPreference: unifiedBoundaryPreference,
geographicBarrier: unifiedGeographicBarrier,
modernCities,
satelliteCities,
markets,
ports,
newTowns,
stations,
});
if (adminCentersRaw.length < targetMunicipalityCount) targetCompartmentCount = Math.max(targetCompartmentCount, minCompartmentTarget);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "natural compartments", targetMunicipalityCount, targetCompartmentCount, seedCount: adminCentersRaw.length });
const compartmentAssignment = assignAdminRegionsFromNaturalCompartments(prefectureMask, sea, elevation, slope, river, boundaryRidgeField, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture, populationDensity, landuse, adminCentersRaw, {
seed,
targetMunicipalityCount,
targetCompartmentCount,
maxNaturalCompartmentArea: Math.max(32, Math.round(maskLandArea(prefectureMask, sea) / Math.max(1, targetCompartmentCount) * 1.65)),
naturalCompartmentId,
naturalCompartments,
naturalBarrierScore,
geography,
habitability: habitability || geography?.habitability,
accessibility: accessibility || geography?.accessibility,
centrality: centrality || geography?.centrality,
boundaryAvoidance: unifiedBoundaryAvoidance,
adminBoundaryPreference: unifiedBoundaryPreference,
geographicBarrier: unifiedGeographicBarrier,
progress: (step) => adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step }),
});
const adminId = compartmentAssignment.adminId;
if (naturalCompartmentId && naturalCompartments) {
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "split oversized municipalities" });
const oversizedCompartmentSplit = splitOversizedCompartmentMunicipalities(adminId, adminCentersRaw, compartmentAssignment.compartments, prefectureMask, sea, {
elevation, slope, ridgeField: boundaryRidgeField, plain, agriculture, basinField, coastalLowland, populationDensity,
}, seed + 21900);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "initial compartment ownership" });
const changedAfterInitialCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "simple administrative hierarchy" });
const hierarchyRepair = enforceSimpleAdministrativeHierarchy(adminId, compartmentAssignment.compartments, prefectureMask, sea, {
minCompartmentsPerMunicipality: 2,
maxUrbanClusterCells: 1600,
modernCities,
});
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "reduce municipality count" });
const municipalityCountReduction = reduceMunicipalityCountByCompartment(
adminId, compartmentAssignment.compartments, prefectureMask, sea, targetMunicipalityCount, modernCities
);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "expand major city municipalities" });
const cityCompartmentExpansion = expandMajorCityMunicipalitiesByCompartment(
adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea, modernCities
);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "final compartment ownership" });
const changedAfterFinalCompartmentOwnership = enforceCompartmentMunicipalityOwnership(adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "compact municipalities" });
let compacted = compactWholeCompartmentMunicipalities(adminId, adminCentersRaw, prefectureMask, sea, { populationDensity, plain, slope });
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "final municipality count cap" });
const finalCountCap = capCompactedMunicipalityCount(compacted, prefectureMask, sea, targetMunicipalityCount, modernCities, { populationDensity, plain, slope });
compacted = finalCountCap.compacted;
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "strict municipality connectivity" });
const strictConnectivityChangedCells = enforceMunicipalityConnectivityStrict(compacted.adminId, prefectureMask, sea, compacted.adminCentersRaw, [...modernCities, ...(compacted.adminCentersRaw || [])], 8);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "strict municipality enclave repair" });
const strictEnclaveRepairChangedCells = repairAdminSingleOwnerEnclaves(compacted.adminId, prefectureMask, sea, 4);
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "extract admin borders" });
const adminBorders = extractAdminBorderSegments(compacted.adminId, prefectureMask);
const actualMunicipalityCount = new Set([...compacted.adminId].filter((id, i) => id >= 0 && prefectureMask[i] && !sea[i])).size;
const naturalCompartmentCount = compartmentAssignment.debug?.naturalCompartmentCount || naturalCompartments.filter((unit) => unit && unit.area > 0).length;
const finalAreas = municipalityAreaById(compacted.adminId, prefectureMask, sea);
const independentSatelliteRows = (satelliteCities || []).filter((sat) => {
if (!sat || sat.municipalityClass !== "independentSatelliteMunicipality" || !inside(sat.x, sat.y)) return false;
const i = indexOf(sat.x, sat.y);
return prefectureMask[i] && !sea[i] && compacted.adminId[i] >= 0;
}).map((sat) => finalAreas.get(compacted.adminId[indexOf(sat.x, sat.y)]) || 0);
const adminDebug = {
...compartmentAssignment.debug,
simpleHierarchyPrototype: true,
administrativeHierarchySpec: "unified-geography->natural-compartments->living-sphere-municipalities->prefectures",
unifiedGeographyAdministrativeBasis: true,
naturalCompartmentsImmutable: true,
municipalitiesAreCompartmentGroups: true,
prefecturesAreMunicipalityGroups: true,
cellLevelAdminSmoothingDisabled: true,
sharedNaturalCompartmentLayer: true,
skippedLegacyCellCleanupForHierarchy: true,
targetMunicipalityCount,
actualMunicipalityCount,
finalMunicipalityCount: actualMunicipalityCount,
changedAfterLandscapePartition: changedAfterInitialCompartmentOwnership + changedAfterFinalCompartmentOwnership,
changedAfterUrbanLock: hierarchyRepair.changedAfterUrbanUnification + cityCompartmentExpansion.changedCells,
changedAfterFinalExclaveRemoval: strictEnclaveRepairChangedCells + (cityCompartmentExpansion.enclaveChangedCells || 0),
changedAfterFinalMerge: hierarchyRepair.changedAfterSingleCompartmentMunicipalityMerge,
changedAfterMunicipalityCountReduction: municipalityCountReduction.changedCells,
municipalitiesMergedForTargetCount: municipalityCountReduction.mergedMunicipalities,
municipalityCountAfterReduction: municipalityCountReduction.finalMunicipalityCount,
changedAfterFinalMunicipalityCountCap: finalCountCap.changedCells,
municipalitiesMergedByFinalCountCap: finalCountCap.mergedMunicipalities,
changedAfterCityCompartmentExpansion: cityCompartmentExpansion.changedCells,
cityMunicipalitiesExpanded: cityCompartmentExpansion.expandedCities,
satelliteMunicipalitiesCreated: independentSatelliteRows.length,
averageSatelliteMunicipalityArea: independentSatelliteRows.length ? independentSatelliteRows.reduce((sum, value) => sum + value, 0) / independentSatelliteRows.length : 0,
changedAfterStrictMunicipalityConnectivity: strictConnectivityChangedCells,
changedAfterStrictMunicipalityEnclaveRepair: strictEnclaveRepairChangedCells,
candidateSeedCount: adminCentersRaw.length,
municipalOfficePointCount: compacted.adminCentersRaw.length,
survivedSeedCount: compacted.activeMunicipalityCount,
absorbedSeedCount: Math.max(0, adminCentersRaw.length - compacted.activeMunicipalityCount),
targetNaturalCompartmentCount: targetCompartmentCount,
naturalCompartmentCount,
compartmentCount: naturalCompartmentCount,
changedAfterCompartmentAssignment: naturalCompartmentCount,
changedAfterInitialCompartmentOwnership,
changedAfterFinalCompartmentOwnership,
changedAfterUrbanUnification: hierarchyRepair.changedAfterUrbanUnification,
urbanComponentsUnified: hierarchyRepair.urbanComponentsUnified,
changedAfterCityMetroMunicipalityUnification: hierarchyRepair.changedAfterCityMetroMunicipalityUnification,
cityMetroMunicipalitiesUnified: hierarchyRepair.cityMetroMunicipalitiesUnified,
changedAfterCompartmentConnectivity: hierarchyRepair.changedAfterCompartmentConnectivity,
disconnectedCompartmentComponentsMerged: hierarchyRepair.disconnectedCompartmentComponentsMerged,
changedAfterAdminEnclaveRepair: hierarchyRepair.changedAfterCompartmentEnclaveRepair,
compartmentEnclaveComponentsMerged: hierarchyRepair.compartmentEnclaveComponentsMerged,
changedAfterSingleCompartmentMunicipalityMerge: hierarchyRepair.changedAfterSingleCompartmentMunicipalityMerge,
singleCompartmentMunicipalitiesMerged: hierarchyRepair.singleCompartmentMunicipalitiesMerged,
remainingSingleCompartmentMunicipalities: hierarchyRepair.remainingSingleCompartmentMunicipalities,
changedAfterPostMergeCompartmentOwnership: changedAfterFinalCompartmentOwnership,
changedAfterOversizedCompartmentSplit: oversizedCompartmentSplit.changedCells,
oversizedCompartmentMunicipalitiesSplit: oversizedCompartmentSplit.splitMunicipalities,
oversizedCompartmentSplitAddedCenters: oversizedCompartmentSplit.addedCenters,
oversizedCompartmentSplitMaxArea: oversizedCompartmentSplit.maxArea || 0,
finalTinyMunicipalityCount: [...municipalityAreaById(compacted.adminId, prefectureMask, sea).values()].filter((area) => area > 0 && area < 8).length,
compartmentBorders: compartmentAssignment.debug?.compartmentBorders || [],
borderNaturalBarrierAverage: compartmentAssignment.debug?.finalBorderNaturalBarrierAverage || 0,
voronoiLikeRate: compartmentAssignment.debug?.voronoiLikeRateAfter || 0,
};
return {
adminCentersRaw: compacted.adminCentersRaw,
adminId: compacted.adminId,
adminBorders,
adminDebug,
naturalCompartmentId: compartmentAssignment.compartmentId,
naturalCompartments: compartmentAssignment.compartments,
};
}
throw new Error("generateAdminLayout requires terrain natural compartments");
}
export function generateAdminLayout(context) {
const layout = generateAdminLayoutForMask(context);
return { ...layout, ...generatePrefecturesFromMunicipalities(context, layout) };
}

View file

@ -1,6 +1,6 @@
import { MAP_H, MAP_W, clamp, hash2, indexOf, inside, pickEntities, rand } from "./mapUtils.js";
export function municipalityCountBoundsForRegion(landCells, meta = {}) {
function municipalityCountBoundsForRegion(landCells, meta = {}) {
// Use the same administrative density curve for the highlighted prefecture
// and neighboring prefectures. Only clipped slivers get a low floor.
let min = 1;
@ -10,7 +10,7 @@ export function municipalityCountBoundsForRegion(landCells, meta = {}) {
if (landCells >= 2400) min = 11;
if (landCells >= 3800) min = 16;
if (landCells >= 5600) min = 22;
const max = clamp(Math.round(landCells / 160 + 6), Math.max(min, 4), 72);
const max = clamp(Math.round(landCells / 190 + 7), Math.max(min, 4), 50);
return { min, max };
}
@ -60,7 +60,7 @@ export function computeTargetMunicipalityCount({ prefectureMask, sea, elevation,
return clamp(rawTarget, min, max);
}
export function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse, habitability, accessibility, centrality, boundaryAvoidance, adminBoundaryPreference, geographicBarrier }) {
function lowlandAdminSeedScore(i, { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse, habitability, accessibility, centrality, boundaryAvoidance, adminBoundaryPreference, geographicBarrier }) {
const lowRelief = clamp((0.70 - elevation[i]) * 1.35) + clamp((0.34 - slope[i]) * 1.55) + clamp((0.48 - ridgeField[i]) * 1.10);
const landuseFit = [1, 2, 3, 4, 7, 8].includes(landuse[i]) ? 0.40 : landuse[i] === 5 || landuse[i] === 6 ? 0.12 : 0;
const unifiedNudge = clamp(

View file

@ -0,0 +1,94 @@
import { SIZE, clamp, indexOf, inside, xyOf } from "./mapUtils.js";
function estimateUrbanComponentArea(city, prefectureMask, sea, landuse, populationDensity) {
if (!city || !inside(city.x, city.y)) return 0;
const start = indexOf(city.x, city.y);
if (!prefectureMask[start] || sea[start]) return 0;
const radius = Math.ceil(Math.max(7, (city.urbanRadius || 6) * 1.7));
const seen = new Uint8Array(SIZE);
const queue = [start];
seen[start] = 1;
let area = 0;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
const d = Math.hypot(x - city.x, y - city.y);
if (d > radius) continue;
const urban = (landuse[cur] >= 2 && landuse[cur] <= 4) || landuse[cur] === 7 || landuse[cur] === 8 || populationDensity[cur] > 0.18;
if (!urban) continue;
area++;
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (seen[ni] || !prefectureMask[ni] || sea[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
return area;
}
function terrainSeparationBetween(a, b, ridgeField, river, flowAccum, populationDensity, landuse) {
if (!a || !b) return { separatedByBarrier: false, ruralGap: false, averageDensity: 0, maxBarrier: 0 };
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
let maxBarrier = 0;
let lowUrbanRun = 0;
let bestLowUrbanRun = 0;
let densitySum = 0;
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
const barrier = Math.max(ridgeField[i] * 0.95, river[i] * 0.85, flowAccum[i] * 0.42);
maxBarrier = Math.max(maxBarrier, barrier);
densitySum += populationDensity[i];
const urban = (landuse[i] >= 2 && landuse[i] <= 4) || landuse[i] === 7 || populationDensity[i] > 0.20;
if (urban) lowUrbanRun = 0;
else {
lowUrbanRun++;
bestLowUrbanRun = Math.max(bestLowUrbanRun, lowUrbanRun);
}
}
return {
separatedByBarrier: maxBarrier > 0.56,
ruralGap: bestLowUrbanRun >= 4,
averageDensity: densitySum / (steps + 1),
maxBarrier,
};
}
export function classifySatelliteMunicipalities(satelliteCities, modernCities, prefectureMask, sea, landuse, populationDensity, roadInfluence, railInfluence2, ridgeField, river, flowAccum) {
let independent = 0;
let attached = 0;
for (const sat of satelliteCities || []) {
if (!sat || !inside(sat.x, sat.y) || !prefectureMask[indexOf(sat.x, sat.y)] || sea[indexOf(sat.x, sat.y)]) continue;
const parent = modernCities[sat.parentCityIndex] || modernCities.slice().sort((a, b) => Math.hypot(a.x - sat.x, a.y - sat.y) - Math.hypot(b.x - sat.x, b.y - sat.y))[0];
const parentDistance = parent ? Math.hypot(parent.x - sat.x, parent.y - sat.y) : 99;
const separation = terrainSeparationBetween(sat, parent, ridgeField, river, flowAccum, populationDensity, landuse);
const urbanArea = estimateUrbanComponentArea(sat, prefectureMask, sea, landuse, populationDensity);
const i = indexOf(sat.x, sat.y);
const continuousUrban = parent && parentDistance < Math.max(10, (parent.urbanRadius || 12) + (sat.urbanRadius || 5) + 5) && separation.averageDensity > 0.14 && !separation.ruralGap && !separation.separatedByBarrier;
const newTownLike = landuse[i] === 7 || (railInfluence2[i] > 0.22 && roadInfluence[i] > 0.12 && (sat.population || 0) < 70000);
let municipalityClass = "independentSatelliteMunicipality";
if (continuousUrban && (sat.population || 0) < 90000) municipalityClass = "suburbanDistrictMergedWithParent";
else if (newTownLike && (sat.population || 0) < 85000 && !separation.separatedByBarrier) municipalityClass = "newTownDistrict";
else if ((sat.population || 0) < 42000 && urbanArea < 55 && !separation.separatedByBarrier) municipalityClass = "smallTownAttachedToRuralMunicipality";
else if ((sat.population || 0) >= 60000 && urbanArea >= 42 && (separation.separatedByBarrier || separation.ruralGap || parentDistance > 15)) municipalityClass = "independentSatelliteMunicipality";
sat.municipalityClass = municipalityClass;
sat.parentX = parent?.x;
sat.parentY = parent?.y;
sat.parentAdminHint = -1;
sat.distinctUrbanComponentArea = urbanArea;
sat.separatedByBarrier = separation.separatedByBarrier || separation.ruralGap;
sat.satelliteMinArea = clamp(90 + Math.sqrt(sat.population || 24000) * 0.62 + (sat.urbanRadius || 5) * 12, 80, 360);
if (municipalityClass === "independentSatelliteMunicipality") independent++;
else attached++;
}
return { independent, attached };
}

View file

@ -1,6 +1,11 @@
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, hash2, indexOf, inside, pickEntities, valueNoise } from "./mapUtils.js";
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, finiteFieldValue, hash2, indexOf, inside, pickEntities, valueNoise } from "./mapUtils.js";
import { localConfluenceScore } from "./mapGeography.js";
export function buildFeatureContext(seed, terrain) {
const worldOriginX = Math.floor(Number(terrain?.generationContext?.originX || terrain?.originX || 0));
const worldOriginY = Math.floor(Number(terrain?.generationContext?.originY || terrain?.originY || 0));
const worldX = (x) => worldOriginX + x;
const worldY = (y) => worldOriginY + y;
const {
elevation,
slope,
@ -34,10 +39,6 @@ export function buildFeatureContext(seed, terrain) {
const geoBarrier = geography.geographicBarrier || naturalBarrierScore || null;
const geoCorridorSuitability = geography.corridorSuitability || null;
function fieldValue(field, i, fallback = 0) {
const v = field?.[i];
return Number.isFinite(v) ? v : fallback;
}
function regionIdAt(x, y) {
if (!inside(x, y)) return -1;
@ -53,20 +54,6 @@ export function buildFeatureContext(seed, terrain) {
return Boolean(p && inside(p.x, p.y) && prefectureMask[indexOf(p.x, p.y)] && !sea[indexOf(p.x, p.y)]);
}
function localConfluenceScore(x, y) {
let arms = 0;
let strong = 0;
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const rv = river[indexOf(nx, ny)];
if (rv > 0.18) arms++;
if (rv > 0.34) strong++;
}
return clamp((arms >= 3 ? 0.22 : arms === 2 ? 0.09 : 0) + strong * 0.04);
}
// --- 1. Human context: one full raster pass -----------------------------
const developable = new Float32Array(SIZE);
const ruralSuitability = new Float32Array(SIZE);
@ -91,16 +78,16 @@ export function buildFeatureContext(seed, terrain) {
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90;
const highPenalty = Math.max(0, elevation[i] - 0.56);
const lowSlope = clamp(1 - slope[i] * 2.3);
const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y) : 0;
const confluence = x > 0 && y > 0 && x < MAP_W - 1 && y < MAP_H - 1 ? localConfluenceScore(x, y, river) : 0;
const openPlainPotential = clamp(plain[i] * 0.68 + agriculture[i] * 0.54 + basinField[i] * 0.30 + lowSlope * 0.22 - river[i] * 0.18 - valleyField[i] * 0.08 - ridgeField[i] * 0.22 - slope[i] * 0.26);
const spine = (arcSpineField?.[i] || 0) * 0.58 + (branchRidgeField?.[i] || 0) * 0.38;
confluenceField[i] = confluence;
const geoH = fieldValue(geoHabitability, i, 0);
const geoLow = fieldValue(geoLowlandCapacity, i, 0);
const geoValley = fieldValue(geoValleyAccess, i, 0);
const geoCoast = fieldValue(geoCoastalAccess, i, 0);
const geoB = fieldValue(geoBarrier, i, naturalBarrier);
const geoH = finiteFieldValue(geoHabitability, i, 0);
const geoLow = finiteFieldValue(geoLowlandCapacity, i, 0);
const geoValley = finiteFieldValue(geoValleyAccess, i, 0);
const geoCoast = finiteFieldValue(geoCoastalAccess, i, 0);
const geoB = finiteFieldValue(geoBarrier, i, naturalBarrier);
const localDevelopable = clamp(
plain[i] * 0.34 +
agriculture[i] * 0.24 +
@ -139,7 +126,7 @@ export function buildFeatureContext(seed, terrain) {
ridgeField[i] * 0.24 -
spine * 0.12
) * 0.76 + geoCoast * 0.32 + geoH * 0.06 - geoB * 0.04);
const clusterNoise = 0.72 + fbm(x * 0.34 + 13, y * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(x, y, seed + 7002, 8) * 0.16;
const clusterNoise = 0.72 + fbm(worldX(x) * 0.34 + 13, worldY(y) * 0.34 - 31, seed + 7001) * 0.46 + valueNoise(worldX(x), worldY(y), seed + 7002, 8) * 0.16;
settlementCluster[i] = clamp((developable[i] * 0.42 + valleySettlement[i] * 0.12 + coastalSettlement[i] * 0.22 + agriculture[i] * 0.48 + plain[i] * 0.32 + openPlainPotential * 0.46) * clusterNoise);
ruralSuitability[i] = clamp(
agriculture[i] * 0.54 +
@ -168,12 +155,12 @@ export function buildFeatureContext(seed, terrain) {
ruralSuitability[i] * 0.48 +
townSuitability[i] * 0.30 +
confluence * 0.08 +
fieldValue(geoHabitability, i, developable[i]) * 0.18 +
fieldValue(geoNaturalCentrality, i, 0) * 0.12 -
fieldValue(geoBarrier, i, 0) * 0.06
finiteFieldValue(geoHabitability, i, developable[i]) * 0.18 +
finiteFieldValue(geoNaturalCentrality, i, 0) * 0.12 -
finiteFieldValue(geoBarrier, i, 0) * 0.06
);
barrierCost[i] = 1 + slope[i] * 6.4 + ridgeField[i] * 3.2 + spine * 2.2 + highPenalty * 5.8 + river[i] * 0.25 + naturalBarrier * 1.2 - valleyField[i] * 0.55 - plain[i] * 0.30 - coastalLowland[i] * 0.14;
corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(x, y, seed + 7011) * 0.05);
corridorCost[i] = Math.max(0.25, barrierCost[i] - developable[i] * 0.42 - valleySettlement[i] * 0.36 - coastalSettlement[i] * 0.12 + hash2(worldX(x), worldY(y), seed + 7011) * 0.05);
}
}
@ -213,18 +200,18 @@ export function buildFeatureContext(seed, terrain) {
if (regionId < 0) continue;
const st = ensureRegion(regionId);
st.area++;
const gHabit = fieldValue(geoHabitability, i, developable[i]);
const gAccess = fieldValue(geoAccessibility, i, 0);
const gCentral = fieldValue(geoNaturalCentrality, i, townSuitability[i]);
const gLow = fieldValue(geoLowlandCapacity, i, plain[i]);
const gHabit = finiteFieldValue(geoHabitability, i, developable[i]);
const gAccess = finiteFieldValue(geoAccessibility, i, 0);
const gCentral = finiteFieldValue(geoNaturalCentrality, i, townSuitability[i]);
const gLow = finiteFieldValue(geoLowlandCapacity, i, plain[i]);
st.developableSum += developable[i];
st.habitabilitySum += gHabit;
st.accessibilitySum += gAccess;
st.centralitySum += gCentral;
st.lowlandCapacitySum += gLow;
if (developable[i] > 0.16 || gHabit > 0.24) st.developableCells++;
if (valleySettlement[i] > 0.24 || fieldValue(geoValleyAccess, i, 0) > 0.25) st.valleyCells++;
if (coastalSettlement[i] > 0.25 || fieldValue(geoCoastalAccess, i, 0) > 0.24) st.coastCells++;
if (valleySettlement[i] > 0.24 || finiteFieldValue(geoValleyAccess, i, 0) > 0.25) st.valleyCells++;
if (coastalSettlement[i] > 0.25 || finiteFieldValue(geoCoastalAccess, i, 0) > 0.24) st.coastCells++;
if (townSuitability[i] > 0.28 || gCentral > 0.31) st.townCells++;
if (plain[i] > 0.24 || gLow > 0.26) st.plainCells++;
if (gCentral > 0.36 && gHabit > 0.18) st.highCentralityCells++;
@ -260,7 +247,7 @@ export function buildFeatureContext(seed, terrain) {
if (sea[i] || !predicate(x, y, i)) continue;
const regionId = regionIdAt(x, y);
if (regionId < 0) continue;
const score = scoreArray[i] + extraScore(x, y, i) + hash2(x, y, seed + seedOffset) * 0.055;
const score = scoreArray[i] + extraScore(x, y, i) + hash2(worldX(x), worldY(y), seed + seedOffset) * 0.055;
if (score < threshold) continue;
if (!byRegion.has(regionId)) byRegion.set(regionId, []);
byRegion.get(regionId).push({ x, y, score, kind, regionId });
@ -277,6 +264,8 @@ export function buildFeatureContext(seed, terrain) {
threshold,
seed: seed + seedOffset + regionId * 1009,
jitter: 0.04,
originX: worldOriginX,
originY: worldOriginY,
}));
}
return out.sort((a, b) => b.score - a.score).slice(0, totalMax);
@ -288,11 +277,11 @@ export function buildFeatureContext(seed, terrain) {
for (let x = 2; x < MAP_W - 2; x += stride) {
const i = indexOf(x, y);
if (sea[i] || !predicate(x, y, i)) continue;
const score = scoreArray[i] + hash2(x, y, seed + seedOffset) * 0.07;
const score = scoreArray[i] + hash2(worldX(x), worldY(y), seed + seedOffset) * 0.07;
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
}
}
return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset });
return pickEntities(candidates, { max, minDistance, threshold, seed: seed + seedOffset, originX: worldOriginX, originY: worldOriginY });
}
@ -306,7 +295,7 @@ export function buildFeatureContext(seed, terrain) {
geoCoastalAccess,
geoBarrier,
geoCorridorSuitability,
fieldValue,
fieldValue: finiteFieldValue,
regionIdAt,
inFocusedPrefecture,
developable,

View file

@ -9,6 +9,7 @@ export function buildFeatureLanduse(ctx) {
modernCities, logisticsParks,
roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence,
cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence,
originX = 0, originY = 0,
} = ctx;
const populationDensity = new Float32Array(SIZE);
@ -102,7 +103,7 @@ export function buildFeatureLanduse(ctx) {
const suburbanity = urban * 0.88 + transport * 0.23 + stationInfluence[i] * 0.12 + townInfluence[i] * 0.08 + riverUrban * 0.08;
const edgeTaper = clamp(cityInfluence[i] * 0.52 + stationInfluence[i] * 0.16 + roadLanduseInfluence[i] * 0.10 + 0.28);
const sprawlBias = clamp(0.58 + hash2(x, y, baseNoiseSeed) * 0.42);
const sprawlBias = clamp(0.58 + hash2(x + originX, y + originY, baseNoiseSeed) * 0.42);
const sprawlScore = suburbanity * sprawlBias * edgeTaper - core * 0.12;
if (sprawlScore > 0.24 && urbanCapacity[i] > 0.10) {
landuse[i] = LANDUSE.SUBURB;
@ -140,7 +141,7 @@ export function buildFeatureLanduse(ctx) {
}
if ((baseLanduse[i] === LANDUSE.FARMLAND || baseLanduse[i] === LANDUSE.RURAL) && urbanCapacity[i] > 0.10) {
const fringeChance = urbanNeighbors * 0.055 + cityInfluence[i] * 0.13 + transport * 0.12 + stationInfluence[i] * 0.08;
const noise = 0.23 + hash2(x, y, seed + 15050) * 0.24;
const noise = 0.23 + hash2(x + originX, y + originY, seed + 15050) * 0.24;
if (fringeChance > 0.34 + noise) {
landuse[i] = LANDUSE.SUBURB;
}

View file

@ -8,6 +8,7 @@ export function buildFeatureTransportCostFields(ctx) {
settlementDemand, urbanEdge, logisticsPreSuitability,
preliminaryTownInfluence, preliminaryVillageInfluence,
valleySettlement, coastalSettlement, developable,
originX = 0, originY = 0,
} = ctx;
const expressway = new Float32Array(SIZE);
const rail = new Float32Array(SIZE);
@ -139,7 +140,7 @@ export function buildFeatureTransportCostFields(ctx) {
seaNear * 2.35 +
seaWide * 1.10 +
openPlainParallelPenalty * 0.12 +
hash2(x, y, seed + 13301) * 0.04
hash2(x + originX, y + originY, seed + 13301) * 0.04
);
rail[i] = Math.max(0.16,
1.48 - railPotential[i] * 1.02 +
@ -151,7 +152,7 @@ export function buildFeatureTransportCostFields(ctx) {
coastalTraversePenalty * 1.75 +
seaNear * 1.50 +
seaWide * 0.70 +
hash2(x, y, seed + 13302) * 0.03
hash2(x + originX, y + originY, seed + 13302) * 0.03
);
national[i] = Math.max(0.16,
1.28 - nationalPotential[i] * 0.84 +
@ -167,7 +168,7 @@ export function buildFeatureTransportCostFields(ctx) {
seaNear * 0.84 +
seaWide * 0.48 -
pass * 0.42 +
hash2(x, y, seed + 13303) * 0.05
hash2(x + originX, y + originY, seed + 13303) * 0.05
);
local[i] = Math.max(0.14,
1.12 - localPotential[i] * 0.86 +
@ -182,7 +183,7 @@ export function buildFeatureTransportCostFields(ctx) {
coastalTraversePenalty * 0.82 +
seaNear * 0.48 +
seaWide * 0.26 +
hash2(x, y, seed + 13304) * 0.07
hash2(x + originX, y + originY, seed + 13304) * 0.07
);
}
}

View file

@ -1,12 +1,13 @@
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf } from "./mapUtils.js";
import { createPointSpatialIndex, distanceToNearest, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
import { buildDensityFlowRoadTransportSystem, createPathInfluenceCache, packDebugField, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.js";
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, distanceToNearestPoint, hash2, indexOf, inside, pickEntities, rand, valueNoise, xyOf, nowMs } from "./mapUtils.js";
import { createPointSpatialIndex, influenceFromPaths, influenceFromPoints, samplePath } from "./mapGeneratorHelpers.js";
import { buildDensityFlowRoadTransportSystem, createPathInfluenceCache, pathAverageField, pathLengthCells, routeQualityAcceptable } from "./mapTransport.js";
import { buildUnifiedRailODNetwork } from "./mapTransportOD.js";
import { buildFeatureContext } from "./mapFeatureContext.js";
import { buildFeatureLanduse } from "./mapFeatureLanduse.js";
import { buildSettlementDemandFields } from "./mapFeatureSettlements.js";
import { buildFeatureTransportCostFields } from "./mapFeatureTransportTools.js";
import { buildCoarseCostGraph, refineCoarsePath, routeCoarsePath } from "./mapTransportGraph.js";
import { labelOccupancyComponents, normalizeTransportPathSet, smoothRasterPath } from "./mapTransportUtils.js";
// Lightweight Human Geography V2
// --------------------------------
@ -17,15 +18,23 @@ import { buildCoarseCostGraph, refineCoarsePath, routeCoarsePath } from "./mapTr
// 3. make sparse approximate transport paths without full-resolution A*
// 4. synthesize population and land-use fields in one raster pass
export function generateMapFeatures(seed, terrain) {
export function generateMapFeatures(seed, terrain, options = {}) {
const SPEED_TOLERANCE = 0.90;
const worldOriginX = Math.floor(Number(options?.generationContext?.originX ?? terrain?.generationContext?.originX ?? terrain?.originX ?? 0));
const worldOriginY = Math.floor(Number(options?.generationContext?.originY ?? terrain?.generationContext?.originY ?? terrain?.originY ?? 0));
const worldX = (x) => worldOriginX + x;
const worldY = (y) => worldOriginY + y;
const patchMode = options?.patchMode === true || options?.generationContext?.hasBoundaryWorld === true;
const largePatchTile = options?.largeExpansionTile === true;
const topCenterSuppression = clamp(Number.isFinite(options?.topCenterSuppression) ? options.topCenterSuppression : (patchMode ? 0.68 : 0), 0, 0.95);
const featureTimings = [];
const nowMs = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
let timingMark = nowMs();
function markFeatureTiming(key) {
const t = nowMs();
featureTimings.push({ key, ms: Math.round((t - timingMark) * 10) / 10 });
const entry = { key, ms: Math.round((t - timingMark) * 10) / 10 };
featureTimings.push(entry);
timingMark = t;
options?.onProgress?.({ status: "feature-step", key: `feature:${key}`, label: `Feature stage: ${key}`, featureTiming: entry });
}
const {
@ -95,6 +104,7 @@ export function generateMapFeatures(seed, terrain) {
ports[0].portClass = "major";
ports[0].kind = "Major Port";
}
markFeatureTiming("ports");
const commercialPorts = ports.filter((p) => p.portClass === "major" || p.portClass === "regional");
const portIndex = createPointSpatialIndex(ports, 12);
const commercialPortIndex = createPointSpatialIndex(commercialPorts, 12);
@ -107,6 +117,7 @@ export function generateMapFeatures(seed, terrain) {
predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09,
}).map((p) => ({ ...p, kind: "River Crossing" }));
const crossingIndex = createPointSpatialIndex(crossings, 8);
markFeatureTiming("crossings");
const passes = pickGlobalPoints(passSuitability || valleySettlement, {
threshold: 0.18 + rand(seed, 1021) * 0.06,
@ -120,6 +131,7 @@ export function generateMapFeatures(seed, terrain) {
// from the unified geography fields before lower-tier villages and market
// towns are placed. These anchors are not rendered as separate settlements;
// they guide city selection and lower-tier spacing.
markFeatureTiming("passes");
const geographicUrbanAnchorScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
@ -164,6 +176,7 @@ export function generateMapFeatures(seed, terrain) {
extraScore: (x, y, i) => fieldValue(geoNaturalCentrality, i, 0) * 0.18 + fieldValue(geoAccessibility, i, 0) * 0.10,
}).map((p) => ({ ...p, candidateKind: "geographicAnchor", anchorScore: p.score }));
const geographicAnchorInfluence = influenceFromPoints(geographicUrbanAnchors, 20, (p) => clamp((p.anchorScore || p.score || 0.4) * 1.35, 0.45, 1.35));
markFeatureTiming("urban-anchors");
const villageScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
@ -206,6 +219,8 @@ export function generateMapFeatures(seed, terrain) {
return { ...p, kind, population };
});
markFeatureTiming("base-villages");
// Supplemental open-plain villages: broad Japanese-style farmland should not be empty
// just because it lacks a river/confluence anchor.
const openPlainVillageScore = new Float32Array(SIZE);
@ -239,6 +254,7 @@ export function generateMapFeatures(seed, terrain) {
return { ...p, kind: "Plain Village", population };
});
villages = [...villages, ...supplementalPlainVillages];
markFeatureTiming("supplemental-villages");
let villageInfluence = influenceFromPoints(villages, 6, (v) => clamp((v.population || 1800) / 4200, 0.35, 1.2));
@ -279,6 +295,7 @@ export function generateMapFeatures(seed, terrain) {
}
}
markFeatureTiming("market-score");
let markets = pickRegionalPoints(marketScore, {
stride: 2,
threshold: 0.245 + rand(seed, 1041) * 0.035,
@ -302,6 +319,8 @@ export function generateMapFeatures(seed, terrain) {
return { ...p, kind, population };
});
markFeatureTiming("base-markets");
const openPlainMarketScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
@ -334,6 +353,7 @@ export function generateMapFeatures(seed, terrain) {
return { ...p, kind: "Plain Market Town", population };
});
markets = [...markets, ...supplementalPlainMarkets];
markFeatureTiming("supplemental-markets");
let marketIndex = createPointSpatialIndex(markets, 12);
let villageIndex = createPointSpatialIndex(villages, 8);
@ -342,6 +362,7 @@ export function generateMapFeatures(seed, terrain) {
// empty while still keeping minimum spacing from existing settlements.
const existingTownInfluenceForSparseFill = influenceFromPoints([...markets, ...commercialPorts], 18, (p) => clamp((p.population || 9000) / 32000, 0.35, 1.25));
const existingSettlementInfluenceForSparseFill = influenceFromPoints([...markets, ...villages, ...ports], 12, (p) => clamp((p.population || 1800) / 16000, 0.18, 1.0));
markFeatureTiming("sparse-influence");
const sparseTownScore = new Float32Array(SIZE);
for (let i = 0; i < SIZE; i++) {
if (sea[i]) continue;
@ -378,6 +399,7 @@ export function generateMapFeatures(seed, terrain) {
return { ...p, kind: "Sparse Market Town", population };
});
markets = [...markets, ...sparseMarkets];
markFeatureTiming("sparse-markets");
marketIndex = createPointSpatialIndex(markets, 12);
const defenseScore = new Float32Array(SIZE);
@ -402,6 +424,8 @@ export function generateMapFeatures(seed, terrain) {
kind: elevation[indexOf(p.x, p.y)] > 0.55 ? "Mountain Castle" : elevation[indexOf(p.x, p.y)] > 0.38 ? "Hilltop Castle" : "Flatland Castle",
}));
markFeatureTiming("castles");
const castleTowns = castles.map((c, n) => {
const near = markets.slice().sort((a, b) => Math.hypot(a.x - c.x, a.y - c.y) - Math.hypot(b.x - c.x, b.y - c.y))[0];
const x = near && Math.hypot(near.x - c.x, near.y - c.y) < 10 ? near.x : c.x;
@ -443,6 +467,7 @@ export function generateMapFeatures(seed, terrain) {
...crossings.filter((p) => confluenceField[indexOf(p.x, p.y)] > 0.12).map((p) => ({ ...p, candidateKind: "crossing" })),
];
markFeatureTiming("urban-candidate-list");
const cityCandidateByRegion = new Map();
for (const p of urbanCandidates) {
const i = indexOf(p.x, p.y);
@ -463,11 +488,13 @@ export function generateMapFeatures(seed, terrain) {
(p.candidateKind === "port" ? 0.48 : 0) +
(p.candidateKind === "castleTown" ? 0.22 : 0) -
fieldValue(geoBarrier, i, 0) * 0.36 +
hash2(p.x, p.y, seed + 12000) * 0.16;
hash2(worldX(p.x), worldY(p.y), seed + 12000) * 0.16;
if (!cityCandidateByRegion.has(regionId)) cityCandidateByRegion.set(regionId, []);
cityCandidateByRegion.get(regionId).push({ ...p, score, capacity, regionId });
}
markFeatureTiming("urban-capacity-scan");
const modernCities = [];
const usedCitySites = [];
for (const [regionId, list] of [...cityCandidateByRegion.entries()].sort((a, b) => a[0] - b[0])) {
@ -501,8 +528,13 @@ export function generateMapFeatures(seed, terrain) {
// selection rules, so the highlighted region is not overwritten after the
// regional pass.
markFeatureTiming("urban-selection");
modernCities.sort((a, b) => b.capacity - a.capacity || b.score - a.score);
const regionalCapitalSlots = Math.max(1, Math.min(2, Math.floor(Math.sqrt(Math.max(1, modernCities.length)) / 2)));
const baseRegionalCapitalSlots = Math.max(1, Math.min(2, Math.floor(Math.sqrt(Math.max(1, modernCities.length)) / 2)));
const regionalCapitalSlots = patchMode
? Math.max(0, Math.min(1, Math.round(baseRegionalCapitalSlots * (1 - topCenterSuppression))))
: baseRegionalCapitalSlots;
const topCenterGeoThreshold = 0.80 + topCenterSuppression * 0.16;
for (const [rank, city] of modernCities.entries()) {
const i = indexOf(city.x, city.y);
const st = regionStats.get(city.regionId);
@ -514,27 +546,33 @@ export function generateMapFeatures(seed, terrain) {
fieldValue(geoAccessibility, i, 0) * 0.18 +
Math.log10((city.capacity || 26000) + 1) / 7 * 0.26
);
const isTopCenter = rank < regionalCapitalSlots || geoTierScore > 0.8;
const isRegionalCapital = isFirstInRegion && (isTopCenter || (city.capacity || 0) > 210000 || (st?.highCentralityCells || 0) > 220);
const slotTopCenter = regionalCapitalSlots > 0 && rank < regionalCapitalSlots;
const exceptionalPatchCenter = patchMode && geoTierScore > topCenterGeoThreshold && (city.capacity || 0) > 360000;
const isTopCenter = (!patchMode && slotTopCenter) || exceptionalPatchCenter || (!patchMode && geoTierScore > topCenterGeoThreshold);
const regionalCapacityThreshold = patchMode ? 300000 : 210000;
const regionalCentralityThreshold = patchMode ? 340 : 220;
const isRegionalCapital = isFirstInRegion && (isTopCenter || (city.capacity || 0) > regionalCapacityThreshold || (st?.highCentralityCells || 0) > regionalCentralityThreshold);
const u = rand(st.seed, city.x, city.y, 9101);
const v = rand(st.seed, city.x, city.y, 9102);
const w = rand(st.seed, city.x, city.y, 9103);
let rawPop;
if (isRegionalCapital) {
if (isTopCenter) {
// largest 3M - 11M
// largest 3M - 11M in full generation; patch candidates are suppressed
// unless they are exceptionally strong geographic centers.
rawPop =
3000000 +
Math.pow(u, 0.42) * 5200000 +
Math.pow(v, 3.2) * 2800000;
if (patchMode) rawPop *= (0.42 + (1 - topCenterSuppression) * 0.28);
} else {
// larger 0.25M - 2.5M
rawPop =
250000 +
Math.pow(u, 0.55) * 1450000 +
Math.pow(v, 2.4) * 900000;
if (patchMode) rawPop *= 0.72;
}
} else {
// normal 5k - 0.75k
@ -543,9 +581,9 @@ if (isRegionalCapital) {
Math.pow(u, 0.72) * 520000 +
Math.pow(v, 3.0) * 320000;
}
const capMultiplier = isRegionalCapital ? (isTopCenter ? 1.66 : 1.42) : 1.20;
const capMultiplier = isRegionalCapital ? (isTopCenter ? (patchMode ? 1.22 : 1.66) : (patchMode ? 1.08 : 1.42)) : 1.20;
const population = Math.round(Math.min(rawPop, city.capacity * capMultiplier) / 1000) * 1000;
const floor = isRegionalCapital ? (isTopCenter ? 210000 : 120000) : 42000;
const floor = isRegionalCapital ? (isTopCenter ? (patchMode ? 150000 : 210000) : (patchMode ? 90000 : 120000)) : 42000;
city.population = Math.max(floor, population);
city.isPrefecturalCapital = isPrefecturalCapital;
city.isRegionalCapital = isRegionalCapital;
@ -623,6 +661,7 @@ if (isRegionalCapital) {
settlementDemand, urbanEdge, logisticsPreSuitability,
preliminaryTownInfluence, preliminaryVillageInfluence,
valleySettlement, coastalSettlement, developable,
originX: worldOriginX, originY: worldOriginY,
});
markFeatureTiming("transport-cost-fields");
const cachedInfluenceFromPaths = createPathInfluenceCache(influenceFromPaths);
@ -664,19 +703,6 @@ if (isRegionalCapital) {
) * 255);
}
function chooseCorridorSeeds(potentialField, spacing, maxCount, threshold, predicate = () => true, seedOffset = 0, mode = "national") {
const candidates = [];
for (let y = 3; y < MAP_H - 3; y += 2) {
for (let x = 3; x < MAP_W - 3; x += 2) {
const i = indexOf(x, y);
if (sea[i] || !predicate(x, y, i)) continue;
const skeletonWeight = mode === "rail" ? 0.24 : mode === "expressway" ? 0.18 : 0.28;
const score = potentialField[i] + (corridorSkeleton[i] / 255) * skeletonWeight + hash2(x, y, seed + seedOffset) * 0.055;
if (score >= threshold) candidates.push({ x, y, score, regionId: regionIdAt(x, y) });
}
}
return pickEntities(candidates, { max: maxCount, minDistance: spacing, threshold, seed: seed + seedOffset, jitter: 0.03 });
}
function corridorAllowance(i) {
return clamp(settlementDemand[i] * 0.58 + valleyField[i] * 0.42 + coastalLowland[i] * 0.36 - plain[i] * 0.18 - agriculture[i] * 0.14);
@ -906,10 +932,6 @@ if (isRegionalCapital) {
return dense.length >= 2 ? dense : path;
}
function endpointFromPath(path) {
const p = path?.[path.length - 1];
return p ? { x: p[0], y: p[1], regionId: regionIdAt(p[0], p[1]) } : null;
}
function qualityLimitsForMode(mode, overrides = {}) {
const base = mode === "rail"
@ -1017,35 +1039,6 @@ if (isRegionalCapital) {
}, qualityLimitsForMode(mode, overrides));
}
function generateCorridorsFromField({ mode = "national", potentialField, costField, spacing, maxCount, threshold, minLength, penaltyRadius, penaltyStrength, curvePenalty, terrainFlowBias = 0, surfaceGrain = 0, relaxRadius = 2, relaxLineWeight = 0.40, seedOffset, startPredicate, goalPredicate }) {
const paths = [];
const penaltyField = new Float32Array(SIZE);
const seeds = chooseCorridorSeeds(potentialField, spacing, maxCount * 2, threshold, startPredicate, seedOffset, mode);
const usedEndpoints = [];
for (const start of seeds) {
if (paths.length >= maxCount) break;
if (distanceToNearest(usedEndpoints, start.x, start.y) < spacing * 0.55) continue;
let path = traceCorridorByCost(
start,
(x, y, i) => goalPredicate(start, x, y, i, usedEndpoints),
costField,
penaltyField,
{ curvePenalty, penaltyStrength: penaltyStrength * 2.2, minGoalDistance: minLength, regionId: start.regionId, terrainFlowBias, surfaceGrain }
);
if (path.length < minLength) continue;
const rawPath = path;
path = relaxRouteToTerrain(rawPath, costField, { radius: relaxRadius, lineWeight: relaxLineWeight, grain: surfaceGrain, iterations: 1 });
if (path.length < Math.max(2, rawPath.length * 0.55)) path = rawPath;
if (!transportRouteAcceptable(path, mode, potentialField, penaltyField, { minLength, maxLength: mode === "expressway" ? 130 : mode === "rail" ? 112 : 150 })) continue;
paths.push(path);
usedEndpoints.push(start);
const end = endpointFromPath(path);
if (end) usedEndpoints.push(end);
addCorridorInfluencePenalty(penaltyField, path, penaltyRadius, penaltyStrength);
}
return paths;
}
function rasterizeNetworkComponents(paths, mode, potentialField) {
const occupied = new Uint8Array(SIZE);
for (const path of paths) {
@ -1053,37 +1046,22 @@ if (isRegionalCapital) {
if (inside(x, y) && !sea[indexOf(x, y)]) occupied[indexOf(x, y)] = 1;
}
}
const componentId = new Int32Array(SIZE);
componentId.fill(-1);
const { labels: componentId, components: rawComponents } = labelOccupancyComponents(occupied, { maxDistanceSq: 2 });
const components = [];
for (let i = 0; i < SIZE; i++) {
if (!occupied[i] || componentId[i] >= 0) continue;
const id = components.length;
const queue = [i];
const cells = [];
for (const raw of rawComponents) {
const id = raw.id;
const cells = raw.cells;
const boundary = [];
componentId[i] = id;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
for (const cur of cells) {
const x = cur % MAP_W;
const y = Math.floor(cur / MAP_W);
let edge = false;
for (let dy = -1; dy <= 1; dy++) {
for (let dy = -1; dy <= 1 && !edge; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) { edge = true; continue; }
const ni = indexOf(nx, ny);
if (!occupied[ni]) {
edge = true;
continue;
}
if (componentId[ni] < 0) {
componentId[ni] = id;
queue.push(ni);
}
if (!inside(nx, ny) || !occupied[indexOf(nx, ny)]) { edge = true; break; }
}
}
if (edge) boundary.push({ x, y, i: cur });
@ -1138,9 +1116,9 @@ if (isRegionalCapital) {
for (let k = 0; k < component.boundary.length; k += stride) {
const p = component.boundary[k];
if (costField[p.i] >= INF) continue;
if (distanceToNearest(usedAnchors, p.x, p.y) < 8) continue;
if (distanceToNearestPoint(usedAnchors, p.x, p.y) < 8) continue;
const d = target ? Math.hypot(p.x - target.x, p.y - target.y) : 0;
const score = d + costField[p.i] * 3.5 - corridorAllowance(p.i) * 2.4 + hash2(p.x, p.y, seed + 13701) * 1.8;
const score = d + costField[p.i] * 3.5 - corridorAllowance(p.i) * 2.4 + hash2(worldX(p.x), worldY(p.y), seed + 13701) * 1.8;
if (score < bestScore) {
bestScore = score;
best = { x: p.x, y: p.y, componentId: component.id, regionId: regionIdAt(p.x, p.y) };
@ -1556,7 +1534,7 @@ if (isRegionalCapital) {
if (mode === "national") score += preliminaryVillageInfluence[i] * 0.24 + crossingSuitability[i] * 0.20;
score -= ridgeField[i] * (mode === "expressway" ? 0.50 : mode === "rail" ? 0.65 : 0.30);
score -= Math.max(0, elevation[i] - 0.62) * (mode === "expressway" ? 1.4 : mode === "rail" ? 1.7 : 0.85);
score += hash2(x, y, seed + 17100 + (mode === "rail" ? 17 : mode === "expressway" ? 31 : 0)) * 0.055;
score += hash2(worldX(x), worldY(y), seed + 17100 + (mode === "rail" ? 17 : mode === "expressway" ? 31 : 0)) * 0.055;
if (score > (mode === "expressway" ? 0.64 : mode === "rail" ? 0.58 : 0.52)) candidates.push({ x, y, score, regionId: regionIdAt(x, y), role: "preference-cell" });
}
}
@ -1939,7 +1917,7 @@ const premodernRoads = [];
markFeatureTiming("rail-od");
const { transportDebugLayers, runLocalAccessPass, stitchRasterNearContacts, stitchLongLocalBranches, sanitizeLocalRoads, downgradeShortNationalRoads, connectAllRoadNetworksFinal } = buildDensityFlowRoadTransportSystem({
const { transportDebugLayers, runLocalAccessPass, stitchDisconnectedRoadGaps, downgradeShortNationalRoads, connectPreAdminRoadComponents } = buildDensityFlowRoadTransportSystem({
seed,
sea, elevation, slope, ridgeField, valleyField, coastalLowland, naturalBarrierScore,
agriculture, basinField, plain, passSuitability, crossingSuitability,
@ -1953,6 +1931,9 @@ const premodernRoads = [];
routeBetweenTrafficCandidates, traceCorridorByCost, addCorridorInfluencePenalty,
relaxRouteToTerrain, transportRouteAcceptable, repairTransportConnectivity,
repairDanglingTransportEndpoints, pruneDanglingTerminalSegments, pruneParallelSameMode,
onProgress: options?.onProgress,
patchMode,
largePatchTile,
});
markFeatureTiming("road-system");
@ -2000,7 +1981,7 @@ const premodernRoads = [];
if (sea[i] || !shouldPlaceRailStation(x, y, i)) continue;
const interval = stationIntervalForCell(i);
if (lastStation && Math.hypot(x - lastStation.x, y - lastStation.y) < interval) continue;
if (distanceToNearest(stations, x, y) < Math.max(4.5, interval * 0.38)) continue;
if (distanceToNearestPoint(stations, x, y) < Math.max(4.5, interval * 0.38)) continue;
if (addStation(x, y, "Station", 0.8)) lastStation = { x, y };
}
}
@ -2015,9 +1996,9 @@ const premodernRoads = [];
function addKernel(grid, p, radius, weight, exponent = 1.7, terrainWeighted = true, combine = "max") {
const r = Math.ceil(radius);
const angle = hash2(p.x, p.y, seed + 14901) * Math.PI * 2;
const stretch = 1.35 + hash2(p.x, p.y, seed + 14902) * 0.85;
const squeeze = 0.62 + hash2(p.x, p.y, seed + 14903) * 0.28;
const angle = hash2(worldX(p.x), worldY(p.y), seed + 14901) * Math.PI * 2;
const stretch = 1.35 + hash2(worldX(p.x), worldY(p.y), seed + 14902) * 0.85;
const squeeze = 0.62 + hash2(worldX(p.x), worldY(p.y), seed + 14903) * 0.28;
const ca = Math.cos(angle);
const sa = Math.sin(angle);
for (let dy = -r; dy <= r; dy++) {
@ -2045,7 +2026,7 @@ const premodernRoads = [];
Math.max(0, elevation[i] - 0.58) * 0.82 +
(river[i] > 0.68 ? 0.60 : river[i] > 0.34 ? 0.22 : 0)
);
const noise = 0.78 + hash2(x, y, seed + 14910 + Math.round((p.population || 0) / 1000)) * 0.46;
const noise = 0.78 + hash2(worldX(x), worldY(y), seed + 14910 + Math.round((p.population || 0) / 1000)) * 0.46;
const d = baseD * (1.10 - conduit * 0.42 + barrier * 0.62) * noise;
if (d > radius) continue;
const terrain = terrainWeighted ? clamp(0.06 + developable[i] * 1.08 + valleySettlement[i] * 0.24 + coastalSettlement[i] * 0.14 + conduit * 0.38 - barrier * 0.70, 0, 1.42) : 1;
@ -2077,7 +2058,7 @@ const premodernRoads = [];
if (sea[i]) continue;
const d = Math.hypot(dx, dy);
if (d < 3 || d > 10) continue;
const score = coastalLowland[i] * 0.22 + developable[i] * 0.22 + roadInfluence[i] * 0.20 + plain[i] * 0.12 - slope[i] * 0.25 + hash2(x, y, seed + 14000) * 0.06;
const score = coastalLowland[i] * 0.22 + developable[i] * 0.22 + roadInfluence[i] * 0.20 + plain[i] * 0.12 - slope[i] * 0.25 + hash2(worldX(x), worldY(y), seed + 14000) * 0.06;
if (score > 0.22) candidates.push({ x, y, score, kind: "Industrial Zone", regionId: regionIdAt(x, y) });
}
}
@ -2166,68 +2147,20 @@ const premodernRoads = [];
targetPredicate: (x, y, i) => roadInfluenceNow[i] > 0.18 || localPenalty[i] > 0.07,
});
}
if (!patchMode) {
addFinalLocalAccessForUnservedSettlements();
addRuralRoadMeshConnectors();
transportDebugLayers.contactStitches = stitchRasterNearContacts();
transportDebugLayers.longLocalStitches = stitchLongLocalBranches();
transportDebugLayers.shortNationalDowngradeFinal = downgradeShortNationalRoads(18, 10);
transportDebugLayers.localSanitizationFinal = sanitizeLocalRoads();
transportDebugLayers.contactStitchesAfterConnectivity = stitchRasterNearContacts();
// Keep this as the final road topology operation. Later sanitization can cut
// the short connectors that intentionally merge isolated components.
transportDebugLayers.finalRoadNetworkConnectivity = connectAllRoadNetworksFinal(46);
markFeatureTiming("local-road-cleanup");
}
// Final road topology is now consolidated below after class cleanup and
// coverage guarantees. Do not repeatedly stitch/sanitize here: those passes
// used to create hundreds of temporary connectors that were immediately
// deduplicated or pruned again.
function dedupeTransportPathSet(paths, options = {}) {
const before = paths.length;
const minLength = options.minLength ?? 0;
const minPoints = options.minPoints ?? 2;
const sampleStep = Math.max(1, options.sampleStep ?? 1);
const seen = new Set();
const kept = [];
let removedDuplicates = 0;
let removedTooShort = 0;
for (const path of paths) {
if (!path || path.length < minPoints) { removedTooShort++; continue; }
const cleaned = [];
for (const pt of path) {
if (!pt || pt.length < 2) continue;
const x = Math.round(pt[0]);
const y = Math.round(pt[1]);
if (!cleaned.length || cleaned[cleaned.length - 1][0] !== x || cleaned[cleaned.length - 1][1] !== y) cleaned.push([x, y]);
}
if (cleaned.length < minPoints || pathLengthCells(cleaned) < minLength) { removedTooShort++; continue; }
const sample = (candidate) => candidate
.map((pt, idx) => (idx % sampleStep === 0 || idx === candidate.length - 1) ? `${pt[0]},${pt[1]}` : '')
.filter(Boolean)
.join('|');
const forward = sample(cleaned);
const backward = sample([...cleaned].reverse());
const sig = forward < backward ? forward : backward;
if (seen.has(sig)) { removedDuplicates++; continue; }
seen.add(sig);
kept.push(cleaned);
}
paths.length = 0;
paths.push(...kept);
return { before, after: kept.length, removedDuplicates, removedTooShort };
const { paths: _normalized, ...stats } = normalizeTransportPathSet(paths, { ...options, mutate: true });
return stats;
}
function pruneTransportPathSet(paths, minLength = 0, minKeep = 0) {
const ranked = (paths || [])
.map((path) => ({ path, len: pathLengthCells(path) }))
.filter((row) => row.path?.length >= 2)
.sort((a, b) => b.len - a.len);
const kept = [];
let pruned = 0;
for (const row of ranked) {
if (row.len >= minLength || kept.length < minKeep) kept.push(row.path);
else pruned++;
}
paths.length = 0;
paths.push(...kept);
return { before: ranked.length, after: kept.length, pruned, minLength, minKeep };
}
function downgradeBranchNationalSpurs(maxLength = 30, importantRadius = 6.5, junctionRadius = 2.6) {
const importantNodes = [
@ -2271,50 +2204,24 @@ const premodernRoads = [];
return { threshold: maxLength, downgraded: downgraded.length, kept: kept.length };
}
transportDebugLayers.postConnectivityDedup = {
function cleanupRoadClassesBeforeFinalTopology() {
const debug = {
dedupe: {
national: dedupeTransportPathSet(nationalRoads, { minLength: 0.95, sampleStep: 1 }),
externalRoads: dedupeTransportPathSet(externalRoads, { minLength: 1.5, sampleStep: 1 }),
minor: dedupeTransportPathSet(minorRoads, { minLength: 0.95, sampleStep: 2 }),
expressways: dedupeTransportPathSet(expressways, { minLength: 4, sampleStep: 2 }),
externalExpressways: dedupeTransportPathSet(externalExpressways, { minLength: 4, sampleStep: 2 }),
},
shortNationalDowngrade: null,
branchNationalDowngrade: null,
minorDedupeAfterDowngrade: null,
};
transportDebugLayers.postConnectivityShortNationalDowngrade = downgradeShortNationalRoads(32, 7);
transportDebugLayers.postConnectivityBranchNationalDowngrade = downgradeBranchNationalSpurs(42);
transportDebugLayers.postConnectivityNationalPrune = pruneTransportPathSet(nationalRoads, 18, 7);
transportDebugLayers.postConnectivityMinorDedup = dedupeTransportPathSet(minorRoads, { minLength: 0.95, sampleStep: 2 });
transportDebugLayers.postConnectivityLocalSanitization = sanitizeLocalRoads();
function smoothRasterPath(path, passes = 1) {
let current = (path || []).map(([x, y]) => [Math.round(x), Math.round(y)]);
for (let pass = 0; pass < passes; pass++) {
if (current.length < 3) break;
const next = [current[0]];
for (let i = 1; i < current.length - 1; i++) {
const [ax, ay] = current[i - 1];
const [bx, by] = current[i];
const [cx, cy] = current[i + 1];
const nx = Math.round((ax + bx * 2 + cx) / 4);
const ny = Math.round((ay + by * 2 + cy) / 4);
if (next[next.length - 1][0] !== nx || next[next.length - 1][1] !== ny) next.push([nx, ny]);
}
next.push(current[current.length - 1]);
current = next;
}
return current;
}
function directBridgeTunnelConnector(a, b, maxSegment = 20) {
if (!a || !b) return [];
const d = Math.hypot(a.x - b.x, a.y - b.y);
const steps = Math.max(2, Math.ceil(d));
const path = [];
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(a.x + (b.x - a.x) * t);
const y = Math.round(a.y + (b.y - a.y) * t);
if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]);
}
return routePhysicalAcceptable(path, 'expressway', { maxSeaRun: maxSegment, maxTunnelRun: Math.min(10, maxSegment), maxSeaShare: 0.70, maxTunnelShare: 0.18 }) ? path : [];
debug.shortNationalDowngrade = downgradeShortNationalRoads(32, 7);
debug.branchNationalDowngrade = downgradeBranchNationalSpurs(42);
// Downgrades append to minor roads, so normalize that class once afterwards.
debug.minorDedupeAfterDowngrade = dedupeTransportPathSet(minorRoads, { minLength: 0.95, sampleStep: 2 });
return debug;
}
function ensureInterchangePoint(x, y, kind = 'Interchange', source = 'expressway-endpoint') {
@ -2406,7 +2313,7 @@ const premodernRoads = [];
corePenalty * 1.18 -
(slope[i] || 0) * 1.05 -
(ridgeField[i] || 0) * 0.82 +
hash2(x, y, seed + 24891 + city.x * 11 + city.y * 17) * 0.04;
hash2(worldX(x), worldY(y), seed + 24891 + worldX(city.x) * 11 + worldY(city.y) * 17) * 0.04;
if (!best || score > best.score) best = { x, y, score, city, population: city.population || 0, regionId: regionIdAt(x, y), role: 'expressway-fringe-anchor' };
}
}
@ -2517,11 +2424,47 @@ const premodernRoads = [];
return debug;
}
transportDebugLayers.postConnectivityNationalCoverage = ensureNationalRoadCoverageForTowns(6000);
transportDebugLayers.postConnectivityMajorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(110000);
transportDebugLayers.postConnectivityExpresswayDedup = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 });
transportDebugLayers.postConnectivityExpresswayEndpointICs = ensureExpresswayEndpointsHaveICs();
markFeatureTiming("post-connectivity-guarantees");
function finalizePreAdminRoadTopology() {
const debug = {};
// A patch candidate is not a standalone transport graph. Its paths are
// merged into the existing world and the whole selected boundary receives
// one final portal/connectivity repair in mapPatch. Avoid repeating global
// routing/coverage searches independently in every large-selection tile.
if (patchMode) {
debug.gapStitches = { skipped: true, reason: "deferred-to-patch-merge" };
debug.classCleanup = cleanupRoadClassesBeforeFinalTopology();
debug.nationalCoverage = { skipped: true, reason: "deferred-to-patch-merge" };
debug.majorCityExpresswayGuarantee = { skipped: true, reason: "deferred-to-patch-merge" };
debug.expresswayDedupe = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 });
debug.networkConnectivity = { added: 0, skipped: true, reason: "deferred-to-patch-merge" };
debug.minorFinalDedupe = dedupeTransportPathSet(minorRoads, { minLength: 0.95, sampleStep: 2 });
debug.expresswayEndpointICs = { skipped: true, reason: "deferred-to-patch-merge" };
return debug;
}
// One component-aware gap/mesh pass replaces the former repeated
// stitch -> long-stitch -> sanitize -> stitch sequence.
debug.gapStitches = stitchDisconnectedRoadGaps();
debug.classCleanup = cleanupRoadClassesBeforeFinalTopology();
// Coverage additions must precede the final connectivity pass; previously
// they could re-introduce disconnected fragments after connectivity had
// already been declared "final".
debug.nationalCoverage = ensureNationalRoadCoverageForTowns(6000);
debug.majorCityExpresswayGuarantee = ensureMajorCityExpresswayConnections(110000);
debug.expresswayDedupe = dedupeTransportPathSet(expressways, { minLength: 8, sampleStep: 2 });
// This is the only pre-administration whole-road connectivity repair.
debug.networkConnectivity = connectPreAdminRoadComponents(46);
debug.minorFinalDedupe = debug.networkConnectivity.added > 0
? dedupeTransportPathSet(minorRoads, { minLength: 0.95, sampleStep: 2 })
: { skipped: true, reason: "no-connectors-added" };
debug.expresswayEndpointICs = ensureExpresswayEndpointsHaveICs();
return debug;
}
transportDebugLayers.preAdminRoadFinalization = finalizePreAdminRoadTopology();
markFeatureTiming("road-finalization");
const finalRoadInfluencePaths = [...nationalRoads, ...ringRoads, ...externalRoads];
roadLanduseInfluence = cachedInfluenceFromPaths(finalRoadInfluencePaths, 2.25, "road:landuse:final");
@ -2541,15 +2484,14 @@ const premodernRoads = [];
modernCities, logisticsParks,
roadLanduseInfluence, roadInfluence, roadDensityInfluence, railInfluence2, stationInfluence, stationDensityInfluence, villageInfluence,
cityInfluence, coreInfluence, oldTownInfluence, townInfluence, industrialInfluence, logisticsInfluence,
originX: worldOriginX, originY: worldOriginY,
});
markFeatureTiming("landuse");
const transportDebug = {
humanStageVersion: "v2-sparse-raster",
featureTimings,
coarseRouting: coarseRouteStats,
aStarRoutes: 0,
regionalNodeCount: [...regionStats.keys()].reduce((sum, regionId) => sum + importantNodesForRegion(regionId).length, 0),
fieldCorridorTransport: false,
unifiedODTransport: true,
settlementHierarchy: settlementHierarchyDebug,
railODTransport: railODDebug,
@ -2558,8 +2500,6 @@ const premodernRoads = [];
nationalRoadFieldCorridors: nationalRoads.length,
localRoadFieldCorridors: minorRoads.length,
layers: transportDebugLayers,
nationalRoadPopulationCoverage: 0,
nationalRoadUncoveredPopulation: 0,
};
return {

359
src/mapGeneratorHelpers.js Normal file
View file

@ -0,0 +1,359 @@
import { generateEntityName } from "./names.js";
import { INF, MAP_H, MAP_W, SIZE, MinHeap, indexOf, inside, nearMapEdge, pickEntities, rand, xyOf } from "./mapUtils.js";
export function neighbors8(x, y) {
const out = [];
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (inside(nx, ny)) out.push([nx, ny, Math.hypot(dx, dy)]);
}
}
return out;
}
export function neighbors4(x, y) {
const out = [];
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const nx = x + dx;
const ny = y + dy;
if (inside(nx, ny)) out.push([nx, ny, 1]);
}
return out;
}
export function createPointSpatialIndex(points, cellSize = 12) {
const buckets = new Map();
const normalized = (points || [])
.filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y))
.map((p) => ({ ...p, x: Math.round(p.x), y: Math.round(p.y) }));
const keyOf = (cx, cy) => `${cx},${cy}`;
for (const p of normalized) {
const cx = Math.floor(p.x / cellSize);
const cy = Math.floor(p.y / cellSize);
const key = keyOf(cx, cy);
let bucket = buckets.get(key);
if (!bucket) {
bucket = [];
buckets.set(key, bucket);
}
bucket.push(p);
}
function nearestDistanceSq(x, y, maxDistance = Math.max(MAP_W, MAP_H)) {
if (!normalized.length) return maxDistance * maxDistance;
const cx = Math.floor(x / cellSize);
const cy = Math.floor(y / cellSize);
const maxRing = Number.isFinite(maxDistance) ? Math.ceil(maxDistance / cellSize) : Math.ceil(Math.max(MAP_W, MAP_H) / cellSize);
let best = maxDistance * maxDistance;
for (let ring = 0; ring <= maxRing; ring++) {
for (let by = cy - ring; by <= cy + ring; by++) {
for (let bx = cx - ring; bx <= cx + ring; bx++) {
if (ring > 0 && bx > cx - ring && bx < cx + ring && by > cy - ring && by < cy + ring) continue;
const bucket = buckets.get(keyOf(bx, by));
if (!bucket) continue;
for (const p of bucket) {
const dx = p.x - x;
const dy = p.y - y;
const d2 = dx * dx + dy * dy;
if (d2 < best) best = d2;
}
}
}
}
return best;
}
return {
points: normalized,
hasWithin(x, y, radius) {
return nearestDistanceSq(x, y, radius) < radius * radius;
},
distance(x, y, fallback = 999) {
const d2 = nearestDistanceSq(x, y, fallback);
return d2 < fallback * fallback ? Math.sqrt(d2) : fallback;
},
nearestDistanceSq,
};
}
export function influenceFromPaths(paths, radius) {
const grid = new Float32Array(SIZE);
const r = Math.ceil(radius);
const r2 = radius * radius;
for (const path of paths) {
for (const [x, y] of path) {
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const nx = x + dx;
const ny = y + dy;
if (!inside(nx, ny)) continue;
const d2 = dx * dx + dy * dy;
if (d2 > r2) continue;
const d = Math.sqrt(d2);
const i = indexOf(nx, ny);
grid[i] = Math.max(grid[i], 1 / (1 + d));
}
}
}
}
return grid;
}
export function influenceFromPoints(points, radius, weightFn = () => 1) {
const grid = new Float32Array(SIZE);
const r = Math.ceil(radius);
const r2 = radius * radius;
for (const p of points) {
const weight = weightFn(p);
for (let dy = -r; dy <= r; dy++) {
for (let dx = -r; dx <= r; dx++) {
const nx = p.x + dx;
const ny = p.y + dy;
if (!inside(nx, ny)) continue;
const d2 = dx * dx + dy * dy;
if (d2 > r2) continue;
const d = Math.sqrt(d2);
const i = indexOf(nx, ny);
grid[i] = Math.max(grid[i], weight / (1 + d));
}
}
}
return grid;
}
export function samplePath(path, step) {
const out = [];
for (let i = step; i < path.length - step; i += step) {
const [x, y] = path[i];
out.push({ x, y, score: 1 });
}
return out;
}
function smoothMask(mask, passes = 2) {
let current = new Uint8Array(mask);
for (let pass = 0; pass < passes; pass++) {
const next = new Uint8Array(current);
for (let y = 1; y < MAP_H - 1; y++) {
for (let x = 1; x < MAP_W - 1; x++) {
const i = indexOf(x, y);
let count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (current[indexOf(x + dx, y + dy)]) count++;
}
}
if (count >= 5) next[i] = 1;
else if (count <= 3) next[i] = 0;
}
}
current = next;
}
return current;
}
function largestConnectedMask(mask) {
const seen = new Uint8Array(SIZE);
let best = [];
const queue = [];
for (let i = 0; i < SIZE; i++) {
if (!mask[i] || seen[i]) continue;
const component = [];
queue.length = 0;
queue.push(i);
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
component.push(cur);
const [x, y] = xyOf(cur);
for (const [nx, ny] of neighbors8(x, y)) {
const ni = indexOf(nx, ny);
if (!mask[ni] || seen[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
if (component.length > best.length) best = component;
}
const out = new Uint8Array(SIZE);
for (const i of best) out[i] = 1;
return out;
}
export function makePrefectureMask(seed, sea, elevation, slope, river) {
const candidates = [];
for (let y = 8; y < MAP_H - 8; y++) {
for (let x = 8; x < MAP_W - 8; x++) {
const i = indexOf(x, y);
if (sea[i]) continue;
const centrality = 1 - Math.hypot((x / MAP_W) - 0.5, (y / MAP_H) - 0.5) / 0.72;
const score = centrality * 0.28 + (1 - slope[i]) * 0.42 + (1 - Math.abs(elevation[i] - 0.42)) * 0.22 + Math.min(0.16, river[i] * 0.08);
candidates.push({ x, y, score });
}
}
const regionSeeds = pickEntities(candidates, {
max: 1,
minDistance: 18,
threshold: 0.35,
seed: seed + 904,
jitter: 0.02,
});
const mask = new Uint8Array(SIZE);
const dist = new Float32Array(SIZE);
dist.fill(INF);
const heap = new MinHeap();
const landCells = sea.reduce((a, v) => a + (v ? 0 : 1), 0);
const target = Math.floor(landCells * (0.23 + rand(seed, 906) * 0.08));
for (const s of regionSeeds) {
const i = indexOf(s.x, s.y);
dist[i] = 0;
heap.push({ i, f: 0 });
}
let claimed = 0;
while (heap.length > 0 && claimed < target) {
const current = heap.pop();
if (!current) continue;
const ci = current.i;
if (current.f > dist[ci] + 1e-5 || mask[ci]) continue;
const [cx, cy] = xyOf(ci);
if (sea[ci]) continue;
mask[ci] = 1;
claimed++;
for (const [nx, ny, step] of neighbors8(cx, cy)) {
const ni = indexOf(nx, ny);
if (sea[ni] || mask[ni]) continue;
const edgePenalty = nearMapEdge(nx, ny, 2) ? 4.2 : nearMapEdge(nx, ny, 5) ? 1.8 : 0;
const ridgePenalty = Math.max(0, elevation[ni] - 0.5) * 5.4 + Math.max(0, elevation[ni] - elevation[ci]) * 3.2;
const slopePenalty = slope[ni] * 4.1;
const riverPenalty = river[ni] > 0.65 ? 2.2 : river[ni] > 0.32 ? 0.9 : 0;
const cost = Math.max(0.18, 1 + edgePenalty + ridgePenalty + slopePenalty + riverPenalty + Math.abs(elevation[ni] - elevation[ci]) * 4.2) * step;
const nd = dist[ci] + cost;
if (nd < dist[ni]) {
dist[ni] = nd;
heap.push({ i: ni, f: nd });
}
}
}
return largestConnectedMask(smoothMask(mask, 2));
}
export function extractMaskBorder(mask, sea = null) {
const segments = [];
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
const a = mask[i];
if (x + 1 < MAP_W) {
const ni = indexOf(x + 1, y);
const b = mask[ni];
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
if (y + 1 < MAP_H) {
const ni = indexOf(x, y + 1);
const b = mask[ni];
if (a !== b && !(sea && (sea[i] || sea[ni]))) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
return segments;
}
export function extractAdminBorderSegments(adminId, prefectureMask, prefectureRegionId = null, sea = null) {
const segments = [];
const validCell = (i) => Boolean(prefectureMask?.[i]) && !(sea?.[i]) && (adminId?.[i] ?? -1) >= 0;
const samePrefecture = (i, j) => !prefectureRegionId
|| ((prefectureRegionId[i] ?? -1) >= 0 && prefectureRegionId[i] === prefectureRegionId[j]);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!validCell(i)) continue;
const a = adminId[i];
if (x + 1 < MAP_W) {
const ni = indexOf(x + 1, y);
if (validCell(ni) && samePrefecture(i, ni)) {
const b = adminId[ni];
if (a !== b) segments.push([[x + 1, y], [x + 1, y + 1]]);
}
}
if (y + 1 < MAP_H) {
const ni = indexOf(x, y + 1);
if (validCell(ni) && samePrefecture(i, ni)) {
const b = adminId[ni];
if (a !== b) segments.push([[x, y + 1], [x + 1, y + 1]]);
}
}
}
}
return segments;
}
export function tagInsidePrefecture(points, prefectureMask) {
return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) }));
}
export function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) {
return points.map((p, i) => {
const id = `${prefix}-${i}`;
const kind = kindOverride || p.kind;
if (prefix === "logistics") {
return {
...p,
id,
name: null,
facilityLabel: p.facilityLabel || "Logistics Park",
kind,
labelStyle: "facility",
suppressSettlementLabel: true,
insidePrefecture: Boolean(p.insidePrefecture),
};
}
const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
if (usedNames) usedNames.add(name);
return {
...p,
id,
name,
kind,
insidePrefecture: Boolean(p.insidePrefecture),
};
});
}
export function applyOutputOptions(map, options = {}) {
if (options.includeDebugFields !== false) return map;
const slim = { ...map };
delete slim.settlementCluster;
delete slim.ridgeField;
delete slim.valleyField;
delete slim.basinField;
delete slim.coastalLowland;
delete slim.flowAccum;
delete slim.erosionField;
delete slim.depositionField;
delete slim.terrainTemplate;
delete slim.ocean;
delete slim.lake;
delete slim.arcSpineField;
delete slim.branchRidgeField;
delete slim.depositionalLowland;
delete slim.alluvialFanField;
delete slim.deltaField;
delete slim.naturalBarrierScore;
return slim;
}

View file

@ -3,7 +3,7 @@ import { influenceFromPoints } from "./mapGeneratorHelpers.js";
const GEOGRAPHY_VERSION = "unified-geography-v1";
function localConfluenceScore(x, y, river) {
export function localConfluenceScore(x, y, river) {
let arms = 0;
let strong = 0;
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]) {

View file

@ -1,6 +1,24 @@
import { INF, MAP_H, MAP_W } from "./mapUtils.js";
const ADMIN_ID_KEYS = ["adminId", "municipalityId", "adminNumericId"];
const PREFECTURE_NAME_KEYS = ["prefectureName", "prefectureRegionName", "regionName", "name", "labelName"];
function usableName(value) {
const text = value == null ? "" : String(value).trim();
if (!text) return "";
if (/^県域\d*$/u.test(text)) return "";
if (/^Unnamed prefecture$/i.test(text)) return "";
if (/^Prefecture\s*-?\d+$/i.test(text)) return "";
return text;
}
function firstUsableName(obj, keys = PREFECTURE_NAME_KEYS) {
for (const key of keys) {
const text = usableName(obj?.[key]);
if (text) return text;
}
return "";
}
function coordIndex(width, height, x, y) {
if (x < 0 || y < 0 || x >= width || y >= height) return -1;
@ -32,7 +50,7 @@ function buildMunicipalStats({ adminId, prefectureRegionId, sea, fields = {}, wi
for (let i = 0; i < adminId.length; i++) {
const id = adminId[i];
if (!Number.isFinite(id) || id < 0 || sea?.[i]) continue;
const row = stats.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF, prefVotes: new Map() };
const row = stats.get(id) || { id, area: 0, sx: 0, sy: 0, bestI: -1, bestScore: -INF, prefVotes: new Map(), bestByPrefecture: new Map() };
const x = i % width;
const y = Math.floor(i / width);
row.area++;
@ -45,6 +63,10 @@ function buildMunicipalStats({ adminId, prefectureRegionId, sea, fields = {}, wi
row.bestScore = score;
row.bestI = i;
}
if (pref >= 0) {
const prefBest = row.bestByPrefecture.get(pref);
if (!prefBest || score > prefBest.score) row.bestByPrefecture.set(pref, { i, score });
}
stats.set(id, row);
}
for (const row of stats.values()) {
@ -57,8 +79,10 @@ function buildMunicipalStats({ adminId, prefectureRegionId, sea, fields = {}, wi
}
}
row.prefectureRegionId = bestPref;
row.x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
row.y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
const preferredCell = row.bestByPrefecture.get(bestPref)?.i ?? row.bestI;
row.bestI = preferredCell;
row.x = preferredCell >= 0 ? preferredCell % width : Math.round(row.sx / Math.max(1, row.area));
row.y = preferredCell >= 0 ? Math.floor(preferredCell / width) : Math.round(row.sy / Math.max(1, row.area));
}
return stats;
}
@ -190,6 +214,8 @@ export function refreshPrefectureRegionsMetadata({
prefectureRegionId,
sea,
existing = [],
adminCenters = [],
capitalCities = [],
fields = {},
width = MAP_W,
height = MAP_H,
@ -219,6 +245,26 @@ export function refreshPrefectureRegionsMetadata({
const id = Number.isFinite(region?.prefectureRegionId) ? Math.floor(region.prefectureRegionId) : Number.isFinite(region?.id) ? Math.floor(region.id) : -1;
if (id >= 0 && !existingById.has(id)) existingById.set(id, region);
}
const nameByPref = new Map();
for (const center of adminCenters || []) {
const id = Number.isFinite(center?.prefectureRegionId) ? Math.floor(center.prefectureRegionId) : -1;
if (id < 0 || nameByPref.has(id)) continue;
const name = firstUsableName(center, ["prefectureName", "prefectureRegionName", "regionName"]);
if (name) nameByPref.set(id, name);
}
const capitalByPref = new Map();
const capitalLike = (city) => !!(city?.isPrefecturalCapital || city?.isRegionalCapital || /Capital/i.test(String(city?.rank || "")) || /Capital/i.test(String(city?.kind || "")));
for (const city of capitalCities || []) {
if (!capitalLike(city)) continue;
const fx = Math.round((city?.x || 0) + pointOffsetX);
const fy = Math.round((city?.y || 0) + pointOffsetY);
const i = coordIndex(width, height, fx, fy);
if (i < 0 || sea?.[i]) continue;
const id = prefectureRegionId[i];
if (!Number.isFinite(id) || id < 0) continue;
const previous = capitalByPref.get(id);
if (!previous || Number(city?.population || 0) > Number(previous?.population || 0)) capitalByPref.set(id, city);
}
let fallbackRegionsAdded = 0;
const prefectureRegions = [];
for (const [id, row] of [...byId.entries()].sort((a, b) => a[0] - b[0])) {
@ -226,6 +272,10 @@ export function refreshPrefectureRegionsMetadata({
if (!base) fallbackRegionsAdded++;
const x = row.bestI >= 0 ? row.bestI % width : Math.round(row.sx / Math.max(1, row.area));
const y = row.bestI >= 0 ? Math.floor(row.bestI / width) : Math.round(row.sy / Math.max(1, row.area));
const resolvedName = firstUsableName(base) || nameByPref.get(id) || `県域${id + 1}`;
const capital = capitalByPref.get(id) || null;
const capitalFieldX = capital ? Math.round((capital.x || 0) + pointOffsetX) : x;
const capitalFieldY = capital ? Math.round((capital.y || 0) + pointOffsetY) : y;
prefectureRegions.push({
...(base || {}),
id,
@ -233,10 +283,20 @@ export function refreshPrefectureRegionsMetadata({
featureId: id,
x: x - pointOffsetX,
y: y - pointOffsetY,
worldX: x,
worldY: y,
capitalX: capitalFieldX - pointOffsetX,
capitalY: capitalFieldY - pointOffsetY,
capitalWorldX: capitalFieldX,
capitalWorldY: capitalFieldY,
insidePrefecture: true,
area: row.area,
kind: base?.kind || (id === 0 ? "Current Prefecture" : "Prefecture"),
name: base?.name || `県域${id + 1}`,
labelName: base?.labelName || base?.name || `県域${id + 1}`,
name: resolvedName,
labelName: firstUsableName(base, ["labelName"]) || resolvedName,
prefectureName: firstUsableName(base, ["prefectureName"]) || resolvedName,
prefectureRegionName: firstUsableName(base, ["prefectureRegionName"]) || resolvedName,
regionName: firstUsableName(base, ["regionName"]) || resolvedName,
forceLabel: true,
labelPriorityBase: base?.labelPriorityBase || 950 + Math.sqrt(row.area),
});
@ -250,16 +310,3 @@ export function refreshPrefectureRegionsMetadata({
};
}
export function municipalCoherenceForMap(map) {
return reconcileMunicipalMetadata({
adminId: map?.adminId,
municipalityId: map?.municipalityId,
prefectureRegionId: map?.prefectureRegionId,
sea: map?.sea,
adminCenters: map?.adminCenters,
municipalityToPrefectureId: map?.municipalityToPrefectureId,
fields: map,
width: map?.width || MAP_W,
height: map?.height || MAP_H,
});
}

View file

@ -1,10 +1,10 @@
import { createNameDebug } from "./names.js";
import { CELL_SIZE, INF, MAP_H, MAP_W, MinHeap, clamp, indexOf, inside, rand, xyOf } from "./mapUtils.js";
import { applyOutputOptions, attachIdsAndNames, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
import { applyOutputOptions, attachIdsAndNames, extractAdminBorderSegments, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
import { reconcileMunicipalMetadata } from "./mapMunicipalCoherence.js";
import { routeQualityAcceptable } from "./mapTransport.js";
import { componentLabelNear, labelOccupancyComponents, makeSpatialIndex, occupancyComponentsFromPathGroups, rasterizePathCells } from "./mapTransportUtils.js";
const MUNICIPAL_SUFFIX_RE = /[市町村区]$/u;
function stripMunicipalSuffix(name) {
return String(name || "").replace(/[市町村区]$/u, "").trim();
@ -27,6 +27,10 @@ function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
value = value.replace(/[市町村区駅港城跡宿]$/gu, "");
const fallback = String(center?.generatedMunicipalityName || "里").replace(/[市町村区駅港城跡宿]$/gu, "");
if (!value) value = fallback || "里";
if (Array.from(value).length < 2) {
const complements = ["川", "野", "原", "里", "浜", "森", "田", "谷"];
value = `${value}${complements[Math.abs((seed + ordinal * 13) | 0) % complements.length]}`;
}
return `${value}${municipalitySuffixForCenter(center, fields, seed, ordinal)}`;
}
@ -174,11 +178,18 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti
}
for (const prefId of [...prefIds].sort((a, b) => a - b)) {
const cities = (modernCities || []).filter((p) => prefAt(p) === prefId);
let target = cities.slice().sort((a, b) =>
const focusedCities = prefId === focusedPrefId && focusedPrefectureMask
? cities.filter((p) => inside(p.x, p.y) && focusedPrefectureMask[indexOf(p.x, p.y)])
: [];
let target = (focusedCities.length ? focusedCities : cities).slice().sort((a, b) =>
((b.isRegionalCapital ? 800000 : 0) + (b.population || 0)) - ((a.isRegionalCapital ? 800000 : 0) + (a.population || 0))
)[0];
if (!target) {
const market = (markets || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.population || 0) - (a.population || 0))[0];
const marketPool = (markets || []).filter((p) => prefAt(p) === prefId);
const focusedMarkets = prefId === focusedPrefId && focusedPrefectureMask
? marketPool.filter((p) => inside(p.x, p.y) && focusedPrefectureMask[indexOf(p.x, p.y)])
: [];
const market = (focusedMarkets.length ? focusedMarkets : marketPool).sort((a, b) => (b.population || 0) - (a.population || 0))[0];
if (market) {
target = {
...market,
@ -191,7 +202,11 @@ function promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCiti
}
}
if (!target) {
const center = (adminCenters || []).filter((p) => prefAt(p) === prefId).sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0))[0];
const centerPool = (adminCenters || []).filter((p) => prefAt(p) === prefId);
const focusedCenters = prefId === focusedPrefId && focusedPrefectureMask
? centerPool.filter((p) => inside(p.x, p.y) && focusedPrefectureMask[indexOf(p.x, p.y)])
: [];
const center = (focusedCenters.length ? focusedCenters : centerPool).sort((a, b) => (b.municipalityPopulation || 0) - (a.municipalityPopulation || 0))[0];
if (center) {
target = {
...center,
@ -413,7 +428,7 @@ export function finishMapOutput({
const {
adminCentersRaw,
adminId,
adminBorders,
adminBorders: stageAdminBorders,
adminDebug,
prefectureRegionId,
municipalityToPrefectureId,
@ -441,6 +456,7 @@ export function finishMapOutput({
let externalGateways = inputExternalGateways;
const outputProgress = (step) => options?.onProgress?.({ status: "output-step", key: "output", label: `Output: ${step}`, step });
const patchMode = options?.patchMode === true || options?.generationContext?.hasBoundaryWorld === true;
outputProgress("final packaging");
// Use all generated prefecture regions for human-geography masks, not only
// the focused prefecture. Population density itself is already generated in
@ -454,11 +470,19 @@ export function finishMapOutput({
// name features.
for (const city of modernCities) {
const cap = cityPopulationCap(city);
if (cap < INF && (city.population || 0) > cap) {
city.population = Math.round(cap / 1000) * 1000;
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, 16);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, 5.2);
city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, 2.0);
let targetCap = cap;
if (patchMode) {
// Patch candidates should not regularly introduce a new top-center-scale
// metropolis. Existing cities in the world are preserved by mapPatch; this
// only affects newly generated candidate cities before they are merged.
const patchCap = city.isPrefecturalCapital ? 820000 : city.isRegionalCapital ? 680000 : 540000;
targetCap = Math.min(targetCap, patchCap);
}
if (targetCap < INF && (city.population || 0) > targetCap) {
city.population = Math.round(targetCap / 1000) * 1000;
city.urbanRadius = clamp(5.0 + Math.sqrt(city.population) / 100, 6, patchMode ? 14 : 16);
city.coreRadius = clamp(1.8 + Math.sqrt(city.population) / 400, 2.2, patchMode ? 4.8 : 5.2);
city.urbanWeight = clamp(0.72 + Math.log10(Math.max(10000, city.population)) * 0.30, 1.0, patchMode ? 1.86 : 2.0);
}
}
@ -724,7 +748,6 @@ export function finishMapOutput({
debugLayers.repairedSegments.push({ mode: "municipal-local", path, from: "municipal-center", to: "network" });
}
}
addMunicipalCenterLocalAccess();
function pruneIsolatedFinalRoadComponents() {
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {};
@ -735,42 +758,6 @@ export function finishMapOutput({
["expressway", expressways],
["externalExpressway", externalExpressways],
];
function rasterize(path, fn) {
for (let k = 0; k < (path?.length || 0); k++) {
const [x0, y0] = path[k];
const [x1, y1] = path[Math.min(k + 1, path.length - 1)];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(x0 + (x1 - x0) * t);
const y = Math.round(y0 + (y1 - y0) * t);
fn(x, y);
}
}
}
function splitPathOnSea(path) {
const chunks = [];
let chunk = [];
function pushPoint(x, y) {
if (!inside(x, y) || sea[indexOf(x, y)]) {
if (chunk.length >= 2) chunks.push(chunk);
chunk = [];
return;
}
if (!chunk.length || chunk[chunk.length - 1][0] !== x || chunk[chunk.length - 1][1] !== y) chunk.push([x, y]);
}
for (let k = 0; k < (path?.length || 0); k++) {
const [x0, y0] = path[k];
const [x1, y1] = path[Math.min(k + 1, path.length - 1)];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
pushPoint(Math.round(x0 + (x1 - x0) * t), Math.round(y0 + (y1 - y0) * t));
}
}
if (chunk.length >= 2) chunks.push(chunk);
return chunks;
}
// Keep sea-crossing cells in the stored path so the renderer can draw
// explicit bridge overlays. Connectivity analysis below ignores sea cells
// when rasterizing components, so preserving them here does not make islands
@ -804,62 +791,16 @@ export function finishMapOutput({
}
function components() {
const occ = new Uint8Array(MAP_W * MAP_H);
for (const [, paths] of groups) for (const path of paths || []) rasterize(path, (x, y) => {
if (inside(x, y) && !sea[indexOf(x, y)]) occ[indexOf(x, y)] = 1;
const { components } = occupancyComponentsFromPathGroups(groups.map(([, paths]) => paths), {
maxDistanceSq: 5,
accept: (_x, _y, i) => !sea[i],
});
const seen = new Uint8Array(MAP_W * MAP_H);
const out = [];
for (let i = 0; i < occ.length; i++) {
if (!occ[i] || seen[i]) continue;
const queue = [i];
const cells = [];
seen[i] = 1;
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
cells.push(cur);
const [x, y] = xyOf(cur);
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
if (!dx && !dy) continue;
if (dx * dx + dy * dy > 5) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!occ[ni] || seen[ni]) continue;
seen[ni] = 1;
queue.push(ni);
}
}
out.push({ cells, size: cells.length });
}
return out.sort((a, b) => b.size - a.size);
return components.sort((a, b) => b.size - a.size);
}
function buildLandComponentIds() {
const ids = new Int32Array(MAP_W * MAP_H);
ids.fill(-1);
let id = 0;
const q = [];
for (let i = 0; i < ids.length; i++) {
if (ids[i] >= 0 || sea[i]) continue;
ids[i] = id;
q.length = 0;
q.push(i);
for (let h = 0; h < q.length; h++) {
const cur = q[h];
const [x, y] = xyOf(cur);
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (sea[ni] || ids[ni] >= 0) continue;
ids[ni] = id;
q.push(ni);
}
}
id++;
}
return ids;
const land = new Uint8Array(MAP_W * MAP_H);
for (let i = 0; i < land.length; i++) if (!sea[i]) land[i] = 1;
return labelOccupancyComponents(land, { maxDistanceSq: 2 }).labels;
}
function majorityLandId(cells, landIds) {
@ -994,7 +935,10 @@ export function finishMapOutput({
const mountainConnect = attemptConnectSameLandmassAdminRoadComponents(comps);
if (mountainConnect.added > 0) comps = components();
const pruned = { minor: 0, national: 0, external: 0, expressway: 0, externalExpressway: 0 };
let prunePasses = 0;
for (let pass = 0; pass < 4 && comps.length > 1; pass++) {
prunePasses++;
let passPruned = 0;
const mainMask = new Uint8Array(MAP_W * MAP_H);
for (const ci of comps[0].cells) {
const [cx, cy] = xyOf(ci);
@ -1006,7 +950,7 @@ export function finishMapOutput({
}
function touchesMain(path) {
let hit = 0, n = 0;
rasterize(path, (x, y) => {
rasterizePathCells(path, (x, y) => {
if (!inside(x, y) || sea[indexOf(x, y)]) return;
n++;
if (mainMask[indexOf(x, y)]) hit++;
@ -1017,18 +961,19 @@ export function finishMapOutput({
const kept = [];
for (const path of paths || []) {
if (touchesMain(path) || pathNearAdminCenter(path) || ((key === "expressway" || key === "externalExpressway") && pathNearRequiredExpresswayCity(path))) kept.push(path);
else pruned[key]++;
else { pruned[key]++; passPruned++; }
}
paths.length = 0;
paths.push(...kept);
}
comps = components();
if (passPruned === 0) break;
}
debugLayers.finalOutputRoadConnectivity = { beforeComponents: before, afterComponents: comps.length, pruned, mountainAdminConnections: mountainConnect };
debugLayers.outputRoadPrune = { beforeComponents: before, afterComponents: comps.length, pruned, prunePasses, mountainAdminConnections: mountainConnect };
return debugLayers.outputRoadPrune;
}
pruneIsolatedFinalRoadComponents();
function ensureAdminCenterCellsAfterOutputPrune() {
function ensureAdminCenterRoadStubs() {
let added = 0;
function roadTouches(center) {
for (const path of [...minorRoads, ...nationalRoads, ...externalRoads]) {
@ -1050,10 +995,10 @@ export function finishMapOutput({
}
if (transportDebug) {
transportDebug.layers ||= {};
transportDebug.layers.adminCenterFinalStubs = added;
transportDebug.layers.adminCenterRequiredStubs = added;
}
return added;
}
ensureAdminCenterCellsAfterOutputPrune();
function connectNearbyRoadEndpoints() {
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {};
@ -1063,58 +1008,12 @@ export function finishMapOutput({
{ key: "external", paths: externalRoads || [] },
{ key: "ring", paths: ringRoads || [] },
];
const occ = new Uint8Array(MAP_W * MAP_H);
function rasterize(path, fn) {
for (let k = 1; k < (path?.length || 0); k++) {
const [x0, y0] = path[k - 1];
const [x1, y1] = path[k];
const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const x = Math.round(x0 + (x1 - x0) * t);
const y = Math.round(y0 + (y1 - y0) * t);
if (inside(x, y) && !sea[indexOf(x, y)]) fn(x, y);
}
}
}
for (const group of ordinaryGroups) for (const path of group.paths || []) rasterize(path, (x, y) => { occ[indexOf(x, y)] = 1; });
const comp = new Int32Array(MAP_W * MAP_H);
comp.fill(-1);
let compId = 0;
const queue = [];
for (let i = 0; i < occ.length; i++) {
if (!occ[i] || comp[i] >= 0) continue;
comp[i] = compId;
queue.length = 0;
queue.push(i);
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
const [x, y] = xyOf(cur);
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!occ[ni] || comp[ni] >= 0) continue;
comp[ni] = compId;
queue.push(ni);
}
}
compId++;
}
const { labels: comp } = occupancyComponentsFromPathGroups(ordinaryGroups.map((group) => group.paths), {
maxDistanceSq: 2,
accept: (_x, _y, i) => !sea[i],
});
function endpointComponent(x, y) {
if (!inside(x, y) || sea[indexOf(x, y)]) return -1;
const here = comp[indexOf(x, y)];
if (here >= 0) return here;
for (let r = 1; r <= 2; r++) {
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
const nx = x + dx, ny = y + dy;
if (!inside(nx, ny) || sea[indexOf(nx, ny)]) continue;
const id = comp[indexOf(nx, ny)];
if (id >= 0) return id;
}
}
return -1;
return componentLabelNear(comp, x, y, 2, (_nx, _ny, i) => !sea[i]);
}
function directConnector(a, b) {
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
@ -1144,11 +1043,12 @@ export function finishMapOutput({
}
}
const pairs = [];
for (let i = 0; i < endpoints.length; i++) {
const a = endpoints[i];
for (let i = 0; i < endpoints.length; i++) endpoints[i].id = i;
const endpointIndex = makeSpatialIndex(endpoints, 7);
for (const a of endpoints) {
if (a.comp < 0) continue;
for (let j = i + 1; j < endpoints.length; j++) {
const b = endpoints[j];
for (const b of endpointIndex.near(a.x, a.y, 6.5)) {
if (b.id <= a.id) continue;
if (b.comp < 0 || a.comp === b.comp) continue;
if (a.group === b.group && a.pathIdx === b.pathIdx) continue;
const d = Math.hypot(a.x - b.x, a.y - b.y);
@ -1175,7 +1075,28 @@ export function finishMapOutput({
return added;
}
connectNearbyRoadEndpoints();
function finalizeOutputRoadTopology() {
// Build required municipal access before pruning so the prune pass can
// preserve those paths directly instead of deleting and re-adding them.
addMunicipalCenterLocalAccess();
const requiredStubsAdded = ensureAdminCenterRoadStubs();
const endpointConnectorsAdded = connectNearbyRoadEndpoints();
const prune = pruneIsolatedFinalRoadComponents();
const finalComponents = occupancyComponentsFromPathGroups(
[minorRoads, nationalRoads, externalRoads, ringRoads, expressways, externalExpressways],
{ maxDistanceSq: 5, accept: (_x, _y, i) => !sea[i] }
).count;
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {};
debugLayers.finalOutputRoadTopology = {
components: finalComponents,
requiredStubsAdded,
endpointConnectorsAdded,
prune,
};
return debugLayers.finalOutputRoadTopology;
}
finalizeOutputRoadTopology();
function renameInterchangesFromMunicipalities() {
if (!interchanges?.length || !adminCenters?.length || !adminId) return 0;
@ -1230,6 +1151,16 @@ export function finishMapOutput({
nameDebug.maxDerivedPerBase = 0;
const prefectureRegions = buildPrefectureRegions(prefectureRegionId, sea, nameFields, seed, usedNames, nameDebug, { modernCities, adminCenters });
const regionalPrefectureBorders = adminRegionalPrefectureBorders || [];
// Municipal vectors are derived only after final prefecture IDs exist. This
// prevents prefecture edges from also being emitted as municipal borders in
// the initial map. The stage-level vectors remain useful during generation,
// but are not authoritative output data.
const adminBorders = extractAdminBorderSegments(adminId, humanRegionMask, prefectureRegionId, sea);
if (adminDebug) {
adminDebug.stageMunicipalBorderCount = Array.isArray(stageAdminBorders) ? stageAdminBorders.length : 0;
adminDebug.finalMunicipalBorderCount = adminBorders.length;
adminDebug.municipalBordersRebuiltFromFinalHierarchy = true;
}
if (regionalDebug) {
regionalDebug.finalRegionalPrefectureBorderCount = regionalPrefectureBorders.length;
regionalDebug.regionalPrefectureBordersRebuiltFromFinalId = true;
@ -1252,7 +1183,13 @@ export function finishMapOutput({
...crossings,
...adminCenters,
...externalGateways,
].filter((p) => p.insidePrefecture || p.kind === "External Gateway");
].filter((p) => (p.insidePrefecture || p.kind === "External Gateway") && typeof p.name === "string" && p.name.length > 0);
nameDebug.outputNamedEntityCount = [
...villages, ...ports, ...crossings, ...passes, ...markets, ...castles, ...castleTowns,
...modernCities, ...stations, ...industrialZones, ...interchanges, ...logisticsParks,
...satelliteCities, ...newTowns, ...castleRuins, ...externalGateways, ...adminCenters,
].filter((item) => item?.id && typeof item.name === "string" && item.name.length > 0).length;
nameDebug.totalGeneratedNameSelections = nameDebug.generatedNamesUsed + nameDebug.customNameListUsed + nameDebug.fallbackAttempts;
return applyOutputOptions({
width: MAP_W,

Some files were not shown because too many files have changed in this diff Show more