This commit is contained in:
33333-33333 2026-08-11 21:51:07 +09:00
commit de7c6c32bd
123 changed files with 13201 additions and 6213 deletions

672
AUDIT_REDUNDANCY_UNUSED.md Normal file
View file

@ -0,0 +1,672 @@
# 完全生成を維持する冗長・無効・重複・未使用処理 監査報告
- 監査日: 2026-08-11
- 対象: 現在の作業ツリー(追跡済みの変更および未追跡ファイルを含む)
- 改訂条件: **簡易生成は禁止**。生成工程・品質基準・候補比較・最終補修を省略する案は採用しない
- 方法: import・参照・呼び出し・配列同一性・全域走査・公開経路・テスト到達性を対象にした静的監査
- 実施状態: 監査後の安全な修正を適用済み。条件付き削除と shadow 比較が必要な項目は未実施として明記
> 行番号とファイル状態は監査時点の作業ツリーに基づく。作業ツリーには監査開始前から多数の変更、削除、未追跡ファイルがあるため、コミット済みの基準版だけを対象にした結果ではない。
## 0. 実施結果2026-08-11
完全生成の candidate plan、quality retry、overscan、full finalist、post-admin transport、visible-crop / seam finalizer は削減していない。実施したのは、公開結果へ反映されない不具合の修復、静的に無効と確定した処理の削除、同一 fixture の共有、テスト実行基盤の修復、完全等価なコピー/比較処理への置換である。
### 実施済み
- A-01: transport 配列の stale alias を解消し、late cleanup が公開 `features.*` と同じ配列を処理するよう修正。配列同一性 invariant も追加した。
- A-02 / A-07c / A-12 / A-14g: suite registry を一元化し、未知 suite / group を即失敗、generic determinism seed を修正、出力収集を共有 byte counter と chunk 連結へ変更し、test failure と infrastructure failure を分離した。
- A-04 / A-05: 未使用の全域 `barrierCost` / `corridorCost` 構築と、結果を破棄していた結合 railway prune を削除した。
- A-07: default の重複 seed 999 determinism を削除し、seed 54321 の variation assertion を既存 terrain map へ移した。coverage-hole best-of は既存 coverage suite に統合し、同一初期 map を共有する。
- A-07: temp/debug 3本、置換済み best-of 単独テスト、旧 determinism helper を削除した。raw-worker browser harness 2本も、上位の実アプリ browser suite に統合済みのため削除した。
- A-11: source text は core suite でのみ遅延読込し、`mapPipeline.js` の二重読込と `test.js` 自己読込を除去した。typed-array determinism 比較は配列化JSON化せず直接比較する。
- A-14a: viewport raster はセル単位 lookup から、clipping を保つ typed-array row copy へ置換した。負座標・fallback cell の回帰検査を追加した。
- A-14f: production fixture の seed hash と最大 lasso 構築を `tests/production-fixtures.mjs` に共通化した。
- A-15: local dead function / closure、未使用 import・束縛・引数、`stableWorldTerrain`、無効な radial fallback、no-op UI、未使用 CSS selector / custom property / transition、同一 CSS block を整理した。
- repo 内参照ゼロかつ package 公開定義もない `mapAdminSeedLifecycle.js``adminRegions.js``mapGenerator.js` を削除した。
- default / release / browser manifest を README に記載し、Windows の Edge を direct-CDP fallback として検出できるようにした。
### 検証結果
- 変更前 canonical baseline: terrain 3件、patch 3件の計6件が既存失敗。runner が通常の assertion failure まで infrastructure failure に誤分類していた。
- 変更後: unit、coverage、core、terrain-name、admin、patch-large、determinism 4 seed、r10 exact-production、r11 selection-native-production、r11.8 を実行。新規の機能失敗はなく、terrain 3件と patch 3件は変更前と同一である。
- r10 は optimized / exhaustive の variant と最終 score が一致し、簡易 human / transport draft を公開していない。r11 は selection-native full production と transport hard gate を通過した。
- 実アプリ browser smoke は preview 公開、canvas 変更、完全 stats、Apply、mirror ACK、bounded progress を通過した。一方、この環境では 60秒の性能予算に対して74.55秒であり、性能 gate は緩和していない。
- r11.6 の3候補 full-production 比較は、この環境で旧130秒およびrunner 300秒を超えた。候補数を減らさず timeout の不整合だけを540秒600秒へ修正したが、延長後の完走は未確認である。
### 未実施・保留
- runner が参照する r10 / r11 系テスト、Node wrapper、今回追加した fixture helper と本監査文書は作業ツリーでは未追跡である。Git index / commit の操作は行っていないため、採用時は runner・README・依存ファイルを同一 commit に含める必要がある。
- A-06b / A-06d: rect-native terrain と旧 raw-draft worker protocol は repo 内未到達だが、export / message protocol の外部互換確認が必要なため削除していない。
- `prepareProductionTerrain``generateMapAsync``generateAdminLayout` 等の未参照 export は、外部 direct import 契約を確定できないため残した。
- A-08〜A-10、A-13、A-14b〜A-14e の高コスト反復・同期非同期統合は、全 seed / mode / shape の shadow 比較と座標・metadata の完全一致が必要である。観測 seed だけを根拠に pass を削除する変更は行っていない。
- r11.5 / r11.7 / r11.8 の seed 共有統合、maximum-worker と selection-native の同一候補共有は、各契約を一つの worker lifecycle に移植する追加作業が必要である。
## 1. 監査の絶対条件
本監査の目的は、完全生成の内容を軽くすることではない。同じ完全生成を、不要な計算・複製・再走査・重複実装なしで実行できる状態にすることである。
### 1.1 禁止事項
次の変更は、処理時間やコード量が減っても採用対象外とする。
- terrain-scout、draft、proxy、private tile の内容を公開結果として再利用する。
- geography、settlement、admin、transport、visible-crop finalizer、seam / quality audit のいずれかを省略する。
- candidate 数、quality retry、overscan、探索範囲、経路制約、密度基準を性能目的で引き下げる。
- admissible でない近似 score や経験的閾値により、勝ち得る候補を full production 比較前に捨てる。
- terrain-routed path を直線、snapped line、簡易 connector へ置換する。
- test の assertion、seed、異なる option の fixture、browser / worker / cancel / mirror 契約を、実行時間短縮だけを理由に削る。
- quality gate を緩和し、処理が速くなったことを最適化の成功とみなす。
### 1.2 現行の完全生成契約
現行コードには、候補選別用の terrain-scout がある。これは公開可能な簡易生成ではなく、次の条件でのみ許容されている非公開の前段評価である。
- `src/mapPatchWorker.js:1969-1981` は full candidate 実行時に `_precomputedDraftCandidate: null` を明示し、再利用を exact terrain field に限定する。
- `src/mapPatch.js:12945-12955` は公開候補で full patch candidate を生成し、簡略 geography / human / transport draft を再利用しない。
- `src/mapPatchWorker.js:2158-2160``reusedWinningDraft: false``fullProductionFromTerrainOnly: true` を記録する。
- `tests/r10-exact-production-worker.mjs:62-72` は optimized search と exhaustive full-production search の variant と score の一致、および簡略 output の非公開を検証する。
- `src/mapPostAdminTransport.js:4008-4015``tests/r11.5-visible-quality-finalizer.mjs:115-120` は visible crop に full production finalizer を要求する。
この契約を弱める変更は、本監査の「短縮」には含めない。scout 自体の範囲を広げる案も出さない。
### 1.3 private tile の扱い
大矩形生成では、一部の global repair を private tile 内で遅延し、組立後の selection 全体で実行する経路がある (`src/mapTransport.js:56-59,1318-1327``src/mapFeatures.js:2644-2659`)。これは、次の全条件を満たす場合に限り簡易生成とは扱わない。
- tile は単独で公開されない。
- `src/mapPatch.js:12831-12837` の品質 soft path は `_internalTile === true` の場合だけである。
- 組立後に authoritative trunk generation、terrain repair、seam audit、Initial Quality Oracle を完走する。
- top-level candidate は同じ soft path を利用できない。
この境界は将来の refactor で崩れやすいため、明示的な invariant test を残す。
### 1.4 用語上の非対象
- `src/app.js` / `src/renderer.js``fastTerrain` は pan / zoom 中の描画省略であり、保存される map generation の簡略化ではない。
- `src/mapAdminStage.js:212-264``simple administrative hierarchy` / `simpleHierarchyPrototype` は現行 production stage の方式名であり、別の軽量 generator への分岐ではない。名称だけを根拠に未使用・簡易生成とは判定しない。
- `src/mapPatchWorker.js:2161``parallelDraftGeneration` は互換目的で残る旧 metadata 名で、現在の実体は terrain-scout である。改名する場合も protocol 互換を先に確認する。
### 1.5 最優先の結論
最優先で扱うべきなのは、単なる整理ではなく、実行した完全生成処理が公開結果へ反映されていない箇所と、検証していないテストが成功扱いになる箇所である。
1. `src/mapPostAdminTransport.js` の最終道路監査の一部は、古い配列別名を変更しており、公開される `features.*` には反映されない。
2. `tests/test.js` は未知の suite 名でも実質的に検証をせず成功し得る。
3. 地物生成では、どこからも使われない2枚の全画面コストラスタを毎回構築している。
4. 鉄道の parallel prune は、一度目が一時配列だけを変更して結果を破棄し、その直後に実配列へ同種処理を繰り返す。
5. repo 内から到達しないモジュール・関数群、到達不能な temp テスト、同一 seed / fixture の重複生成が複数ある。
6. post-admin transport などの反復修復は削減余地が大きいが、順序依存が強い。出力等価性を証明せず、pass を省略・簡略化してはならない。
監査で分類した確信度は次の通り。
- **確定**: 現在の repo 内参照、値の使用、または配列同一性から、未使用・無反映・重複を静的に証明できる。
- **条件付き**: repo 内からは未使用だが、外部 import、外部 CI、手動実行の契約が見えない。
- **要計測**: 処理の反復や走査は多いが、代表 seed で差分ゼロというだけでは削除できない。全対象条件で不要と証明するか、同じ変換を一度で行う等価実装へ置換するもの。
## 2. 優先順位一覧
| ID | 優先度 | 分類 | 要点 | 判定 | 修正リスク |
|---|---:|---|---|---|---:|
| A-01 | P0 | 無反映 | final transport cleanup が stale alias を変更 | 確定 | 中 |
| A-02 | P0 | 無意味な検証 | 未知 suite が検証なしで成功し得る | 確定 | 低 |
| A-03 | P0 | 再現性 | canonical runner / README が未追跡テストを必須参照 | 確定・作業ツリー依存 | 中 |
| A-04 | P1 | 無使用計算 | `barrierCost` / `corridorCost` の全域構築結果が不使用 | 確定 | 低 |
| A-05 | P1 | 無反映 | 一時的な結合鉄道配列への prune 結果を破棄 | 確定 | 低 |
| A-06 | P1 | 不使用 | rect-native terrain 系と seed lifecycle が repo 内未到達 | 条件付き | 低〜中 |
| A-07 | P1 | 重複 | 同じ seed / fixture の full-map・worker 生成を重複実行 | 確定 | 低〜中 |
| A-08 | P1 | 迂遠 | transport dedupe・repair・prune の多段反復 | 要計測 | 中〜高 |
| A-09 | P1 | 迂遠 | 自治体ごとの全域走査、人口再推定、合併反映の反復 | 確定 | 中 |
| A-10 | P1 | 重複 | worker / pipeline の同期・非同期実装が大規模重複 | 確定 | 中〜高 |
| A-11 | P1 | 低効果テスト | 大量のソース文字列 `includes` 検査 | 確定 | 中 |
| A-12 | P1 | 迂遠 | test runner の出力連結が出力量に対して二次的 | 確定 | 低 |
| A-13 | P1 | 迂遠 | crop の小範囲探索ごとに全寸法 workspace を確保 | 確定 | 中 |
| A-14 | P2 | 重複 | UI診断、preview差分、mirror protocol、A* 等の重複 | 確定 | 低〜高 |
| A-15 | P2 | 不使用 | local helper、import、分割代入、CSS、DOM経路 | 確定 | 極低〜低 |
P0 は正しさ・検証信頼性の問題、P1 は大きな計算量または保守コスト、P2 は安全に整理しやすい局所項目、P3 は小規模なコード整理を表す。
実施区分は優先度とは別に扱う。
- **正しさの修復:** A-01〜A-03。現状の無反映や偽成功を直すため、出力差分が発生し得る。
- **意味を変えない除去:** A-04、A-05、A-15の確定項目。未使用結果または破棄済み結果だけを取り除く。
- **外部契約確認後の除去:** A-06。repo 内参照ゼロだけで公開 API を消さない。
- **完全生成の等価最適化:** A-07〜A-14。生成段階や品質基準は減らさず、同一計算の共有、workspace 再利用、全域走査の一括化、実装共通化だけを行う。
## 3. 公開結果へ反映されない、またはほぼ無意味な処理
### A-01. stale alias により final transport cleanup が公開結果へ反映されない
**根拠**
- `src/mapPostAdminTransport.js:1073-1077``features.minorRoads``nationalRoads``externalRoads``expressways``externalExpressways``const` のローカル別名へ保持する。
- `src/mapPostAdminTransport.js:3838-3842``dedupePaths(..., { mutate: false })` の戻り値を `features.*` へ再代入する。
- `src/mapTransportUtils.js:193-228``dedupePaths``mutate: false` の場合、新しい配列と座標列を返す。この時点で冒頭の別名は古い配列を指す。
- その後の `src/mapPostAdminTransport.js:3886``pruneShortFinalTrunkSegments()` を呼ぶが、同関数の `3778-3813` は古い `expressways``nationalRoads``minorRoads` を変更する。
- `src/mapPostAdminTransport.js:3891-3894` の shared-alignment / parallel-prune も古い配列を変更する。
このため、次の診断名で数えた変更は、公開される `features.*` に反映されない。
- `finalShortTrunkCleanupAfterService`
- `finalExpresswaySharedAlignment`
- `finalNationalSharedAlignment`
- `finalExpresswayParallelPruneExactOutput`
- `finalNationalParallelPruneExactOutput`
なお、直後の major-city link 補完など、明示的に `features.*` を渡す処理まで無効という意味ではない。
**短縮・是正案**
- 配列の identity を維持する in-place 正規化へ統一する、または再代入後はローカル別名を廃止し `features.*` だけを参照する。
- 修正すると、今まで死んでいた prune が実際に出力を変える可能性がある。出力差分を仕様として確認し、`features.*` と処理対象の identity を検証する回帰テストを先に置く。
- **禁止:** 無反映だった pass 自体を削除して「無駄を除いた」とはしない。本来要求された full final cleanup を実配列へ適用するのが先である。
### A-04. 不使用の全域コストラスタを毎回構築
**根拠**
- `src/mapFeatureContext.js:64-65``barrierCost``corridorCost``Float32Array` を確保する。
- 同ファイル `72-74``162-163` で全セルを走査し、hash を含む値を格納する。
- `src/mapFeatures.js:94-95` は両者を分割代入するが、その後一度も読む箇所がない。repo 内にも別の利用者はない。
hidden generation の 354 × 279 = 98,766 セルでは、配列本体だけで約 790 KB を一時保持し、さらに全域の演算を行う。結果が使われないため、計算・メモリとも削除候補である。
**短縮案**
- 2配列の確保、代入、返却、受け取りをまとめて除去する。
- その結果 `mapFeatureContext.js``INF` import 等が未使用になる場合は同時に整理する。
### A-05. 一時的な結合鉄道配列への高コスト prune を破棄
**根拠**
- `src/mapTransportOD.js:387-392``[...railways, ...branchRailways]` という一時配列へ `pruneParallelSameMode` を実行する。
- `src/mapFeatures.js:1777-1836` の処理は、サンプリングと平行判定を行い、渡された配列そのものを変更する。
- 一時配列はその後使われず、直後の `src/mapTransportOD.js:395-406` で実際の各レイヤーへ同種の prune を再実行する。
一度目は出力を変えず、得られる debug 値も公開配列への効果を表していない。
**短縮案**
- 一度目を削除する。
- 結合ネットワークの監査値が必要なら、変更しない専用 audit 関数へ分離する。
- rail / branch の実配列に対する2回目以降の production prune は残す。変更前後の公開経路配列が一致することを確認する。
### A-15a. 常時 no-op の UI / production 小項目
| 場所 | 状態 | 推奨 |
|---|---|---|
| `src/app.js:90,2031,2048` | `floatingLegendGrid` に対応する DOM id が `index.html` にない。null guard で毎回終了し、`rows.slice(0, 5)` だけ発生 | 廃止済みなら query と描画呼び出しを削除。必要機能なら DOM を復元 |
| `src/mapPatch.js:12851` | `seamHardFailed` を計算するが参照しない | 式ごと削除 |
| `src/mapPostAdminTransport.js:2858` | `urbanStreetDebug` を構築するが参照しない。直後に同等の debug flag を個別設定 | オブジェクト構築を削除 |
| `src/mapPostAdminTransport.js:4118` | `desiredGap = 15.75` は不使用。実処理は別の動的間隔を使う | 宣言と、それを存在確認する source-string テストを削除・置換 |
| `src/mapPatchWorker.js:2192-2208` | `materializeAndPublish` closure は定義のみ | 削除 |
| `src/mapTerrain.js:13-20` | `createExactNoiseMemo` の空 `latticeBySeed``exactDirectNoise` に利用者がない | 互換契約がなければ返却値を簡素化 |
| `src/mapTransportUtils.js:110-125` | 有限かつ非負の通常 radius では radial-kernel fallback に到達しない | 無効値を明示 reject し fallback を削除 |
## 4. repo 内未使用・未到達の処理
### A-06a. `mapAdminSeedLifecycle.js` 全体
`src/mapAdminSeedLifecycle.js` は約198行で、以下を export するが、現 repo 内に import / 呼び出しがない。
- `absorbSeedCompartments` (`:4`)
- `splitOversizedLowlandsWithPendingSeeds` (`:26`)
- `promotePendingSeedsForMunicipalityCount` (`:95`)
- `restoreSurvivedSeedsByCompartment` (`:156`)
**判定:** repo 内では未到達。外部 import がないことを確認できればファイル単位で削除できる。
### A-06b. rect-native terrain 系
`src/mapTerrain.js:1323-1999` の rect 専用 helper 群と、次の export は repo 内から参照されない。
- `generateTerrainRect` (`src/mapTerrain.js:1842`)
- `finalizeRectTerrainForFixedMap` (`src/mapTerrain.js:1967`)
`src/rectContext.js``mapTerrain.js` から静的 import されるものの、その export はこの未呼び出し経路でしか使われない。rect 経路を除去すれば import と同モジュール全体も不要になり、合わせて約760行規模になる。
**判定:** 削減効果は大きいが export を含む。外部 API / 手動スクリプトの利用確認後に、経路と `rectContext.js` を一括で扱う。
### A-06c. repo 内未参照の facade / export 候補
| 場所 | 状態 |
|---|---|
| `src/adminRegions.js` | 1行の re-export facade。repo 内参照なし |
| `src/mapGenerator.js` | 1行の re-export facade。repo 内参照なし |
| `src/mapPipeline.js:118-130` | `prepareProductionTerrain` は定義 / export のみ |
| `src/mapAdminStage.js:509` | `generateAdminLayout` は呼び出しなし(名称を含むエラー文字列はある) |
| `src/mapPipeline.js:388` | `generateMapAsync` は未参照 facade からの re-export だけ |
いずれも repo 内では削除候補だが、公開 entrypoint として外から import されていないかを確認する。
### A-06d. terrain-scout 移行後に残った旧 draft protocol
現行 production の candidate search は `src/mapPatchWorker.js:1745-1753` から `evaluatePatchDraftCandidate` を呼ぶ際、常に `_terrainScoutOnly: true` を渡す。そのため `src/mapPatch.js:9757-9758` で terrain-scout へ即委譲し、`:9759-9879` の旧 draft 本体へ到達しない。ここでいう未到達部分は公開候補の full production 本体ではない。
この変更に追随せず残っている経路は次の通り。
- `src/mapPatch.js:9881-9901``buildRawPatchDraftRequest` は定義のみ。
- `src/mapPatchWorker.js:150-159``precomputeRawDraftBatch` は dependency object (`:2674`) に載せるだけで、dependency を読む箇所がない。
- その配下の `runRawDraftTask` (`:129-131`) と `generate-raw-patch-draft` message 分岐 (`:112`) も内部到達不能になる。
- `src/patchCandidateWorker.js:38-43` の draft message 分岐、旧 full-draft 側だけが使う `continueMapDraftFromTerrain` (`src/mapPipeline.js:222`) と `generateMapDraft` (`:268`) も同じ互換クラスタに属する。
- `src/mapPatch.js:2``generateMapDraft` import は既に完全未使用である。
**判定:** current app から辿る production path では未到達。ただし worker message protocol、外部 import、テストの source assertion に互換目的がある可能性がある。message type と公開 export の廃止を一つの変更として宣言し、呼び出し元がないことを確認してからクラスタ単位で削除する。
**完全生成上の境界:** 削除対象は旧 `generate-raw-patch-draft` 互換クラスタだけである。`generatePatchCandidate``generateMap`、full finalist 実行、terrain 以外の全 production stage、組立後 finalizer は削除・迂回しない。旧 draft 経路を消すことを、scout output の公開許可と組み合わせてはならない。
### A-15b. local dead code
以下は export されず、識別子の repo 内出現が定義だけである。外部 API よりも安全に削除しやすい。
- `src/mapPatch.js:1525-1535` `dedupeSegments`
- `src/mapPatch.js:6726-6757` `buildBoundarySegmentsFromField`
- `src/mapPatch.js:6759-6784` `buildMaskBoundarySegmentsFromField`
- `src/mapPatch.js:8836-8838` `rectKey`
- `src/renderer.js:402-408` `mixRgb`
- `src/renderer.js:865` `drawLandRailway`
- `src/mapTerrain.js:24-29` `normalizeCoord`
- `src/mapTerrain.js:37-41` `rotate`
### A-15c. 未使用 import・引数・分割代入・戻り値束縛
| 場所 | 未使用項目 | 注意 |
|---|---|---|
| `src/mapMunicipalDemography.js:1` | `MAP_W`, `SIZE`, `clamp` | import から除去 |
| `src/mapPatch.js:2` | `generateMapDraft` | import から除去 |
| `src/mapFeatures.js:94-95` | `barrierCost`, `corridorCost` | A-04 と同時に producer も除去 |
| `src/mapOutput.js:350` | `stationInfluence` | contract を確認して除去 |
| `src/mapTransport.js:43,45-46` | `externalRailways`, `regionStats`, `inFocusedPrefecture`, `importantNodesForRegion` | builder の受取項目を整理 |
| `src/mapTransportOD.js:23` | `agriculture` | 受取項目を整理 |
| `src/mapPatch.js:11897` | `aggregateAdministrativeMetadataAfterCapitals` の戻り値束縛 | 呼び出しには副作用があり得るため、束縛だけ除去 |
| `src/mapPrefectureStage.js:977-979` | 3つの `changedFor...` counter | 呼び出しは状態を変更するため残し、未使用の束縛だけ除去 |
### A-15d. 無用な option / 引数の受け渡し
| 場所 | 状態 | 推奨 |
|---|---|---|
| `src/mapPatch.js:9396,9524,9692,9779,13281,13359,13408,13481``src/mapPipeline.js:121` | `stableWorldTerrain` を渡すだけで、repo 内に読み取りがない | option を削除 |
| `src/mapPipeline.js:30``src/mapPatch.js` 各所 | `legacyTerrain` は metadata へコピーまたは `true` を渡すだけで、制御・計算には使わない | 出力 schema の互換性を確認後に除去 |
| `src/initialGenerationCrop.js:811` | `repair(..., outputPaths, ...)``outputPaths` を読まない | 引数と全 call-site の渡し値を除去 |
| `src/mapPostAdminTransport.js:4201` | `snapNearMissEndpoints(..., outPaths, ...)``outPaths` を読まないが、多数の call-site が渡す | 引数と渡し値を除去 |
| `src/mapPostAdminTransport.js:1065` | `finalizeAdminAwareTransport``geography` は signature だけ。pipeline の `generationContext` は callee で bind もされない | call contract を縮小 |
| `src/mapAdminStage.js:117-135` | `villageInfluence``industrialZones``logisticsParks``geographicBarrierCost` は signature のみ。`stations` は未使用引数として次段へ渡すだけ | producer / consumer 間で段階的に除去 |
`stableWorldTerrain` は挙動への効果がないことを repo 内で確認できる。`legacyTerrain` は計算には不要でも serialized metadata を外部が読む可能性があるため、同じ確信度で即削除とはしない。
## 5. 短縮できる高コスト・迂遠処理
### A-08. post-admin transport の dedupe / repair / prune 反復
`src/mapPostAdminTransport.js` では、修復で経路を追加し、dedupe / prune を行い、service repair で再追加する流れが `3838-4006``4395-4701` に集中している。
主な呼び出し回数(関数定義を除く)は次の通り。
- `dedupePaths`: 69回
- major-city national link 補完: 14回
- major-city rail link 補完: 16回
- major-city expressway link 補完: 12回
- parallel corridor collapse: 15回
- final parallel prune: 13回
- rail / national ratio の densify: 9回
- rail / national ratio の cap: 9回
`dedupePaths``mutate: false` の場合、重複がゼロでも全 path と全 `[x, y]` を複製する。さらに `src/mapTransportUtils.js:213-219` は各 path 内で署名関数を作り、`map` / `filter` / `join` を往復2回、逆順用の配列も生成する。
**短縮案**
1. network ごとの revision / dirty flag を持ち、変更されていない配列の再正規化だけを省く。正規化の semantic boundary は移動しない。
2. repair 群を上限付き収束ループへまとめる場合、従来の pass 順、上限、tie-break を維持し、state revision が不変のときだけ終了する。
3. 各 stage の add / remove / replace 件数を seed corpus で記録する。ただし、観測した seed で0だったことだけを根拠に pass を削除しない。静的 precondition または shadow 実行との完全一致を削除条件にする。
4. path signature は helper を外へ出し、一時配列を作らない添字走査にする。
順序を変えると地図出力が変わるため、一括統合はしない。これは完全生成 pass の削減案ではなく、同じ pass の重複実行・全量複製を対象にした等価最適化候補である。各変更で path 順序、座標列、debug counter、最終 hash の一致を要求する。
### A-09a. 自治体 topology repair の O(所有者数 × 全セル) 走査
`src/adminRegionsCore.js:5-97``enforceMunicipalityConnectivityStrict` は、全所有者の component を一度の全域走査で集める。一方 `repairAdminTopology` (`:117-143`) は所有者ごとに `Uint8Array(SIZE)` を確保し、全セルを走査する。同処理は `:1132``:1262` から呼ばれる。
**短縮案:** 共通の `collectComponentsByOwner` を一度実行し、中心 component の保護・再割当規則だけを各処理へ残す。owner 順、component 探索順、同点時の再割当先を変えない。
### A-09b. 人口再推定と合併反映の全域走査
- `src/mapAdminStage.js:386` で人口を推定する。
- 合併があると `:478` で再推定し、その直後 `:500` でも無条件に再推定する。
- 合併先ごとに `:459-461``adminId` 全域を再走査する。
- 実体の `src/mapMunicipalDemography.js:13-42` もラスタ全域を走査する。
無合併でも推定2回、合併時は3回になり得る。合併先 remap を蓄積して最後に一度だけ全域へ適用し、変更なしなら初回推定を再利用、変更ありなら最後に一度だけ再推定できる。ただし、中間推定値を読む処理がないことを call graph と regression test の両方で確認し、人口モデル自体は変更しない。
### A-13. 小さな local connector ごとの全寸法 workspace
`src/initialGenerationCrop.js:749-890``localConnector` は、小さな bbox と展開上限360で探索する一方、呼び出しごとに全マップ寸法の `Float32Array``Int32Array``Uint8Array` を確保・初期化する (`:768-774`)。open set も線形最小値探索と `splice` を使う (`:776-782`)。
**短縮案:** bbox 分だけの配列、世代番号付きの再利用 workspace、binary heap のいずれかへ置換する。類似する `src/mapPostAdminTransport.js:4201-4260` の near-miss endpoint repair と共通化できるが、hidden crop の寸法差は明示的に注入する。heap 化では同 cost の取り出し順が変わるため、既存の tie-break を key に含め、経路座標の完全一致を合格条件にする。
### A-14a. viewport raster をフィールドごと・セルごとにコピー
`src/worldViewport.js:66-80` は各フィールドについて全 x / y を走査し、各セルで `worldIndexOf` を呼ぶ。この処理が `:205-208` から多数の raster に繰り返される。`src/initialGenerationCrop.js:110-118` には既に `subarray` + `set` による行単位コピーがある。
**短縮案:** 行ごとの source / destination 範囲を一度求め、範囲内を bulk copy、画面外だけ既存値で fill する。負の camera 座標、部分領域外、各 typed-array constructor について cell-by-cell 実装との完全一致を検証する。
### A-14b. admin target / compartment / output の反復走査
| 場所 | 現状 | 短縮案 |
|---|---|---|
| `src/mapAdminCompartmentRepair.js:208-315,608-615` | owner 一覧を typed array の spread / filter / Set で毎回作り、owner ごとに全 compartment を filter | 1巡で `Map<owner, compartments[]>` を構築 |
| `src/mapAdminTargets.js:31-55` | 一度全域走査した直後に `basinField` を spread / filter して再集計 | 初回走査で basin 数も加算 |
| `src/mapAdminTargets.js:160-168` | 各セルで全 `modernCities``some` + `Math.hypot` | dense-core mask の事前 raster 化または spatial index |
| `src/mapOutput.js:495-517` | 各中心について same-admin / fallback の2段階で全 feature を走査 | feature の admin id を一度付け、1巡で bestSame / bestAny を更新 |
| `src/mapAdminUrbanCatchments.js:64-92` | satellite ごとに全都市を複製・距離 sort して先頭だけ使用 | 二乗距離の線形 min scan。satellite index も一度だけ取得 |
これらは生成規則の近似化ではない。同じ候補集合と同じ tie-break から同じ結果を得る、走査回数と一時配列だけの削減である。
## 6. 重複実装と仕様ずれの温床
### A-10a. `mapPatchWorker` の同期・非同期探索
`src/mapPatchWorker.js:1150-1518``:1522-2608` は、初期化、候補 prepare / evaluate / finalize、snapshot 構築を数百行規模で重複する。通常候補探索だけでも `:1241-1517``:2328-2607` がほぼ同じである。
**統合案:** search environment と候補処理を共通化し、同期 executor と yield / cancel 対応 executor の違いだけを wrapper に残す。候補数や full-finalization 数は減らさず、進捗イベント順、cancel 境界、transaction rollback、candidate ranking を契約テストで固定してから行う。
### A-10b. `mapPipeline` の同期・非同期版
`src/mapPipeline.js:273-386``:388-494` は terrain、geography、features、admin context、transport、出力組立を重複する。既に以下の仕様差がある。
- 同期版 `:285-294``_precomputedTerrainDraftCandidate` を扱う。
- 非同期版 `:397-403``_precomputedDraftCandidate` だけを扱う。
- matcher も `:169-180``:182-193` に近似重複する。
**統合案:** 共通 stage plan / core を作り、同期版は直列 drain、非同期版は yield / await を注入する。stage の省略ではなく同一 stage list の共有とし、同じ seed / option で生成 field、feature、debug、hash が一致することを必須にする。
### A-14c. preview 差分判定
`src/app.js:2625-2692``src/mapPatchWorker.js:1091-1148` が同じ field / feature 集合を別実装で比較する。worker 側の comparator (`:477-515`) は `Date``Map``Set``patchGenerated` を扱うが app 側は扱わず、fallback 時の意味が一致しない。
**統合案:** field 定数、structured comparator、delta builder を pure module に切り出す。
### A-14d. A* 系と influence cache
- `src/mapPostAdminTransport.js:232-373``:375-474` は bounds、stamped workspace、8近傍、terrain cost、relaxation、traceback を重複する。`routeTerrainSearch(..., profile)` の共通 core にできるが、tie-break と探索順を完全に維持する必要がある。
- `src/mapTransportUtils.js:46-56` の influence cache signature は path 数、総点数、端点座標の総和だけで、内部頂点だけが変わると衝突し得る。一方 `src/mapFeatures.js` は phase ごとに異なる label を多用して hit しにくい。path-set identity + revision + radius を key にし、変更時に invalidate する方が単純で安全である。
- `src/mapFeatures.js:2161-2163` は同じ道路配列の結合を3回作るため、一度だけ hoist できる。
### A-14e. UI診断と worker mirror protocol
- viewport 診断行: `src/app.js:1182-1203``:1683-1695`
- feature count 行: `src/app.js:1205-1216``:1697-1709`
- worker 行: `src/app.js:1270-1280``:1712-1722`
- world 行: `src/app.js:1282-1291``:1724-1735`
UI表示と clipboard report に同じ row builder を使える。
また、production の mirror protocol (`src/app.js:2722-2768`) を `tests/patch-worker-mirror-sync.mjs:11-48` が独自に再実装している。テストが production helper ではなく自分のコピーを検証し得るため、pure helper を export して直接テストする。
### A-14f. 小規模な共通化候補
- `centerMunicipalityId`: `src/mapAdminStage.js:338-344``src/mapOutput.js:47-53`
- quantile: `src/mapTerrain.js:43-50``src/rectContext.js:79-86`rect 系を残す場合)
- `nowMs`: `src/mapUtils.js:14-16``src/mapPatchWorker.js:786-788`
- patch result metadata: `src/mapPatch.js:13376-13425``:13453-13501`
- `src/mapPatchWorker.js:653-689` の field category 判定は変更セルごとでなく field loop 外へ移せる
## 7. テストの重複・低効果・不使用
### A-02. 未知 suite が何も検証せず成功し得る
`tests/test-all.mjs:22-23,54-64` は環境変数の任意文字列を `test.js --suite=...` へ渡す。`tests/test.js:20-34` は既知名との比較だけで、未知名を reject しない。その場合、主要 test block を一つも通らず、最後の所要時間 assertion (`:1583-1592`) だけで exit 0 になり得る。
**是正案:** suite を単一 registry にし、未知名は実行前に即失敗させる。これは整理より先に直すべき test trust の問題である。
### A-03. runner / README が未追跡ファイルを必須参照
監査時点では次が未追跡である。
- `tests/test-all.mjs:8-9,46-47,58-61` が参照する `tests/r10-exact-production-worker.mjs`
- 同じく参照する `tests/r11-selection-native-production.mjs`
- `README.md:34` が案内する `tests/r11.8-terrain-topology-tooltip.mjs`
- 上記 r11.8 が import する `tests/helpers-generation-worker-node-wrapper.mjs`
作業ツリー全体では動いても、一部だけ commit すると canonical command が壊れる。正式テストなら依存を一括追跡し、実験物なら runner / README から外す。単純削除はしない。
### A-07a. 到達不能・temp・旧 helper
| ファイル | 根拠 | 推奨 |
|---|---|---|
| `tests/r11-selection-debug-temp.mjs:76-120` | 結果出力直後の `process.exit(0)` により後続 assert / finally が到達不能 | 削除 |
| `tests/r11-selection-v1-temp.mjs` | `r11-selection-native-production.mjs` と実質7行程度の差 | variant をパラメータ化して統合後、temp を削除 |
| `tests/r11-large-bestof-temp.mjs` | 3行の診断スクリプト。正式な `r11.6-large-bestof-quality.mjs` が同 fixture を詳細検証 | 削除 |
| `tests/test-determinism-worker.mjs` | repo 内参照なし。現 determinism shard は `test.js` 内で直接生成 | 外部 CI 直呼びを確認後に削除 |
### A-07b. full-map / worker generation の重複
ここで削減するのは、**テスト間で同じ完全生成を無条件にやり直す回数**だけである。production の candidate plan、quality retry、full-finalization 数を減らす提案ではない。fixture を共有できるのは seed、variant、rect、全 option、worker 経路が一致し、各 assertion が生成物を変更しない場合に限る。
1. **seed 999 determinism**
- core (`tests/test.js:1291-1308`) が seed 999 を2回生成し、広い決定性を検査する。
- default の `determinism-999` (`tests/test-all.mjs:20`, `tests/test.js:1373-1380`) も2回生成し、前者の真部分集合だけを検査する。
- broad core assertion が subset を完全に包含することを対応表で残した上で、重複する invocation だけを外せる。 retained core は同じ完全生成を行う。
2. **seed 54321 の弱い variation check**
- `tests/test.js:710``other = generateTestMap(54321)``:1285-1287` の「3種の件数のどれかが seed 12345 と違う」という1 assertion にしか使わない。
- terrain suite が同一 option の seed 54321 完全生成物を保持できる構成へ変更し、その生成物へ assertion を移せる。別設定なら共有しない。
3. **r11.5 / r11.7 / r11.8**
- seed 1 を3回、seed 2を2回、同じ worker 経路で full generation する。
- 各ファイルの生成 option と worker 経路が一致し、検査が read-only であることを確認してから、seed ごとに一度完全生成し複数 contract を当てる table-driven `initial-generation-quality` へ統合する。この範囲では5回から3回になる。
4. **coverage-hole fixture**
- `additional-generation-coverage-worker.mjs``r11-bestof-coverage-context.mjs` は world seed 8、rect `{20,120,180,230}`、base seed 123 を共有する。
- best-of 固有 assertion と候補数は維持したまま既存 coverage worker の別 case へ統合し、同一の初期 full map だけを再利用できる。
5. **maximum expansion fixture**
- `additional-generation-max-worker.mjs``r11-selection-native-production.mjs` は world seed 114514、variant 0、470 × 333 lasso、同じ seed hash / option / 1 candidate を使う。
- 安定した1本へ assertion を統合する。canonical 実行と extended performance 実行の違いは timeout / memory 計測の有無に限定し、生成 option、候補数、quality gate は切り替えない。
fixture helper と seed derivation も `additional-generation-e2e.js`、max-worker、cancel、r11 selection、r11.6 などにコピーされている。正式に残すテストだけを対象に helper 化する。
`tests/r10-exact-production-worker.mjs` の optimized 対 exhaustive 比較は、「簡易生成を公開しない」ことの中核契約なので削減対象外とする。また、r11.4 の同一 seed は `initialGenerationOverscan` 等の option が異なるため、同じ fixture とみなさない。
### A-07c. determinism suite の seed 指定が実質無効
`tests/test.js:1373-1375` では suite 名が単に `determinism` の場合も `"determinism-".length` で slice し、空文字を `Number("") === 0` と解釈する。そのため `--suite=determinism --seed=...` は指定 seed でなく常に0を選ぶ。
suffix が空のときだけ `DETERMINISM_SEED` を使う分岐にする。
### A-11. 大量の source-string assertion
`tests/test.js:46-61` は test.js shard の起動ごとに約1.43 MBの source を文字列として無条件に読む。default では同ファイルを多数の process で起動し、`mapPipeline.js` は二重読込、`test.js` 自身も自己ソース検査のため読む。
`tests/test.js` には `*Source.includes(...)` が238箇所あり、主に `:830-1086` に集中する。同種検査は r11.4、r11.5、r11.7、r11.8 にも重複する。この方式はコメントや死んだコードでも通り、rename だけで壊れるため、機能契約としての信頼性が低い。実際 `tests/r11.8-terrain-topology-tooltip.mjs:178` は不使用の `desiredGap` 宣言の存在を要求し、dead code を温存している。
**短縮案**
- 構造制約が必要なものだけ AST / lint ベースの少数 static suite へ分離する。
- 機能契約は behavior test へ移し、置換テストが先に通るまで source assertion を外さない。
- 少なくとも source 読込は該当 suite 内で lazy にし、二重読込と自己読込を除去する。
とくに `fullProductionFromTerrainOnly``reusedWinningDraft === false`、optimized / exhaustive の同一 variant・score、`simplifiedOutputForbidden` は文字列存在検査だけにせず、実 Worker の結果で維持する。
### A-12. `test-all` の出力収集
`tests/test-all.mjs:70-85` は chunk ごとに `current + chunk` で既存文字列全体をコピーし、`Buffer.byteLength(next)` で全体を再走査するため、出力増加に対して二次的になり得る。stdout / stderr がそれぞれ32 MBまで許されるので、`maxOutputBytes` は合算32 MBではなく最大約64 MBであり、最後に `:120` で再度連結する。
**短縮案:** stdout / stderr 共通の byte counter と chunk 配列を使い、必要時に一度だけ連結する。
また、standalone の Node assert failure は通常 `NG:` を出さないため、`:121,165-170` では test failure でなく `infrastructureFailure` に分類される。非zero exit と spawn / timeout / signal / overflow を分ける。
決定性検査にも不要な大型一時値がある。`tests/test.js:1296-1307,1378` は typed array を `[...array]` で通常配列へ展開し、さらに `JSON.stringify` で巨大文字列へ変換する。同ファイルには既に `arraysEqual` (`:76-82`) があるため、直接比較または byte compare に統一できる。
### A-14g. test suite registry と到達経路
`tests/test-all.mjs` は suite 名を次の4箇所で重複管理する。
- default list (`:5-21`)
- timeout map (`:28-39`)
- standalone file 定数 (`:43-47`)
- 実行先を選ぶ nested ternary (`:54-62`)
`{ name, file, timeout, group }` の descriptor registry に一元化し、`canonical``extended/performance``browser` の manifest を明示する。「fast」は簡易生成 mode と誤解されるため test group 名には使わない。
`README.md` も canonical command と release / perf / browser / manual gate の境界を説明していない。manifest 整理後に各入口と採用基準を短く記載する。
現在、追跡済みでも canonical `test-all` から到達しない独立 gate がある。
- `additional-generation-max-worker.mjs`
- `patch-worker-cancel.mjs`
- `patch-worker-mirror-sync.mjs`
- `run-additional-generation-browser.mjs`
r11.4〜r11.7にも固有 assertion があるため、到達しないという理由だけで削除してはいけない。manifest へ正式採用するか、既存 suite へ contract を移植してから削除する。
### raw worker E2E と実アプリ browser E2E
`additional-generation-e2e.html/js` は raw worker の generation / preview / apply / progress / heap を検査し、`run-additional-generation-browser.mjs` は実際の `index.html` を開いて同じ契約と UI / cancel を検査する。後者の server にある `/` から raw HTML への route は、その runner 自身から使われない。
raw harness 固有 assertion を実アプリ runner へ先に移植し、同じ full worker candidate、preview / apply、progress、cancel 契約を検証できた場合に限り、手動利用のない HTML / JS / unused route を削除できる。
### performance / memory gate の意味
`tests/additional-generation-max-worker.mjs:42-49``process.memoryUsage().heapUsed` は親 isolate 側の値で、重い Worker heap の直接ピークを表さない。RSS は process 全体だが、機能検査は他の470 × 333 fixture と重複する。性能 gate を残すなら Worker 内計測または browser `performance.memory` に一本化する。
## 8. CSS・静的資産
### 未使用 selector / property
- `styles/styles.css:135` `.microcopy`
- `styles/styles.css:196` `.express-line`
- `styles/styles.css:120` button transition の `transform .16s`(対象の通常 / hover / active / disabled に transform 指定なし)
### 未使用 custom property
次の custom property は CSS 全体で `var(...)` 参照がない。
- `styles/styles.css:3` `--bg`
- `styles/styles.css:4` `--bg-2`
- `styles/styles.css:8` `--surface-soft`
- `styles/styles.css:9` `--panel`
- `styles/styles.css:17` `--accent-soft`
将来の token 化予定がなければ削除できる。
### 同一 block / 分散 media query
- `.diagnostic-table` (`styles/styles.css:480-487`) と `.diagnostic-log` (`:504-511`) の宣言は完全一致するため selector を結合できる。
- 1280px と720pxの media query が複数箇所に分散している。breakpoint 単位の集約は行数と可読性の改善に留まり、優先度は低い。
## 9. 削除しないもの・先に契約確認が必要なもの
### server helper
`scripts/start_server.bat` / `scripts/start_server.sh``README.md:16-28` から使われるクロスプラットフォーム入口であり、保持が妥当である。`scripts/router.php:7-15` の dot-segment 配信防止も実効性がある。`scripts/router.php:4-5` の legacy directory / `.htaccess` 説明だけは現作業ツリーとずれており、コメント更新候補である。
### standalone test の固有 coverage
mirror sync、cancel、coverage、browser UI などは canonical runner から到達しなくても固有 assertion がある。別 suite へ移植する前の単純削除は coverage を失う。
同様に、`src/committedWorldDelta.js:97``materializeCommittedWorldDelta``src/mapPatchWorker.js:1150` の同期 `runPatchCandidateSearch` は production caller がなくてもテストから利用される。後者を削る場合は、テストを非同期版へ移して同じ cancellation / progress / ordering 契約を維持する必要がある。
### export / facade
repo 内参照ゼロでも、`mapGenerator.js``adminRegions.js`、rect terrain export などは外部 consumer が使う可能性がある。package 公開面、HTML の動的 import、外部 CI / script を確認してから削除する。
`src/app.js:2826` の private `runPatchInWorker` は、唯一の caller (`:3217-3222`) が常に `operation` を渡すため、`:3127-3131` の full-world dispatch 互換分岐は current production では到達不能である。private 関数なので削除しやすいが、worker mirror protocol の整理と同時に扱うと意図が明確になる。local preview fallback や旧 fixed-batch API はコメント上も互換経路であり、関連テストを移す前には削除しない。
### r11.4 fixture
同じ seed を使っていても `initialGenerationOverscan: false` など option が異なる fixture は等価ではない。seed だけを見て統合しない。
### 完全生成の契約テストと finalizer
次は重い、反復している、名称が draft / fast を含むという理由では削除しない。
- `tests/r10-exact-production-worker.mjs` の optimized 対 exhaustive full-production 比較
- `tests/r11.5-visible-quality-finalizer.mjs` の visible crop full-production 検査
- `tests/r11.7-terrain-routed-transport-density.mjs` の terrain-routed transport / density 契約
- `src/mapPatchWorker.js:1934-2005` の full finalist 実行
- `src/mapPatch.js:12945-12960` の full patch candidate 生成
- `src/mapPostAdminTransport.js:3985-4015` の visible-core service / density / alignment finalizer
これらの内部で重複走査を減らす場合も、stage の存在と最終契約は維持する。
### repository layout assertion
`tests/r11.8-terrain-topology-tooltip.mjs:191``archive/``docs/` が存在しないこと自体を assert する。製品挙動ではなく repository policy であり、文書追加だけでも失敗要因になる。この監査文書を repo root に置いた理由でもある。必要なら runtime regression から lint / policy check へ移す。
## 10. 完全生成を維持する実施順序
今回は未実施。実作業へ進む場合は、次の順なら原因切り分けと回帰確認がしやすい。
### Phase 0: full-production baseline の固定
1. seed、variant、rect、全 option、candidate plan を固定した baseline を作る。
2. field typed array、feature 座標と順序、admin / transport debug、accepted world hash、quality score を保存する。
3. optimized search と exhaustive search が同一 winner / score を返すことを確認する。
4. published candidate が `reusedWinningDraft === false``fullProductionFromTerrainOnly === true` を満たすことを確認する。
Node 実行環境が利用可能になるまでは、この baseline を必要とする production refactor に着手しない。
### Phase 1: 検証基盤の修復
1. suite registry を一元化し、未知 suite を reject する。
2. runner が参照する未追跡テストの採否を決める。
3. determinism seed 指定の不具合を直す。
4. source-string だけの完全生成契約を実 Worker behavior test へ置換する。
### Phase 2: 公開結果へ反映されない処理の修復
1. stale alias の identity 回帰テストを追加する。
2. A-01 の pass を削らず、公開 `features.*` へ適用する。
3. 修正後の trunk service、density、parallel alignment、terrain validity を full generation で確認する。
4. これは意図した correctness change なので、差分を「等価最適化」として隠さない。
### Phase 3: 静的に確定した無駄を除去
1. `barrierCost` / `corridorCost` の生成を除去する。
2. 一時結合鉄道配列の破棄される prune を除去する。
3. local dead helper、unused import / binding、no-op UI / CSS を整理する。
4. temp / unreachable test は、固有 assertion を retained full-production test へ移植してから削除する。
5. 各変更で full-production baseline が同一であることを確認する。
### Phase 4: 重複する高コスト test と実装を等価統合
1. 全 option が一致する read-only fixture に限り、一度の完全生成へ複数 contract を適用する。
2. test manifests と fixture helper を整理するが、candidate、seed、assertion、quality gate は減らさない。
3. preview delta、diagnostic rows、mirror helper を共通化する。
4. worker / pipeline の同期・非同期 core を段階的に共有し、stage list と output を一致させる。
### Phase 5: shadow 比較後に反復処理を等価短縮
1. post-admin transport の各 pass に add / remove / replace / elapsed time を付ける。
2. representative seed corpus と最大矩形で、連続 zero-delta pass と直後に打ち消される変更を記録する。
3. 計測は候補発見にだけ使い、sample 上の zero-delta だけでは pass を削らない。
4. 旧実装を shadow 実行し、dirty flag、workspace 再利用、全域走査一括化、上限付き収束ループを一つずつ比較する。
5. path / field / feature / debug / hash が一致した等価置換だけを採用する。違いが出た場合は簡易化せず旧実装を維持する。
## 11. 検証条件と限界
- repo 全体の識別子検索、import / export 参照、呼び出し位置、値の読み取り、配列再代入後の identity を静的に照合した。
- `node``npm``eslint` は監査時の shell から解決できず、test 実行、benchmark、heap profile は行っていない。
- そのため、反復 pass の削減率や時間短縮値は断定していない。A-08 は特に計測後判断とする。
- 外部 repository、外部 CI、利用者の手動 command、公開 API consumer は監査対象外である。
- 監査開始前から削除状態だった `archive/generated-history``archive/legacy-project` 等には触れず、削除済み内容も評価対象外とした。
## 12. 完了条件の目安
整理作業を完了とみなすには、行数削減や速度向上だけでなく次を満たす必要がある。
- 全 suite 名が registry に存在し、未知名は失敗する。
- runner / README の全必須ファイルが追跡され、fresh checkout から到達できる。
- final transport の監査対象と公開 `features.*` が同じ配列 identity、または明示的に同じ戻り値を使う。
- repo 内参照ゼロの export は外部契約の採否が記録される。
- full-map / worker fixture の重複回数が manifest 上で説明できる。
- 等価最適化では同じ seed / option の field、feature、順序、quality score、accepted world hash が一致する。
- correctness 修正で意図的に出力が変わる場合は、差分と改善された production contract が記録される。
- scout / draft / private tile は単独で公開されず、公開候補は terrain 以外の全 production stage を再実行する。
- optimized search は exhaustive full-production search と同一 winner / score を返す。
- candidate 数、quality retry、overscan、transport parity、地形制約、quality threshold を性能目的で下げていない。
- source assertion を削る場合は、同じ契約を検証する behavior test が先に存在する。
- 反復 pass は変更件数と所要時間を観測でき、旧実装との shadow 比較で完全一致する。
どれかを満たせない変更は、短縮ではなく簡易生成または仕様変更として却下する。

View file

@ -1,84 +1,54 @@
# Prefecture Map Generator
A browser-based procedural prefecture map generator.
Browser-based procedural prefecture map generator.
## Project layout
- `src/` - application modules and web workers
- `styles/` - application styles
- `tests/` - reusable browser and Node.js tests
- `tests/` - browser and Node.js regression tests
- `scripts/` - local development server helpers
- `docs/` - design history and release verification
- `archive/` - recoverable historical and legacy files, excluded from the active app
- `index.html` - application entry point
## Run locally
On Windows:
Windows:
```bat
scripts\start_server.bat 8000
```
On macOS or Linux:
macOS / Linux:
```sh
./scripts/start_server.sh 8000
```
Then open `http://127.0.0.1:8000/`.
Open `http://127.0.0.1:8000/`.
## Tests
Run the aggregate Node.js regression runner:
The default manifest runs the fast, canonical Node.js gates:
```sh
node tests/test-all.mjs
```
Heavy full-map shards can also be run independently, which is the recommended CI layout for memory-constrained workers:
The release manifest includes the default gates plus the longer production,
quality, cancellation, and mirror-synchronization checks:
```sh
node tests/additional-generation-unit.mjs
node tests/additional-generation-coverage-worker.mjs
node tests/test.js --suite=core
node tests/test.js --suite=terrain
node tests/test.js --suite=terrain-name
node tests/test.js --suite=admin
node tests/test.js --suite=patch
node tests/test.js --suite=patch-large
node tests/test.js --suite=determinism-114514
TEST_GROUP=release node tests/test-all.mjs
```
Run the focused additional-generation release gates:
The browser manifest launches the application-level Chromium regression test:
```sh
node tests/patch-worker-mirror-sync.mjs
node tests/patch-worker-cancel.mjs
node tests/additional-generation-max-worker.mjs
TEST_GROUP=browser node tests/test-all.mjs
```
The maximum-visible Expansion gate accepts a world seed and Variant through environment variables. CI should run these as independent matrix jobs rather than retaining multiple full worlds in one process:
```sh
PATCH_TEST_WORLD_SEED=12345 PATCH_TEST_VARIANT=0 node tests/additional-generation-max-worker.mjs
PATCH_TEST_WORLD_SEED=54321 PATCH_TEST_VARIANT=1 node tests/additional-generation-max-worker.mjs
```
Run the browser smoke profile:
```sh
node tests/run-additional-generation-browser.mjs
```
Run the 20-sample maximum-visible Expansion browser profile:
```sh
BROWSER_E2E_PROFILE=release \
BROWSER_E2E_WORKLOADS=expansion-max-visible \
node tests/run-additional-generation-browser.mjs
```
The browser test page is also available at `http://127.0.0.1:8000/tests/test.html`.
For the current r3 additional-generation verification record, see `docs/additional-generation-release-verification-20260810.md`.
On PowerShell, set a group with `$env:TEST_GROUP = "release"` (or
`"browser"`) before running the same command. To run specific registered
suites, provide a comma-separated `TEST_SUITES` value. Unknown suite and group
names fail immediately rather than succeeding without assertions.

View file

@ -1,8 +0,0 @@
# 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

@ -1,38 +0,0 @@
# 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

@ -1,198 +0,0 @@
{
"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

@ -1,30 +0,0 @@
{
"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

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

View file

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

View file

@ -1,96 +0,0 @@
{
"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

@ -1,47 +0,0 @@
# 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

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

View file

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

View file

@ -1,36 +0,0 @@
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

@ -1,28 +0,0 @@
# 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

@ -1,27 +0,0 @@
# 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

@ -1,42 +0,0 @@
# 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

@ -1,77 +0,0 @@
# 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

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

View file

@ -1,51 +0,0 @@
{
"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

@ -1,18 +0,0 @@
{
"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

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

View file

@ -1,26 +0,0 @@
{
"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

@ -1,17 +0,0 @@
{
"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

@ -1,36 +0,0 @@
# 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

@ -1,126 +0,0 @@
# 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

@ -1,21 +0,0 @@
{
"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

@ -1,83 +0,0 @@
{
"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

@ -1,8 +0,0 @@
{"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

@ -1,168 +0,0 @@
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

@ -1,137 +0,0 @@
{
"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

@ -1,137 +0,0 @@
{
"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

@ -1,86 +0,0 @@
# 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

@ -1,63 +0,0 @@
{
"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

@ -1,112 +0,0 @@
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

@ -1,29 +0,0 @@
{
"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

@ -1,29 +0,0 @@
{
"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

@ -1,128 +0,0 @@
# 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

@ -1,181 +0,0 @@
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

@ -1,207 +0,0 @@
# 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

@ -1,123 +0,0 @@
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

@ -1,145 +0,0 @@
# 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

@ -1,181 +0,0 @@
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

@ -1,32 +0,0 @@
{
"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

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

View file

@ -1,61 +0,0 @@
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

@ -1,37 +0,0 @@
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

@ -1,97 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,12 +0,0 @@
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

@ -1,18 +0,0 @@
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);});
const summary={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};
console.log(JSON.stringify(summary,null,2));
assert.equal(summary.outer,true,'worker transport must succeed');
assert.equal(summary.inner,true,`large patch must succeed: ${summary.reason||'unknown failure'}`);
assert.equal(summary.tiled,true,'large selection must use tiled production generation');
assert.ok(summary.tileCount>=2,'large selection must execute multiple production tiles');
assert.ok(summary.ms<60000,`large worker patch exceeded 60 s budget: ${summary.ms} ms`);
await worker.terminate();

View file

@ -1,115 +0,0 @@
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

@ -1,57 +0,0 @@
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

@ -1,13 +0,0 @@
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

@ -1,109 +0,0 @@
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

@ -1,70 +0,0 @@
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

@ -1,23 +0,0 @@
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 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);
assert.equal(ra.ok,true,`258x183 patch failed: ${ra.reason||ra.code||'unknown'}`);
assert.equal(rb.ok,true,`259x184 patch failed: ${rb.reason||rb.code||'unknown'}`);
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));
assert.ok(out.common.elevation.rate<=0.08,`common elevation changed too much: ${out.common.elevation.rate}`);
assert.ok(out.common.sea.rate<=0.05,`common sea mask changed too much: ${out.common.sea.rate}`);
assert.ok(out.common.prefectureRegionIdBoundary.normalized<=0.15,`prefecture boundary threshold instability: ${out.common.prefectureRegionIdBoundary.normalized}`);
assert.ok(out.common.adminIdBoundary.normalized<=0.15,`municipal boundary threshold instability: ${out.common.adminIdBoundary.normalized}`);

View file

@ -1,27 +0,0 @@
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

@ -1,20 +0,0 @@
# 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

@ -1,351 +0,0 @@
{
"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

@ -1,73 +0,0 @@
{
"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

@ -1,42 +0,0 @@
{
"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

@ -1,6 +0,0 @@
<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

@ -1,44 +0,0 @@
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

@ -1,183 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1,27 +0,0 @@
{
"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

@ -1,198 +0,0 @@
"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);
}
});

77
debug.log Normal file
View file

@ -0,0 +1,77 @@
[0811/195703.472:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/195718.384:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/200517.655:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/200517.673:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/200626.597:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/200716.147:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201020.374:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201020.507:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201020.524:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201020.541:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201027.313:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201027.343:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201052.598:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201220.250:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201220.259:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201236.912:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201236.985:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201536.206:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201536.289:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201536.315:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/201536.378:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202025.932:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202025.934:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202025.921:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202025.952:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202025.954:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202025.965:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202025.972:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202026.096:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202026.107:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202026.119:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202026.318:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202144.261:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202144.317:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202144.444:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202144.471:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202206.145:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202206.211:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202214.252:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202338.620:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202703.523:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202703.524:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202703.591:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202711.074:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/202936.029:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203018.405:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203140.233:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203140.254:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203141.219:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203207.351:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203419.276:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203419.337:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203419.410:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203507.754:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203507.811:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203507.885:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203712.218:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203712.588:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203712.636:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203712.645:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203712.658:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/203712.685:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/204025.059:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/204026.065:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/204206.473:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/205218.401:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/205254.833:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/205504.560:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/205539.533:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/205745.555:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/210100.929:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/210717.034:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/210717.220:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/210717.270:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/210717.324:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/210717.325:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)
[0811/210719.058:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: アクセスが拒否されました。 (0x5)

View file

@ -1,8 +1,8 @@
<?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.
// Router for PHP's built-in development server. Reject dot-prefixed path
// segments explicitly because the built-in server does not apply .htaccess.
$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) {

View file

@ -1 +0,0 @@
export * from "./adminRegionsCore.js";

View file

@ -87,7 +87,6 @@ const showLabelsInput = document.getElementById("showLabels");
const showSeamDiagnosticsInput = document.getElementById("showSeamDiagnostics");
const modeGrid = document.getElementById("modeGrid");
const mainLegendGrid = document.getElementById("mainLegendGrid");
const floatingLegendGrid = document.getElementById("floatingLegendGrid");
const statsEl = document.getElementById("stats");
const advancedGenerationStatsEl = document.getElementById("advancedGenerationStats");
const advancedGenerationHistoryEl = document.getElementById("advancedGenerationHistory");
@ -128,8 +127,9 @@ let patchGeometryPreviewCache = null;
let patchWorkerEpoch = 0;
let patchWorkerConstructorCount = 0;
let patchSearchSeries = { contextId: null, consumedCandidateIds: new Set(), nextVariant: null };
const PATCH_SEARCH_DEFAULT_LIMIT = 3;
const PATCH_SEARCH_LARGE_LIMIT = 2;
const PATCH_SEARCH_BATCH_SIZE = 3;
const PATCH_SEARCH_DEFAULT_LIMIT = 12;
const PATCH_SEARCH_LARGE_LIMIT = 12;
const PATCH_WORKER_STALL_MS = 120_000;
const PATCH_NON_COOPERATIVE_DEADLINE_MS = 300_000;
let generationWorker = null;
@ -718,6 +718,14 @@ function commitPendingPatch({ redrawAfter = true } = {}) {
const acceptedPatch = state.pendingPatch;
const applyWorker = acceptedPatch.worker && acceptedPatch.result?.applyToken ? patchWorker : null;
state.world = acceptedPatch.world;
// Seam diagnostics are a preview aid. Once the patch is applied, disable the
// magenta/red dashed overlay in both the committed source data and UI state so
// it cannot remain stuck on the map after the selection overlay disappears.
if (state.world?.sourceMap?.patchSeamDiagnostics) {
state.world.sourceMap.patchSeamDiagnostics.enabled = false;
}
state.showSeamDiagnostics = false;
if (showSeamDiagnosticsInput) showSeamDiagnosticsInput.checked = false;
const committedRevision = advanceCommittedRevision({ preservePatchWorker: !!applyWorker });
state.map = state.world.sourceMap || state.map;
state.lastPatchResult = state.pendingPatch.result || state.world.lastPatchResult || state.lastPatchResult;
@ -1236,7 +1244,7 @@ function seamDiagnosticRows() {
{ label: "Candidate mode", value: d.patchGenerationMode || "-", sub: modeSub },
{ label: "Candidate window", value: `${Number(d.candidateWidth || 0)}×${Number(d.candidateHeight || 0)}`, sub: `${formatPercent(Number(d.candidateAreaRatio || 0) * 100)} of full candidate area` },
...(d.qualityPolicyVersion ? [
{ label: "Expansion quality gate", value: d.qualityHardPass ? "PASS" : "BEST AVAILABLE", sub: `${d.qualityPolicyVersion} · score ${Number(d.qualityScore || 0).toFixed(3)} · selected variant ${Number(d.qualitySelectedVariant || 0)}` },
{ label: "Expansion quality gate", value: d.qualityHardPass ? "PASS" : "FAIL", sub: `${d.qualityPolicyVersion} · score ${Number(d.qualityScore || 0).toFixed(3)} · selected variant ${Number(d.qualitySelectedVariant || 0)}` },
{ label: "Candidate land quality", value: formatPercent(Number(d.qualityLandRatio || 0) * 100), sub: `${d.qualityTerrainType || "-"} · ${formatPercent(Number(d.qualityDevelopableRatio || 0) * 100)} developable · ${formatPercent(Number(d.qualityLargestComponentRatio || 0) * 100)} largest component` },
{ label: "Candidate place density", value: Number(d.qualityLabelCount || 0).toLocaleString(), sub: `${Number(d.qualitySettlementCount || 0)} settlements · ${Number(d.qualityLabelDensityPer1000 || 0).toFixed(2)} labels / 1000 land cells` },
{ label: "Merged patch quality", value: d.qualityFinalHardPass ? "PASS" : "WARNING", sub: `${formatPercent(Number(d.qualityFinalOwnedLandRatio || 0) * 100)} land in owned interior · ${Number(d.qualityFinalLabelCount || 0)} labels / ${Number(d.qualityFinalSettlementCount || 0)} settlements` },
@ -2036,7 +2044,6 @@ function renderLegendGrid(container, rows) {
function renderLegend() {
const rows = legendRowsForMode(state.mode);
renderLegendGrid(mainLegendGrid, rows);
renderLegendGrid(floatingLegendGrid, rows.slice(0, 5));
}
function buildHoverEntities(map) {
@ -2246,13 +2253,49 @@ function updateTooltip(event) {
if (entity?.population) lines.splice(1, 0, `Population: ${entity.population.toLocaleString()}`);
tooltipEl.innerHTML = lines.join("<br>");
const margin = 8;
const offset = 14;
const maxLeft = Math.max(margin, rect.width - tooltipEl.offsetWidth - margin);
const maxTop = Math.max(margin, rect.height - tooltipEl.offsetHeight - margin);
const desiredLeft = event.clientX - rect.left + offset;
const desiredTop = event.clientY - rect.top + offset;
tooltipEl.style.left = `${Math.min(Math.max(margin, desiredLeft), maxLeft)}px`;
tooltipEl.style.top = `${Math.min(Math.max(margin, desiredTop), maxTop)}px`;
const gap = 18;
const cursorX = event.clientX - rect.left;
const cursorY = event.clientY - rect.top;
const tooltipWidth = tooltipEl.offsetWidth;
const tooltipHeight = tooltipEl.offsetHeight;
const maxLeft = Math.max(margin, rect.width - tooltipWidth - margin);
const maxTop = Math.max(margin, rect.height - tooltipHeight - margin);
// Keep following the cursor all the way to the bottom. Near the lower edge,
// prefer moving to the cursor's left/right while pinning the tooltip to the
// bottom margin; the old vertical flip made it appear to stop moving well
// before the cursor reached the bottom of the map.
const roomRight = rect.width - cursorX - gap;
const roomLeft = cursorX - gap;
const canRight = roomRight >= tooltipWidth;
const canLeft = roomLeft >= tooltipWidth;
let left = canRight ? cursorX + gap
: canLeft ? cursorX - gap - tooltipWidth
: Math.min(Math.max(margin, cursorX + gap), maxLeft);
let top = Math.min(Math.max(margin, cursorY + 10), maxTop);
left = Math.min(Math.max(margin, left), maxLeft);
// Cursor exclusion is a hard invariant. If horizontal separation is not
// available (very narrow viewport), then and only then move vertically.
const exclusion = 12;
const overlapsCursor = () => cursorX >= left - exclusion && cursorX <= left + tooltipWidth + exclusion
&& cursorY >= top - exclusion && cursorY <= top + tooltipHeight + exclusion;
if (overlapsCursor()) {
const leftAlt = cursorX - gap - tooltipWidth;
const rightAlt = cursorX + gap;
if (leftAlt >= margin) left = leftAlt;
else if (rightAlt + tooltipWidth <= rect.width - margin) left = rightAlt;
}
if (overlapsCursor()) {
const above = cursorY - gap - tooltipHeight;
const below = cursorY + gap;
if (above >= margin) top = above;
else if (below + tooltipHeight <= rect.height - margin) top = below;
}
left = Math.min(Math.max(margin, left), maxLeft);
top = Math.min(Math.max(margin, top), maxTop);
tooltipEl.style.left = `${left}px`;
tooltipEl.style.top = `${top}px`;
tooltipEl.classList.add("visible");
}
@ -2443,7 +2486,7 @@ function patchSearchContextId(world, rect, terrainType, requestedPatchMode, reso
world?.height || 0,
world?.originX || 0,
world?.originY || 0,
"production-search-v2|single-explicit-production-candidate-v2",
"quality-batched-production-search-v4|initial-quality-oracle-transport-parity-v3",
].join("|");
}
@ -2489,7 +2532,7 @@ function buildPatchCandidatePlan(world, rect, terrainType, requestedPatchMode, r
function consumePatchSearchAttempts(contextId, attempts = []) {
if (patchSearchSeries.contextId !== contextId) return;
for (const attempt of attempts) {
if (attempt?.status !== "rejected" && attempt?.status !== "success") continue;
if (attempt?.status !== "rejected" && attempt?.status !== "evaluated" && attempt?.status !== "success") continue;
const candidateId = `${contextId}|${normalizePatchVariant(attempt.variant)}|${normalizePatchVariant(attempt.seed)}`;
patchSearchSeries.consumedCandidateIds.add(candidateId);
}
@ -3059,6 +3102,8 @@ function runPatchInWorker(world, rect, options, operation = null) {
totalCandidateCount: operation.candidateLimit || operation.candidatePlan.length,
reuseCommittedMirror,
resolvedPatchMode: operation.resolvedPatchMode,
selectBestCandidate: operation.selectBestCandidate === true,
draftSelection: operation.draftSelection === true,
candidatePlan: operation.candidatePlan,
} : null,
});
@ -3199,6 +3244,41 @@ async function generatePatchPreviewWorld(baseWorld, rect, options, operation) {
operation.executionAttempt = infrastructureRetries + 1;
continue;
}
// r9 quality search is batched: only three drafts are evaluated at a time.
// A batch that contains no publishable, fully finalized candidate advances
// to the next three variants without ever exposing a draft or a quality-
// rejected production result. Structural/invariant failures remain terminal.
const contentBatchExhausted = execution.result?.searchStatus === "exhausted"
&& ["patch-search-exhausted", "patch-draft-search-exhausted"].includes(String(execution.result?.code || ""));
const allCandidatePlan = Array.isArray(operation.allCandidatePlan) ? operation.allCandidatePlan : operation.candidatePlan;
const nextCandidateIndex = Math.max(0, Number(operation.nextCandidateIndex || 0));
if (contentBatchExhausted && nextCandidateIndex < allCandidatePlan.length) {
operation.completedAttemptSummaries ||= [];
for (const attempt of execution.result?.searchAttempts || []) {
const identity = `${attempt?.candidateId || `${attempt?.variant}:${attempt?.seed}`}|${attempt?.status || "unknown"}`;
if (!operation.completedAttemptSummaries.some((entry) => `${entry?.candidateId || `${entry?.variant}:${entry?.seed}`}|${entry?.status || "unknown"}` === identity)) {
operation.completedAttemptSummaries.push({ ...attempt });
}
}
const batchSize = Math.max(1, Number(operation.candidateBatchSize || PATCH_SEARCH_BATCH_SIZE));
const batchEnd = Math.min(allCandidatePlan.length, nextCandidateIndex + batchSize);
operation.candidatePlan = allCandidatePlan.slice(nextCandidateIndex, batchEnd);
operation.nextCandidateIndex = batchEnd;
operation.candidateBatchOrdinal = Math.max(1, Number(operation.candidateBatchOrdinal || 1)) + 1;
operation.executionAttempt = Math.max(1, Number(operation.executionAttempt || 1)) + 1;
if (progressStageEl) {
const firstOrdinal = operation.candidatePlan[0]?.candidateOrdinal || (nextCandidateIndex + 1);
const lastOrdinal = operation.candidatePlan[operation.candidatePlan.length - 1]?.candidateOrdinal || batchEnd;
progressStageEl.textContent = `Quality batch ${operation.candidateBatchOrdinal}: candidates ${firstOrdinal}-${lastOrdinal}/${allCandidatePlan.length}; previous batch did not meet the production quality floor...`;
}
recordDiagnosticLog("info", "Patch quality search continuing", `No publishable candidate in quality batch ${operation.candidateBatchOrdinal - 1}; evaluating the next batch.`, {
completedCandidates: nextCandidateIndex,
remainingCandidates: allCandidatePlan.length - nextCandidateIndex,
nextVariants: operation.candidatePlan.map((candidate) => candidate.variant >>> 0),
});
continue;
}
job = execution;
} catch (error) {
executionRecord.endedAt = performance.now();
@ -3311,8 +3391,10 @@ async function generateSelectedPatch(kind = "Patch preview") {
patchMode,
resolvedPatchMode,
candidateWindowSignature,
generatorPolicyVersion: "production-search-v2",
qualityPolicyVersion: "single-explicit-production-candidate-v2",
generatorPolicyVersion: "quality-batched-production-search-v4",
qualityPolicyVersion: "initial-quality-oracle-transport-parity-v3",
selectBestCandidate: true,
draftSelection: true,
requestedVariant: variant,
requestedSeed: seed,
currentVariant: variant,
@ -3322,7 +3404,11 @@ async function generateSelectedPatch(kind = "Patch preview") {
completedAttemptSummaries: [],
executions: [],
candidateLimit: searchPlan.candidateLimit,
candidatePlan: searchPlan.plan,
candidateBatchSize: PATCH_SEARCH_BATCH_SIZE,
allCandidatePlan: searchPlan.plan,
candidatePlan: searchPlan.plan.slice(0, PATCH_SEARCH_BATCH_SIZE),
nextCandidateIndex: Math.min(PATCH_SEARCH_BATCH_SIZE, searchPlan.plan.length),
candidateBatchOrdinal: 1,
estimatedTileCount: searchPlan.estimatedTileCount,
includeSeamVisualization: state.showSeamDiagnostics,
selectionRect: validation.rect,
@ -3333,7 +3419,7 @@ async function generateSelectedPatch(kind = "Patch preview") {
state.patchBusyVariant = variant;
state.patchStatusMessage = "";
updatePatchControls();
setProgressVisible(true, `Searching up to ${searchPlan.plan.length} complete candidate${searchPlan.plan.length === 1 ? "" : "s"} from variant ${variant}...`);
setProgressVisible(true, `Generating quality batch 1 (${operation.candidatePlan.length} lightweight drafts, up to ${searchPlan.plan.length} candidates); only fully finalized candidates can be previewed...`);
await nextFrame();
try {
if (!isPatchOperationCurrent(operation)) return;
@ -3342,12 +3428,10 @@ async function generateSelectedPatch(kind = "Patch preview") {
patchMode,
seed,
variant,
// Expansion previews use the same complete production pipeline as initial
// generation. A quality rejection is shown for this exact variant; only
// the explicit Alternative action requests another complete candidate.
// Three lightweight drafts are ranked first. Only the top draft normally
// runs administration, final transport, merge repair, seam audit, delta,
// and hash construction. Structural/invariant failures remain fatal.
maxQualityRetries: 0,
// One attempt still runs the complete production pipeline. Additional
// complete candidates are generated only if the strict final gate fails.
qualityTerrainAttempts: 1,
acceptBestAvailableQuality: false,
includeSeamVisualization: operation.includeSeamVisualization,
@ -3423,20 +3507,17 @@ async function generateSelectedPatch(kind = "Patch preview") {
inputDispatchMs: job.dispatchMs, inputMirrorReused: job.mirrorReused, estimatedTileCount: operation.estimatedTileCount,
});
state.patchStatusMessage = !previewDelta.identical
? `Candidate ${result.candidateOrdinal || 1}/${result.candidateCount || searchPlan.plan.length}, variant ${actualVariant}, is displayed (render revision ${job.world.renderRevision}); ${previewDelta.changedCells.toLocaleString()} cells and ${previewDelta.featureLayersChanged.toLocaleString()} feature layers differ from the committed map.`
? `Highest-quality candidate ${result.candidateOrdinal || 1}/${result.candidateCount || searchPlan.plan.length}, variant ${actualVariant}, is displayed (render revision ${job.world.renderRevision}); ${previewDelta.changedCells.toLocaleString()} cells and ${previewDelta.featureLayersChanged.toLocaleString()} feature layers differ from the committed map.`
: `Variant ${actualVariant} completed but is identical to the committed map in the audited change scope.`;
updatePatchControls();
const modeText = `${result.patchMode || patchMode} / ${result.patchGenerationMode}`;
const quality = result.candidateQuality;
const qualityText = quality
? ` / quality ${quality.hardPass ? "PASS" : "best available"} ${Number(quality.score || 0).toFixed(3)} / selected terrain variant ${Number(quality.selectedVariant || 0)}`
? ` / quality ${quality.hardPass ? "PASS" : "FAIL"} ${Number(quality.score || 0).toFixed(3)} / selected terrain variant ${Number(quality.selectedVariant || 0)}`
: "";
const retryText = job.qualityWorkerRetryCount ? ` / quality retries ${job.qualityWorkerRetryCount}` : "";
const searchText = ` / candidate ${result.candidateOrdinal || 1}/${result.candidateCount || searchPlan.plan.length} / complete attempts ${searchAttempts.length || 1}`;
const searchText = ` / best candidate ${result.candidateOrdinal || 1}/${result.candidateCount || searchPlan.plan.length} / evaluated ${searchAttempts.length || 1}`;
finishProgress(`Preview variant ${actualVariant} displayed${job.worker ? " from worker" : ""}: ${previewDelta.changedCells.toLocaleString()} changed cells + ${previewDelta.featureLayersChanged.toLocaleString()} changed feature layers / end-to-end ${formatMs(performance.now() - patchStartedAt)} / dispatch ${formatMs(job.dispatchMs || 0)} / render ${formatMs(renderMs)}${searchText}${retryText}${qualityText} / ${modeText}.`, result.patchTimings || [], 1400);
if (result.candidateQuality && !result.candidateQuality.hardPass) {
recordDiagnosticLog("warning", "Expansion quality floor not fully met", `Selected the best available production candidate (score ${Number(result.candidateQuality.score || 0).toFixed(3)}).`, { terrainType, variant: actualVariant, requestedVariant: variant, candidateQuality: result.candidateQuality });
}
} catch (error) {
if (error?.name === "AbortError") {
if (requestId === patchRequestSeq) {

View file

@ -1,16 +1,74 @@
import { generateMap } from "./mapPipeline.js";
import { collectTransferableBuffers } from "./transferUtils.js";
import {
cropInitialGenerationMap,
DEFAULT_INITIAL_OVERSCAN_MARGIN,
VISIBLE_MAP_H,
VISIBLE_MAP_W,
} from "./initialGenerationCrop.js";
self.onmessage = (message) => {
let pipelinePromise = null;
function loadInitialGenerationPipeline() {
if (!pipelinePromise) {
// All production layers (terrain, administration, cities, local/trunk roads,
// rail, labels) are generated on a genuinely larger hidden raster. Only
// after the complete pipeline has finished do we crop the central 258x183.
// Setting this before importing mapPipeline is essential because mapUtils
// constants are fixed at module-evaluation time inside this Worker.
const margin = DEFAULT_INITIAL_OVERSCAN_MARGIN;
globalThis.__JAPAN_MAP_GENERATION_DIMENSIONS__ = {
width: VISIBLE_MAP_W + margin * 2,
height: VISIBLE_MAP_H + margin * 2,
};
pipelinePromise = import("./mapPipeline.js");
}
return pipelinePromise;
}
self.onmessage = async (message) => {
const { id, seed, options } = message.data || {};
if (!Number.isFinite(id)) return;
try {
const map = generateMap(seed, {
const { generateMap } = await loadInitialGenerationPipeline();
const margin = DEFAULT_INITIAL_OVERSCAN_MARGIN;
const generated = generateMap(seed, {
...(options || {}),
// Literal hidden-raster generation supersedes the older virtual frame /
// gateway approximation. The hidden full map has its own real edges.
initialGenerationOverscan: false,
// The whole hidden raster is production-generated, while this rectangle
// identifies the viewport that will be published. Transport finalization
// may use it only as a density/service audit focus; routes are still
// solved against the complete hidden terrain and OD context.
initialVisibleCrop: {
x0: margin, y0: margin,
x1: margin + VISIBLE_MAP_W, y1: margin + VISIBLE_MAP_H,
},
onProgress: (event) => {
self.postMessage({ type: "progress", id, event });
},
});
const cropStarted = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
self.postMessage({
type: "progress",
id,
event: { status: "start", key: "crop", label: "Cropping central viewport from hidden overscan map" },
});
const map = cropInitialGenerationMap(generated);
const cropFinished = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
const cropMs = Math.round((cropFinished - cropStarted) * 10) / 10;
map.generationTimings = [...(map.generationTimings || []), {
key: "crop",
label: "Central crop from full hidden overscan generation",
ms: cropMs,
literalOverscan: true,
}];
map.generationTotalMs = (Number(map.generationTotalMs) || 0) + cropMs;
self.postMessage({
type: "progress",
id,
event: { status: "done", key: "crop", label: "Central crop from hidden overscan map", ms: cropMs, timings: map.generationTimings },
});
const transfer = Array.from(collectTransferableBuffers(map));
self.postMessage({ type: "result", id, ok: true, map }, transfer);
} catch (error) {

1018
src/initialGenerationCrop.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -68,6 +68,42 @@ export function enforceCompartmentMunicipalityOwnership(adminId, compartmentId,
return changed;
}
export function repairRasterCompartmentOwnershipGaps(adminId, compartmentId, compartments, prefectureMask, sea) {
if (!compartmentId || !compartments) return 0;
// Repair only cached-cell omissions that actually create a local municipal cut inside one
// natural compartment. Missing cells that do not disagree with their same-compartment
// neighbors are harmless metadata gaps and must not be reassigned, because doing so can
// undo the strict connectivity repair and destabilize later prefecture grouping.
const listed = new Uint8Array(SIZE);
for (const comp of compartments) {
for (const i of comp?.cells || []) if (i >= 0 && i < SIZE) listed[i] = 1;
}
const changes = [];
for (let i = 0; i < SIZE; i++) {
if (listed[i] || !prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const compId = compartmentId[i];
if (compId < 0) continue;
const [x, y] = xyOf(i);
const counts = new Map();
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 (!prefectureMask[ni] || sea[ni] || compartmentId[ni] !== compId || adminId[ni] < 0) continue;
// Prefer a cached neighbor, because it is part of the authoritative compartment cell set.
const weight = listed[ni] ? 3 : 1;
counts.set(adminId[ni], (counts.get(adminId[ni]) || 0) + weight);
}
if (!counts.size) continue;
let bestId = adminId[i], bestCount = counts.get(bestId) || 0;
for (const [id, count] of counts) {
if (count > bestCount || (count === bestCount && id < bestId)) { bestId = id; bestCount = count; }
}
if (bestId !== adminId[i] && bestCount >= 3) changes.push([i, bestId]);
}
for (const [i, bestId] of changes) adminId[i] = bestId;
return changes.length;
}
function ownerAreaByCompartment(owner, compartments) {
const area = new Map();
@ -509,12 +545,57 @@ export function expandMajorCityMunicipalitiesByCompartment(adminId, compartmentI
}
if (localChanged > 0) expandedCities++;
}
// Preserve the dense core of major cities as one municipality. The municipality-count cap
// intentionally works at whole-compartment granularity, but a later merge can otherwise leave
// the central few urban cells split almost 50/50 between two municipality owners. Reclaim only
// natural compartments that actually intersect the compact city core; never paint individual
// cells and never steal another major city's home compartment.
let coreLockChangedCells = 0;
let coreLockedCities = 0;
for (const city of cities.filter((row) => (row.population || 0) >= 180000)) {
const center = indexOf(city.x, city.y);
const homeUnitId = compartmentId[center];
const homeOwner = homeUnitId >= 0 ? owner[homeUnitId] : -1;
if (homeOwner < 0) continue;
const radius = Math.ceil(Math.max(3, city.coreRadius || 4));
const hitCount = new Map();
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (Math.hypot(dx, dy) > radius) continue;
const x = city.x + dx, y = city.y + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (!prefectureMask[i] || sea[i]) continue;
const unitId = compartmentId[i];
if (unitId < 0) continue;
hitCount.set(unitId, (hitCount.get(unitId) || 0) + 1);
}
}
let localCoreChanged = 0;
for (const [unitId, hits] of hitCount) {
const unit = compartments[unitId];
if (!unit || unit.area <= 0 || owner[unitId] === homeOwner) continue;
if (majorCityUnit.has(unitId) && unitId !== homeUnitId) continue;
const distance = Math.hypot((unit.x || 0) - city.x, (unit.y || 0) - city.y);
const compactHit = hits >= 2 || distance <= radius * 1.35;
if (!compactHit) continue;
owner[unitId] = homeOwner;
localCoreChanged += unit.area || 0;
}
if (localCoreChanged > 0) {
coreLockChangedCells += localCoreChanged;
coreLockedCities++;
}
}
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,
changedCells: changedCells + coreLockChangedCells + connectivity.changedCells + enclaves.changedCells,
expandedCities,
coreLockChangedCells,
coreLockedCities,
connectivityChangedCells: connectivity.changedCells,
enclaveChangedCells: enclaves.changedCells,
};

View file

@ -1,200 +0,0 @@
import { INF, clamp, indexOf, inside } from "./mapUtils.js";
import { applyCompartmentOwners, dominantCompartmentOwners, municipalityAreaById } from "./mapAdminShared.js";
export function absorbSeedCompartments(adminId, compartments, seedLifecycle) {
const owner = dominantCompartmentOwners(compartments, adminId);
const absorbed = new Set(seedLifecycle.filter((seed) => seed.state === "absorbed").map((seed) => seed.id));
let changed = 0;
for (const unit of compartments) {
if (!unit || unit.area === 0 || !absorbed.has(owner[unit.id])) continue;
let bestId = -1, bestScore = -INF;
for (const [neighborId, edge] of unit.adjacent) {
const candidate = owner[neighborId];
if (candidate < 0 || absorbed.has(candidate)) continue;
const neighbor = compartments[neighborId];
const score = edge.count * 2 - (edge.target / Math.max(1, edge.count)) * 2 + (neighbor?.area || 0) * 0.002;
if (score > bestScore) { bestScore = score; bestId = candidate; }
}
if (bestId < 0) continue;
owner[unit.id] = bestId;
changed += unit.area;
}
applyCompartmentOwners(adminId, compartments, owner);
return changed;
}
export function splitOversizedLowlandsWithPendingSeeds(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, settlements = []) {
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
const areas = [...areaById.values()].sort((a, b) => a - b);
const median = areas.length ? areas[Math.floor(areas.length / 2)] : 0;
if (!median) return { changedCells: 0, splitMunicipalities: 0, pendingSeedsUsed: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
const unitsByOwner = new Map();
for (const unit of compartments) {
if (!unit || unit.area === 0 || owner[unit.id] < 0) continue;
if (!unitsByOwner.has(owner[unit.id])) unitsByOwner.set(owner[unit.id], []);
unitsByOwner.get(owner[unit.id]).push(unit);
}
const pending = seedLifecycle.filter((seed) => seed.state === "pending" && !seed.protected);
let changedCells = 0;
let splitMunicipalities = 0;
let pendingSeedsUsed = 0;
for (const [id, units] of unitsByOwner) {
const area = areaById.get(id) || 0;
if (area < Math.max(260, median * 1.45) || units.length < 6) continue;
let lowland = 0, rough = 0;
for (const unit of units) {
for (const i of unit.cells) {
lowland += (plain[i] || 0) * 0.38 + (agriculture[i] || 0) * 0.20 + basinField[i] * 0.22 + coastalLowland[i] * 0.22 + valleyField[i] * 0.10;
rough += ridgeField[i] * 0.48 + slope[i] * 0.34 + Math.max(0, elevation[i] - 0.58) * 0.30;
}
}
if (lowland / area < 0.26 || rough / area > 0.48) continue;
const localPending = pending.filter((seed) => {
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) return false;
const centerOwner = adminId[indexOf(center.x, center.y)];
return centerOwner === id || Math.hypot(center.x - (adminCenters[id]?.x || center.x), center.y - (adminCenters[id]?.y || center.y)) < 28;
});
const localSettlements = settlements.filter((p) => p && inside(p.x, p.y) && adminId[indexOf(p.x, p.y)] === id);
if (localPending.length === 0 || localPending.length + localSettlements.length < 2) continue;
let municipalitySplit = false;
for (const seed of localPending.slice(0, 3)) {
const center = adminCenters[seed.id];
if (!center) continue;
const targetArea = clamp(95 + (center.score || 0.5) * 60, 90, 180);
let claimed = 0;
const candidates = units
.filter((unit) => owner[unit.id] === id && unit.classId !== 8 && unit.classId !== 9)
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 8 - (unit.urbanWeight || 0) * 3,
}))
.sort((a, b) => a.score - b.score);
if (candidates.length < 2) continue;
for (const { unit } of candidates) {
if (claimed >= targetArea && claimed >= 2) break;
owner[unit.id] = seed.id;
claimed += unit.area;
changedCells += unit.area;
}
if (claimed >= 45) {
seed.state = "survived";
seed.area = claimed;
pendingSeedsUsed++;
municipalitySplit = true;
}
}
if (municipalitySplit) splitMunicipalities++;
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, splitMunicipalities, pendingSeedsUsed };
}
export function promotePendingSeedsForMunicipalityCount(adminId, prefectureMask, sea, fields, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
const { plain, agriculture, basinField, coastalLowland, valleyField, ridgeField, slope, elevation } = fields;
let areaById = municipalityAreaById(adminId, prefectureMask, sea);
let currentCount = areaById.size;
if (currentCount >= targetMinCount) return { changedCells: 0, promotedSeeds: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
let changedCells = 0;
let promotedSeeds = 0;
const pending = seedLifecycle
.filter((seed) => seed.state === "pending" && !seed.protected)
.sort((a, b) => (adminCenters[b.id]?.score || 0) - (adminCenters[a.id]?.score || 0));
for (const seed of pending) {
if (currentCount >= targetMinCount) break;
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) continue;
const existingArea = areaById.get(seed.id) || 0;
if (existingArea >= 12) {
seed.state = "survived";
seed.area = existingArea;
promotedSeeds++;
continue;
}
const candidates = compartments
.filter((unit) => {
if (!unit || unit.area === 0) return false;
const currentOwner = owner[unit.id];
if (currentOwner < 0 || currentOwner === seed.id) return false;
const ownerArea = areaById.get(currentOwner) || 0;
if (ownerArea < 90) return false;
const lowlandFit = (unit.lowlandFitness || 0) + (unit.basinIdentity || 0) * 0.15 + (unit.coastalExposure || 0) * 0.15;
if (lowlandFit < 0.26) return false;
return Math.hypot(unit.x - center.x, unit.y - center.y) < 36;
})
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2,
}))
.sort((a, b) => a.score - b.score);
if (candidates.length === 0) continue;
let claimed = 0;
for (const { unit } of candidates) {
const currentOwner = owner[unit.id];
if ((areaById.get(currentOwner) || 0) - unit.area < 70) continue;
owner[unit.id] = seed.id;
areaById.set(currentOwner, (areaById.get(currentOwner) || 0) - unit.area);
areaById.set(seed.id, (areaById.get(seed.id) || 0) + unit.area);
claimed += unit.area;
changedCells += unit.area;
if (claimed >= 55) break;
}
if (claimed >= 25) {
seed.state = "survived";
seed.area = areaById.get(seed.id) || claimed;
promotedSeeds++;
currentCount++;
}
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, promotedSeeds };
}
export function restoreSurvivedSeedsByCompartment(adminId, prefectureMask, sea, compartments, adminCenters, seedLifecycle, targetMinCount = 20) {
const areaById = municipalityAreaById(adminId, prefectureMask, sea);
let currentCount = areaById.size;
if (currentCount >= targetMinCount) return { changedCells: 0, restoredSeeds: 0 };
const owner = dominantCompartmentOwners(compartments, adminId);
let changedCells = 0;
let restoredSeeds = 0;
const missing = seedLifecycle
.filter((seed) => (seed.state === "survived" || seed.protected) && (areaById.get(seed.id) || 0) === 0)
.sort((a, b) => (b.protected ? 1 : 0) - (a.protected ? 1 : 0));
for (const seed of missing) {
if (currentCount >= targetMinCount) break;
const center = adminCenters[seed.id];
if (!center || !inside(center.x, center.y)) continue;
const candidates = compartments
.filter((unit) => {
if (!unit || unit.area === 0 || unit.classId === 8 || unit.classId === 9) return false;
const currentOwner = owner[unit.id];
if (currentOwner < 0 || currentOwner === seed.id) return false;
if ((areaById.get(currentOwner) || 0) - unit.area < 25) return false;
return Math.hypot(unit.x - center.x, unit.y - center.y) < 90 && ((unit.lowlandFitness || 0) > 0.08 || seed.protected);
})
.map((unit) => ({
unit,
score: Math.hypot(unit.x - center.x, unit.y - center.y) - (unit.lowlandFitness || 0) * 6 - (unit.urbanWeight || 0) * 2 + (unit.classId === 8 || unit.classId === 9 ? 20 : 0),
}))
.sort((a, b) => a.score - b.score);
if (candidates.length === 0) continue;
const unit = candidates[0].unit;
const oldOwner = owner[unit.id];
if ((areaById.get(oldOwner) || 0) - unit.area < 25) continue;
owner[unit.id] = seed.id;
const claimed = unit.area;
areaById.set(oldOwner, (areaById.get(oldOwner) || 0) - claimed);
changedCells += claimed;
areaById.set(seed.id, claimed);
seed.area = claimed;
restoredSeeds++;
currentCount++;
}
applyCompartmentOwners(adminId, compartments, owner);
return { changedCells, restoredSeeds };
}

View file

@ -15,9 +15,11 @@ import {
expandMajorCityMunicipalitiesByCompartment,
reduceMunicipalityCountByCompartment,
repairAdminSingleOwnerEnclaves,
repairRasterCompartmentOwnershipGaps,
splitOversizedCompartmentMunicipalities
} from "./mapAdminCompartmentRepair.js";
import { generatePrefecturesFromMunicipalities } from "./mapPrefectureStage.js";
import { estimateMunicipalityPopulations, municipalityIdAtPoint } from "./mapMunicipalDemography.js";
import {
computeTargetMunicipalityCount,
@ -112,7 +114,6 @@ function generateAdminLayoutForMask({
stationInfluence,
roadInfluence,
railInfluence2,
villageInfluence,
landuse,
modernCities,
satelliteCities,
@ -120,9 +121,6 @@ function generateAdminLayoutForMask({
markets,
villages,
ports,
stations,
industrialZones,
logisticsParks,
naturalCompartmentId,
naturalCompartments,
geography = null,
@ -130,7 +128,6 @@ function generateAdminLayoutForMask({
accessibility = null,
centrality = null,
geographicBarrier = null,
geographicBarrierCost = null,
adminBoundaryPreference = null,
boundaryAvoidance = null,
adminRegionMeta = {},
@ -178,7 +175,6 @@ function generateAdminLayoutForMask({
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 });
@ -228,10 +224,25 @@ function generateAdminLayoutForMask({
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;
// The final count cap can merge a neighboring municipality into a major city's compact core
// and leave the surviving id split across dense urban compartments. Re-run the existing
// compartment-aware city expansion after the cap so the published hierarchy preserves a
// coherent metropolitan core without introducing any cell-level/Voronoi special case.
adminProgress?.({ status: "admin-step", regionId: adminRegionMeta.regionId, step: "restore major city municipalities after cap" });
const finalCityCompartmentExpansion = expandMajorCityMunicipalitiesByCompartment(
compacted.adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea, modernCities
);
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);
// Strict cell-level topology repair can touch a raster cell that is missing from the cached
// natural-compartment cell list. Repair only those residual ownership gaps at publication;
// do not run another municipality expansion here because that can destabilize prefecture
// grouping after the final count cap.
const changedAfterPublishCityOwnership = repairRasterCompartmentOwnershipGaps(
compacted.adminId, compartmentAssignment.compartmentId, compartmentAssignment.compartments, prefectureMask, sea
);
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;
@ -265,8 +276,11 @@ function generateAdminLayoutForMask({
municipalityCountAfterReduction: municipalityCountReduction.finalMunicipalityCount,
changedAfterFinalMunicipalityCountCap: finalCountCap.changedCells,
municipalitiesMergedByFinalCountCap: finalCountCap.mergedMunicipalities,
changedAfterCityCompartmentExpansion: cityCompartmentExpansion.changedCells,
cityMunicipalitiesExpanded: cityCompartmentExpansion.expandedCities,
changedAfterCityCompartmentExpansion: cityCompartmentExpansion.changedCells + finalCityCompartmentExpansion.changedCells,
cityMunicipalitiesExpanded: cityCompartmentExpansion.expandedCities + finalCityCompartmentExpansion.expandedCities,
changedAfterFinalCityCompartmentExpansion: finalCityCompartmentExpansion.changedCells,
finalCityMunicipalitiesExpanded: finalCityCompartmentExpansion.expandedCities,
changedAfterPublishCityOwnership,
satelliteMunicipalitiesCreated: independentSatelliteRows.length,
averageSatelliteMunicipalityArea: independentSatelliteRows.length ? independentSatelliteRows.reduce((sum, value) => sum + value, 0) / independentSatelliteRows.length : 0,
changedAfterStrictMunicipalityConnectivity: strictConnectivityChangedCells,
@ -315,7 +329,178 @@ function generateAdminLayoutForMask({
}
export function generateAdminLayout(context) {
const layout = generateAdminLayoutForMask(context);
return { ...layout, ...generatePrefecturesFromMunicipalities(context, layout) };
function centerMunicipalityId(center, fallback = -1) {
for (const key of ["adminId", "municipalityId", "adminNumericId"]) {
const value = center?.[key];
if (Number.isFinite(value) && value >= 0) return Math.floor(value);
}
return fallback;
}
function annotateMunicipalityDemography(layout, context, estimate = null) {
if (!layout?.adminId) return null;
const settlementFeatures = [
...(context.modernCities || []), ...(context.markets || []), ...(context.villages || []),
...(context.satelliteCities || []), ...(context.newTowns || []), ...(context.ports || []),
];
const fields = {
sea: context.sea, populationDensity: context.populationDensity, landuse: context.landuse,
plain: context.plain, agriculture: context.agriculture,
};
const demographics = estimate || estimateMunicipalityPopulations(layout.adminId, fields, settlementFeatures, context.seed || 0);
for (const [index, center] of (layout.adminCentersRaw || []).entries()) {
const id = centerMunicipalityId(center, index);
const row = demographics.stats.get(id);
const population = demographics.populations.get(id) || 0;
center.adminId = id;
center.municipalityId = id;
center.adminNumericId = id;
center.municipalityPopulation = population;
center.population = Math.max(center.population || 0, population);
if (row) {
center.municipalityDemography = {
landCells: row.landCells,
inhabitedCells: row.inhabitedCells,
densityMean: row.densityMean,
agricultureMean: row.agricultureMean,
ruralBaseline: Math.round(row.ruralBaseline || 0),
};
}
}
return demographics;
}
function probabilisticallyConsolidateSparseMunicipalities(layout, context) {
if (!layout?.adminId?.length) return { mergedMunicipalities: 0, changedCells: 0, before: 0, after: 0, candidates: 0 };
const adminId = layout.adminId;
const mask = context.prefectureMask;
const sea = context.sea;
const barrier = context.naturalBarrierScore;
const seed = context.seed || 0;
const demographics = annotateMunicipalityDemography(layout, context);
const populations = demographics?.populations || new Map();
const stats = demographics?.stats || new Map();
const activeIds = [...populations.keys()];
const before = activeIds.length;
if (before < 8) return { mergedMunicipalities: 0, changedCells: 0, before, after: before, candidates: 0 };
const protectedIds = new Set();
for (const feature of [...(context.modernCities || []), ...(context.markets || []), ...(context.ports || [])]) {
if (!feature || !inside(feature.x, feature.y)) continue;
const id = municipalityIdAtPoint(adminId, feature);
const pop = Number(feature.population || 0);
if (id >= 0 && (pop >= 22000 || feature.isRegionalCapital || feature.isPrefecturalCapital || feature.portClass === "major")) protectedIds.add(id);
}
const neighbors = new Map();
const addBoundary = (a, b, value) => {
if (a < 0 || b < 0 || a === b) return;
if (!neighbors.has(a)) neighbors.set(a, new Map());
const row = neighbors.get(a).get(b) || { count: 0, barrier: 0 };
row.count++;
row.barrier += value;
neighbors.get(a).set(b, row);
};
for (let y = 0; y < MAP_H; y++) for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (!mask?.[i] || sea?.[i] || adminId[i] < 0) continue;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
if (!inside(nx, ny)) continue;
const ni = indexOf(nx, ny);
if (!mask?.[ni] || sea?.[ni] || adminId[ni] < 0 || adminId[ni] === adminId[i]) continue;
const b = ((barrier?.[i] || 0) + (barrier?.[ni] || 0)) * 0.5;
addBoundary(adminId[i], adminId[ni], b);
addBoundary(adminId[ni], adminId[i], b);
}
}
const candidates = activeIds
.filter((id) => !protectedIds.has(id) && (populations.get(id) || 0) < 5200)
.sort((a, b) => (populations.get(a) || 0) - (populations.get(b) || 0) || a - b);
const mergeLimit = Math.max(1, Math.floor(before * 0.16));
const absorbed = new Map();
const absorbingTargets = new Set();
let mergedMunicipalities = 0;
let changedCells = 0;
for (const source of candidates) {
if (mergedMunicipalities >= mergeLimit || absorbed.has(source) || absorbingTargets.has(source)) continue;
const pop = populations.get(source) || 0;
const row = stats.get(source) || {};
let chance = pop < 900 ? 0.86 : pop < 1600 ? 0.72 : pop < 2600 ? 0.53 : pop < 3800 ? 0.35 : 0.20;
if ((row.landCells || 0) < 26) chance += 0.08;
if ((row.densityMean || 0) < 0.025) chance += 0.07;
if ((row.agricultureMean || 0) < 0.10 && (row.densityMean || 0) < 0.04) chance += 0.05;
chance = clamp(chance, 0.12, 0.92);
if (rand(seed + 73117, source * 97 + 13) >= chance) continue;
const options = [...(neighbors.get(source) || new Map()).entries()]
.filter(([target]) => target >= 0 && target !== source && !absorbed.has(target))
.map(([target, edge]) => {
const targetPop = populations.get(target) || 0;
const targetStats = stats.get(target) || {};
const meanBarrier = edge.count ? edge.barrier / edge.count : 0;
const score = edge.count * 3.4 + Math.log1p(targetPop) * 3.1 + Math.sqrt(targetStats.landCells || 1) * 0.45 - meanBarrier * 20 + (protectedIds.has(target) ? 7 : 0);
return { target, score, meanBarrier, targetPop };
})
.filter((r) => r.meanBarrier < 0.90 || pop < 1200)
.sort((a, b) => b.score - a.score || b.targetPop - a.targetPop || a.target - b.target);
const chosen = options[0];
if (!chosen) continue;
absorbed.set(source, chosen.target);
absorbingTargets.add(chosen.target);
populations.set(chosen.target, (populations.get(chosen.target) || 0) + pop);
for (let i = 0; i < adminId.length; i++) {
if (adminId[i] === source) { adminId[i] = chosen.target; changedCells++; }
}
mergedMunicipalities++;
}
if (mergedMunicipalities) {
const absorbedIds = new Set(absorbed.keys());
const targetAbsorbed = new Map();
for (const [source, target] of absorbed) {
if (!targetAbsorbed.has(target)) targetAbsorbed.set(target, []);
targetAbsorbed.get(target).push(source);
}
layout.adminCentersRaw = (layout.adminCentersRaw || []).filter((center, index) => !absorbedIds.has(centerMunicipalityId(center, index)));
for (const [index, center] of (layout.adminCentersRaw || []).entries()) {
const id = centerMunicipalityId(center, index);
if (targetAbsorbed.has(id)) center.absorbedMunicipalityIds = [...(center.absorbedMunicipalityIds || []), ...targetAbsorbed.get(id)];
}
layout.adminBorders = extractAdminBorderSegments(adminId, mask);
annotateMunicipalityDemography(layout, context);
}
const after = new Set([...adminId].filter((id, i) => id >= 0 && mask?.[i] && !sea?.[i])).size;
layout.adminDebug ||= {};
layout.adminDebug.sparseMunicipalityConsolidation = {
probabilistic: true,
populationThreshold: 5200,
before,
after,
candidates: candidates.length,
mergedMunicipalities,
changedCells,
mergeRate: before ? mergedMunicipalities / before : 0,
};
layout.adminDebug.actualMunicipalityCount = after;
layout.adminDebug.finalMunicipalityCount = after;
return layout.adminDebug.sparseMunicipalityConsolidation;
}
export function generateMunicipalLayout(context) {
const layout = generateAdminLayoutForMask(context);
probabilisticallyConsolidateSparseMunicipalities(layout, context);
annotateMunicipalityDemography(layout, context);
return layout;
}
export function finalizePrefectureLayout(context, municipalLayout) {
return { ...municipalLayout, ...generatePrefecturesFromMunicipalities(context, municipalLayout) };
}
export function generateAdminLayout(context) {
const layout = generateMunicipalLayout(context);
return finalizePrefectureLayout(context, layout);
}

View file

@ -10,7 +10,11 @@ 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 / 190 + 7), Math.max(min, 4), 50);
// Preserve municipal density when initial generation runs on the hidden
// overscan raster. Patch workers retain the canonical 258x183 dimensions, so
// their cap remains unchanged.
const hiddenAreaScale = Math.max(1, (MAP_W * MAP_H) / (258 * 183));
const max = clamp(Math.round(landCells / 175 + 8), Math.max(min, 4), Math.round(62 * hiddenAreaScale));
return { min, max };
}
@ -116,7 +120,6 @@ export function buildLowlandAdminSeeds({
markets,
ports,
newTowns,
stations,
}) {
const fields = { elevation, slope, ridgeField, plain, basinField, coastalLowland, settlementScore, populationDensity, roadInfluence, railInfluence2, stationInfluence, landuse, habitability, accessibility, centrality, boundaryAvoidance, adminBoundaryPreference, geographicBarrier };
function validLowlandPoint(p, strict = true) {
@ -187,4 +190,3 @@ export function buildLowlandAdminSeeds({
return picked.slice(0, targetMunicipalityCount);
}

View file

@ -1,4 +1,4 @@
import { INF, MAP_H, MAP_W, SIZE, clamp, fbm, finiteFieldValue, hash2, indexOf, inside, pickEntities, valueNoise } from "./mapUtils.js";
import { 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) {
@ -61,19 +61,13 @@ export function buildFeatureContext(seed, terrain) {
const valleySettlement = new Float32Array(SIZE);
const coastalSettlement = new Float32Array(SIZE);
const confluenceField = new Float32Array(SIZE);
const barrierCost = new Float32Array(SIZE);
const corridorCost = new Float32Array(SIZE);
const settlementCluster = new Float32Array(SIZE);
const settlementScore = new Float32Array(SIZE);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const i = indexOf(x, y);
if (sea[i]) {
barrierCost[i] = INF;
corridorCost[i] = INF;
continue;
}
if (sea[i]) continue;
const naturalBarrier = naturalBarrierScore?.[i] || 0;
const depositional = (depositionalLowland?.[i] || 0) + (alluvialFanField?.[i] || 0) * 0.62 + (deltaField?.[i] || 0) * 0.90;
const highPenalty = Math.max(0, elevation[i] - 0.56);
@ -159,8 +153,6 @@ export function buildFeatureContext(seed, terrain) {
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(worldX(x), worldY(y), seed + 7011) * 0.05);
}
}
@ -304,8 +296,6 @@ export function buildFeatureContext(seed, terrain) {
valleySettlement,
coastalSettlement,
confluenceField,
barrierCost,
corridorCost,
settlementCluster,
settlementScore,
regionStats,
@ -313,4 +303,4 @@ export function buildFeatureContext(seed, terrain) {
pickRegionalPoints,
pickGlobalPoints,
};
}
}

View file

@ -69,14 +69,24 @@ export function buildFeatureTransportCostFields(ctx) {
const openPlainParallelPenalty = clamp(plain[i] * 0.48 + agriculture[i] * 0.26 - density * 0.20 - valleyField[i] * 0.20 - coastalLowland[i] * 0.12);
const boundaryRidgePenalty = Math.pow(naturalBarrierScore?.[i] || 0, 2);
if (extremeMountain > 0.92 && pass < 0.34) {
expressway[i] = rail[i] = INF;
national[i] = elevation[i] >= 0.68 ? INF : 2.9 + extremeMountain * 2.8 + waterCrossingPenalty;
local[i] = elevation[i] >= 0.69 ? INF : 2.2 + extremeMountain * 2.3 + waterCrossingPenalty * 0.55;
// Hard trunk barrier. A road route must go *around* a severe ridge or
// use an actual mapped pass; it must never be allowed to draw a nearly
// straight line and rely on the renderer to hide the impossible cells.
// Local roads remain somewhat more permissive because mountain villages
// still need access, but national roads / motorways / railways cannot
// traverse these cells at all.
const hardNaturalBarrier = (naturalBarrierScore?.[i] || 0) >= 0.82
&& (elevation[i] >= 0.60 || slope[i] >= 0.45 || ridgeField[i] >= 0.62)
&& pass < 0.46;
if ((extremeMountain > 0.80 && pass < 0.46) || hardNaturalBarrier) {
expressway[i] = rail[i] = national[i] = INF;
local[i] = elevation[i] >= 0.69 || (naturalBarrierScore?.[i] || 0) >= 0.93
? INF
: 2.35 + extremeMountain * 2.55 + boundaryRidgePenalty * 1.30 + waterCrossingPenalty * 0.55;
expresswayPotential[i] = 0;
railPotential[i] = 0;
nationalPotential[i] = clamp(lowland * 0.18 + pass * 0.24 - extremeMountain * 0.55);
localPotential[i] = clamp(valleySettlement[i] * 0.14 + pass * 0.18 - extremeMountain * 0.28);
nationalPotential[i] = 0;
localPotential[i] = clamp(valleySettlement[i] * 0.14 + pass * 0.22 - extremeMountain * 0.30 - boundaryRidgePenalty * 0.20);
continue;
}

View file

@ -26,6 +26,20 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const worldY = (y) => worldOriginY + y;
const patchMode = options?.patchMode === true || options?.generationContext?.hasBoundaryWorld === true;
const largePatchTile = options?.largeExpansionTile === true;
// generationWorker can run the initial production pipeline on a genuinely
// larger hidden raster and crop the centre afterwards. Preserve the same
// settlement/transport density as the canonical 258x183 map: the old fixed
// per-map caps otherwise spread roughly the same number of towns and cities
// over more than twice the land area, making the published centre sparse.
const canonicalInitialArea = 258 * 183;
const initialHiddenAreaScale = !patchMode && SIZE > canonicalInitialArea
? Math.max(1, SIZE / canonicalInitialArea)
: 1;
const scaleInitialCount = (count) => Math.max(1, Math.round(count * initialHiddenAreaScale));
// r9 quality contract: full patch finalists run the same trunk-transport
// guarantees as initial generation. Draft ranking and internal tiling may
// still defer them; those products are never publishable.
const productionTransportParity = options?.productionTransportParity === true;
const topCenterSuppression = clamp(Number.isFinite(options?.topCenterSuppression) ? options.topCenterSuppression : (patchMode ? 0.68 : 0), 0, 0.95);
const featureTimings = [];
const generationProgress = options?.onProgress;
@ -77,8 +91,6 @@ export function generateMapFeatures(seed, terrain, options = {}) {
valleySettlement,
coastalSettlement,
confluenceField,
barrierCost,
corridorCost,
settlementCluster,
settlementScore,
regionStats,
@ -90,7 +102,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
// --- 2. Sparse points ----------------------------------------------------
let ports = pickGlobalPoints(portSuitability || coastalSettlement, {
threshold: 0.30 + rand(seed, 1001) * 0.08,
max: 10,
max: scaleInitialCount(10),
minDistance: 13,
seedOffset: 1000,
predicate: (x, y, i) => coastalSettlement[i] > 0.14 || (portSuitability?.[i] || 0) > 0.25,
@ -112,7 +124,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const crossings = pickGlobalPoints(crossingSuitability || confluenceField, {
threshold: 0.30 + rand(seed, 1011) * 0.06,
max: 18,
max: scaleInitialCount(18),
minDistance: 9,
seedOffset: 1010,
predicate: (x, y, i) => river[i] > 0.12 || confluenceField[i] > 0.09,
@ -122,7 +134,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const passes = pickGlobalPoints(passSuitability || valleySettlement, {
threshold: 0.18 + rand(seed, 1021) * 0.06,
max: 12,
max: scaleInitialCount(12),
minDistance: 11,
seedOffset: 1020,
predicate: (x, y, i) => elevation[i] > 0.42 && !sea[i],
@ -160,7 +172,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const geographicUrbanAnchors = pickRegionalPoints(geographicUrbanAnchorScore, {
stride: 2,
threshold: 0.33 + rand(seed, 1026) * 0.025,
totalMax: 18,
totalMax: scaleInitialCount(18),
minDistance: 24,
seedOffset: 1025,
kind: "Geographic Urban Anchor",
@ -171,7 +183,8 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const centralCells = st.highCentralityCells || 0;
const raw = (centralCells / 180 + st.developableCells / 980 + 0.85) * vf;
const min = st.area > 1800 || st.developableCells > 260 ? 1 : 0;
const max = st.area > 4200 ? 4 : st.area > 2400 ? 3 : st.area > 900 ? 2 : 1;
const baseMax = st.area > 4200 ? 4 : st.area > 2400 ? 3 : st.area > 900 ? 2 : 1;
const max = scaleInitialCount(baseMax);
return Math.round(clamp(raw + rand(seed, 1027 + regionId * 17) * 0.7, min, max));
},
extraScore: (x, y, i) => fieldValue(geoNaturalCentrality, i, 0) * 0.18 + fieldValue(geoAccessibility, i, 0) * 0.10,
@ -201,7 +214,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
let villages = pickRegionalPoints(villageScore, {
stride: 2,
threshold: 0.18 + rand(seed, 1031) * 0.030,
totalMax: 190,
totalMax: scaleInitialCount(190),
minDistance: 6,
seedOffset: 1030,
kind: "Village",
@ -210,7 +223,8 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const vf = visibilityFactor(regionId, st);
const raw = (st.developableCells / 40 + st.plainCells / 58 + st.valleyCells / 48 + st.coastCells / 46 + 2.1) * vf;
const min = st.area > 2600 ? 12 : st.area > 1400 ? 7 : st.area > 520 ? 3 : st.area > 220 ? 1 : 0;
const max = st.area > 3600 ? 46 : st.area > 2200 ? 32 : st.area > 900 ? 16 : 7;
const baseMax = st.area > 3600 ? 46 : st.area > 2200 ? 32 : st.area > 900 ? 16 : 7;
const max = scaleInitialCount(baseMax);
return Math.round(clamp(raw + rand(seed, 1033 + regionId * 19) * 1.5, min, max));
},
}).map((p, n) => {
@ -234,7 +248,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const supplementalPlainVillages = pickRegionalPoints(openPlainVillageScore, {
stride: 2,
threshold: 0.235 + rand(seed, 1036) * 0.020,
totalMax: 60,
totalMax: scaleInitialCount(60),
minDistance: 7,
seedOffset: 1035,
kind: "Plain Village",
@ -244,7 +258,8 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const vf = visibilityFactor(regionId, st);
const raw = (st.plainCells / 92 + st.developableCells / 260 + 0.9) * vf;
const min = st.plainCells > 360 ? 3 : st.plainCells > 160 ? 1 : st.plainCells > 90 ? 1 : 0;
const max = st.plainCells > 720 ? 12 : st.plainCells > 360 ? 8 : st.plainCells > 140 ? 4 : 2;
const baseMax = st.plainCells > 720 ? 12 : st.plainCells > 360 ? 8 : st.plainCells > 140 ? 4 : 2;
const max = scaleInitialCount(baseMax);
return Math.round(clamp(raw + rand(seed, 1037 + regionId * 29) * 1.4, min, max));
},
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.34 + agriculture[i] * 0.26 - river[i] * 0.20 - valleyField[i] * 0.12),
@ -300,7 +315,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
let markets = pickRegionalPoints(marketScore, {
stride: 2,
threshold: 0.245 + rand(seed, 1041) * 0.035,
totalMax: 70,
totalMax: scaleInitialCount(70),
minDistance: 9,
seedOffset: 1040,
kind: "Market Town",
@ -309,14 +324,15 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const vf = visibilityFactor(regionId, st);
const raw = (st.developableCells / 132 + st.plainCells / 148 + st.valleyCells / 128 + st.coastCells / 104 + 1.8) * vf;
const min = st.area > 2600 ? 5 : st.area > 1200 ? 3 : st.area > 520 ? 1 : 0;
const max = st.area > 3600 ? 20 : st.area > 2200 ? 14 : st.area > 800 ? 7 : 3;
const baseMax = st.area > 3600 ? 20 : st.area > 2200 ? 14 : st.area > 800 ? 7 : 3;
const max = scaleInitialCount(baseMax);
return Math.round(clamp(raw + rand(seed, 1043 + regionId * 23) * 0.8, min, max));
},
extraScore: (x, y, i) => (commercialPortIndex.hasWithin(x, y, 10) ? 0.12 : 0) + coastalSettlement[i] * 0.08 + Math.max(0, plain[i] * 0.32 + agriculture[i] * 0.20 - river[i] * 0.16) * 0.09 + confluenceField[i] * 0.035,
}).map((p, n) => {
const i = indexOf(p.x, p.y);
const kind = coastalSettlement[i] > 0.38 && portIndex.hasWithin(p.x, p.y, 11) ? "Port Town" : valleySettlement[i] > 0.48 ? "Valley Market Town" : "Market Town";
const population = Math.round((9000 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.02) * 70000 + marketScore[i] * 40000 + villageInfluence[i] * 7800 + Math.max(0, plain[i] * 0.48 + agriculture[i] * 0.30 + basinField[i] * 0.18 - river[i] * 0.14) * 22000 + coastalSettlement[i] * 12000) / 1000) * 1000;
const population = Math.round((2400 + Math.pow(rand(seed, 18100 + n * 19 + p.x * 5 + p.y), 1.18) * 22000 + marketScore[i] * 13500 + villageInfluence[i] * 2600 + Math.max(0, plain[i] * 0.48 + agriculture[i] * 0.30 + basinField[i] * 0.18 - river[i] * 0.14) * 7200 + coastalSettlement[i] * 4200) / 500) * 500;
return { ...p, kind, population };
});
@ -333,7 +349,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const supplementalPlainMarkets = pickRegionalPoints(openPlainMarketScore, {
stride: 2,
threshold: 0.335 + rand(seed, 1046) * 0.025,
totalMax: 26,
totalMax: scaleInitialCount(26),
minDistance: 12,
seedOffset: 1045,
kind: "Plain Market Town",
@ -343,14 +359,15 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const vf = visibilityFactor(regionId, st);
const raw = (st.plainCells / 380 + st.developableCells / 720 + 0.25) * vf;
const min = st.plainCells > 520 ? 1 : st.plainCells > 260 ? 1 : 0;
const max = st.plainCells > 900 ? 4 : st.plainCells > 420 ? 3 : st.plainCells > 160 ? 1 : 1;
const baseMax = st.plainCells > 900 ? 4 : st.plainCells > 420 ? 3 : st.plainCells > 160 ? 1 : 1;
const max = scaleInitialCount(baseMax);
return Math.round(clamp(raw + rand(seed, 1047 + regionId * 31) * 0.9, min, max));
},
extraScore: (x, y, i) => Math.max(0, plain[i] * 0.24 + agriculture[i] * 0.18 - river[i] * 0.14 - valleyField[i] * 0.08),
}).filter((p) => !preSupplementalMarketIndex.hasWithin(p.x, p.y, 10.5) && !villageIndexForMarketSpacing.hasWithin(p.x, p.y, 4.5))
.map((p, n) => {
const i = indexOf(p.x, p.y);
const population = Math.round((10000 + Math.pow(rand(seed, 18340 + n * 37 + p.x * 11 + p.y), 1.02) * 52000 + openPlainMarketScore[i] * 26000 + agriculture[i] * 9000 + plain[i] * 8000) / 1000) * 1000;
const population = Math.round((2200 + Math.pow(rand(seed, 18340 + n * 37 + p.x * 11 + p.y), 1.18) * 18000 + openPlainMarketScore[i] * 9000 + agriculture[i] * 3200 + plain[i] * 2800) / 500) * 500;
return { ...p, kind: "Plain Market Town", population };
});
markets = [...markets, ...supplementalPlainMarkets];
@ -379,7 +396,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const sparseMarkets = pickRegionalPoints(sparseTownScore, {
stride: 2,
threshold: 0.315 + rand(seed, 1049) * 0.025,
totalMax: 24,
totalMax: scaleInitialCount(24),
minDistance: 15,
seedOffset: 1048,
kind: "Sparse Market Town",
@ -389,14 +406,14 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const vf = visibilityFactor(regionId, st);
const underServed = clamp(1.0 - ((marketCountByRegion.get(regionId) || 0) / Math.max(1, st.area / 850)));
const raw = (st.developableCells / 900 + st.plainCells / 720 + st.coastCells / 560 + 0.55) * vf * (0.55 + underServed * 0.75);
return Math.round(clamp(raw + rand(seed, 1050 + regionId * 37) * 0.45, 0, st.area > 2000 ? 2 : 1));
return Math.round(clamp(raw + rand(seed, 1050 + regionId * 37) * 0.45, 0, scaleInitialCount(st.area > 2000 ? 2 : 1)));
},
extraScore: (x, y, i) => clamp((0.34 - existingTownInfluenceForSparseFill[i]) * 0.38 + plain[i] * 0.10 + agriculture[i] * 0.08 + coastalSettlement[i] * 0.06),
})
.filter((p) => !preSparseMarketIndex.hasWithin(p.x, p.y, 12) && !preSparseVillageIndex.hasWithin(p.x, p.y, 4.5))
.map((p, n) => {
const i = indexOf(p.x, p.y);
const population = Math.round((8000 + Math.pow(rand(seed, 18480 + n * 41 + p.x * 13 + p.y), 1.08) * 36000 + sparseTownScore[i] * 26000) / 1000) * 1000;
const population = Math.round((1600 + Math.pow(rand(seed, 18480 + n * 41 + p.x * 13 + p.y), 1.20) * 13500 + sparseTownScore[i] * 8200) / 500) * 500;
return { ...p, kind: "Sparse Market Town", population };
});
markets = [...markets, ...sparseMarkets];
@ -417,7 +434,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
}
const castles = pickGlobalPoints(defenseScore, {
threshold: 0.34 + rand(seed, 1051) * 0.06,
max: 5,
max: scaleInitialCount(5),
minDistance: 16,
seedOffset: 1050,
}).map((p) => ({
@ -505,7 +522,7 @@ export function generateMapFeatures(seed, terrain, options = {}) {
const maxCities = clamp(
Math.round((st.developableCells / 680 + (st.highCentralityCells || 0) / 360 + 0.95) * vf + rand(seed, 12100 + regionId * 17) * 1.1),
(st.area > 900 || st.developableCells > 180) ? 1 : 0,
st.area > 3600 ? 6 : st.area > 2200 ? 4 : st.area > 900 ? 3 : 1
scaleInitialCount(st.area > 3600 ? 6 : st.area > 2200 ? 4 : st.area > 900 ? 3 : 1)
);
const selected = pickEntities(list, {
max: maxCities,
@ -536,6 +553,11 @@ export function generateMapFeatures(seed, terrain, options = {}) {
? Math.max(0, Math.min(1, Math.round(baseRegionalCapitalSlots * (1 - topCenterSuppression))))
: baseRegionalCapitalSlots;
const topCenterGeoThreshold = 0.80 + topCenterSuppression * 0.16;
// Halve the map-level appearance probability of a 1M+ city. A single
// deterministic gate applies to all candidate cities in this generation, so
// having two regional-capital slots does not turn a nominal 50% per-city gate
// into a ~75% chance that the map still contains a megacity.
const allowMillionPlusMap = !patchMode && rand(seed, 918271) < 0.50;
for (const [rank, city] of modernCities.entries()) {
const i = indexOf(city.x, city.y);
const st = regionStats.get(city.regionId);
@ -555,32 +577,41 @@ export function generateMapFeatures(seed, terrain, options = {}) {
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);
// r11.4 demographic calibration: initial generation used to make a 1M+
// metropolis almost automatic whenever a top-center slot existed. Keep the
// same geography/capacity selection, but halve the probability of entering the
// million-plus population tier. Patch candidates are already capped below 1M.
let rawPop;
if (isRegionalCapital) {
if (isTopCenter) {
// 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);
// Only half of eligible top centers enter the million-plus tier. The other
// half remains a large regional capital below 1M rather than being forced
// into a megacity by slot assignment alone.
if (allowMillionPlusMap) {
rawPop =
1800000 +
Math.pow(u, 0.46) * 4300000 +
Math.pow(v, 3.2) * 2200000;
} else {
rawPop = 650000 + Math.pow(u, 0.58) * 220000 + Math.pow(v, 2.5) * 80000;
if (patchMode) rawPop *= (0.72 + (1 - topCenterSuppression) * 0.16);
}
} else {
// larger 0.25M - 2.5M
rawPop =
250000 +
Math.pow(u, 0.55) * 1450000 +
Math.pow(v, 2.4) * 900000;
if (!allowMillionPlusMap && rawPop >= 1000000) rawPop = 950000;
if (patchMode) rawPop *= 0.72;
}
} else {
// normal 5k - 0.75k
rawPop =
52000 +
Math.pow(u, 0.72) * 520000 +
Math.pow(v, 3.0) * 320000;
42000 +
Math.pow(u, 0.76) * 360000 +
Math.pow(v, 3.0) * 180000;
}
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;
@ -1054,7 +1085,7 @@ if (isRegionalCapital) {
if (!inside(x, y)) continue;
sampled++;
const i = indexOf(x, y);
const isTunnel = !sea[i] && ((elevation[i] >= 0.72 && ridgeField[i] >= 0.34) || naturalBarrierScore[i] >= 0.72);
const isTunnel = !sea[i] && elevation[i] >= 0.58 && ((elevation[i] >= 0.69 && ridgeField[i] >= 0.34) || (ridgeField[i] >= 0.62 && elevation[i] >= 0.60) || (naturalBarrierScore[i] >= 0.82 && elevation[i] >= 0.60));
if (isTunnel) {
tunnelCells++;
currentTunnelRun++;
@ -1071,22 +1102,86 @@ if (isRegionalCapital) {
if (!path?.length) return false;
const water = pathWaterCrossingStats(path);
const tunnel = pathTunnelStats(path);
const bridgeLimit = overrides.bridgeLimit ?? (mode === "expressway" ? 20 : 10);
const tunnelLimit = overrides.tunnelLimit ?? (mode === "expressway" ? 10 : mode === "national" ? 10 : 0);
const maxSeaRun = overrides.maxSeaRun ?? bridgeLimit;
const maxTunnelRun = overrides.maxTunnelRun ?? tunnelLimit;
const maxSeaShare = overrides.maxSeaShare ?? (mode === "expressway" ? 0.22 : mode === "rail" ? 0.030 : mode === "national" ? 0.10 : 0.05);
const maxTunnelShare = overrides.maxTunnelShare ?? (mode === "expressway" ? 0.12 : mode === "national" ? 0.12 : 0);
// Production paths never use renderer-side clipping as a substitute for
// routing. Unless a caller explicitly opts into a bridge, every road/rail
// cell must be land. Severe terrain is a routing constraint, not a part of
// the polyline that may later be hidden by the renderer.
const maxSeaRun = overrides.maxSeaRun ?? 0;
const maxTunnelRun = overrides.maxTunnelRun ?? (mode === "local" ? 4 : 0);
const maxSeaShare = overrides.maxSeaShare ?? 0;
const maxTunnelShare = overrides.maxTunnelShare ?? (mode === "local" ? 0.04 : 0);
if (water.maxSeaRun > maxSeaRun || water.seaShare > maxSeaShare) return false;
if (tunnel.maxTunnelRun > maxTunnelRun || tunnel.tunnelShare > maxTunnelShare) return false;
if (water.seaCells > 0) {
const first = path[0];
const last = path[path.length - 1];
const ai = inside(first?.[0], first?.[1]) ? indexOf(first[0], first[1]) : -1;
const bi = inside(last?.[0], last?.[1]) ? indexOf(last[0], last[1]) : -1;
const demand = clamp((ai >= 0 ? settlementDemand[ai] || 0 : 0) + (bi >= 0 ? settlementDemand[bi] || 0 : 0));
const threshold = mode === "local" ? 0.62 : mode === "rail" ? 0.54 : 0.42;
if (demand < threshold && mode !== "national" && mode !== "expressway") return false;
const maxElevation = overrides.maxElevation ?? (mode === "local" ? 0.84 : 0.695);
const maxBarrier = overrides.maxBarrier ?? (mode === "local" ? 0.90 : 0.84);
const maxSlope = overrides.maxSlope ?? (mode === "local" ? 0.64 : mode === "national" ? 0.56 : 0.52);
let samples = 0, rugged = 0, slopeSum = 0, elevationSum = 0, valleyPassSum = 0;
for (const [x, y] of path) {
if (!inside(x, y)) return false;
const i = indexOf(x, y);
if (sea[i]) return false;
const pass = passSuitability?.[i] || 0;
const elev = elevation[i] || 0;
const slopeV = slope[i] || 0;
const ridgeV = ridgeField[i] || 0;
const barrierV = naturalBarrierScore?.[i] || 0;
const extremeBarrier = barrierV > maxBarrier || slopeV > maxSlope || (ridgeV > 0.62 && elev >= 0.58);
if (elev >= maxElevation) return false;
if (mode !== "local" && elev >= 0.66 && pass < 0.50) return false;
if (extremeBarrier && pass < (mode === "local" ? 0.40 : 0.46)) return false;
samples++;
slopeSum += slopeV;
elevationSum += elev;
valleyPassSum += Math.max(valleyField?.[i] || 0, pass);
if (mode !== "local" && (slopeV >= 0.22 || elev >= 0.58 || (ridgeV >= 0.44 && elev >= 0.50) || (barrierV >= 0.58 && elev >= 0.50))) rugged++;
}
if (mode !== "local" && samples && path.length >= 2) {
const first = path[0], last = path[path.length - 1];
const direct = Math.hypot(last[0] - first[0], last[1] - first[1]);
let length = 0;
for (let k = 1; k < path.length; k++) length += Math.hypot(path[k][0] - path[k - 1][0], path[k][1] - path[k - 1][1]);
const straightness = direct / Math.max(1, length);
const ruggedShare = rugged / samples;
const meanSlope = slopeSum / samples;
const meanElevation = elevationSum / samples;
const meanValleyPass = valleyPassSum / samples;
// "Straight" here means the subtle near-chord shape reported by the user,
// not sparse coordinate jumps. It is invalid only where the terrain says a
// trunk should detour through a valley/pass; flat-country straights remain.
if (length >= 18 && straightness > 0.90 && ruggedShare > 0.18 && meanValleyPass < 0.46) return false;
if (length >= 30 && straightness > 0.84 && ruggedShare > 0.30 && (meanSlope > 0.14 || meanElevation > 0.53) && meanValleyPass < 0.50) return false;
// Also compare with the literal endpoint chord. This catches the more
// subtle failure where a path has many adjacent vertices and tiny bends
// but still behaves like a straight ruler line through terrain that ought
// to force a visible detour.
const chordSteps = Math.max(1, Math.ceil(direct));
let chordSamples = 0, chordHard = 0, chordBurden = 0, routeBurden = 0;
for (const [x, y] of path) {
if (!inside(x, y)) continue;
const i = indexOf(x, y);
const e = elevation[i] || 0, sV = slope[i] || 0, rV = ridgeField[i] || 0, bV = naturalBarrierScore[i] || 0;
const vV = valleyField?.[i] || 0, pV = passSuitability?.[i] || 0;
routeBurden += Math.max(0, sV * 3.2 + rV * 2.0 + bV * 2.4 + Math.max(0, e - 0.48) * 6.0 - vV * 1.25 - pV * 1.55);
}
routeBurden /= Math.max(1, path.length);
for (let q = 0; q <= chordSteps; q++) {
const t = q / chordSteps;
const x = Math.round(first[0] + (last[0] - first[0]) * t), y = Math.round(first[1] + (last[1] - first[1]) * t);
if (!inside(x, y)) { chordHard++; chordSamples++; continue; }
const i = indexOf(x, y);
const e = elevation[i] || 0, sV = slope[i] || 0, rV = ridgeField[i] || 0, bV = naturalBarrierScore[i] || 0;
const vV = valleyField?.[i] || 0, pV = passSuitability?.[i] || 0;
const seaV = !!sea[i];
const hostile = seaV || (e >= 0.66 && pV < 0.50) || ((sV >= 0.42 || (rV >= 0.58 && e >= 0.54) || (bV >= 0.78 && e >= 0.56)) && pV < 0.46);
if (hostile) chordHard++;
chordBurden += seaV ? 12 : Math.max(0, sV * 3.2 + rV * 2.0 + bV * 2.4 + Math.max(0, e - 0.48) * 6.0 - vV * 1.25 - pV * 1.55);
chordSamples++;
}
const chordHardShare = chordHard / Math.max(1, chordSamples);
const meanChordBurden = chordBurden / Math.max(1, chordSamples);
if (direct >= 18 && chordHardShare >= 0.08 && straightness > 0.90) return false;
if (direct >= 24 && meanChordBurden > routeBurden * 1.30 + 0.18 && straightness > 0.91) return false;
}
return true;
}
@ -1495,42 +1590,10 @@ if (isRegionalCapital) {
);
if (routed.length >= 2) return relaxRouteToTerrain(routed, costField, { radius: 2, lineWeight: 0.34, grain: 0.035, iterations: 1 });
// Fallback for rare isolated cells: still use a snapped line, but keep the
// radius small and terrain-weighted so it does not become a long artificial
// chord across mountains.
const steps = Math.max(2, Math.ceil(dist * 1.35));
const out = [];
let lastKey = "";
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const fx = start.x + (target.x - start.x) * t;
const fy = start.y + (target.y - start.y) * t;
let best = null;
let bestCost = INF;
const radius = Math.max(1, Math.min(snapRadius, 2));
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const x = Math.round(fx + dx);
const y = Math.round(fy + dy);
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea[i] || costField[i] >= INF) continue;
const lineDist = Math.hypot(x - fx, y - fy);
const cost = lineDist * 0.92 + costField[i] * 1.10 - valleyField[i] * 0.22 - coastalLowland[i] * 0.10 + ridgeField[i] * 0.22 + slope[i] * 0.18;
if (cost < bestCost) {
bestCost = cost;
best = [x, y];
}
}
}
if (!best) continue;
const key = `${best[0]},${best[1]}`;
if (key !== lastKey) {
out.push(best);
lastKey = key;
}
}
return relaxRouteToTerrain(out, costField, { radius: 2, lineWeight: 0.36, grain: 0.030, iterations: 1 });
// No straight/snapped fallback. If the terrain router cannot connect the
// points, leave them disconnected for a later topology pass rather than
// fabricating a Euclidean chord.
return [];
}
function importantNodesForRegion(regionId) {
@ -1624,7 +1687,17 @@ if (isRegionalCapital) {
if (sea[indexOf(start.x, start.y)] || sea[indexOf(target.x, target.y)]) return [];
const d = Math.hypot(start.x - target.x, start.y - target.y);
const snap = options.snapRadius ?? (mode === "expressway" ? 3 : mode === "rail" ? 2.5 : 2.25);
const searchPad = options.searchPad ?? Math.ceil(Math.max(16, Math.min(48, d * (mode === "expressway" ? 0.40 : mode === "rail" ? 0.37 : 0.31))));
const trunkMode = mode === "expressway" || mode === "national" || mode === "rail";
const requestedSearchPad = options.searchPad ?? Math.ceil(Math.max(16, Math.min(48, d * (mode === "expressway" ? 0.40 : mode === "rail" ? 0.37 : 0.31))));
// Every production trunk call-site, including late repair/service passes, is
// terrain-first. Older callers were able to pass a large Euclidean
// heuristic/line relaxation and recreate the user's subtly-curved but
// essentially straight motorway/national-road failure. A wider search
// window plus hard caps here makes the guarantee systemic rather than
// dependent on each individual caller remembering the right constants.
const searchPad = trunkMode
? Math.max(requestedSearchPad, Math.ceil(Math.max(28, Math.min(112, d * 0.54))))
: requestedSearchPad;
const bounds = options.bounds || {
minX: Math.max(0, Math.min(start.x, target.x) - searchPad),
maxX: Math.min(MAP_W - 1, Math.max(start.x, target.x) + searchPad),
@ -1633,7 +1706,7 @@ if (isRegionalCapital) {
};
const coarseThreshold = options.coarseThreshold ?? (mode === "local" ? 18 : mode === "rail" ? 22 : mode === "expressway" ? 30 : 20);
let path = [];
if (!options.forceFullResolution && d >= coarseThreshold) {
if (mode === "local" && !options.forceFullResolution && d >= coarseThreshold) {
coarseRouteStats.attempted++;
const graph = coarseGraphFor(costField, mode);
const coarse = routeCoarsePath(start, target, graph, { heuristicWeight: mode === "rail" ? 0.72 : 0.86 });
@ -1661,16 +1734,22 @@ if (isRegionalCapital) {
costField,
penaltyField || null,
{
curvePenalty: options.curvePenalty ?? (mode === "expressway" ? 0.12 : mode === "rail" ? 0.15 : mode === "national" ? 0.070 : 0.040),
curvePenalty: trunkMode
? Math.min(options.curvePenalty ?? (mode === "expressway" ? 0.020 : mode === "rail" ? 0.018 : 0.016), mode === "expressway" ? 0.024 : mode === "rail" ? 0.022 : 0.020)
: (options.curvePenalty ?? 0.040),
penaltyStrength: options.penaltyStrength ?? (mode === "expressway" ? 1.9 : mode === "rail" ? 1.35 : mode === "national" ? 1.05 : 0.78),
minGoalDistance: Math.min(8, Math.max(2, d * 0.08)),
keepRegion: false,
maxExpanded: Math.min(Math.floor(SIZE * SPEED_TOLERANCE), Math.max(1300, Math.floor(d * d * (mode === "expressway" ? 2.9 : mode === "rail" ? 3.5 : 3.9)))),
terrainFlowBias: options.terrainFlowBias ?? (mode === "expressway" ? 0.10 : mode === "rail" ? 0.12 : mode === "national" ? 0.24 : 0.30),
surfaceGrain: options.surfaceGrain ?? (mode === "national" ? 0.030 : mode === "local" ? 0.042 : 0.010),
terrainFlowBias: trunkMode
? Math.max(options.terrainFlowBias ?? 0, mode === "expressway" ? 0.42 : mode === "rail" ? 0.48 : 0.58)
: (options.terrainFlowBias ?? 0.30),
surfaceGrain: options.surfaceGrain ?? (mode === "national" ? 0.042 : mode === "rail" ? 0.024 : mode === "expressway" ? 0.018 : 0.042),
bounds,
goalHint: options.goalHint || target,
heuristicWeight: options.heuristicWeight ?? (mode === "expressway" ? 0.66 : mode === "rail" ? 0.54 : mode === "national" ? 0.46 : 0.30),
heuristicWeight: trunkMode
? Math.min(options.heuristicWeight ?? 0.08, mode === "national" ? 0.07 : 0.09)
: (options.heuristicWeight ?? 0.30),
progressLabel: `${mode} corridor`,
}
);
@ -1680,7 +1759,9 @@ if (isRegionalCapital) {
const skipRelax = options.skipRelax ?? (mode === "local" && path.length > 42);
const relaxed = skipRelax ? path : relaxRouteToTerrain(path, costField, {
radius: options.relaxRadius ?? (mode === "national" ? 2 : 1),
lineWeight: options.relaxLineWeight ?? (mode === "national" ? 0.30 : mode === "expressway" ? 0.24 : 0.48),
lineWeight: trunkMode
? Math.min(options.relaxLineWeight ?? 0.05, mode === "national" ? 0.045 : 0.055)
: (options.relaxLineWeight ?? 0.48),
grain: options.surfaceGrain ?? 0.020,
iterations: 1,
});
@ -1692,50 +1773,65 @@ if (isRegionalCapital) {
}
function pruneParallelSameMode(paths, mode, potentialField, options = {}) {
if (!paths?.length) return { mode, pruned: 0, kept: 0 };
if (!paths?.length) return { mode, pruned: 0, kept: 0, directionAware: true };
const scored = paths.map((path, originalIndex) => {
const len = pathLengthCells(path);
return { path, originalIndex, len, score: pathAverageField(path, potentialField) * 12 + Math.log1p(len) + (len > 80 ? 0.30 : 0) };
}).sort((a, b) => b.score - a.score);
const accepted = new Uint8Array(SIZE);
const kept = [];
const acceptedSamples = [];
let pruned = 0;
const radius = options.radius ?? (mode === "expressway" ? 3 : mode === "rail" ? 2 : 2);
const threshold = options.threshold ?? (mode === "expressway" ? 0.54 : mode === "rail" ? 0.58 : 0.62);
function mark(path) {
for (const [px, py] of path) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy > radius * radius) continue;
const x = px + dx, y = py + dy;
if (inside(x, y)) accepted[indexOf(x, y)] = 1;
}
}
}
const radius = options.radius ?? (mode === "expressway" ? 4 : mode === "rail" ? 3 : 3);
const threshold = options.threshold ?? (mode === "expressway" ? 0.34 : mode === "rail" ? 0.44 : 0.42);
const directionDot = options.directionDot ?? 0.88;
const stride = options.sampleStride ?? 2;
function tangent(path, k) {
const a = path[Math.max(0, k - 2)] || path[k];
const b = path[Math.min(path.length - 1, k + 2)] || path[k];
const dx = (b?.[0] || 0) - (a?.[0] || 0), dy = (b?.[1] || 0) - (a?.[1] || 0);
const d = Math.hypot(dx, dy) || 1;
return [dx / d, dy / d];
}
function overlap(path) {
let hit = 0, n = 0;
for (const [x, y] of path) {
if (!inside(x, y)) continue;
n++;
if (accepted[indexOf(x, y)]) hit++;
function samples(path) {
const out = [];
for (let k = 0; k < (path?.length || 0); k += stride) {
const p = path[k];
if (!p || !inside(p[0], p[1])) continue;
const [tx, ty] = tangent(path, k);
out.push({ x: p[0], y: p[1], tx, ty });
}
return n ? hit / n : 0;
return out;
}
function parallelOverlap(sampleSet) {
let hit = 0;
for (const p of sampleSet) {
let parallel = false;
for (const q of acceptedSamples) {
const dx = p.x - q.x, dy = p.y - q.y;
if (Math.abs(dx) > radius || Math.abs(dy) > radius || dx * dx + dy * dy > radius * radius) continue;
if (Math.abs(p.tx * q.tx + p.ty * q.ty) < directionDot) continue; // crossing, not parallel
parallel = true;
break;
}
if (parallel) hit++;
}
return sampleSet.length ? hit / sampleSet.length : 0;
}
for (const item of scored) {
const ov = overlap(item.path);
const shortStub = item.len < (options.shortLength ?? (mode === "expressway" ? 40 : mode === "rail" ? 30 : 22));
if (kept.length >= (options.minKeep ?? 2) && ov > threshold && (shortStub || ov > threshold + 0.13)) {
const sampleSet = samples(item.path);
const ov = parallelOverlap(sampleSet);
const shortStub = item.len < (options.shortLength ?? (mode === "expressway" ? 36 : mode === "rail" ? 26 : 20));
if (kept.length >= (options.minKeep ?? 2) && ov > threshold && (shortStub || ov > threshold + 0.10)) {
pruned++;
continue;
}
kept.push(item);
mark(item.path);
acceptedSamples.push(...sampleSet);
}
kept.sort((a, b) => a.originalIndex - b.originalIndex);
paths.length = 0;
paths.push(...kept.map((item) => item.path));
return { mode, pruned, kept: kept.length };
return { mode, pruned, kept: kept.length, directionAware: true, radius, threshold };
}
function endpointList(paths) {
@ -1937,41 +2033,93 @@ const premodernRoads = [];
// Expressways are generated later by the density-flow portal system.
// External gateways at land edges; used by naming/UI and later transport work.
// Initial generation is the centre crop of a larger virtual planning frame.
// Gateways therefore live on actual crop-boundary land cells and carry a
// virtual point outside the visible raster. Roads/rail can visibly continue
// through the crop edge instead of terminating as though the screen were the
// end of the world. Patch generation keeps its separate boundary-world logic.
const initialOverscan = !patchMode && options?.initialGenerationOverscan === true;
const initialOverscanMargin = initialOverscan ? clamp(Math.round(options?.initialOverscanMargin || 64), 28, 80) : 12;
const gatewayBand = initialOverscan ? 3 : 12;
for (const regionId of [...regionStats.keys()].sort((a, b) => a - b)) {
const st = regionStats.get(regionId);
if (!st || st.area < 140) continue;
const edgeCandidates = [];
for (let y = st.minY; y <= st.maxY; y += 3) {
for (const x of [st.minX, st.maxX]) {
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] });
const seen = new Set();
function considerGatewayCell(x, y, side, edgeDistance) {
if (!inside(x, y)) return;
const i = indexOf(x, y);
if (sea[i] || regionIdAt(x, y) !== regionId) return;
// A crop gateway represents where a trunk corridor from the off-screen
// planning halo enters the visible map. Do not force that continuation
// through a mountain wall just because it happens to touch the crop edge.
if ((elevation?.[i] || 0) > 0.68 || (slope?.[i] || 0) > 0.46 || (ridgeField?.[i] || 0) > 0.74) return;
const key = `${x},${y}`;
if (seen.has(key)) return;
seen.add(key);
let virtualX = x, virtualY = y;
if (side === "west") virtualX = -initialOverscanMargin;
else if (side === "east") virtualX = MAP_W - 1 + initialOverscanMargin;
else if (side === "north") virtualY = -initialOverscanMargin;
else if (side === "south") virtualY = MAP_H - 1 + initialOverscanMargin;
const score = (developable[i] || 0) * 0.75 + (valleySettlement[i] || 0) * 0.65 + (preliminaryUrbanInfluence?.[i] || 0) * 0.35 + Math.max(0, gatewayBand - edgeDistance) * 0.10;
edgeCandidates.push({ x, y, score, edgeSide: side, virtualX, virtualY, virtualOutsideDistance: initialOverscanMargin + edgeDistance });
}
for (let d = 0; d <= gatewayBand; d++) {
for (let y = 0; y < MAP_H; y += 2) {
considerGatewayCell(d, y, "west", d);
considerGatewayCell(MAP_W - 1 - d, y, "east", d);
}
for (let x = 0; x < MAP_W; x += 2) {
considerGatewayCell(x, d, "north", d);
considerGatewayCell(x, MAP_H - 1 - d, "south", d);
}
}
for (let x = st.minX; x <= st.maxX; x += 3) {
for (const y of [st.minY, st.maxY]) {
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (!sea[i] && regionIdAt(x, y) === regionId && (x < 5 || y < 5 || x > MAP_W - 6 || y > MAP_H - 6)) edgeCandidates.push({ x, y, score: developable[i] + valleySettlement[i] });
}
}
const gateway = pickEntities(edgeCandidates, { max: (regionStats.get(regionId)?.area || 0) > 2200 ? 2 : 1, minDistance: 16, seed: seed + 13200 + regionId * 11 })[0];
if (gateway) {
gateway.kind = "External Gateway";
gateway.regionId = regionId;
externalGateways.push(gateway);
const target = importantNodesForRegion(regionId)[0];
if (target) {
const path = routeLight(gateway, target, 3, transportFields.national);
if (path.length > 2) externalRoads.push(path);
}
// Prefer true crop-edge portals; a deeper candidate is used only when the
// immediate boundary cell is unusable because of local coast geometry.
edgeCandidates.sort((a, b) => {
const ad = Math.min(a.x, a.y, MAP_W - 1 - a.x, MAP_H - 1 - a.y);
const bd = Math.min(b.x, b.y, MAP_W - 1 - b.x, MAP_H - 1 - b.y);
return ad - bd || b.score - a.score;
});
const gateways = pickEntities(edgeCandidates.slice(0, Math.max(24, Math.min(160, edgeCandidates.length))), {
max: (st.area || 0) > 2200 ? (initialOverscan ? 7 : 4) : (initialOverscan ? 4 : 2),
minDistance: initialOverscan ? 18 : 22,
seed: seed + 13200 + regionId * 11,
});
if (gateways?.length) {
const targets = importantNodesForRegion(regionId).slice(0, 4);
gateways.forEach((gateway, gatewayIndex) => {
gateway.kind = "External Gateway";
gateway.regionId = regionId;
gateway.initialOverscan = initialOverscan;
gateway.overscanMargin = initialOverscanMargin;
// Hidden planning-halo demand. The public raster is still the centre
// crop, but transport OD sees a deterministic settlement beyond the
// crop boundary instead of a fixed anonymous edge weight. This makes
// initial trunk routes behave as continuations of a larger generated
// region without allocating a second full off-screen raster.
if (initialOverscan) {
const demandDraw = rand(seed + regionId * 131 + gatewayIndex * 29, 20831 + gateway.x * 7 + gateway.y * 11);
const largeHubDraw = rand(seed + regionId * 173 + gatewayIndex * 41, 20891 + gateway.x * 13 + gateway.y * 5);
const baseDemand = 18000 + Math.max(0, gateway.score || 0) * 52000 + Math.pow(demandDraw, 1.6) * 92000;
gateway.virtualPopulation = Math.round((baseDemand + (largeHubDraw < 0.18 ? 90000 + demandDraw * 150000 : 0)) / 1000) * 1000;
gateway.virtualRole = gateway.virtualPopulation >= 180000 ? "overscan-major-city" : gateway.virtualPopulation >= 70000 ? "overscan-regional-town" : "overscan-town";
gateway.virtualDemandWeight = clamp(0.55 + gateway.virtualPopulation / 260000, 0.60, 1.65);
}
externalGateways.push(gateway);
const target = targets[gatewayIndex % Math.max(1, targets.length)] || targets[0] || null;
if (target) {
const path = routeLight(gateway, target, 3, transportFields.national);
if (path.length > 2) externalRoads.push(path);
}
});
}
}
const railOD = buildUnifiedRailODNetwork({
seed,
sea, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, agriculture, naturalBarrierScore, passSuitability,
sea, elevation, slope, ridgeField, valleyField, basinField, coastalLowland, plain, naturalBarrierScore, passSuitability,
transportFields, settlementDemand, preliminaryUrbanInfluence, preliminaryTownInfluence, preliminaryVillageInfluence,
modernCities, markets, ports, commercialPorts, externalGateways, geographicUrbanAnchors,
regionIdAt, routeBetweenTrafficCandidates, addCorridorInfluencePenalty, transportRouteAcceptable, pruneParallelSameMode, cachedInfluenceFromPaths,
@ -1990,16 +2138,17 @@ const premodernRoads = [];
settlementDemand, preliminaryVillageInfluence, preliminaryTownInfluence,
logisticsPreSuitability, urbanEdge,
transportFields, cachedInfluenceFromPaths,
nationalRoads, minorRoads, railways, externalRoads, externalRailways,
nationalRoads, minorRoads, railways, externalRoads,
expressways, externalExpressways, icAccessRoads, interchanges, externalGateways,
modernCities, markets, villages, ports, commercialPorts, passes, regionStats,
regionIdAt, inFocusedPrefecture, importantNodesForRegion, dedupePointCandidates,
modernCities, markets, villages, ports, commercialPorts, passes,
regionIdAt, dedupePointCandidates,
routeBetweenTrafficCandidates, traceCorridorByCost, addCorridorInfluencePenalty,
relaxRouteToTerrain, transportRouteAcceptable, repairTransportConnectivity,
repairDanglingTransportEndpoints, pruneDanglingTerminalSegments, pruneParallelSameMode,
onProgress: options?.onProgress,
patchMode,
largePatchTile,
productionTransportParity,
});
markFeatureTiming("road-system");
@ -2159,7 +2308,7 @@ const premodernRoads = [];
}
const logisticsParks = pickGlobalPoints(logisticsScore, {
threshold: 0.34,
max: 18,
max: scaleInitialCount(18),
minDistance: 12,
seedOffset: 1450,
predicate: (x, y, i) => logisticsScore[i] > 0.30 && (roadInfluence[i] > 0.10 || railInfluence2[i] > 0.08 || stationInfluence[i] > 0.08),
@ -2213,7 +2362,7 @@ const premodernRoads = [];
targetPredicate: (x, y, i) => roadInfluenceNow[i] > 0.18 || localPenalty[i] > 0.07,
});
}
if (!patchMode) {
if (!patchMode || productionTransportParity) {
addFinalLocalAccessForUnservedSettlements();
addRuralRoadMeshConnectors();
}
@ -2496,7 +2645,7 @@ const premodernRoads = [];
// 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) {
if (patchMode && !productionTransportParity) {
debug.gapStitches = { skipped: true, reason: "deferred-to-patch-merge" };
debug.classCleanup = cleanupRoadClassesBeforeFinalTopology();
debug.nationalCoverage = { skipped: true, reason: "deferred-to-patch-merge" };

View file

@ -1 +0,0 @@
export { generateMap, generateMapAsync, CELL_SIZE, MAP_H, MAP_W, indexOf } from "./mapPipeline.js";

View file

@ -0,0 +1,122 @@
import { indexOf, inside, rand } from "./mapUtils.js";
function featureClass(feature) {
const kind = String(feature?.kind || feature?.rank || "").toLowerCase();
if (feature?.isPrefecturalCapital || feature?.isRegionalCapital || kind.includes("city") || kind.includes("capital")) return "city";
if (kind.includes("market") || kind.includes("town")) return "market";
if (kind.includes("village")) return "village";
return "other";
}
export function estimateMunicipalityPopulations(adminId, fields = {}, settlementFeatures = [], seed = 0) {
if (!adminId?.length) return { populations: new Map(), stats: new Map(), skippedDuplicateSettlementPopulation: 0 };
let maxId = -1;
for (let i = 0; i < adminId.length; i++) if (adminId[i] >= 0) maxId = Math.max(maxId, adminId[i]);
if (maxId < 0) return { populations: new Map(), stats: new Map(), skippedDuplicateSettlementPopulation: 0 };
const totals = new Float64Array(maxId + 1);
const settlementTotals = new Float64Array(maxId + 1);
const landCells = new Uint32Array(maxId + 1);
const inhabitedCells = new Uint32Array(maxId + 1);
const plainSum = new Float64Array(maxId + 1);
const agricultureSum = new Float64Array(maxId + 1);
const densitySum = new Float64Array(maxId + 1);
const builtCells = new Uint32Array(maxId + 1);
for (let i = 0; i < adminId.length; i++) {
const id = adminId[i];
if (id < 0 || fields.sea?.[i]) continue;
landCells[id]++;
const density = Math.max(0, fields.populationDensity?.[i] || 0);
const lu = fields.landuse?.[i] ?? 0;
const plain = Math.max(0, fields.plain?.[i] || 0);
const agri = Math.max(0, fields.agriculture?.[i] || 0);
const builtWeight = lu === 3 ? 56 : lu === 2 ? 36 : lu === 4 || lu === 7 || lu === 8 ? 28 : lu === 5 || lu === 6 ? 16 : lu === 1 ? 9 : 3;
const ruralFloor = lu === 1 ? 0.8 + agri * 2.4 + plain * 1.5 : 0;
if (density > 0.006 || ruralFloor > 0 || lu > 0) inhabitedCells[id]++;
if ([2, 3, 4, 7, 8].includes(lu)) builtCells[id]++;
densitySum[id] += density;
plainSum[id] += plain;
agricultureSum[id] += agri;
totals[id] += density * builtWeight + ruralFloor;
}
// A coordinate can appear in multiple semantic layers after promotion. Keep
// only the strongest population-bearing entity at each raster cell.
const uniqueSettlementByCell = new Map();
let allSettlementPopulation = 0;
for (const feature of settlementFeatures || []) {
if (!feature || !Number.isFinite(feature.population) || feature.population <= 0 || !inside(feature.x, feature.y)) continue;
const i = indexOf(feature.x, feature.y);
if (fields.sea?.[i]) continue;
allSettlementPopulation += feature.population || 0;
const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`;
const priority = (feature.isPrefecturalCapital ? 4_000_000 : 0) + (feature.isRegionalCapital ? 1_000_000 : 0) + (feature.population || 0);
const current = uniqueSettlementByCell.get(key);
if (!current || priority > current.priority) uniqueSettlementByCell.set(key, { feature, priority, i });
}
let keptSettlementPopulation = 0;
for (const { feature, i } of uniqueSettlementByCell.values()) {
const id = adminId[i];
if (id < 0 || id >= settlementTotals.length) continue;
const cls = featureClass(feature);
const weight = cls === "city" ? 1.0 : cls === "market" ? 0.075 : cls === "village" ? 0.10 : 0.085;
settlementTotals[id] += feature.population * weight;
keptSettlementPopulation += feature.population || 0;
}
const populations = new Map();
const stats = new Map();
for (let id = 0; id <= maxId; id++) {
if (!landCells[id]) continue;
const land = landCells[id];
const inhabited = inhabitedCells[id];
const ruralPotential =
180 +
Math.sqrt(land) * 50 +
inhabited * 3.0 +
builtCells[id] * 9.0 +
agricultureSum[id] * 2.2 +
plainSum[id] * 1.5 +
densitySum[id] * 7.0;
// Two deterministic draws make the distribution broad without producing a
// uniform 2,000-person shelf. Small mountain villages can remain below 1k,
// while similarly sized lowland municipalities can naturally reach 5-15k.
const drawA = rand(seed + 61013, id * 37 + 11);
const drawB = rand(seed + 61037, id * 53 + 19);
const demographicFactor = 0.52 + Math.pow(drawA, 0.72) * 0.90 + drawB * 0.27;
const unconstrainedRural = Math.max(900, ruralPotential * demographicFactor);
// Use a soft ceiling rather than a hard 2k/19k plateau. Large rural
// municipalities compress gradually into the 20-40k range while retaining
// their relative differences.
const ruralBaseline = unconstrainedRural <= 14500
? unconstrainedRural
: 14500 + Math.sqrt(unconstrainedRural - 14500) * 70;
const raw = Math.max(Math.min(32000, ruralBaseline), (totals[id] || 0) * 0.38 + (settlementTotals[id] || 0));
const rounded = raw >= 18000 ? Math.round(raw / 1000) * 1000 : raw >= 3000 ? Math.round(raw / 500) * 500 : Math.round(raw / 100) * 100;
const population = Math.max(1000, rounded);
populations.set(id, population);
stats.set(id, {
id,
population,
landCells: land,
inhabitedCells: inhabited,
builtCells: builtCells[id],
densityMean: densitySum[id] / Math.max(1, land),
plainMean: plainSum[id] / Math.max(1, land),
agricultureMean: agricultureSum[id] / Math.max(1, land),
settlementPopulation: settlementTotals[id] || 0,
ruralBaseline,
});
}
return {
populations,
stats,
skippedDuplicateSettlementPopulation: Math.max(0, allSettlementPopulation - keptSettlementPopulation),
};
}
export function municipalityIdAtPoint(adminId, point) {
if (!adminId || !point || !inside(point.x, point.y)) return -1;
return adminId[indexOf(point.x, point.y)] ?? -1;
}

View file

@ -2,6 +2,7 @@ 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, extractAdminBorderSegments, influenceFromPaths, tagInsidePrefecture } from "./mapGeneratorHelpers.js";
import { reconcileMunicipalMetadata } from "./mapMunicipalCoherence.js";
import { estimateMunicipalityPopulations } from "./mapMunicipalDemography.js";
import { routeQualityAcceptable } from "./mapTransport.js";
import { componentLabelNear, labelOccupancyComponents, makeSpatialIndex, occupancyComponentsFromPathGroups, rasterizePathCells } from "./mapTransportUtils.js";
@ -12,13 +13,21 @@ function stripMunicipalSuffix(name) {
function municipalitySuffixForCenter(center, fields, seed, ordinal = 0) {
const i = inside(center?.x || 0, center?.y || 0) ? indexOf(center.x, center.y) : 0;
const population = Math.max(0, Number(center?.municipalityPopulation || center?.population || 0));
const density = fields.populationDensity?.[i] || 0;
const land = fields.landuse?.[i] ?? 0;
const urban = density > 0.36 || [2, 3, 4, 7, 8].includes(land) || center?.protectedSatellite;
const rural = (fields.elevation?.[i] || 0) > 0.58 || (fields.slope?.[i] || 0) > 0.40 || (fields.ridgeField?.[i] || 0) > 0.46;
if (urban) return "市";
if (rural && rand(seed + ordinal * 17, 9021) < 0.58) return "村";
return "町";
const capital = Boolean(center?.isPrefecturalCapital || center?.isRegionalCapital);
// Municipality type is primarily demographic. The former density-only rule
// turned most lowland municipalities into cities even when their actual
// population was small. Keep a small capital exception, otherwise use a
// Japan-like city/town/village population hierarchy.
if (population >= 50000 || (capital && population >= 30000)) return "市";
if (population >= 5000) return "町";
// Very small but visibly urbanized municipal seats may still be towns; this
// is deliberately a narrow exception so rural areas retain villages.
const modestUrban = density > 0.42 && [2, 3, 4, 7, 8].includes(land);
if (population >= 3500 && modestUrban && rand(seed + ordinal * 17, 9021) < 0.35) return "町";
return "村";
}
function municipalityNameFromRoot(root, center, fields, seed, ordinal = 0) {
@ -43,78 +52,29 @@ function centerMunicipalityId(center, fallback = -1) {
return fallback;
}
function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = []) {
function assignMunicipalityPopulations(adminCenters, adminId, fields, settlementFeatures = [], seed = 0) {
if (!adminCenters?.length || !adminId) return;
const estimate = estimateMunicipalityPopulations(adminId, fields, settlementFeatures, seed);
const centerById = new Map();
let maxId = -1;
for (const center of adminCenters) {
const id = centerMunicipalityId(center);
if (id >= 0 && !centerById.has(id)) {
centerById.set(id, center);
maxId = Math.max(maxId, id);
}
}
for (let i = 0; i < adminId.length; i++) if (adminId[i] >= 0) maxId = Math.max(maxId, adminId[i]);
const totals = new Float64Array(maxId + 1);
const settlementTotals = new Float64Array(maxId + 1);
const landCells = new Uint32Array(maxId + 1);
const inhabitedCells = new Uint32Array(maxId + 1);
for (let i = 0; i < adminId.length; i++) {
const id = adminId[i];
if (id < 0 || fields.sea?.[i]) continue;
landCells[id]++;
const density = fields.populationDensity?.[i] || 0;
const lu = fields.landuse?.[i] ?? 0;
const plain = fields.plain?.[i] || 0;
const agri = fields.agriculture?.[i] || 0;
const builtWeight = lu === 3 ? 520 : lu === 2 ? 360 : lu === 4 || lu === 7 || lu === 8 ? 260 : lu === 5 || lu === 6 ? 160 : lu === 1 ? 82 : 22;
const ruralFloor = lu === 1 ? 10 + agri * 18 + plain * 10 : 0;
if (density > 0.006 || ruralFloor > 0 || lu > 0) inhabitedCells[id]++;
totals[id] += density * builtWeight + ruralFloor;
}
// Population-bearing generated settlements are canonical entities, so add
// their explicit populations exactly once to the municipality containing the
// point. Some towns are promoted to cities later, so the same coordinate can
// appear in both `markets` and `modernCities`; keep only the strongest record
// per coordinate to avoid double counting.
const uniqueSettlementByCell = new Map();
for (const feature of settlementFeatures || []) {
if (!feature || !Number.isFinite(feature.population) || feature.population <= 0) continue;
if (!inside(feature.x, feature.y)) continue;
const i = indexOf(feature.x, feature.y);
if (fields.sea?.[i]) continue;
const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`;
const current = uniqueSettlementByCell.get(key);
const priority = (feature.isPrefecturalCapital ? 4_000_000 : 0) + (feature.isRegionalCapital ? 1_000_000 : 0) + (feature.population || 0);
if (!current || priority > current.priority) uniqueSettlementByCell.set(key, { feature, priority, i });
}
let skippedDuplicateSettlementPopulation = 0;
for (const { feature, i } of uniqueSettlementByCell.values()) {
const id = adminId[i];
if (id < 0 || id >= settlementTotals.length) continue;
settlementTotals[id] += feature.population;
}
for (const feature of settlementFeatures || []) {
if (!feature || !Number.isFinite(feature.population) || feature.population <= 0 || !inside(feature.x, feature.y)) continue;
const key = `${Math.round(feature.x)}:${Math.round(feature.y)}`;
const kept = uniqueSettlementByCell.get(key)?.feature;
if (kept && kept !== feature) skippedDuplicateSettlementPopulation += feature.population || 0;
if (id >= 0 && !centerById.has(id)) centerById.set(id, center);
}
for (const [id, center] of centerById) {
const raw = (totals[id] || 0) + (settlementTotals[id] || 0);
const minimumResidentPopulation = landCells[id] > 0
? Math.round(Math.max(100, Math.min(3800, 80 + landCells[id] * 9 + inhabitedCells[id] * 16)) / 100) * 100
: 0;
const adjustedRaw = Math.max(raw, minimumResidentPopulation);
const rounded = adjustedRaw >= 10000 ? Math.round(adjustedRaw / 1000) * 1000 : Math.max(minimumResidentPopulation, Math.round(adjustedRaw / 100) * 100);
const safePopulation = Math.max(landCells[id] > 0 ? 100 : 0, rounded);
const row = estimate.stats.get(id);
const safePopulation = estimate.populations.get(id) || 0;
center.municipalityPopulation = safePopulation;
// Some consumers still read the generic `population` field from municipal
// centers. Mirror the municipality total there so no municipality is shown
// as 0人 merely because it is not a canonical city/market entity.
center.population = Math.max(center.population || 0, safePopulation);
center.municipalitySettlementPopulation = Math.max(0, Math.round((settlementTotals[id] || 0) / 100) * 100);
center.municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round(skippedDuplicateSettlementPopulation / 100) * 100);
center.municipalitySettlementPopulation = Math.max(0, Math.round((row?.settlementPopulation || 0) / 100) * 100);
center.municipalitySkippedDuplicateSettlementPopulation = Math.max(0, Math.round((estimate.skippedDuplicateSettlementPopulation || 0) / 100) * 100);
center.municipalityDemography = row ? {
landCells: row.landCells,
inhabitedCells: row.inhabitedCells,
builtCells: row.builtCells,
densityMean: row.densityMean,
agricultureMean: row.agricultureMean,
ruralBaseline: Math.round(row.ruralBaseline || 0),
} : null;
}
}
@ -387,7 +347,6 @@ export function finishMapOutput({
const {
cityPopulationCap,
stationInfluence,
roadInfluence,
railInfluence2,
settlementCluster,
@ -469,7 +428,14 @@ export function finishMapOutput({
// it here from land-use or municipal offices; output should only package and
// name features.
for (const city of modernCities) {
const cap = cityPopulationCap(city);
const transferredCap = Number(city?.__productionPopulationCap);
const cap = typeof cityPopulationCap === "function"
? cityPopulationCap(city)
: (Number.isFinite(transferredCap) ? transferredCap : INF);
// Resident draft workers materialize this value solely to cross the
// structured-clone boundary. Never leak transport-only scratch metadata to
// the published map.
if (Object.prototype.hasOwnProperty.call(city, "__productionPopulationCap")) delete city.__productionPopulationCap;
let targetCap = cap;
if (patchMode) {
// Patch candidates should not regularly introduce a new top-center-scale
@ -570,6 +536,16 @@ export function finishMapOutput({
center.municipalityRootName = center.generatedMunicipalityName;
}
}
// Population must be known before municipality suffix selection so 市/町/村
// classification follows the municipality's demographic scale rather than
// a density proxy at the office cell.
assignMunicipalityPopulations(adminCenters, adminId, nameFields, [
...modernCities,
...markets,
...villages,
...satelliteCities,
...newTowns,
], seed);
const usedAdminNames = new Set();
for (const [index, center] of adminCenters.entries()) {
const municipalId = centerMunicipalityId(center, index);
@ -602,13 +578,6 @@ export function finishMapOutput({
usedAdminNames.add(center.name);
}
const promotedPrefectureCapitals = promotePrefectureCapitalPopulations(prefectureRegionId, sea, modernCities, markets, adminCenters, nameFields, seed, 220000, prefectureMask);
assignMunicipalityPopulations(adminCenters, adminId, nameFields, [
...modernCities,
...markets,
...villages,
...satelliteCities,
...newTowns,
]);
outputProgress("population packaging");
function addMunicipalCenterLocalAccess() {
@ -629,18 +598,27 @@ export function finishMapOutput({
const accessInfluence = cachedInfluence([...nationalRoads, ...railways, ...externalRoads, ...minorRoads], 8, "municipal-access");
const localPenalty = cachedInfluence(minorRoads, 4, "municipal-minor");
const perPrefectureQuota = new Map();
const candidates = adminCenters
const accessCandidateMap = new Map();
for (const p of [...(adminCenters || []), ...(modernCities || [])]) {
if (!p) continue;
const key = `${Math.round(p.x)},${Math.round(p.y)}`;
const prior = accessCandidateMap.get(key);
const pop = Number(p.municipalityPopulation || p.population || 0);
const priorPop = Number(prior?.municipalityPopulation || prior?.population || 0);
if (!prior || pop > priorPop || p.isPrefecturalCapital) accessCandidateMap.set(key, p);
}
const candidates = [...accessCandidateMap.values()]
.filter((p) => {
if (!inside(p.x, p.y) || sea[indexOf(p.x, p.y)]) return false;
const i = indexOf(p.x, p.y);
const meaningful = (p.municipalityPopulation || 0) >= 4500 || (populationDensity?.[i] || 0) > 0.08 || (p.representativeFeatureName && accessInfluence[i] < 0.22);
const meaningful = (p.municipalityPopulation || p.population || 0) >= 4500 || (populationDensity?.[i] || 0) > 0.08 || (p.representativeFeatureName && accessInfluence[i] < 0.22);
return meaningful && accessInfluence[i] < 0.42;
})
.sort((a, b) => {
const ai = indexOf(a.x, a.y);
const bi = indexOf(b.x, b.y);
const as = (a.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[ai]) * 145000;
const bs = (b.municipalityPopulation || 0) + Math.max(0, 0.42 - accessInfluence[bi]) * 145000;
const as = (a.municipalityPopulation || a.population || 0) + Math.max(0, 0.42 - accessInfluence[ai]) * 145000;
const bs = (b.municipalityPopulation || b.population || 0) + Math.max(0, 0.42 - accessInfluence[bi]) * 145000;
return bs - as;
})
.filter((p) => {
@ -969,7 +947,15 @@ export function finishMapOutput({
for (const [key, paths] of groups) {
const kept = [];
for (const path of paths || []) {
if (touchesMain(path) || pathNearAdminCenter(path) || ((key === "expressway" || key === "externalExpressway") && pathNearRequiredExpresswayCity(path))) kept.push(path);
// Trunk hierarchy has already gone through terrain, anti-parallel,
// service, and graph repair in post-admin transport. Output cleanup
// must not delete a national/expressway corridor merely because the
// combined ordinary-road component raster chose another component as
// its temporary "main" component; that was a major source of broken
// national roads. Only local roads are eligible for this visual/topology
// cleanup pass.
const protectedTrunk = key !== "minor";
if (protectedTrunk || touchesMain(path) || pathNearAdminCenter(path) || ((key === "expressway" || key === "externalExpressway") && pathNearRequiredExpresswayCity(path))) kept.push(path);
else { pruned[key]++; passPruned++; }
}
paths.length = 0;
@ -1085,6 +1071,51 @@ export function finishMapOutput({
}
// No synthetic urban grid is created in output finalization. Dense-city local
// access is generated upstream by mapTransport.js using the same terrain-aware
// local-road algorithm as ordinary settlement access.
function pruneRuralDanglingMinorRoads() {
const debugLayers = transportDebug ? (transportDebug.layers || (transportDebug.layers = {})) : {};
const trunkAndOther = [...nationalRoads, ...externalRoads, ...ringRoads, ...expressways, ...externalExpressways, ...railways, ...branchRailways];
const otherInfluence = influenceFromPaths(trunkAndOther, 3);
const civic = [...(villages || []), ...(markets || []), ...(ports || []), ...(modernCities || []), ...(adminCenters || [])];
function endpointValid(raw) {
if (!raw) return false;
const x = Math.round(raw[0]), y = Math.round(raw[1]);
if (!inside(x, y) || sea[indexOf(x, y)]) return false;
const i = indexOf(x, y);
if (otherInfluence[i] > 0.06) return true;
for (const p of civic) if (p && Math.hypot((p.x || 0) - x, (p.y || 0) - y) <= 3.6) return true;
return false;
}
const kept = [];
let pruned = 0;
for (const path of minorRoads || []) {
if (!path || path.length < 2) continue;
const a = path[0], b = path[path.length - 1];
const va = endpointValid(a), vb = endpointValid(b);
const len = (path || []).slice(1).reduce((sum, q, idx) => sum + Math.hypot(q[0] - path[idx][0], q[1] - path[idx][1]), 0);
let density = 0, n = 0;
const step = Math.max(1, Math.floor(path.length / 12));
for (let k = 0; k < path.length; k += step) {
const x = Math.round(path[k][0]), y = Math.round(path[k][1]);
if (!inside(x, y)) continue;
density += populationDensity?.[indexOf(x, y)] || 0;
n++;
}
density /= Math.max(1, n);
const rural = density < 0.08;
const drop = rural && ((!va && !vb && len < 30) || ((va !== vb) && len < 13));
if (drop && minorRoads.length - pruned > 28) pruned++;
else kept.push(path);
}
minorRoads.length = 0;
minorRoads.push(...kept);
debugLayers.ruralDanglingMinorPruned = pruned;
return pruned;
}
function finalizeOutputRoadTopology() {
// Build required municipal access before pruning so the prune pass can
// preserve those paths directly instead of deleting and re-adding them.
@ -1092,8 +1123,16 @@ export function finishMapOutput({
outputProgress("municipal road access");
const requiredStubsAdded = ensureAdminCenterRoadStubs();
outputProgress("required road stubs");
const urbanStreetMeshAdded = 0;
if (transportDebug) {
transportDebug.layers ||= {};
transportDebug.layers.syntheticUrbanStreetMeshDisabled = true;
}
outputProgress("urban local access preserved");
const endpointConnectorsAdded = connectNearbyRoadEndpoints();
outputProgress("road endpoint connectors");
const ruralDanglingMinorPruned = pruneRuralDanglingMinorRoads();
outputProgress("rural dangling road pruning");
const prune = pruneIsolatedFinalRoadComponents();
outputProgress("road component pruning");
const finalComponents = occupancyComponentsFromPathGroups(
@ -1104,7 +1143,9 @@ export function finishMapOutput({
debugLayers.finalOutputRoadTopology = {
components: finalComponents,
requiredStubsAdded,
urbanStreetMeshAdded,
endpointConnectorsAdded,
ruralDanglingMinorPruned,
prune,
};
return debugLayers.finalOutputRoadTopology;
@ -1120,11 +1161,23 @@ export function finishMapOutput({
if (id >= 0 && !centerByAdmin.has(id)) centerByAdmin.set(id, center);
}
const allCenters = [...centerByAdmin.values()].filter((c) => c?.name);
const nearbyNamedPlaces = [
...(modernCities || []).map((p) => ({ ...p, _icPlaceWeight: 5.0 + Math.log1p(Math.max(0, p.population || 0)) * 0.18 })),
...(markets || []).map((p) => ({ ...p, _icPlaceWeight: 4.0 })),
...(villages || []).map((p) => ({ ...p, _icPlaceWeight: 3.4 })),
...(ports || []).map((p) => ({ ...p, _icPlaceWeight: p.portClass === "major" ? 4.8 : 3.5 })),
...(satelliteCities || []).map((p) => ({ ...p, _icPlaceWeight: 4.1 })),
...(newTowns || []).map((p) => ({ ...p, _icPlaceWeight: 3.9 })),
...(stations || []).map((p) => ({ ...p, _icPlaceWeight: 2.2 })),
].filter((p) => p?.name && inside(p.x, p.y));
const used = new Set();
const directionNames = ["北", "東", "南", "西", "中央", "上", "下", "新"];
let renamed = 0;
function cleanBase(name) {
return String(name || "").replace(/[ICインターチェンジ\s]+$/u, "").replace(/[市町村区]$/u, "");
return String(name || "")
.replace(/[ICインターチェンジ\s]+$/u, "")
.replace(/[市町村区駅港城跡宿]$/u, "")
.trim();
}
for (const [idx, ic] of interchanges.entries()) {
if (!ic || !inside(ic.x, ic.y)) continue;
@ -1134,31 +1187,52 @@ export function finishMapOutput({
const nearbyCenters = allCenters
.slice()
.sort((a, b) => Math.hypot(a.x - ic.x, a.y - ic.y) - Math.hypot(b.x - ic.x, b.y - ic.y));
const placeRows = nearbyNamedPlaces
.map((place) => {
const d = Math.hypot(place.x - ic.x, place.y - ic.y);
const sameAdmin = admin >= 0 && adminId[indexOf(place.x, place.y)] === admin;
const score = d - (place._icPlaceWeight || 0) * 0.85 - (sameAdmin ? 2.4 : 0);
return { place, d, score, sameAdmin };
})
.filter((row) => row.d <= 24)
.sort((a, b) => a.score - b.score || a.d - b.d);
const candidates = [];
// Japanese IC names normally inherit a nearby settlement/local place name,
// not an abstract municipal-office identifier. Prefer a named place in or
// near the containing municipality, then fall back to the municipality.
for (const row of placeRows.slice(0, 8)) {
const base = cleanBase(row.place.name);
if (base) candidates.push(`${base}IC`);
}
if (primary?.name) candidates.push(`${cleanBase(primary.municipalityName || primary.name)}IC`);
for (const center of nearbyCenters.slice(0, 12)) {
for (const center of nearbyCenters.slice(0, 8)) {
const base = cleanBase(center.municipalityName || center.name);
if (base) candidates.push(`${base}IC`);
}
if (primary?.name) {
const base = cleanBase(primary.municipalityName || primary.name);
for (const dir of directionNames) candidates.push(`${base}${dir}IC`);
}
const directionalBase = cleanBase(placeRows[0]?.place?.name || primary?.municipalityName || primary?.name);
if (directionalBase) for (const dir of directionNames) candidates.push(`${directionalBase}${dir}IC`);
candidates.push(`自治${idx + 1}IC`);
let name = candidates.find((candidate) => candidate && !used.has(candidate));
if (!name) name = `自治${idx + 1}IC`;
ic.name = name;
ic.labelName = name;
ic.municipalityNameBased = true;
ic.municipalityNameBased = !placeRows.length;
ic.nearbyPlaceNameBased = placeRows.length > 0;
if (placeRows[0]) {
ic.nearbyPlaceName = placeRows[0].place.name;
ic.nearbyPlaceDistance = Math.round(placeRows[0].d * 10) / 10;
}
used.add(name);
renamed++;
}
if (transportDebug) {
transportDebug.layers ||= {};
transportDebug.layers.municipalityBasedInterchangeNames = renamed;
transportDebug.layers.interchangeNamesPreferNearbyPlaces = true;
}
return renamed;
}
renameInterchangesFromMunicipalities();
outputProgress("interchange naming");

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@ import { CELL_SIZE, MAP_H, MAP_W, indexOf, nowMs } from "./mapUtils.js";
import { generateTerrainAndRivers } from "./mapTerrain.js";
import { generateMapFeatures } from "./mapFeatures.js";
import { finishMapOutput } from "./mapOutput.js";
import { generateAdminLayout } from "./mapAdminStage.js";
import { finalizePrefectureLayout, generateMunicipalLayout } from "./mapAdminStage.js";
import { buildGeographicBasis, finalizeGeographicBasis } from "./mapGeography.js";
import { finalizeAdminAwareTransport } from "./mapPostAdminTransport.js";
@ -75,6 +75,20 @@ function contextualSeed(seed, context, options = {}) {
return h >>> 0;
}
function applyInitialGenerationOverscanDefaults(options = {}) {
const isPatch = options.patchMode === true || options.worldNative === true || !!options.boundaryWorld || options?.generationContext?.hasBoundaryWorld === true;
if (isPatch || options.initialGenerationOverscan === false) return options;
// The fixed raster remains the visible crop, but terrain and transport demand
// are evaluated as the centre of a larger virtual frame. This gives the
// initial map real off-screen context without changing public map dimensions.
return {
...options,
initialGenerationOverscan: true,
terrainFrameScale: Number.isFinite(options.terrainFrameScale) ? options.terrainFrameScale : 1.42,
initialOverscanMargin: Number.isFinite(options.initialOverscanMargin) ? options.initialOverscanMargin : 64,
};
}
function makeRuntimeOptions(options, baseSeed) {
const generationContext = normalizeGenerationContext(options);
const effectiveSeed = contextualSeed(baseSeed, generationContext, options);
@ -103,7 +117,8 @@ function generateInitialTerrain(seed, options = {}) {
export function prepareProductionTerrain(seedInput = 114514, options = {}) {
const baseSeed = Number(seedInput) >>> 0;
const runtimeOptions = makeRuntimeOptions({ ...options, terrainOverride: undefined, stableWorldTerrain: false }, baseSeed);
options = applyInitialGenerationOverscanDefaults(options);
const runtimeOptions = makeRuntimeOptions({ ...options, terrainOverride: undefined }, baseSeed);
const terrain = generateTerrainAndRivers(runtimeOptions.effectiveSeed, runtimeOptions);
return {
terrain,
@ -150,16 +165,133 @@ async function timedStageAsync(timings, options, key, label, fn) {
}
function draftMatchesRuntime(draft, baseSeed, seed, options) {
if (!draft || typeof draft !== "object" || !draft.terrain || !draft.features || !draft.geographyBasis) return false;
const context = options.generationContext || {};
const draftContext = draft.generationContext || {};
return (draft.baseSeed >>> 0) === (baseSeed >>> 0)
&& (draft.effectiveSeed >>> 0) === (seed >>> 0)
&& Number(draftContext.originX) === Number(context.originX)
&& Number(draftContext.originY) === Number(context.originY)
&& Number(draftContext.width) === Number(context.width)
&& Number(draftContext.height) === Number(context.height)
&& (Number(draftContext.variant) >>> 0) === (Number(context.variant) >>> 0);
}
function terrainDraftMatchesRuntime(draft, baseSeed, seed, options) {
if (!draft || typeof draft !== "object" || !draft.terrain) return false;
const context = options.generationContext || {};
const draftContext = draft.generationContext || {};
return (draft.baseSeed >>> 0) === (baseSeed >>> 0)
&& (draft.effectiveSeed >>> 0) === (seed >>> 0)
&& Number(draftContext.originX) === Number(context.originX)
&& Number(draftContext.originY) === Number(context.originY)
&& Number(draftContext.width) === Number(context.width)
&& Number(draftContext.height) === Number(context.height)
&& (Number(draftContext.variant) >>> 0) === (Number(context.variant) >>> 0);
}
function markReusedDraftStage(timings, options, key, label, sourceTimings = []) {
const source = sourceTimings.find((row) => row?.key === key);
const entry = { key, label: `${label} (reused from draft)`, ms: 0, reused: true, draftMs: Number(source?.ms || 0) };
timings.push(entry);
options?.onProgress?.({ status: "done", key, label: entry.label, ms: 0, reused: true, draftMs: entry.draftMs, timings: timings.slice() });
}
export function generateMapTerrainDraft(seedInput = 114514, options = {}) {
const baseSeed = Number(seedInput) >>> 0;
options = applyInitialGenerationOverscanDefaults(options);
options = makeRuntimeOptions(options, baseSeed);
const seed = options.effectiveSeed;
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
const generationTimings = [];
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
const terrain = stage("terrain", terrainStageLabel(options), () => generateInitialTerrain(seed, options));
return {
terrain,
generationTimings,
generationTotalMs: generationTimings.reduce((sum, row) => sum + Number(row.ms || 0), 0),
baseSeed: options.baseSeed,
effectiveSeed: seed,
generationContext: { ...options.generationContext },
terrainDraftOnly: true,
};
}
export function continueMapDraftFromTerrain(seedInput = 114514, terrainDraft, options = {}) {
const baseSeed = Number(seedInput) >>> 0;
options = applyInitialGenerationOverscanDefaults(options);
options = makeRuntimeOptions(options, baseSeed);
const seed = options.effectiveSeed;
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
if (!terrainDraft?.terrain
|| (terrainDraft.baseSeed >>> 0) !== (options.baseSeed >>> 0)
|| (terrainDraft.effectiveSeed >>> 0) !== (seed >>> 0)) {
const error = new Error("Terrain draft is incompatible with the requested generation runtime.");
error.code = "terrain-draft-runtime-mismatch";
throw error;
}
const expectedContext = options.generationContext || {};
const actualContext = terrainDraft.generationContext || {};
for (const key of ["originX", "originY", "width", "height", "variant"]) {
if (Number(actualContext[key]) !== Number(expectedContext[key])) {
const error = new Error(`Terrain draft generationContext mismatch for ${key}.`);
error.code = "terrain-draft-context-mismatch";
throw error;
}
}
const generationTimings = (terrainDraft.generationTimings || []).map((row) => ({ ...row }));
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
const terrain = terrainDraft.terrain;
const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
const terrainWithGeography = { ...terrain, geography: geographyBasis, generationContext: options.generationContext };
const features = stage("settlements", "Settlements, towns, ports, and draft transport demand", () => generateMapFeatures(seed, terrainWithGeography, options));
return {
terrain,
geographyBasis,
features,
// Flatten the fields/layers needed by patch draft quality evaluation without
// running administration, final transport, names, or output packaging.
...terrain,
...features,
generationTimings,
generationTotalMs: generationTimings.reduce((sum, row) => sum + Number(row.ms || 0), 0),
baseSeed: options.baseSeed,
effectiveSeed: seed,
generationContext: { ...options.generationContext },
draftOnly: true,
};
}
export function generateMapDraft(seedInput = 114514, options = {}) {
const terrainDraft = generateMapTerrainDraft(seedInput, options);
return continueMapDraftFromTerrain(seedInput, terrainDraft, options);
}
export function generateMap(seedInput = 114514, options = {}) {
const baseSeed = Number(seedInput) >>> 0;
options = applyInitialGenerationOverscanDefaults(options);
options = makeRuntimeOptions(options, baseSeed);
const seed = options.effectiveSeed;
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
const generationTimings = [];
const stage = (key, label, fn) => timedStage(generationTimings, options, key, label, fn);
const precomputedDraft = draftMatchesRuntime(options._precomputedDraftCandidate, baseSeed, seed, options)
? options._precomputedDraftCandidate
: null;
const precomputedTerrainDraft = !precomputedDraft
&& terrainDraftMatchesRuntime(options._precomputedTerrainDraftCandidate, baseSeed, seed, options)
? options._precomputedTerrainDraftCandidate
: null;
const terrain = stage("terrain", terrainStageLabel(options), () => generateInitialTerrain(seed, options));
const terrain = precomputedDraft
? (markReusedDraftStage(generationTimings, options, "terrain", terrainStageLabel(options), precomputedDraft.generationTimings), precomputedDraft.terrain)
: precomputedTerrainDraft
? (markReusedDraftStage(generationTimings, options, "terrain", terrainStageLabel(options), precomputedTerrainDraft.generationTimings), precomputedTerrainDraft.terrain)
: stage("terrain", terrainStageLabel(options), () => generateInitialTerrain(seed, options));
const {
elevation,
slope,
@ -179,10 +311,14 @@ export function generateMap(seedInput = 114514, options = {}) {
naturalCompartments,
} = terrain;
const geographyBasis = stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
const geographyBasis = precomputedDraft
? (markReusedDraftStage(generationTimings, options, "geography", "Unified geographic basis", precomputedDraft.generationTimings), precomputedDraft.geographyBasis)
: stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
const terrainWithGeography = { ...terrain, geography: geographyBasis, generationContext: options.generationContext };
const features = stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options));
const features = precomputedDraft
? (markReusedDraftStage(generationTimings, options, "settlements", "Settlements, towns, ports, and land-use demand", precomputedDraft.generationTimings), precomputedDraft.features)
: stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options));
const {
settlementScore,
villages,
@ -204,10 +340,10 @@ export function generateMap(seedInput = 114514, options = {}) {
const geography = stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis, options));
const admin = stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
seed, generationContext: options.generationContext, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
const adminContext = {
seed, prefectureMask: landMask || prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports,
adminProgress: (event) => options?.onProgress?.({
...event,
key: "admin",
@ -218,9 +354,20 @@ export function generateMap(seedInput = 114514, options = {}) {
: `Admin region ${event.regionId}`,
timings: generationTimings.slice(),
}),
}));
};
let admin = stage("admin", "Municipal administration and demographic consolidation", () => generateMunicipalLayout(adminContext));
stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography, generationContext: options.generationContext }));
if (options.deferAdminAwareTransport === true) {
const entry = { key: "transport", label: "Administrative-aware final transport network (deferred to selection-native finalizer)", ms: 0, deferred: true, selectionNative: true };
generationTimings.push(entry);
features.transportDebug ||= {};
features.transportDebug.selectionNativeDeferredPostAdminTransport = true;
options?.onProgress?.({ status: "done", key: "transport", label: entry.label, ms: 0, deferred: true, selectionNative: true, timings: generationTimings.slice() });
} else {
stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, initialVisibleCrop: options.initialVisibleCrop }));
}
admin = stage("prefecture", "Transport-aware prefectural administration", () => finalizePrefectureLayout({ ...adminContext, transportFeatures: features }, admin));
const output = stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
seed,
@ -240,14 +387,20 @@ export function generateMap(seedInput = 114514, options = {}) {
export async function generateMapAsync(seedInput = 114514, options = {}) {
const baseSeed = Number(seedInput) >>> 0;
options = applyInitialGenerationOverscanDefaults(options);
options = makeRuntimeOptions(options, baseSeed);
const seed = options.effectiveSeed;
if (typeof options.onProgress !== "function") options = { ...options, onProgress: () => {} };
const generationTimings = [];
const stage = (key, label, fn) => timedStageAsync(generationTimings, options, key, label, fn);
const precomputedDraft = draftMatchesRuntime(options._precomputedDraftCandidate, baseSeed, seed, options)
? options._precomputedDraftCandidate
: null;
const terrain = await stage("terrain", terrainStageLabel(options), () => generateInitialTerrain(seed, options));
const terrain = precomputedDraft
? (markReusedDraftStage(generationTimings, options, "terrain", terrainStageLabel(options), precomputedDraft.generationTimings), precomputedDraft.terrain)
: await stage("terrain", terrainStageLabel(options), () => generateInitialTerrain(seed, options));
const {
elevation,
slope,
@ -267,10 +420,14 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
naturalCompartments,
} = terrain;
const geographyBasis = await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
const geographyBasis = precomputedDraft
? (markReusedDraftStage(generationTimings, options, "geography", "Unified geographic basis", precomputedDraft.generationTimings), precomputedDraft.geographyBasis)
: await stage("geography", "Unified geographic basis", () => buildGeographicBasis(seed, terrain, options));
const terrainWithGeography = { ...terrain, geography: geographyBasis, generationContext: options.generationContext };
const features = await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options));
const features = precomputedDraft
? (markReusedDraftStage(generationTimings, options, "settlements", "Settlements, towns, ports, and land-use demand", precomputedDraft.generationTimings), precomputedDraft.features)
: await stage("settlements", "Settlements, towns, ports, and land-use demand", () => generateMapFeatures(seed, terrainWithGeography, options));
const {
settlementScore,
villages,
@ -292,10 +449,10 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
const geography = await stage("geographyFinal", "Unified accessibility and centrality fields", () => finalizeGeographicBasis(seed, terrain, features, geographyBasis, options));
const admin = await stage("admin", "Municipal and prefectural administration", () => generateAdminLayout({
seed, generationContext: options.generationContext, prefectureMask: landMask || prefectureMask, focusedPrefectureMask: prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, geographicBarrierCost: geography.geographicBarrierCost, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, villageInfluence, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports, stations, industrialZones, logisticsParks,
const adminContext = {
seed, prefectureMask: landMask || prefectureMask, naturalCompartmentId, naturalCompartments, sea, elevation, slope, river, ridgeField, naturalBarrierScore, valleyField, basinField, coastalLowland, flowAccum, plain, agriculture,
geography, habitability: geography.habitability, accessibility: geography.accessibility, centrality: geography.centrality, geographicBarrier: geography.geographicBarrier, adminBoundaryPreference: geography.adminBoundaryPreference, boundaryAvoidance: geography.boundaryAvoidance,
settlementScore, populationDensity, stationInfluence, roadInfluence, railInfluence2, landuse, modernCities, satelliteCities, newTowns, markets, villages, ports,
adminProgress: (event) => options?.onProgress?.({
...event,
key: "admin",
@ -306,9 +463,20 @@ export async function generateMapAsync(seedInput = 114514, options = {}) {
: `Admin region ${event.regionId}`,
timings: generationTimings.slice(),
}),
}));
};
let admin = await stage("admin", "Municipal administration and demographic consolidation", () => generateMunicipalLayout(adminContext));
await stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, geography, generationContext: options.generationContext }));
if (options.deferAdminAwareTransport === true) {
const entry = { key: "transport", label: "Administrative-aware final transport network (deferred to selection-native finalizer)", ms: 0, deferred: true, selectionNative: true };
generationTimings.push(entry);
features.transportDebug ||= {};
features.transportDebug.selectionNativeDeferredPostAdminTransport = true;
options?.onProgress?.({ status: "done", key: "transport", label: entry.label, ms: 0, deferred: true, selectionNative: true, timings: generationTimings.slice() });
} else {
await stage("transport", "Administrative-aware final transport network", () => finalizeAdminAwareTransport({ seed, terrain, features, admin, initialVisibleCrop: options.initialVisibleCrop }));
}
admin = await stage("prefecture", "Transport-aware prefectural administration", () => finalizePrefectureLayout({ ...adminContext, transportFeatures: features }, admin));
const output = await stage("output", "Names, population totals, and final packaging", () => finishMapOutput({
seed,

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import { INF, MAP_H, MAP_W, SIZE, MinHeap, clamp, hash2, indexOf, inside, xyOf } from "./mapUtils.js";
import { averageFieldAcrossOwnerBorders } from "./mapAdminShared.js";
import { createIncrementalPathInfluence } from "./mapTransportUtils.js";
function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, settlementFeatures = [], geography = {}) {
const nodes = new Map();
@ -7,7 +8,7 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
for (let i = 0; i < SIZE; i++) {
if (!prefectureMask[i] || sea[i] || adminId[i] < 0) continue;
const id = adminId[i];
if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, cityPopulation: 0, settlementPopulation: 0, majorCityCount: 0, sx: 0, sy: 0, touchesOutside: false, habitability: 0, accessibility: 0, centrality: 0, boundaryAvoidance: 0, adminBoundaryPreference: 0, geographicBarrier: 0 });
if (!nodes.has(id)) nodes.set(id, { id, area: 0, population: 0, cityPopulation: 0, settlementPopulation: 0, majorCityCount: 0, sx: 0, sy: 0, touchesOutside: false, habitability: 0, accessibility: 0, centrality: 0, boundaryAvoidance: 0, adminBoundaryPreference: 0, geographicBarrier: 0, transportIntegration: 0 });
const node = nodes.get(id);
const [x, y] = xyOf(i);
if (x === 0 || y === 0 || x === MAP_W - 1 || y === MAP_H - 1) node.touchesOutside = true;
@ -24,6 +25,7 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
node.boundaryAvoidance += geography.boundaryAvoidance?.[i] || 0;
node.adminBoundaryPreference += geography.adminBoundaryPreference?.[i] || 0;
node.geographicBarrier += geography.geographicBarrier?.[i] || 0;
node.transportIntegration += geography.transportIntegration?.[i] || 0;
node.sx += x;
node.sy += y;
for (const [nx, ny] of [[x + 1, y], [x, y + 1]]) {
@ -33,13 +35,14 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
const a = Math.min(id, adminId[ni]);
const b = Math.max(id, adminId[ni]);
const key = `${a}:${b}`;
const edge = edges.get(key) || { a, b, count: 0, barrier: 0, adminBoundaryPreference: 0, boundaryAvoidance: 0, centrality: 0, accessibility: 0 };
const edge = edges.get(key) || { a, b, count: 0, barrier: 0, adminBoundaryPreference: 0, boundaryAvoidance: 0, centrality: 0, accessibility: 0, transportIntegration: 0 };
edge.count++;
edge.barrier += ((naturalBarrierScore?.[i] || 0) + (naturalBarrierScore?.[ni] || 0)) * 0.5;
edge.adminBoundaryPreference += ((geography.adminBoundaryPreference?.[i] || 0) + (geography.adminBoundaryPreference?.[ni] || 0)) * 0.5;
edge.boundaryAvoidance += ((geography.boundaryAvoidance?.[i] || 0) + (geography.boundaryAvoidance?.[ni] || 0)) * 0.5;
edge.centrality += ((geography.centrality?.[i] || 0) + (geography.centrality?.[ni] || 0)) * 0.5;
edge.accessibility += ((geography.accessibility?.[i] || 0) + (geography.accessibility?.[ni] || 0)) * 0.5;
edge.transportIntegration += ((geography.transportIntegration?.[i] || 0) + (geography.transportIntegration?.[ni] || 0)) * 0.5;
edges.set(key, edge);
}
}
@ -67,6 +70,7 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
node.boundaryAvoidance /= Math.max(1, node.area);
node.adminBoundaryPreference /= Math.max(1, node.area);
node.geographicBarrier /= Math.max(1, node.area);
node.transportIntegration /= Math.max(1, node.area);
node.adjacent = new Map();
}
for (const edge of edges.values()) {
@ -75,6 +79,7 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
edge.boundaryAvoidance /= Math.max(1, edge.count);
edge.centrality /= Math.max(1, edge.count);
edge.accessibility /= Math.max(1, edge.count);
edge.transportIntegration /= Math.max(1, edge.count);
// Prefecture grouping should pay the same natural-compartment crossing
// cost that municipality generation uses: ridges, rivers, valley walls and
// other strong natural dividers should be expensive to cross. Short shared
@ -85,7 +90,8 @@ function buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScor
edge.adminBoundaryPreference * 6.4 -
edge.boundaryAvoidance * 2.6 -
edge.centrality * 1.4 -
edge.accessibility * 0.9 +
edge.accessibility * 0.9 -
edge.transportIntegration * 3.6 +
2.6 / Math.sqrt(Math.max(1, edge.count))
);
nodes.get(edge.a)?.adjacent.set(edge.b, edge);
@ -100,12 +106,14 @@ function choosePrefectureMunicipalitySeeds(nodes, seed) {
// Real Japan's smallest prefecture by municipality count is roughly Toyama's 15.
// Keep generated prefectures near that scale by limiting prefecture count unless
// enough municipalities exist to give each prefecture a meaningful set.
const minMunicipalitiesPerPrefecture = 10;
const maxByMunicipalityCount = Math.max(3, Math.floor(active.length / minMunicipalitiesPerPrefecture));
const areaBased = clamp(Math.round(totalArea / 6200), 4, 6);
const targetCount = clamp(Math.min(areaBased, maxByMunicipalityCount || areaBased), 3, 6);
const minMunicipalitiesPerPrefecture = 12;
const maxByMunicipalityCount = Math.max(2, Math.floor(active.length / minMunicipalitiesPerPrefecture));
const areaBased = clamp(Math.round(totalArea / 9800), 2, 4);
// Prefer larger prefectures. Four is the normal ceiling for the fixed initial
// frame; a fifth region tended to create Toyama-scale-or-smaller fragments.
const targetCount = clamp(Math.min(Math.max(2, areaBased), Math.max(2, maxByMunicipalityCount || areaBased)), 2, 4);
const seeds = [];
const minSpacing = Math.max(18, Math.sqrt(totalArea / Math.max(1, targetCount)) * 0.46);
const minSpacing = Math.max(22, Math.sqrt(totalArea / Math.max(1, targetCount)) * 0.50);
function tryAdd(node, relaxed = false) {
if (!node || seeds.includes(node) || seeds.length >= targetCount) return false;
const nearest = seeds.length ? Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y))) : INF;
@ -131,7 +139,7 @@ function choosePrefectureMunicipalitySeeds(nodes, seed) {
if (seeds.includes(node)) continue;
const nearest = Math.min(...seeds.map((s) => Math.hypot(node.x - s.x, node.y - s.y)));
const capitalBonus = Math.sqrt(Math.max(0, node.cityPopulation || 0)) * 0.05 + (node.majorCityCount || 0) * 8;
const livingCoreBonus = (node.centrality || 0) * 9.5 + (node.accessibility || 0) * 4.0 + (node.habitability || 0) * 2.0 - (node.geographicBarrier || 0) * 3.8;
const livingCoreBonus = (node.centrality || 0) * 9.5 + (node.accessibility || 0) * 4.0 + (node.transportIntegration || 0) * 7.2 + (node.habitability || 0) * 2.0 - (node.geographicBarrier || 0) * 3.8;
const score = nearest * (1.0 + hash2(seed, node.id) * 0.08) + Math.sqrt(node.area) * 0.12 + node.population * 0.24 + capitalBonus + livingCoreBonus;
if (score > bestScore) { bestScore = score; best = node; }
}
@ -153,7 +161,7 @@ function assignMunicipalitiesToPrefectures(nodes, seeds) {
heap.push({ i: node.id, id, f: 0 });
});
const totalArea = [...nodes.values()].reduce((sum, node) => sum + node.area, 0);
const maxArea = Math.max(900, totalArea * 0.30);
const maxArea = Math.max(1250, totalArea * 0.36);
while (heap.length) {
const cur = heap.pop();
if (!cur || owner.get(cur.i) !== cur.id) continue;
@ -492,7 +500,7 @@ function mergeTinyMunicipalityPrefectures(nodes, owner) {
areaByPref.set(pref, (areaByPref.get(pref) || 0) + node.area);
}
const totalArea = [...areaByPref.values()].reduce((sum, value) => sum + value, 0);
const minArea = Math.max(520, totalArea / Math.max(1, areaByPref.size) * 0.34);
const minArea = Math.max(780, totalArea / Math.max(1, areaByPref.size) * 0.42);
const tiny = [...areaByPref.entries()]
.filter(([, area]) => area > 0 && area < minArea && areaByPref.size > 4)
.sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
@ -565,6 +573,76 @@ function wouldRemainConnectedAfterRemoval(nodes, owner, adminId, prefId) {
return seen.size === members.length;
}
function wouldRemainConnectedAfterRemovalSet(nodes, owner, removalSet, prefId) {
const members = [...nodes.keys()].filter((id) => owner.get(id) === prefId && !removalSet.has(id));
if (members.length <= 1) return true;
const memberSet = new Set(members);
const seen = new Set([members[0]]);
const queue = [members[0]];
for (let q = 0; q < queue.length; q++) {
const cur = queue[q];
for (const next of nodes.get(cur)?.adjacent.keys() || []) {
if (!memberSet.has(next) || seen.has(next)) continue;
seen.add(next);
queue.push(next);
}
}
return seen.size === members.length;
}
// Individual articulation checks can get stuck with a 30+ municipality donor
// next to a seven-municipality prefecture. Grow a connected boundary cluster
// and validate donor connectivity after removing the whole cluster.
function forceRebalanceTinyPrefecturesByGraphGrowth(nodes, owner, minCount = 12, maxPasses = 12) {
let changed = 0;
for (let pass = 0; pass < maxPasses; pass++) {
const counts = prefectureMunicipalityCounts(owner);
const small = [...counts.entries()].filter(([, c]) => c > 0 && c < minCount).sort((a, b) => a[1] - b[1] || a[0] - b[0])[0];
if (!small) break;
const [smallPref, smallCount] = small;
const need = minCount - smallCount;
let bestPlan = null;
for (const [donorPref, donorCount] of counts) {
if (donorPref === smallPref || donorCount - need < minCount) continue;
const boundary = [...nodes.keys()].filter((id) => owner.get(id) === donorPref && [...(nodes.get(id)?.adjacent.keys() || [])].some((n) => owner.get(n) === smallPref));
for (const start of boundary) {
const picked = new Set([start]);
const frontier = [start];
for (let qi = 0; qi < frontier.length && picked.size < need; qi++) {
const cur = frontier[qi];
const nexts = [...(nodes.get(cur)?.adjacent || [])]
.filter(([id]) => owner.get(id) === donorPref && !picked.has(id))
.sort((a, b) => (a[1]?.crossingCost ?? 1) - (b[1]?.crossingCost ?? 1) || (nodes.get(a[0])?.area || 0) - (nodes.get(b[0])?.area || 0));
for (const [id] of nexts) {
picked.add(id); frontier.push(id);
if (picked.size >= need) break;
}
}
if (picked.size < need) continue;
if (!wouldRemainConnectedAfterRemovalSet(nodes, owner, picked, donorPref)) continue;
let score = 0;
for (const id of picked) {
const node = nodes.get(id);
score += Math.sqrt(Math.max(1, node?.area || 1)) * 0.02;
for (const [n, edge] of node?.adjacent || []) if (owner.get(n) === smallPref) score -= (edge?.count || 1) * 2.0;
}
if (!bestPlan || score < bestPlan.score) bestPlan = { donorPref, picked, score };
}
}
// Never borrow municipalities across a sea gap merely to satisfy a count
// target. Prefecture geometry must remain a connected land jurisdiction;
// municipality generation is responsible for providing enough local units
// on islands.
if (!bestPlan) break;
for (const id of bestPlan.picked) owner.set(id, smallPref);
changed += bestPlan.picked.size;
// The selected donor remainder is already connectivity-checked. Do not run
// generic prefecture connectivity repair here: an across-strait island +
// mainland jurisdiction is intentionally disconnected in the land graph.
}
return changed;
}
function rebalanceSmallPrefecturesByMunicipalityCount(nodes, owner, minCount = 14, maxPasses = 96) {
let changed = 0;
for (let pass = 0; pass < maxPasses; pass++) {
@ -841,8 +919,19 @@ function lockPrefectureCapitalNeighborMunicipalities(owner, nodes, seeds = [], m
export function generatePrefecturesFromMunicipalities(context, adminResult) {
const { adminId } = adminResult;
const { prefectureMask, sea, naturalBarrierScore, populationDensity, landuse, modernCities, markets, ports, seed, geography = null, habitability = null, accessibility = null, centrality = null, adminBoundaryPreference = null, boundaryAvoidance = null, geographicBarrier = null } = context;
const geographyFields = geography || { habitability, accessibility, centrality, adminBoundaryPreference, boundaryAvoidance, geographicBarrier };
const { prefectureMask, sea, naturalBarrierScore, populationDensity, landuse, modernCities, markets, ports, seed, geography = null, habitability = null, accessibility = null, centrality = null, adminBoundaryPreference = null, boundaryAvoidance = null, geographicBarrier = null, transportFeatures = null } = context;
const geographyFields = { ...(geography || { habitability, accessibility, centrality, adminBoundaryPreference, boundaryAvoidance, geographicBarrier }) };
if (transportFeatures) {
const integration = createIncrementalPathInfluence([], 5, { sea, exponent: 1.12 });
for (const path of transportFeatures.expressways || []) integration.add(path, 1.00, 5.5);
for (const path of transportFeatures.externalExpressways || []) integration.add(path, 0.92, 5.5);
for (const path of transportFeatures.nationalRoads || []) integration.add(path, 0.82, 4.5);
for (const path of transportFeatures.externalRoads || []) integration.add(path, 0.74, 4.5);
for (const path of transportFeatures.railways || []) integration.add(path, 0.88, 4.3);
for (const path of transportFeatures.branchRailways || []) integration.add(path, 0.62, 3.8);
for (const path of transportFeatures.minorRoads || []) integration.add(path, 0.18, 2.4);
geographyFields.transportIntegration = integration.field;
}
const graph = buildMunicipalityGraph(adminId, prefectureMask, sea, naturalBarrierScore, populationDensity, [...(modernCities || []), ...(markets || []), ...(ports || [])], geographyFields);
const firstStageSeeds = choosePrefectureMunicipalitySeeds(graph.nodes, seed + 91001);
const firstStageOwner = assignMunicipalitiesToPrefectures(graph.nodes, firstStageSeeds);
@ -861,11 +950,11 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
changedForMetroUnification += lockCityMetroMunicipalitiesToSinglePrefecture(owner, adminId, { prefectureMask, sea, landuse, populationDensity, modernCities });
const changedForPostRepairLivingSphereUnification = lockLivingSphereMunicipalitiesToSinglePrefecture(owner, graph.nodes, adminId, { prefectureMask, sea, modernCities, markets, ports });
const changedForMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, seeds.minMunicipalitiesPerPrefecture || 14);
const minimumRegionalCount = Math.min(4, Math.max(2, seeds.length));
const minimumRegionalCount = 2;
const changedForTinyPrefectureCountMerge = mergePersistentlyTinyPrefecturesByCount(graph.nodes, owner, Math.max(8, (seeds.minMunicipalitiesPerPrefecture || 10) - 2), minimumRegionalCount);
const changedForPostMergeMunicipalityCountRebalance = rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, Math.max(12, (seeds.minMunicipalitiesPerPrefecture || 14) - 1), 64);
const changedForOversizedPrefectureSplit = splitOversizedPrefecturesByMunicipalityCount(graph.nodes, owner, 44, 8);
const changedForAreaRebalance = rebalancePrefectureAreas(graph.nodes, owner, 0.39, 180);
const changedForOversizedPrefectureSplit = splitOversizedPrefecturesByMunicipalityCount(graph.nodes, owner, 78, 5);
const changedForAreaRebalance = rebalancePrefectureAreas(graph.nodes, owner, 0.43, 180);
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
// Rebalancing and oversized splitting can create small municipality-level
// exclaves. Run enclave/connectivity repair as the final owner operation so
@ -874,7 +963,7 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 10);
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
const changedForCapitalNeighborLock = lockPrefectureCapitalNeighborMunicipalities(owner, graph.nodes, seeds, 7);
const changedForCapitalNeighborLock = lockPrefectureCapitalNeighborMunicipalities(owner, graph.nodes, seeds, 9);
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12);
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
@ -882,6 +971,21 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
changedForEnclaveRepair += repairPrefectureMunicipalityEnclaves(graph.nodes, owner, 12);
changedForConnectivity += repairPrefectureMunicipalityConnectivity(graph.nodes, owner);
// Capital/living-sphere locks and area smoothing can re-create a tiny
// prefecture late in the pipeline. Enforce a final municipality-count floor
// instead of allowing five-municipality prefectures to survive to output.
mergePersistentlyTinyPrefecturesByCount(graph.nodes, owner, 12, 2);
rebalanceSmallPrefecturesByMunicipalityCount(graph.nodes, owner, 12, 96);
forceRebalanceTinyPrefecturesByGraphGrowth(graph.nodes, owner, 12, 16);
// Absolute final floor. If graph-growth cannot make a tiny prefecture viable
// without damaging neighbouring prefectures, merge the entire tiny unit into
// its strongest neighbour. When three or more prefectures exist, an implausibly tiny one is merged into a
// real neighbour. With only two land-disconnected prefectures, do not collapse
// the whole map into one jurisdiction or create an across-strait ownership hack.
const changedForHardTinyPrefectureMerge = mergePersistentlyTinyPrefecturesByCount(graph.nodes, owner, 10, 2);
// Forced count balancing / whole-prefecture merge are the final owner
// mutations. Do not run land-only connectivity repair afterward: island
// prefectures may legitimately span a strait.
const maxAdminId = Math.max(-1, ...[...graph.nodes.keys()]);
const municipalityToPrefectureId = new Int16Array(maxAdminId + 1);
municipalityToPrefectureId.fill(-1);
@ -922,17 +1026,21 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
prefectureUrbanMetroUnificationChangedMunicipalities: changedForMetroUnification,
prefectureLivingSphereUnificationChangedMunicipalities: (changedForLivingSphereUnification || 0) + (changedForPostRepairLivingSphereUnification || 0),
prefectureUnifiedGeographyBasis: true,
prefecturesFinalizedAfterTransport: Boolean(transportFeatures),
prefectureTransportIntegrationBasis: Boolean(geographyFields.transportIntegration),
prefectureMunicipalityCountRebalanceChangedMunicipalities: (changedForMunicipalityCountRebalance || 0) + (changedForPostMergeMunicipalityCountRebalance || 0),
prefectureTinyMunicipalityCountMergeChangedMunicipalities: changedForTinyPrefectureCountMerge || 0,
prefectureHardTinyMunicipalityCountMergeChangedMunicipalities: changedForHardTinyPrefectureMerge || 0,
prefectureHardMunicipalityCountFloor: 10,
prefectureOversizedCountSplitChangedMunicipalities: changedForOversizedPrefectureSplit || 0,
prefectureAreaRebalanceChangedMunicipalities: changedForAreaRebalance || 0,
prefectureCapitalNeighborLockChangedMunicipalities: changedForCapitalNeighborLock || 0,
prefectureBoundaryShareRelaxationChangedMunicipalities: changedForBoundaryShareRelaxation || 0,
finalRegionalMaxMunicipalityCount: Math.max(...prefectureMunicipalityCounts(owner).values()),
finalRegionalMunicipalityCountCap: 88,
finalRegionalMunicipalityCountCap: 96,
finalRegionalMinMunicipalityCount: Math.min(...prefectureMunicipalityCounts(owner).values()),
finalRegionalMaxAreaShare: totalArea ? Math.max(0, ...areaByPref.values()) / totalArea : 0,
finalRegionalTinyPrefectureCount: [...areaByPref.values()].filter((area) => area < 520).length,
finalRegionalTinyPrefectureCount: [...areaByPref.values()].filter((area) => area < 650).length,
finalRegionalPrefectureBorderCount: regionalPrefectureBorders.length,
regionalPrefectureBordersRebuiltFromFinalId: true,
finalRegionalBorderUnifiedBoundaryPreferenceAverage: averageFieldAcrossOwnerBorders((i) => municipalityToPrefectureId[adminId[i]] ?? -1, prefectureMask, sea, geographyFields.adminBoundaryPreference),
@ -942,4 +1050,3 @@ export function generatePrefecturesFromMunicipalities(context, adminResult) {
};
}

View file

@ -11,56 +11,13 @@ const ASPECT = MAP_W / MAP_H;
const SQRT2 = Math.SQRT2;
export function createExactNoiseMemo() {
const latticeBySeed = new Map();
const lattice = (x, y, seed) => {
let cache = latticeBySeed.get(seed);
if (!cache) {
cache = new Map();
latticeBySeed.set(seed, cache);
}
// Terrain noise lattice coordinates stay far inside this stride. Using one
// numeric key avoids allocating a string for every hot-loop lookup.
const key = x * 131072 + y;
const cached = cache.get(key);
if (cached !== undefined) return cached;
const value = hash2(x, y, seed);
cache.set(key, value);
return value;
};
const memoValueNoise = (x, y, seed, scale) => {
const sx = x / scale;
const sy = y / scale;
const x0 = Math.floor(sx);
const y0 = Math.floor(sy);
const tx = smoothstep(sx - x0);
const ty = smoothstep(sy - y0);
const a = lattice(x0, y0, seed);
const b = lattice(x0 + 1, y0, seed);
const c = lattice(x0, y0 + 1, seed);
const d = lattice(x0 + 1, y0 + 1, seed);
return lerp(lerp(a, b, tx), lerp(c, d, tx), ty);
};
const memoFbm = (x, y, seed) => {
let amp = 1;
let scale = 54;
let sum = 0;
let norm = 0;
for (let octave = 0; octave < 5; octave++) {
sum += memoValueNoise(x, y, seed + octave * 101, scale) * amp;
norm += amp;
amp *= 0.5;
scale *= 0.5;
}
return sum / norm;
};
return { valueNoise: memoValueNoise, fbm: memoFbm, latticeBySeed };
}
function normalizeCoord(x, y) {
return {
px: (x + 0.5) / MAP_W,
py: (y + 0.5) / MAP_H,
};
// r11: numeric Map memoization was bit-exact but slower than recomputing the
// tiny integer hash lattice on modern JS engines. A production terrain cell
// performs many Map lookups across many seeds/octaves; the hash itself is only
// a handful of integer operations. Keep the compatibility surface while using
// the canonical noise functions directly. This is mathematically/output exact:
// valueNoise/fbm are the same equations and octave order the memo wrappers used.
return { valueNoise, fbm };
}
function distNorm(ax, ay, bx, by) {
@ -69,12 +26,6 @@ function distNorm(ax, ay, bx, by) {
return Math.hypot(dx, dy);
}
function rotate(dx, dy, angle) {
const c = Math.cos(angle);
const s = Math.sin(angle);
return { u: dx * c + dy * s, v: -dx * s + dy * c };
}
function quantile(values, q) {
const arr = Array.from(values).filter(Number.isFinite).sort((a, b) => a - b);
if (!arr.length) return 0;

View file

@ -40,18 +40,28 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
settlementDemand, preliminaryVillageInfluence, preliminaryTownInfluence,
logisticsPreSuitability, urbanEdge,
transportFields, cachedInfluenceFromPaths,
nationalRoads, minorRoads, railways, externalRoads, externalRailways,
nationalRoads, minorRoads, railways, externalRoads,
expressways, externalExpressways, icAccessRoads, interchanges, externalGateways,
modernCities, markets, villages, ports, commercialPorts, passes, regionStats,
regionIdAt, inFocusedPrefecture, importantNodesForRegion, dedupePointCandidates,
modernCities, markets, villages, ports, commercialPorts, passes,
regionIdAt, dedupePointCandidates,
routeBetweenTrafficCandidates, traceCorridorByCost, addCorridorInfluencePenalty,
relaxRouteToTerrain, transportRouteAcceptable, repairTransportConnectivity,
repairDanglingTransportEndpoints, pruneDanglingTerminalSegments, pruneParallelSameMode,
onProgress,
patchMode = false,
largePatchTile = false,
productionTransportParity = false,
} = ctx;
// A full large-selection production finalist must use the same trunk-network
// guarantees as initial generation, including its canonical internal tiles.
// `largePatchTile` remains a bounded fast mode only when parity is explicitly off.
const constrainedLargePatchTile = largePatchTile && !productionTransportParity;
// Local-road budgets must scale with the literal hidden raster used by initial
// generation. Fixed r11.6 caps were tuned for 258x183 and made the overscan
// map (and therefore the published countryside) conspicuously road-poor.
const transportAreaScale = Math.max(1, SIZE / (258 * 183));
let roadTimingMark = Date.now();
function markRoadTiming(key) {
const now = Date.now();
@ -92,20 +102,30 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
let sum = 0;
let n = 0;
let blocked = 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);
// The straight chord is only a cheap OD-ranking probe. It is *not* a
// feasibility test: a valid road may bend around a bay or mountain range.
// The former early-INF return removed those pairs before the terrain A*
// ever had a chance to find the realistic detour, producing broken trunk
// networks. Penalize blocked chord samples instead and let the production
// router decide actual feasibility.
if (sea[i] || costField[i] >= INF) {
if (cacheKey) lineCostCache.set(cacheKey, INF);
return INF;
blocked++;
sum += sea[i] ? 8.0 : 5.5;
n++;
continue;
}
sum += costField[i];
n++;
}
const result = n ? sum / n : INF;
const blockedShare = n ? blocked / n : 1;
const result = n && blockedShare < 0.72 ? (sum / n) * (1 + blockedShare * 1.35) : INF;
if (cacheKey) lineCostCache.set(cacheKey, result);
return result;
}
@ -172,16 +192,20 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
costField: () => expresswayCorridorCost,
potentialField: () => transportFields.expresswayPotential,
routeOptions: {
curvePenalty: 0.125,
penaltyStrength: 7.20,
terrainFlowBias: 0.08,
surfaceGrain: 0.006,
// Low heuristic/turn bias: terrain cost, not Euclidean straightness,
// determines the corridor. Smoothing is applied only after a fully
// terrain-routed path exists.
curvePenalty: 0.012,
penaltyStrength: 7.80,
terrainFlowBias: 0.40,
surfaceGrain: 0.018,
relaxRadius: 1,
relaxLineWeight: 0.19,
snapRadius: 3.5,
heuristicWeight: 0.72,
relaxLineWeight: 0.045,
snapRadius: 1.4,
heuristicWeight: 0.08,
forceFullResolution: true,
},
backbone: { minDistance: 38, maxDistance: 190, maxDegree: 2, maxExtra: 0, parallelRadius: 84, penaltyStrengthMark: 16.50 },
backbone: { minDistance: 38, maxDistance: 190, maxDegree: 2, maxExtra: 0, parallelRadius: 8, penaltyStrengthMark: 2.60 },
fieldPolicyMode: "expressway",
mountainMode: "expresswayMountainOnly",
connector: { curvePenalty: 0.095, terrainFlowBias: 0.12 },
@ -191,16 +215,17 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
costField: () => transportFields.national,
potentialField: () => transportFields.nationalPotential,
routeOptions: {
curvePenalty: 0.065,
penaltyStrength: 1.05,
terrainFlowBias: 0.24,
surfaceGrain: 0.030,
curvePenalty: 0.008,
penaltyStrength: 1.95,
terrainFlowBias: 0.65,
surfaceGrain: 0.050,
relaxRadius: 2,
relaxLineWeight: 0.28,
snapRadius: 2.5,
heuristicWeight: 0.50,
relaxLineWeight: 0.035,
snapRadius: 1.2,
heuristicWeight: 0.06,
forceFullResolution: true,
},
backbone: { minDistance: 12, maxDistance: 130, maxDegree: 4, maxExtra: 8, parallelRadius: 6, penaltyStrengthMark: 0.32 },
backbone: { minDistance: 12, maxDistance: 130, maxDegree: 4, maxExtra: 2, parallelRadius: 6, penaltyStrengthMark: 1.25 },
fieldPolicyMode: "national",
mountainMode: "national",
connector: { curvePenalty: 0.045, terrainFlowBias: 0.22 },
@ -541,7 +566,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
const portPortals = commercialPorts
.filter((p) => p.portClass === "major" || p.portClass === "regional")
.map((p) => portalSearchAroundPoint(p, "expressway", "port-fringe", { inner: 4, outer: 14 }) || { ...p, role: "port-fringe", population: p.population || 55000, score: 0.9 });
const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 90000, score: 0.92 }));
const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: g.virtualPopulation || g.population || 90000, score: 0.92 + Math.min(0.28, (g.virtualPopulation || 0) / 700000) }));
const fieldPortals = densityFieldAnchors("expressway", 11);
return dedupeAnchors([...urbanPortals, ...portPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 11).slice(0, 30);
}
@ -555,7 +580,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
.map((v) => ({ ...v, role: "large-village", score: 0.34 + (v.population || 0) / 17000, population: v.population || 0 }));
const portPortals = ports.map((p) => ({ ...p, role: "port", score: 0.72 + (p.portClass === "major" ? 0.48 : p.portClass === "regional" ? 0.28 : 0), population: p.population || 18000 }));
const passPortals = passes.map((p) => ({ ...p, role: "pass", score: 0.46 + (passSuitability?.[indexOf(p.x, p.y)] || 0), population: 8000 }));
const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }));
const gatewayPortals = externalGateways.map((g) => ({ ...g, role: "external-gateway", population: g.virtualPopulation || g.population || 42000, score: 0.92 + Math.min(0.24, (g.virtualPopulation || 0) / 850000) }));
const fieldPortals = densityFieldAnchors("national", 40);
return dedupeAnchors([...cityPortals, ...marketPortals, ...villagePortals, ...portPortals, ...passPortals, ...gatewayPortals, ...fieldPortals].sort((a, b) => b.score - a.score), 5.5).slice(0, 68);
}
@ -768,7 +793,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
expressways.push(...cleaned);
markRoadTiming("national-outbound");
pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.08, radius: 92, minKeep: 1, shortLength: 220 });
pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.28, radius: 4, directionDot: 0.90, minKeep: 1, shortLength: 38 });
if (debug) debug.expresswaySanitization = { before, after: expressways.length, loopTrimmed, urbanPruned, shortPruned };
return { before, after: expressways.length, loopTrimmed, urbanPruned, shortPruned };
}
@ -793,7 +818,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
const policy = fieldBackbonePolicy(config.fieldPolicyMode || mode);
let connectedAdds = 0;
let extraAdds = 0;
const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(7, Math.max(3, Math.ceil((options.nodeCount || 1) / 5))) : Math.min(26, Math.max(12, Math.ceil((options.nodeCount || 1) * 0.36))));
const maxAdded = options.maxAdded ?? (mode === "expressway" ? Math.min(3, Math.max(1, Math.ceil((options.nodeCount || 1) / 8))) : Math.min(26, Math.max(12, Math.ceil((options.nodeCount || 1) * 0.36))));
const maxExtra = options.maxExtra ?? config.backbone?.maxExtra ?? 0;
const maxDegree = options.maxDegree ?? config.backbone?.maxDegree ?? 3;
for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) {
@ -1221,27 +1246,27 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
.filter((p) => p.role === "urban-fringe-ic")
.map((p) => ({ x: p.x, y: p.y, city: p.city?.name, population: p.population }));
const expressFlow = buildTrafficFlowField("expressway", expressAnchors, expresswayCorridorCost, largePatchTile ? 4 : 10);
const expressFlow = buildTrafficFlowField("expressway", expressAnchors, expresswayCorridorCost, constrainedLargePatchTile ? 4 : 10);
const expressCost = fieldAdjustedCost(expresswayCorridorCost, "expressway", expressFlow);
markRoadTiming("express-flow");
buildFieldBackbone(debug, "expressway", expressways, expressAnchors, expressCost, transportFields.expresswayPotential, {
...roadMode("expressway").backbone,
maxNodes: largePatchTile ? 18 : 34,
maxAdded: largePatchTile ? Math.min(2, Math.max(1, Math.ceil(expressAnchors.length / 18))) : Math.min(3, Math.max(2, Math.ceil(expressAnchors.length / 14))),
maxNodes: constrainedLargePatchTile ? 18 : 34,
maxAdded: constrainedLargePatchTile ? Math.min(2, Math.max(1, Math.ceil(expressAnchors.length / 18))) : Math.min(3, Math.max(2, Math.ceil(expressAnchors.length / 14))),
});
if (!largePatchTile) ensureExpresswayCityIntercity(debug, expressAnchors, expressCost);
if (!constrainedLargePatchTile) ensureExpresswayCityIntercity(debug, expressAnchors, expressCost);
markRoadTiming("express-backbone");
// Recompute national flow with expressways already present. National roads
// are allowed to cross/approach motorways but are discouraged from becoming
// a duplicate motorway frontage road for long distances.
const nationalFlow = buildTrafficFlowField("national", nationalAnchors, transportFields.national, largePatchTile ? 12 : 34);
const nationalFlow = buildTrafficFlowField("national", nationalAnchors, transportFields.national, constrainedLargePatchTile ? 12 : 34);
const nationalCost = fieldAdjustedCost(transportFields.national, "national", nationalFlow);
markRoadTiming("national-flow");
buildFieldBackbone(debug, "national", nationalRoads, nationalAnchors, nationalCost, transportFields.nationalPotential, {
...roadMode("national").backbone,
maxNodes: largePatchTile ? 32 : 58,
maxAdded: largePatchTile ? Math.min(12, Math.max(6, Math.ceil(nationalAnchors.length * 0.18))) : Math.min(27, Math.max(13, Math.ceil(nationalAnchors.length * 0.34))),
maxNodes: constrainedLargePatchTile ? 32 : 58,
maxAdded: constrainedLargePatchTile ? Math.min(12, Math.max(6, Math.ceil(nationalAnchors.length * 0.18))) : Math.min(27, Math.max(13, Math.ceil(nationalAnchors.length * 0.34))),
});
markRoadTiming("national-backbone");
@ -1250,8 +1275,8 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
// deduped away. The target remains a portal/field anchor, not the city point.
const nationalInfluence = influenceFromPaths([...nationalRoads, ...externalRoads], 7);
const nationalPenalty = influenceFromPaths(nationalRoads, 6);
const nationalTargets = dedupeAnchors([...nationalAnchors, ...externalGateways.map((g) => ({ ...g, role: "external-gateway", population: 42000, score: 0.92 }))], 5);
for (const city of (largePatchTile ? [] : modernCities.filter((c) => (c.population || 0) >= 90000).sort((a, b) => (b.population || 0) - (a.population || 0)))) {
const nationalTargets = dedupeAnchors([...nationalAnchors, ...externalGateways.map((g) => ({ ...g, role: "external-gateway", population: g.virtualPopulation || g.population || 42000, score: 0.92 + Math.min(0.24, (g.virtualPopulation || 0) / 850000) }))], 5);
for (const city of (constrainedLargePatchTile ? [] : modernCities.filter((c) => (c.population || 0) >= 90000).sort((a, b) => (b.population || 0) - (a.population || 0)))) {
if ((nationalInfluence[indexOf(city.x, city.y)] || 0) > 0.18) continue;
const portals = cityPortalAnchors(city, "national");
const start = portals[0] || portalSearchAroundPoint(city, "national", "urban-portal");
@ -1281,12 +1306,12 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
}
markRoadTiming("national-city-guarantee");
if (!largePatchTile) ensureNationalCityOutbound(debug, nationalAnchors, nationalCost);
if (!constrainedLargePatchTile) ensureNationalCityOutbound(debug, nationalAnchors, nationalCost);
markRoadTiming(largePatchTile ? "national-outbound-deferred" : "national-outbound");
markRoadTiming(constrainedLargePatchTile ? "national-outbound-deferred" : "national-outbound");
pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.08, radius: 92, minKeep: 1, shortLength: 220 });
pruneParallelSameMode(nationalRoads, "national", transportFields.nationalPotential, { threshold: 0.62, radius: 2, minKeep: 10, shortLength: 18 });
pruneParallelSameMode(expressways, "expressway", transportFields.expresswayPotential, { threshold: 0.28, radius: 4, directionDot: 0.90, minKeep: 1, shortLength: 38 });
pruneParallelSameMode(nationalRoads, "national", transportFields.nationalPotential, { threshold: 0.42, radius: 3, directionDot: 0.90, minKeep: 8, shortLength: 20 });
markRoadTiming("parallel-prune");
@ -1294,12 +1319,12 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
// Patch candidates are merged into an existing world and receive a final
// whole-selection transport repair later; repeating these searches per tile
// can become pathologically expensive on particular candidate terrains.
if (!patchMode) {
if (!patchMode || productionTransportParity) {
ensureExpresswayCityIntercity(debug, expressAnchors, expressCost);
ensureNationalCityOutbound(debug, nationalAnchors, nationalCost);
}
sanitizeExpresswayNetwork(debug);
markRoadTiming(patchMode ? "final-guarantees-deferred" : "final-guarantees");
markRoadTiming(patchMode && !productionTransportParity ? "final-guarantees-deferred" : "final-guarantees");
}
function rebuildRoadTransportByCorridors() {
@ -1464,8 +1489,10 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
debug.expressway = stitchEndpoints(expressways, "expressway", expresswayCells, expressState, 10, 46.0, 0.62);
if (debug.expressway > 0) {
// Interchange demand helpers are initialized later in the transport pipeline.
// Sanitize the repaired motorway here, then let the single authoritative
// interchange pass run after its demand/index caches exist.
sanitizeExpresswayNetwork();
if (!patchMode) generateInterchangesForExpressways();
}
return debug;
}
@ -1611,22 +1638,22 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
function generateLocalRoadsForUnservedSettlements() {
const trunkInfluence = cachedInfluenceFromPaths([...nationalRoads, ...railways, ...externalRoads], 8, "local:trunk");
const candidates = [...villages, ...markets, ...ports]
const candidates = [...modernCities, ...villages, ...markets, ...ports]
.filter((p) => inside(p.x, p.y) && !sea[indexOf(p.x, p.y)])
.map((p) => {
const i = indexOf(p.x, p.y);
const settlementWeight = p.portClass ? 1.2 : p.population ? clamp(p.population / 18000, 0.35, 1.4) : 0.45;
return { ...p, score: settlementWeight + transportFields.localPotential[i] * 0.65 - trunkInfluence[i] * 1.15 };
})
.filter((p) => p.score > 0.06 && trunkInfluence[indexOf(p.x, p.y)] < 0.34)
.filter((p) => p.score > -0.02 && trunkInfluence[indexOf(p.x, p.y)] < 0.48)
.sort((a, b) => b.score - a.score)
.slice(0, 92);
.slice(0, Math.round(124 * Math.min(2.45, transportAreaScale)));
const localPenaltyAccumulator = createIncrementalPathInfluence([], 4, { sea });
const localPenalty = localPenaltyAccumulator.field;
const paths = [];
const served = [];
for (const start of candidates) {
if (paths.length >= 72) break;
if (paths.length >= Math.round(88 * Math.min(2.30, transportAreaScale))) break;
if (distanceToNearestPoint(served, start.x, start.y) < 4.5) continue;
let path = traceCorridorByCost(
start,
@ -1645,6 +1672,14 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
return paths;
}
function buildRoadOccupancy(paths) {
const occupied = new Uint8Array(SIZE);
for (const path of paths || []) for (const [x, y] of path || []) {
if (inside(x, y) && !sea[indexOf(x, y)]) occupied[indexOf(x, y)] = 1;
}
return occupied;
}
function runLocalAccessPass({
candidates,
accessInfluence,
@ -1656,6 +1691,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
from = "unserved",
to = "network",
targetPredicate = null,
targetOccupancy = null,
}) {
const served = [];
let added = 0;
@ -1664,7 +1700,9 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
if (distanceToNearestPoint(served, start.x, start.y) < minSpacing) continue;
let path = traceCorridorByCost(
start,
targetPredicate || ((x, y, i) => accessInfluence[i] > 0.16 || localPenalty[i] > 0.075),
targetPredicate || (targetOccupancy
? ((_x, _y, i) => targetOccupancy[i] === 1)
: ((x, y, i) => accessInfluence[i] > 0.16 || localPenalty[i] > 0.075)),
transportFields.local,
localPenalty,
{ curvePenalty: 0.020, penaltyStrength: 0.90, minGoalDistance: 3, regionId: start.regionId, maxExpanded: Math.floor(SIZE * 0.50), terrainFlowBias: 0.26, surfaceGrain: 0.042 }
@ -1680,11 +1718,116 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
transportDebugLayers.repairedSegments.push({ mode: "local", path, from, to });
addCorridorInfluencePenalty(localPenalty, path, 4, 0.18);
if (accessInfluence) markPathInfluence(accessInfluence, path, 5, 0.22);
if (targetOccupancy) for (const [x, y] of path) if (inside(x, y)) targetOccupancy[indexOf(x, y)] = 1;
}
return added;
}
minorRoads.push(...generateLocalRoadsForUnservedSettlements());
// r11.7 rural coverage pass. This is the same terrain-aware local-access
// algorithm, not a synthetic road mesh: low-access villages/markets are
// routed organically toward the existing local/trunk network. Scaling by
// hidden-raster area prevents countryside roads from disappearing when the
// initial generator uses overscan.
function generateRuralLocalAccessInfill() {
const roadInfluence = cachedInfluenceFromPaths([...minorRoads, ...nationalRoads, ...externalRoads], 5, "rural-local-existing");
const localPenalty = cachedInfluenceFromPaths(minorRoads, 3, "rural-local-penalty");
const raw = [...villages, ...markets, ...ports]
.filter((p) => p && inside(p.x, p.y) && !sea[indexOf(p.x, p.y)])
.map((p) => {
const i = indexOf(p.x, p.y);
const urban = Math.max(preliminaryTownInfluence?.[i] || 0, urbanEdge?.[i] || 0);
const access = roadInfluence[i] || 0;
const rural = (preliminaryVillageInfluence?.[i] || 0) * 0.90 + (agriculture?.[i] || 0) * 0.38 + (transportFields.localPotential?.[i] || 0) * 0.34;
return { ...p, regionId: p.regionId ?? regionIdAt(p.x, p.y), kind: "Rural Local Access", score: rural - access * 1.10 - urban * 0.32 };
})
.filter((p) => (roadInfluence[indexOf(p.x, p.y)] || 0) < 0.70 && p.score > -0.18)
.sort((a, b) => b.score - a.score);
const selected = [];
const roadOccupancy = buildRoadOccupancy([...minorRoads, ...nationalRoads, ...externalRoads]);
const maxCandidates = Math.round(260 * Math.min(2.35, transportAreaScale));
for (const p of raw) {
if (selected.length >= maxCandidates) break;
if (distanceToNearestPoint(selected, p.x, p.y) < 3.2) continue;
selected.push(p);
}
const added = runLocalAccessPass({
candidates: selected,
accessInfluence: roadInfluence,
localPenalty,
maxAdded: Math.round(210 * Math.min(2.25, transportAreaScale)),
minSpacing: 2.6,
maxLength: 82,
debugMode: "rural-local-infill",
from: "rural-settlement",
to: "existing-road-network",
targetOccupancy: roadOccupancy,
});
return { candidates: selected.length, added, areaScale: transportAreaScale };
}
transportDebugLayers.ruralLocalAccess = generateRuralLocalAccessInfill();
// Dense urban areas need more local access, but not a synthetic grid. Reuse
// the same terrain-aware local-access routing algorithm used for ordinary
// settlements: sample low-access urban cells and grow short organic spurs
// toward the existing street/trunk network.
function generateUrbanLocalAccessInfill() {
const cities = (modernCities || [])
.filter((c) => c && inside(c.x, c.y) && !sea[indexOf(c.x, c.y)] && (c.population || 0) >= 12000)
.sort((a, b) => (b.population || 0) - (a.population || 0))
.slice(0, 40);
const accessInfluence = cachedInfluenceFromPaths([...minorRoads, ...nationalRoads, ...externalRoads], 5, "urban-local-access-existing");
const localPenalty = cachedInfluenceFromPaths(minorRoads, 2, "urban-local-access-penalty");
const candidates = [];
const urbanRoadOccupancy = buildRoadOccupancy([...minorRoads, ...nationalRoads, ...externalRoads]);
for (const city of cities) {
const radius = clamp(Math.round((city.urbanRadius || 8) * 0.90), 5, 15);
const quota = (city.population || 0) >= 300000 ? 22 : (city.population || 0) >= 120000 ? 16 : (city.population || 0) >= 40000 ? 11 : 7;
const local = [];
for (let y = Math.max(0, city.y - radius); y <= Math.min(MAP_H - 1, city.y + radius); y += 2) {
for (let x = Math.max(0, city.x - radius); x <= Math.min(MAP_W - 1, city.x + radius); x += 2) {
const d = Math.hypot(x - city.x, y - city.y);
if (d < 2.5 || d > radius) continue;
const i = indexOf(x, y);
if (sea[i] || highAltitudeRoadClosed(i) || transportFields.local[i] >= INF) continue;
if ((accessInfluence[i] || 0) > 0.44) continue;
const pop = settlementDemand?.[i] || 0;
const urban = urbanEdge?.[i] || 0;
const town = preliminaryTownInfluence?.[i] || 0;
if (Math.max(pop, urban, town) < 0.052) continue;
const score = urban * 1.25 + pop * 0.92 + town * 0.55 + (transportFields.localPotential?.[i] || 0) * 0.35 - d / Math.max(10, radius * 5);
local.push({ x, y, regionId: city.regionId, kind: "Urban Local Infill", score });
}
}
local.sort((a, b) => b.score - a.score);
const selected = [];
for (const point of local) {
if (selected.length >= quota) break;
if (distanceToNearestPoint(selected, point.x, point.y) < 3.2) continue;
selected.push(point);
}
candidates.push(...selected);
}
candidates.sort((a, b) => (b.score || 0) - (a.score || 0));
const added = runLocalAccessPass({
candidates,
accessInfluence,
localPenalty,
maxAdded: Math.min(150, Math.max(36, candidates.length)),
minSpacing: 2.8,
maxLength: 56,
debugMode: "urban-local-infill",
from: "urban-cell",
to: "existing-local-network",
// End on an actual road cell rather than merely entering the influence
// halo around one. This prevents the common one-cell visual gap between
// dense-city local streets.
targetOccupancy: urbanRoadOccupancy,
});
return { cities: cities.length, candidates: candidates.length, added };
}
transportDebugLayers.urbanStreetMeshes = { ...generateUrbanLocalAccessInfill(), algorithm: "existing-local-access" };
markRoadTiming("local-access-generation");
const localEndpointRepair = patchMode
@ -1721,6 +1864,8 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
});
transportDebugLayers.parallelPruning.push(localParallelPruning);
markRoadTiming("local-parallel-prune");
transportDebugLayers.finalRoadGapStitch = stitchDisconnectedRoadGaps();
markRoadTiming("local-rural-gap-stitch");
const sampledNetworkCells = (paths, step = 2) => sampledNetworkCellsBase(paths, step, sea);
const nationalForIc = sampledNetworkCells([...nationalRoads, ...externalRoads], 2).map((q) => ({ ...q, roadClass: "national" }));
@ -1800,22 +1945,6 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
}
function directLandConnector(a, b) {
const d = Math.hypot(a.x - b.x, a.y - b.y);
const steps = Math.max(1, 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 (!inside(x, y)) return [];
const i = indexOf(x, y);
if (sea[i] || highAltitudeRoadClosed(i) || transportFields.local[i] >= INF) return [];
if (!path.length || path[path.length - 1][0] !== x || path[path.length - 1][1] !== y) path.push([x, y]);
}
return path;
}
function addInterchange(p, hit) {
if (!p || !inside(p.x, p.y)) return false;
if (interchanges.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 5.0)) return false;
@ -1834,7 +1963,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
snapRadius: 0.5,
searchPad: Math.max(10, Math.ceil(roadHit.d + 10)),
});
const finalConnector = connector.length >= 2 ? connector : directLandConnector(p, roadHit);
const finalConnector = connector.length >= 2 ? connector : [];
if (finalConnector.length < 2) return false;
interchanges.push({ x: p.x, y: p.y, kind: "Interchange", score: transportFields.expresswayPotential[i] + (roadHit ? 0.24 : 0), regionId: regionIdAt(p.x, p.y) });
icAccessRoads.push(finalConnector);
@ -1893,7 +2022,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
}
}
if (!patchMode) {
if (!patchMode || productionTransportParity) {
generateInterchangesForExpressways();
markRoadTiming("interchanges");
} else {
@ -1917,11 +2046,36 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
}
function normalizeRoadGroups() {
const groups = roadGroups();
const pathFullyValid = (path, costField) => {
let valid = true;
for (let k = 0; k < (path?.length || 0) && valid; k++) {
const a = path[k], b = path[Math.min(k + 1, path.length - 1)];
const steps = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1])));
for (let s = 0; s <= steps; s++) {
if (k > 0 && s === 0) continue;
const t = s / steps;
const x = Math.round(a[0] + (b[0] - a[0]) * t), y = Math.round(a[1] + (b[1] - a[1]) * t);
if (!inside(x, y)) { valid = false; break; }
const i = indexOf(x, y);
if (sea[i] || highAltitudeRoadClosed(i) || costField[i] >= INF) { valid = false; break; }
}
}
return valid;
};
for (let gi = 0; gi < groups.length; gi++) {
const group = groups[gi];
const costField = groupCostField(gi);
const out = [];
for (const path of group || []) out.push(...splitPathToValidCells(path, costField, 2));
for (const path of group || []) {
// Never turn one invalid national road or motorway into several
// disconnected straight-looking fragments. Reject the whole generated
// trunk here; the post-admin production service pass will reroute it.
if (gi === 1 || gi === 3) {
if (pathFullyValid(path, costField)) out.push(path);
} else {
out.push(...splitPathToValidCells(path, costField, 2));
}
}
group.length = 0;
group.push(...out);
}
@ -1997,7 +2151,7 @@ export function buildDensityFlowRoadTransportSystem(ctx) {
heuristicWeight: 0.50,
searchPad: Math.ceil(Math.max(12, Math.min(72, d * 0.45 + 10))),
});
const rawPath = routed?.length ? routed : (d <= 12 ? directLandConnector(a, b) : []);
const rawPath = routed?.length ? routed : [];
const connectedPath = rawPath?.length ? [[a.x, a.y], ...rawPath, [b.x, b.y]] : [];
const dedupedPath = [];
for (const pt of connectedPath) {

View file

@ -20,7 +20,6 @@ export function buildUnifiedRailODNetwork(ctx) {
basinField,
coastalLowland,
plain,
agriculture,
naturalBarrierScore,
passSuitability,
transportFields,
@ -240,11 +239,11 @@ export function buildUnifiedRailODNetwork(ctx) {
function keyOf(p) { return `${p.x},${p.y}`; }
const regionalCityNodes = modernCities
.filter((c) => c.isRegionalCapital || c.isPrefecturalCapital || (c.population || 0) >= 90000)
.filter((c) => c.isRegionalCapital || c.isPrefecturalCapital || (c.population || 0) >= 70000)
.map((c) => nearbyRailAnchor(c, c.isRegionalCapital ? "regional-capital-rail" : c.isPrefecturalCapital ? "prefectural-capital-rail" : "major-city-rail", { outer: 8 }))
.filter(Boolean);
const secondaryCityNodes = modernCities
.filter((c) => !regionalCityNodes.some((n) => n.source === c) && (c.population || 0) >= 38000)
.filter((c) => !regionalCityNodes.some((n) => n.source === c) && (c.population || 0) >= 18000)
.map((c) => nearbyRailAnchor(c, "secondary-city-rail", { outer: 7 }))
.filter(Boolean);
const portNodes = [...commercialPorts, ...ports]
@ -253,22 +252,22 @@ export function buildUnifiedRailODNetwork(ctx) {
.map((p) => nearbyRailAnchor(p, "port-rail", { outer: 8 }))
.filter(Boolean);
const externalNodes = externalGateways
.map((g) => nearbyRailAnchor({ ...g, population: 60000 }, "external-rail-gateway", { outer: 5 }))
.map((g) => nearbyRailAnchor({ ...g, population: g.virtualPopulation || g.population || 60000 }, "external-rail-gateway", { outer: 5 }))
.filter(Boolean);
const anchorNodes = geographicUrbanAnchors
.filter((a) => (a.score || 0) > 0.76)
.slice(0, Math.max(5, Math.round(8 * speedScale)))
.slice(0, Math.max(8, Math.round(12 * speedScale)))
.map((a) => nearbyRailAnchor({ ...a, population: a.population || 52000 }, "geo-anchor-rail", { outer: 6 }))
.filter(Boolean);
let trunkNodes = dedupeNodes([...regionalCityNodes, ...secondaryCityNodes, ...portNodes, ...externalNodes, ...anchorNodes], 7.5)
.sort((a, b) => (b.population || 0) - (a.population || 0))
.slice(0, Math.max(24, Math.round(34 * speedScale)));
.slice(0, Math.max(30, Math.round(42 * speedScale)));
if (trunkNodes.length < 2) {
trunkNodes = dedupeNodes([
...modernCities.map((c) => nearbyRailAnchor(c, "fallback-city-rail", { outer: 8 })),
...markets.filter((m) => (m.population || 0) >= 14000).map((m) => nearbyRailAnchor(m, "fallback-market-rail", { outer: 5 })),
], 7).slice(0, Math.max(14, Math.round(18 * speedScale)));
], 7).slice(0, Math.max(18, Math.round(24 * speedScale)));
}
debug.nodeCounts = {
regionalCityNodes: regionalCityNodes.length,
@ -297,7 +296,7 @@ export function buildUnifiedRailODNetwork(ctx) {
const uf = makeUnionFind(trunkNodes, keyOf);
const penalty = new Float32Array(SIZE);
const maxTrunk = Math.min(Math.max(14, Math.round(18 * speedScale)), Math.max(4, trunkNodes.length - 1));
const maxTrunk = Math.min(Math.max(18, Math.round(24 * speedScale)), Math.max(4, trunkNodes.length - 1));
let connectedEdges = 0;
for (const pair of trunkPairs) {
if (connectedEdges >= maxTrunk) break;
@ -319,7 +318,7 @@ export function buildUnifiedRailODNetwork(ctx) {
let loopAdded = 0;
const trunkInfluenceBeforeLoops = cachedInfluenceFromPaths(railways, 5, "rail-od:trunk-before-loops");
for (const pair of trunkPairs) {
if (loopAdded >= Math.min(Math.max(4, Math.round(7 * speedScale)), Math.max(2, Math.ceil(trunkNodes.length / 6)))) break;
if (loopAdded >= Math.min(Math.max(8, Math.round(13 * speedScale)), Math.max(3, Math.ceil(trunkNodes.length / 3)))) break;
const ai = indexOf(pair.a.x, pair.a.y);
const bi = indexOf(pair.b.x, pair.b.y);
if ((trunkInfluenceBeforeLoops[ai] || 0) > 0.62 && (trunkInfluenceBeforeLoops[bi] || 0) > 0.62 && pair.demand < 0.88) continue;
@ -336,15 +335,15 @@ export function buildUnifiedRailODNetwork(ctx) {
const railInfluence = cachedInfluenceFromPaths(railways, 8, "rail-od:trunk-for-branches");
const branchCandidates = dedupeNodes([
...modernCities
.filter((c) => (c.population || 0) >= 22000 && (c.population || 0) < 90000)
.filter((c) => (c.population || 0) >= 8000 && (c.population || 0) < 70000)
.map((c) => nearbyRailAnchor(c, "branch-city-rail", { outer: 6 })),
...markets
.filter((m) => (m.population || 0) >= 16000)
.filter((m) => (m.population || 0) >= 4000)
.map((m) => nearbyRailAnchor(m, "branch-market-rail", { outer: 5 })),
...ports
.filter((p) => p.portClass === "regional" || (p.population || 0) >= 10000)
.map((p) => nearbyRailAnchor(p, "branch-port-rail", { outer: 7 })),
], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, Math.max(26, Math.round(36 * speedScale)));
], 6).sort((a, b) => (b.population || 0) - (a.population || 0)).slice(0, Math.max(36, Math.round(50 * speedScale)));
const trunkTargets = [];
for (const path of railways) {
@ -358,13 +357,13 @@ export function buildUnifiedRailODNetwork(ctx) {
let branchAdded = 0;
for (const node of branchCandidates) {
if (branchAdded >= Math.max(10, Math.round(14 * speedScale))) break;
if (branchAdded >= Math.max(22, Math.round(30 * speedScale))) break;
const ni = indexOf(node.x, node.y);
if ((railInfluence[ni] || 0) > 0.34) continue;
if ((railInfluence[ni] || 0) > 0.48) continue;
const options = trunkTargets
.map((q) => {
const d = Math.hypot(q.x - node.x, q.y - node.y);
if (d < 8 || d > 54) return null;
if (d < 6 || d > 60) return null;
const pair = pairScore(node, q, "branch");
if (!pair) return null;
return pair;
@ -372,7 +371,7 @@ export function buildUnifiedRailODNetwork(ctx) {
.filter(Boolean)
.sort((a, b) => a.score - b.score);
for (const pair of options.slice(0, 3)) {
if (odDemand(pair.a, pair.b, "branch") < 0.34 && pair.d > 32) { reject("branchWeakDemand"); continue; }
if (odDemand(pair.a, pair.b, "branch") < 0.26 && pair.d > 36) { reject("branchWeakDemand"); continue; }
const path = routeRailPair(pair, penalty, true);
if (!path.length) continue;
branchRailways.push(path);
@ -384,14 +383,8 @@ export function buildUnifiedRailODNetwork(ctx) {
}
}
debug.parallelPruning = pruneParallelSameMode([...railways, ...branchRailways], "rail", transportFields.railPotential, {
minKeep: Math.min(3, railways.length),
radius: 2,
threshold: 0.62,
shortLength: 24,
});
// pruneParallelSameMode mutates only the temporary array above, so repeat a
// conservative in-place pass per layer to preserve trunk/branch classification.
// Prune the published layers directly. A former cross-layer pass mutated a
// temporary concatenated array, so it could not affect either output layer.
debug.trunkParallelPruning = pruneParallelSameMode(railways, "rail", transportFields.railPotential, {
minKeep: 2,
radius: 2,

View file

@ -9,21 +9,28 @@ const radialInfluenceKernelCache = new Map();
export function getRadialInfluenceKernel(radius, exponent = 1.35) {
const r = Number(radius);
const e = Number(exponent);
if (!Number.isInteger(r) || r < 0 || !Number.isFinite(e)) return null;
if (!Number.isFinite(r) || r < 0 || !Number.isFinite(e)) return null;
// Non-integer radii are common in post-admin transport audits (2.0, 2.6,
// 3.2, 3.4...). Cache the exact same integer offset set that the former
// ceil(radius) nested loops visited, while keeping the true floating radius
// in the distance cutoff and weight calculation.
const key = `${r}:${e}`;
let cached = radialInfluenceKernelCache.get(key);
if (cached) return cached;
const dx = [];
const dy = [];
const weight = [];
const denominator = Math.max(1, r);
for (let oy = -r; oy <= r; oy++) {
for (let ox = -r; ox <= r; ox++) {
if (ox * ox + oy * oy > r * r) continue;
const d = Math.hypot(ox, oy);
const bound = Math.ceil(r);
const denominator = Math.max(0.001, r);
const r2 = r * r;
for (let oy = -bound; oy <= bound; oy++) {
for (let ox = -bound; ox <= bound; ox++) {
const d2 = ox * ox + oy * oy;
if (d2 > r2) continue;
const d = Math.sqrt(d2);
dx.push(ox);
dy.push(oy);
weight.push(Math.pow(1 - d / denominator, e));
weight.push(Math.pow(Math.max(0, 1 - d / denominator), e));
}
}
cached = {
@ -82,38 +89,20 @@ export function pathAverageField(path, field) {
return n ? sum / n : 0;
}
export function markPathInfluence(field, path, radius = 5, strength = 1, sea = null) {
const kernel = getRadialInfluenceKernel(radius, 1.35);
if (kernel) {
for (const [px, py] of path || []) {
for (let k = 0; k < kernel.length; k++) {
const x = px + kernel.dx[k];
const y = py + kernel.dy[k];
// The production grid is fixed MAP_W x MAP_H. Inline the bounds/index
// check in this hot loop while preserving the exact same accepted cells.
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) continue;
const i = y * MAP_W + x;
if (sea?.[i]) continue;
const v = strength * kernel.weight[k];
if (v > field[i]) field[i] = v;
}
}
return;
}
// Preserve legacy behavior for unusual non-integer radii.
export function markPathInfluence(field, path, radius = 5, strength = 1, sea = null, exponent = 1.35) {
const kernel = getRadialInfluenceKernel(radius, exponent);
if (!kernel) throw new RangeError("markPathInfluence requires a finite non-negative radius and finite exponent");
for (const [px, py] of path || []) {
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy > radius * radius) continue;
const x = px + dx;
const y = py + dy;
if (!inside(x, y)) continue;
const i = indexOf(x, y);
if (sea?.[i]) continue;
const d = Math.hypot(dx, dy);
const v = strength * Math.pow(1 - d / Math.max(1, radius), 1.35);
if (v > field[i]) field[i] = v;
}
for (let k = 0; k < kernel.length; k++) {
const x = px + kernel.dx[k];
const y = py + kernel.dy[k];
// The production grid is fixed MAP_W x MAP_H. Inline the bounds/index
// check in this hot loop while preserving the exact same accepted cells.
if (x < 0 || y < 0 || x >= MAP_W || y >= MAP_H) continue;
const i = y * MAP_W + x;
if (sea?.[i]) continue;
const v = strength * kernel.weight[k];
if (v > field[i]) field[i] = v;
}
}
}
@ -121,11 +110,12 @@ export function markPathInfluence(field, path, radius = 5, strength = 1, sea = n
export function createIncrementalPathInfluence(initialPaths = [], radius = 5, options = {}) {
const field = new Float32Array(SIZE);
const sea = options.sea || null;
for (const path of initialPaths || []) markPathInfluence(field, path, radius, 1, sea);
const exponent = Number.isFinite(options.exponent) ? options.exponent : 1.35;
for (const path of initialPaths || []) markPathInfluence(field, path, radius, 1, sea, exponent);
return {
field,
add(path, strength = 1, addRadius = radius) {
markPathInfluence(field, path, addRadius, strength, sea);
markPathInfluence(field, path, addRadius, strength, sea, exponent);
return field;
},
};
@ -182,6 +172,17 @@ export function splitPathToValidCells(path, isValid, minCells = 2) {
}
function sampledPathSignature(path, sampleStep, reversed = false) {
const sampled = [];
const last = path.length - 1;
for (let idx = 0; idx <= last; idx++) {
if (idx % sampleStep !== 0 && idx !== last) continue;
const pt = path[reversed ? last - idx : idx];
sampled.push(`${pt[0]},${pt[1]}`);
}
return sampled.join("|");
}
export function normalizeTransportPathSet(paths, options = {}) {
const minLength = options.minLength ?? 0;
const minPoints = options.minPoints ?? 2;
@ -202,12 +203,8 @@ export function normalizeTransportPathSet(paths, options = {}) {
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 signature = (candidate) => candidate
.map((pt, idx) => (idx % sampleStep === 0 || idx === candidate.length - 1) ? `${pt[0]},${pt[1]}` : "")
.filter(Boolean)
.join("|");
const forward = signature(cleaned);
const backward = signature([...cleaned].reverse());
const forward = sampledPathSignature(cleaned, sampleStep);
const backward = sampledPathSignature(cleaned, sampleStep, true);
const sig = forward < backward ? forward : backward;
if (seen.has(sig)) { removedDuplicates++; continue; }
seen.add(sig);

View file

@ -1,5 +1,11 @@
export const MAP_W = 258;
export const MAP_H = 183;
// Full-map generation may run in a dedicated Worker on a larger hidden raster
// and crop the central 258x183 viewport before publication. The main thread
// and patch Workers never set this override, so their canonical dimensions
// remain exactly 258x183. Reading the override at module-evaluation time keeps
// all downstream algorithms on one internally consistent SIZE.
const generationDimensionOverride = typeof globalThis !== "undefined" ? globalThis.__JAPAN_MAP_GENERATION_DIMENSIONS__ : null;
export const MAP_W = Number.isFinite(generationDimensionOverride?.width) ? Math.max(258, Math.floor(generationDimensionOverride.width)) : 258;
export const MAP_H = Number.isFinite(generationDimensionOverride?.height) ? Math.max(183, Math.floor(generationDimensionOverride.height)) : 183;
export const CELL_SIZE = 4;
export const SIZE = MAP_W * MAP_H;

View file

@ -1,7 +1,28 @@
import { generateMap } from "./mapPipeline.js";
import { generateMap, generateMapDraft, generateMapTerrainDraft } from "./mapPipeline.js";
import { collectTransferableBuffers } from "./transferUtils.js";
import { compactRawPatchCandidate, summarizeRawPatchCandidate } from "./rawPatchCandidate.js";
function prepareDraftForTransfer(candidate) {
if (!candidate || typeof candidate !== "object") return candidate;
// generateMapFeatures exposes cityPopulationCap as a closure over production
// terrain fields. Functions are not structured-cloneable, so materialize the
// exact per-city caps before transferring a resident draft to the coordinator.
// finishMapOutput consumes and removes this temporary numeric field when the
// winning draft is finalized; no approximation is introduced.
const features = candidate.features;
const capFn = features?.cityPopulationCap;
if (typeof capFn === "function") {
for (const city of features?.modernCities || []) {
const cap = Number(capFn(city));
if (Number.isFinite(cap)) city.__productionPopulationCap = cap;
}
delete features.cityPopulationCap;
}
if (typeof candidate.cityPopulationCap === "function") delete candidate.cityPopulationCap;
return candidate;
}
function scopeTaskProgress(event = {}, taskId) {
const scoped = { ...event };
if (event?.workUnitId != null && String(event.workUnitId).length > 0) {
@ -13,19 +34,22 @@ function scopeTaskProgress(event = {}, taskId) {
if (typeof self !== "undefined") {
self.onmessage = (event) => {
const message = event.data || {};
if (message.type !== "generate-raw-patch-candidate") return;
const taskId = String(message.taskId || `raw-${message.id || 0}`);
const terrainScoutOnly = message.type === "generate-raw-patch-terrain-scout";
const draftOnly = message.type === "generate-raw-patch-draft";
if (!terrainScoutOnly && !draftOnly && message.type !== "generate-raw-patch-candidate") return;
const taskId = String(message.taskId || `${terrainScoutOnly ? "terrain-scout" : draftOnly ? "draft" : "raw"}-${message.id || 0}`);
const startedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
try {
let candidate = generateMap(Number(message.seed) >>> 0, {
const generator = terrainScoutOnly ? generateMapTerrainDraft : draftOnly ? generateMapDraft : generateMap;
let candidate = generator(Number(message.seed) >>> 0, {
...(message.mapOptions || {}),
// A boolean sentinel is sufficient: mapPipeline only records whether a
// boundary world exists. Actual seam/quality work remains in the parent
// patch worker after this raw candidate has been transferred back.
// patch worker after this raw candidate/draft has been transferred back.
boundaryWorld: true,
onProgress: (progress) => {
self.postMessage({
type: "raw-patch-candidate-progress",
type: terrainScoutOnly ? "raw-patch-terrain-scout-progress" : draftOnly ? "raw-patch-draft-progress" : "raw-patch-candidate-progress",
id: message.id,
taskId,
progress: scopeTaskProgress(progress, taskId),
@ -33,12 +57,47 @@ if (typeof self !== "undefined") {
},
});
const elapsedMs = (typeof performance !== "undefined" && performance.now ? performance.now() : Date.now()) - startedAt;
let transferSummary;
if (terrainScoutOnly) {
const transferables = [...collectTransferableBuffers(candidate)];
transferSummary = {
transferableBytes: transferables.reduce((sum, buffer) => sum + Number(buffer?.byteLength || 0), 0),
transferableCount: transferables.length,
};
self.postMessage({
type: "raw-patch-terrain-scout-result",
id: message.id, taskId, ok: true, elapsedMs, transferSummary, candidate,
}, transferables);
return;
}
if (draftOnly) {
candidate = prepareDraftForTransfer(candidate);
// Drafts stay complete because the finalist reuses terrain, geographic
// basis and features verbatim. Transferable buffers move ownership to
// the coordinator without copying while the helper Worker remains alive
// for the next lane task.
const transferables = [...collectTransferableBuffers(candidate)];
transferSummary = {
transferableBytes: transferables.reduce((sum, buffer) => sum + Number(buffer?.byteLength || 0), 0),
transferableCount: transferables.length,
};
self.postMessage({
type: "raw-patch-draft-result",
id: message.id,
taskId,
ok: true,
elapsedMs,
transferSummary,
candidate,
}, transferables);
return;
}
// Transfer only the roots consumed by mapPatch. Full-map geography/debug
// graphs can exceed the actual patch payload and are never consulted by
// candidate merge/quality logic. Dropping them before structured clone
// creates a hard cross-worker memory bound for large tiled operations.
candidate = compactRawPatchCandidate(candidate);
const transferSummary = summarizeRawPatchCandidate(candidate);
transferSummary = summarizeRawPatchCandidate(candidate);
const transferables = [...collectTransferableBuffers(candidate)];
self.postMessage({
type: "raw-patch-candidate-result",
@ -51,11 +110,11 @@ if (typeof self !== "undefined") {
}, transferables);
} catch (error) {
self.postMessage({
type: "raw-patch-candidate-result",
type: terrainScoutOnly ? "raw-patch-terrain-scout-result" : draftOnly ? "raw-patch-draft-result" : "raw-patch-candidate-result",
id: message.id,
taskId,
ok: false,
code: error?.code || "raw-patch-candidate-error",
code: error?.code || (terrainScoutOnly ? "raw-patch-terrain-scout-error" : draftOnly ? "raw-patch-draft-error" : "raw-patch-candidate-error"),
error: error?.message || String(error),
stack: error?.stack || "",
});

View file

@ -399,15 +399,6 @@ function waterVisualShade(map, fx, fy) {
return clamp(0.992 + tone * 0.018, 0.976, 1.012);
}
function mixRgb(a, b, t) {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
function blendOutside(color, isInside) {
if (isInside) return color;
return [
@ -499,17 +490,10 @@ function terrainColorContinuous(map, fx, fy, mode) {
}
const centerWater = Boolean(map.sea?.[i]);
if (centerWater) {
// Keep the visible coastline and the filled water side derived from the same
// sea mask. Only a very narrow anti-aliased edge borrows land color; broad
// land/sea averaging made coast strokes disagree with the underlying fill.
const edgeLand = clamp((0.58 - waterCoverage) / 0.26);
color = edgeLand > 0 ? mixRgb(waterColor, landColor, edgeLand * 0.42) : waterColor;
} else {
const shore = clamp((waterCoverage - 0.10) / 0.42);
const shoreColor = [224, 229, 213];
color = shore > 0 ? mixRgb(landColor, shoreColor, shore * 0.34) : landColor;
}
// r11.3: land fill and coastline share the exact same binary sea mask.
// Bilinear sea coverage is still available to water shading, but it must not
// move the visible land/water boundary away from the vector coastline.
color = centerWater ? waterColor : landColor;
return blendOutside(color, isInside);
}
@ -869,10 +853,6 @@ function drawRailway(ctx, path, color, lineWidth, tickLen, spacing) {
ctx.restore();
}
function drawLandRailway(ctx, map, path, color, lineWidth, tickLen, spacing) {
for (const chunk of landOnlySubpaths(map, path, 3)) drawRailway(ctx, chunk, color, lineWidth, tickLen, spacing);
}
function drawSegments(ctx, segments, color, width, dashed = false) {
ctx.save();
ctx.strokeStyle = color;
@ -1203,6 +1183,27 @@ function labelWithCollision(ctx, p, occupied) {
ctx.restore();
return true;
}
// A forced municipal/prefectural label is a publication contract, not merely
// a collision preference. If every normal candidate lies outside the visible
// canvas (common for municipalities cut by the literal initial-generation
// crop), clamp one final placement into the viewport rather than silently
// dropping the only label for that municipality.
if (p.forceLabel && !fallback && !isMunicipalityLabel) {
const mapPixelWidth = ctx.__mapPixelWidth || MAP_W * CELL_SIZE;
const mapPixelHeight = ctx.__mapPixelHeight || MAP_H * CELL_SIZE;
const x = Math.max(3, Math.min(mapPixelWidth - textW - 3, baseX + 6));
const y = Math.max(textH + 2, Math.min(mapPixelHeight - 4, baseY - 4));
const box = { x1: x - 3, y1: y - textH, x2: x + textW + 3, y2: y + 5 };
ctx.lineJoin = "round";
ctx.lineWidth = isPrefectureLabel ? 6.2 : isMunicipalityLabel ? 3.0 : 3.5;
ctx.strokeStyle = isPrefectureLabel ? "rgba(255, 255, 255, 0.98)" : "rgba(255, 255, 255, 0.95)";
ctx.strokeText(p.name, x, y);
ctx.fillStyle = isPrefectureLabel ? "rgba(62, 38, 112, 0.98)" : p.isPrefecturalCapital ? "#111111" : isMunicipalityLabel ? "rgba(72, 62, 82, 0.92)" : "#333333";
ctx.fillText(p.name, x, y);
occupied.push(box);
ctx.restore();
return true;
}
ctx.restore();
return false;
}
@ -1213,7 +1214,10 @@ function drawLabels(ctx, points, limit = Infinity, occupied = null) {
.filter((p) => p?.name)
.map((p) => ({ ...p, labelPriority: (p.labelPriorityBase || 0) + (p.isPrefecturalCapital ? 1000 : 0) + (p.population || 0) / 900 + (p.portClass === "major" ? 170 : p.portClass === "regional" ? 95 : 0) }))
.sort((a, b) => b.labelPriority - a.labelPriority);
for (const p of prioritized.slice(0, limit)) labelWithCollision(ctx, p, used);
if (!used.drawnPoints) used.drawnPoints = [];
for (const p of prioritized.slice(0, limit)) {
if (labelWithCollision(ctx, p, used)) used.drawnPoints.push(p);
}
return used;
}
@ -1306,8 +1310,8 @@ function* drawMapSteps(canvas, map, options) {
markTiming("urbanFill");
yield { phase: "urbanFill" };
const coastSegments = getCoastlineSegments(map);
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.2, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.1, false, { iterations: 2, tolerance: 0.06, offsetX: -0.5, offsetY: -0.5 });
drawVectorSegments(ctx, coastSegments, "rgba(120, 175, 210, 0.22)", 2.0, false, { iterations: 0, tolerance: 0, offsetX: 0, offsetY: 0 });
drawVectorSegments(ctx, coastSegments, "rgba(248, 250, 242, 0.68)", 1.0, false, { iterations: 0, tolerance: 0, offsetX: 0, offsetY: 0 });
markTiming("coastline");
yield { phase: "coastline" };
@ -1425,14 +1429,14 @@ function* drawMapSteps(canvas, map, options) {
for (const path of generalRoadPaths) drawLandPath(ctx, map, path, localRoadCasing, 3.05);
}
if (showRoads) {
for (const path of map.nationalRoads) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.ringRoads || []) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.externalRoads) drawLandPath(ctx, map, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(190, 175, 140, 1)", 3.8);
}
if (showModern || showRoads) {
for (const path of map.railways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3);
for (const path of map.branchRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.64)", 2.6, false, 3);
for (const path of map.externalRailways) drawLandPath(ctx, map, path, "rgba(255, 255, 255, 0.78)", 3.6, false, 3);
for (const path of map.railways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6);
for (const path of map.branchRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.64)", 2.6);
for (const path of map.externalRailways) drawPath(ctx, path, "rgba(255, 255, 255, 0.78)", 3.6);
}
if (showRoads) {
for (const path of map.expressways) drawPath(ctx, path, "rgba(105, 125, 105, 0.78)", 4.6);
@ -1444,14 +1448,14 @@ function* drawMapSteps(canvas, map, options) {
for (const path of generalRoadPaths) drawLandPath(ctx, map, path, localRoadFill, 1.45, false);
}
if (showRoads) {
for (const path of map.nationalRoads) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.ringRoads || []) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.externalRoads) drawLandPath(ctx, map, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.nationalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.ringRoads || []) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
for (const path of map.externalRoads) drawPath(ctx, path, "rgba(245, 225, 130, 1)", 2.0);
}
if (showModern || showRoads) {
for (const path of map.railways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
for (const path of map.branchRailways) drawLandRailway(ctx, map, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0);
for (const path of map.externalRailways) drawLandRailway(ctx, map, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
for (const path of map.railways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
for (const path of map.branchRailways) drawRailway(ctx, path, "rgba(125, 125, 125, 1)", 1.05, 4.0, 6.0);
for (const path of map.externalRailways) drawRailway(ctx, path, "rgba(95, 95, 95, 1)", 1.55, 5.0, 7.0);
}
if (showRoads) {
for (const path of map.expressways) drawPath(ctx, path, "rgba(135, 160, 135, 0.88)", 2.4);
@ -1510,9 +1514,12 @@ function* drawMapSteps(canvas, map, options) {
const prefectureLabels = (map.prefectureRegions || []).map((p) => ({ ...p, isPrefectureLabel: true, forceLabel: true, labelPriorityBase: p.labelPriorityBase || 1900 }));
if (mode === "admin") {
const municipalLabels = (map.adminCenters || [])
.filter((p) => p && p.name && Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < map.width && p.y < map.height && map.adminId?.[Math.round(p.y) * map.width + Math.round(p.x)] === (p.adminId ?? p.municipalityId ?? p.adminNumericId))
.filter((p) => p && !p.suppressMunicipalLabel && !p.seatOutsideVisibleCrop && p.name && Number.isFinite(p.x) && Number.isFinite(p.y) && p.x >= 0 && p.y >= 0 && p.x < map.width && p.y < map.height && map.adminId?.[Math.round(p.y) * map.width + Math.round(p.x)] === (p.adminId ?? p.municipalityId ?? p.adminNumericId))
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1200 }));
drawLabels(ctx, [...prefectureLabels, ...municipalLabels], Infinity);
// Administrative hierarchy is also a visual z-order contract: municipal
// labels are placed first and prefecture names are always painted last.
const adminOccupied = drawLabels(ctx, municipalLabels, Infinity);
drawLabels(ctx, prefectureLabels, Infinity, adminOccupied);
markTiming("labels");
yield { phase: "labels" };
return finish();
@ -1524,13 +1531,35 @@ function* drawMapSteps(canvas, map, options) {
return finish();
}
const important = [
...prefectureLabels,
...map.modernCities,
...map.ports,
...(map.satelliteCities || []),
...settlementIconLabelPoints,
].filter((p) => !p.suppressSettlementLabel && (p.isPrefectureLabel || p.forceAllLayerTownLabel || p.insidePrefecture || p.isRegionalCapital || p.kind === "External Gateway" || (p.population || 0) >= 5000));
drawLabels(ctx, important, mode === "all" || mode === "history" ? 78 : 60);
const occupiedLabels = drawLabels(ctx, important, mode === "all" || mode === "history" ? 90 : 72);
// Rural label floor: after normal labels have been placed, guarantee at
// least one visible label for every municipality that still has none. This
// uses the municipal seat name and a compact style instead of promoting
// every rural settlement to a city-style label.
const labelledMunicipalities = new Set();
for (const p of occupiedLabels.drawnPoints || []) {
if (!p || p.isPrefectureLabel || !Number.isFinite(p.x) || !Number.isFinite(p.y)) continue;
const x = Math.round(p.x), y = Math.round(p.y);
if (x < 0 || y < 0 || x >= map.width || y >= map.height) continue;
const id = map.adminId?.[y * map.width + x];
if (Number.isFinite(id) && id >= 0) labelledMunicipalities.add(id);
}
const municipalFallbackLabels = (map.adminCenters || [])
.filter((p) => p && !p.suppressMunicipalLabel && !p.seatOutsideVisibleCrop && p.name && Number.isFinite(p.x) && Number.isFinite(p.y))
.filter((p) => {
const id = p.adminId ?? p.municipalityId ?? p.adminNumericId;
return Number.isFinite(id) && id >= 0 && !labelledMunicipalities.has(id);
})
.map((p) => ({ ...p, labelStyle: "municipality", forceLabel: true, labelPriorityBase: 1080 }));
drawLabels(ctx, municipalFallbackLabels, Infinity, occupiedLabels);
// Prefecture names must remain visually above city/municipality labels in
// every mixed map mode, not just win a priority sort and then get overdrawn.
drawLabels(ctx, prefectureLabels, Infinity, occupiedLabels);
}
markTiming("labels");
yield { phase: "labels" };
@ -1578,4 +1607,3 @@ export async function drawMapCooperative(canvas, map, options, cooperative = {})
sliceStartedAt = nowMs();
}
}

View file

@ -13,6 +13,13 @@ const INITIAL_QUALITY_SETTLEMENT_KEYS = ["villages", "markets", "modernCities",
const INITIAL_QUALITY_LABEL_KEYS = [...INITIAL_QUALITY_SETTLEMENT_KEYS, "adminCenters"];
const INITIAL_QUALITY_ROAD_KEYS = ["premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "expressways", "icAccessRoads"];
const INITIAL_QUALITY_RAIL_KEYS = ["railways", "branchRailways", "ringRailways"];
const INITIAL_QUALITY_TRANSPORT_CLASSES = Object.freeze({
local: ["premodernRoads", "minorRoads", "ringRoads", "icAccessRoads"],
national: ["nationalRoads", "externalRoads"],
expressway: ["expressways", "externalExpressways"],
railTrunk: ["railways", "externalRailways"],
railBranch: ["branchRailways", "ringRailways"],
});
const FULL_MAP_ONLY_SOURCE_ROOTS = new Set([
// These graphs are required while generateMap() is building the initial map,
@ -34,12 +41,33 @@ function captureInitialQualityReference(initialMap) {
for (let i = 0; i < sea.length; i++) if (!sea[i]) landCells++;
}
const count = (keys) => keys.reduce((sum, key) => sum + (Array.isArray(initialMap?.[key]) ? initialMap[key].length : 0), 0);
const pathCells = (keys) => keys.reduce((sum, key) => sum + (Array.isArray(initialMap?.[key])
? initialMap[key].reduce((inner, path) => inner + (Array.isArray(path) ? path.length : 0), 0)
: 0), 0);
const safeLand = Math.max(1, landCells);
const transportClasses = {};
for (const [key, layers] of Object.entries(INITIAL_QUALITY_TRANSPORT_CLASSES)) {
const paths = count(layers);
const cells = pathCells(layers);
transportClasses[key] = {
paths,
cells,
pathsPer1000Land: paths * 1000 / safeLand,
cellsPer1000Land: cells * 1000 / safeLand,
};
}
const majorCities = (initialMap?.modernCities || []).filter((p) => (Number(p?.population) || 0) >= 75000).length;
const trunkCities = (initialMap?.modernCities || []).filter((p) => (Number(p?.population) || 0) >= 30000).length;
return {
landCells: Math.max(1, landCells),
oracleVersion: "initial-production-quality-v2",
landCells: safeLand,
settlements: count(INITIAL_QUALITY_SETTLEMENT_KEYS),
labels: count(INITIAL_QUALITY_LABEL_KEYS),
roadPathCount: count(INITIAL_QUALITY_ROAD_KEYS),
railPathCount: count(INITIAL_QUALITY_RAIL_KEYS),
transportClasses,
majorCities,
trunkCities,
};
}

View file

@ -1,4 +1,4 @@
import { MAP_H, MAP_W, worldIndexOf } from "./mapUtils.js";
import { MAP_H, MAP_W } from "./mapUtils.js";
import { defaultCellFieldValue } from "./fieldSchema.js";
const EMPTY_ARRAY_KEYS = new Set([
@ -71,11 +71,17 @@ function copyViewportField(name, source, world, camera, viewWidth, viewHeight) {
const cx = Math.round(camera.x || 0);
const cy = Math.round(camera.y || 0);
const sourceX0 = Math.max(0, cx);
const sourceX1 = Math.min(world.width, cx + viewWidth);
if (sourceX1 <= sourceX0) return out;
const copyWidth = sourceX1 - sourceX0;
const outputX0 = sourceX0 - cx;
for (let y = 0; y < viewHeight; y++) {
for (let x = 0; x < viewWidth; x++) {
const src = worldIndexOf(world, cx + x, cy + y);
if (src >= 0) out[y * viewWidth + x] = source[src];
}
const sourceY = cy + y;
if (sourceY < 0 || sourceY >= world.height) continue;
const sourceOffset = sourceY * world.width + sourceX0;
const outputOffset = y * viewWidth + outputX0;
out.set(source.subarray(sourceOffset, sourceOffset + copyWidth), outputOffset);
}
return out;
}

View file

@ -1,12 +1,8 @@
*{box-sizing:border-box}
:root{
--bg:#090b10;
--bg-2:#0d1118;
--surface:#11161f;
--surface-2:#151d28;
--surface-3:#1a2431;
--surface-soft:rgba(20,28,39,.88);
--panel:rgba(20,28,39,.9);
--line:rgba(173,190,211,.12);
--line-strong:rgba(173,190,211,.24);
--text:#e8eef8;
@ -14,7 +10,6 @@
--muted-2:#6f8199;
--accent:#4f8cff;
--accent-strong:#3e75d9;
--accent-soft:rgba(79,140,255,.14);
--danger:#ff6861;
--shadow:0 24px 60px rgba(0,0,0,.36);
--shadow-soft:0 10px 26px rgba(0,0,0,.24);
@ -117,7 +112,7 @@ h2,h3{margin:0;letter-spacing:-.02em}h2{font-size:16px}h3{font-size:14px}
padding:9px 10px;
cursor:pointer;
font-weight:800;
transition:background .16s,border-color .16s,color .16s,transform .16s, box-shadow .16s;
transition:background .16s,border-color .16s,color .16s,box-shadow .16s;
min-height:38px;
}
.primary-button{border:1px solid var(--accent);background:var(--accent);color:#fff;box-shadow:0 8px 16px rgba(79,140,255,.18)}
@ -132,7 +127,6 @@ h2,h3{margin:0;letter-spacing:-.02em}h2{font-size:16px}h3{font-size:14px}
.segmented-button{width:100%;display:flex;align-items:center;justify-content:center;text-align:center;padding-left:8px;padding-right:8px}
.segmented-button:hover,.mode-button:hover{background:#202b3a}
.segmented-button.active,.mode-button.active{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 8px 16px rgba(79,140,255,.16)}
.microcopy{margin:9px 0 0;color:var(--muted);font-size:11px;line-height:1.45}
.mode-grid{display:grid;grid-template-columns:1fr;gap:6px}.mode-button{min-height:32px;padding:7px 9px;font-size:12px;border-radius:9px;text-align:left}
.checkbox-row{display:flex;gap:8px;align-items:center;margin-top:9px;color:#d2dbeb;font-size:13px;cursor:pointer}.checkbox-row input{accent-color:var(--accent)}
.patch-status{margin:10px 0 0;color:var(--muted);font-size:12px;line-height:1.45}.patch-status.invalid{color:var(--danger);font-weight:750}
@ -193,7 +187,7 @@ h2,h3{margin:0;letter-spacing:-.02em}h2{font-size:16px}h3{font-size:14px}
.summary-stats .stat-row{display:grid;grid-template-columns:1fr;gap:4px;border-bottom:0;border-right:1px solid rgba(173,190,211,.08);min-height:58px}.summary-stats .stat-row:last-child{border-right:0}
.stat-row strong{color:#edf3fe;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;text-align:right;white-space:nowrap}.summary-stats .stat-row strong{text-align:left;font-size:15px}
.legend-grid{display:flex;flex-direction:column;gap:8px}.legend-row{display:grid;grid-template-columns:28px 1fr;gap:8px;align-items:center;min-height:20px;color:#d0dae9;font-size:12px;line-height:1.35}
.legend-line{display:inline-block;width:24px;height:4px;border-radius:999px;justify-self:center}.express-line{background:#87a087;border:1px solid #697d69}.road-line{background:#f5e182;border:1px solid #beaf8c}.river-major{background:#74a5ca;border:none;height:3px}.old-road-line{background:#fff;border:1px solid #8f8f86;height:3px}.minor-road-line{background:#fff;border:1px solid #a7a7a0;height:3px}.rail-line{background:#e8e8e8;height:1.5px;position:relative;border:none;margin-top:2px;border-radius:0}.rail-line::after{content:"";position:absolute;top:-2.5px;left:0;right:0;height:7px;background:repeating-linear-gradient(90deg,transparent,transparent 5px,#343a43 5px,#343a43 6px)}
.legend-line{display:inline-block;width:24px;height:4px;border-radius:999px;justify-self:center}.road-line{background:#f5e182;border:1px solid #beaf8c}.river-major{background:#74a5ca;border:none;height:3px}.old-road-line{background:#fff;border:1px solid #8f8f86;height:3px}.minor-road-line{background:#fff;border:1px solid #a7a7a0;height:3px}.rail-line{background:#e8e8e8;height:1.5px;position:relative;border:none;margin-top:2px;border-radius:0}.rail-line::after{content:"";position:absolute;top:-2.5px;left:0;right:0;height:7px;background:repeating-linear-gradient(90deg,transparent,transparent 5px,#343a43 5px,#343a43 6px)}
.legend-swatch{display:inline-block;width:24px;height:14px;border-radius:4px;background:#f5f5f5;justify-self:center}.border-swatch{border:2px dashed rgba(171,145,171,1);box-shadow:inset 0 0 0 1px rgba(255,255,255,1),0 0 0 1px rgba(255,255,255,1)}.admin-swatch{border:2px dashed rgba(190,180,190,.9);background:#fff}.terrain-swatch{background:linear-gradient(90deg,#7da564,#e3daa2,#98a58f)}.urban-swatch{background:#f0dccd;border:1px solid rgba(0,0,0,.1)}.sea-swatch{background:#9fc7df;border:1px solid rgba(70,120,160,.35)}
.legend-icon{display:inline-block;width:14px;height:14px;justify-self:center;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 1px rgba(0,0,0,.15)}.city-icon{background:#f06e6e}.town-icon{width:10px;height:10px;border-radius:3px;background:#f06e6e}.station-icon{background:#fff;border-color:#444}.industry-icon{background:#8caaa0;border-radius:3px}.port-icon{width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:14px solid #4682c8;border-top:0;box-shadow:none;background:transparent;border-radius:0}.castle-icon{background:#b44646;border-radius:3px}
@ -201,7 +195,7 @@ h2,h3{margin:0;letter-spacing:-.02em}h2{font-size:16px}h3{font-size:14px}
.zoom-control button{min-width:58px;height:40px;border:0;border-bottom:1px solid rgba(173,190,211,.10);background:transparent;color:var(--text);font-weight:850;cursor:pointer}.zoom-control button:last-child{border-bottom:0;font-size:12px}.zoom-control button:hover{background:rgba(79,140,255,.12);color:#fff}
.map-hint{position:absolute;left:18px;top:18px;z-index:25;max-width:440px;background:rgba(14,27,50,.86);border:1px solid rgba(79,140,255,.36);color:#dce8ff;border-radius:13px;box-shadow:0 10px 26px rgba(0,0,0,.22);padding:10px 12px;font-size:13px;font-weight:800;backdrop-filter:blur(8px)}
.hidden{display:none!important}
.map-tooltip{position:absolute;z-index:26;pointer-events:none;min-width:200px;max-width:280px;background:rgba(17,22,31,.92);border:1px solid rgba(173,190,211,.16);border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.28);backdrop-filter:blur(10px);padding:10px 12px;color:var(--text);font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity .15s ease,transform .15s ease;font-weight:500}.map-tooltip.visible{opacity:1;transform:translateY(0)}
.map-tooltip{position:absolute;z-index:26;pointer-events:none;min-width:200px;max-width:280px;background:rgba(17,22,31,.84);border:1px solid rgba(173,190,211,.16);border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.28);backdrop-filter:blur(10px);padding:10px 12px;color:var(--text);font-size:12px;line-height:1.5;opacity:0;transform:translateY(4px);transition:opacity .15s ease,transform .15s ease;font-weight:500}.map-tooltip.visible{opacity:1;transform:translateY(0)}
.generation-progress{position:absolute;inset:18px auto auto 18px;z-index:30;min-width:300px;max-width:440px;background:rgba(17,22,31,.96);border:1px solid rgba(173,190,211,.16);border-radius:14px;box-shadow:0 14px 36px rgba(0,0,0,.34);padding:14px 16px;color:var(--text);font-size:13px;line-height:1.5}.progress-title{font-weight:850;margin-bottom:4px}.progress-stage{color:var(--muted);margin-bottom:10px}.progress-timings{display:flex;flex-direction:column;gap:4px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#cfdbef}.progress-timing-row{display:flex;justify-content:space-between;gap:16px;border-top:1px solid rgba(173,190,211,.08);padding-top:4px}
.map-selection-svg{position:absolute;left:0;top:0;width:0;height:0;z-index:18;display:none;pointer-events:none;overflow:visible}.map-selection-svg polygon{fill:rgba(79,140,255,.16);stroke:rgba(79,140,255,.92);stroke-width:2;vector-effect:non-scaling-stroke;stroke-linejoin:round}.map-selection-svg.invalid polygon{fill:rgba(255,104,97,.14);stroke:rgba(255,104,97,.92)}.map-selection{position:absolute;z-index:18;display:none;pointer-events:none;border:2px solid rgba(79,140,255,.88);background:rgba(79,140,255,.16);box-shadow:0 0 0 1px rgba(255,255,255,.20) inset,0 8px 22px rgba(79,140,255,.20)}.map-selection.invalid{border-color:rgba(255,104,97,.92);background:rgba(255,104,97,.14);box-shadow:0 0 0 1px rgba(255,255,255,.20) inset,0 8px 22px rgba(255,104,97,.18)}
.map-canvas.is-zooming{image-rendering:auto;pointer-events:auto}
@ -477,7 +471,7 @@ h2,h3{margin:0;letter-spacing:-.02em}h2{font-size:16px}h3{font-size:14px}
@media (max-width:1280px){.metric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media (max-width:720px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.aggregate-header,.aggregate-row{grid-template-columns:1fr .55fr}.aggregate-header strong,.aggregate-row strong{grid-column:2}.aggregate-header strong:nth-of-type(1)::before,.aggregate-row strong:nth-of-type(1)::before{content:"Avg ";color:var(--muted-2);font-family:inherit}.aggregate-header strong:nth-of-type(2)::before,.aggregate-row strong:nth-of-type(2)::before{content:"Max ";color:var(--muted-2);font-family:inherit}.aggregate-header strong:nth-of-type(3)::before,.aggregate-row strong:nth-of-type(3)::before{content:"P95 ";color:var(--muted-2);font-family:inherit}}
.diagnostic-grid{margin-bottom:10px}
.diagnostic-table{
.diagnostic-table,.diagnostic-log{
display:flex;
flex-direction:column;
overflow:hidden;
@ -501,14 +495,6 @@ h2,h3{margin:0;letter-spacing:-.02em}h2{font-size:16px}h3{font-size:14px}
.diagnostic-row span{color:#dbe5f4;font-weight:800}
.diagnostic-row strong{color:#fff;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.diagnostic-row small{color:var(--muted-2);line-height:1.25}
.diagnostic-log{
display:flex;
flex-direction:column;
overflow:hidden;
border:1px solid rgba(173,190,211,.10);
border-radius:12px;
background:rgba(10,14,20,.22);
}
.diagnostic-log-row{
display:grid;
grid-template-columns:86px minmax(140px,.7fr) minmax(0,1.8fr);

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