diff --git a/MODIFICATION_NOTES.md b/MODIFICATION_NOTES.md new file mode 100644 index 0000000..51a930d --- /dev/null +++ b/MODIFICATION_NOTES.md @@ -0,0 +1,446 @@ +# 文明シミュレータ 改修記録 + +## 目的 + +ユーザー指定の操作系・反乱・交易路・戦争損害・地形計算の変更を、既存シミュレーションの構造を維持したまま追加する。 + +## 実装した変更 + +- 「1週進める」ボタンを削除。 +- 初期人口スライダーを削除。初期人口は標準値 50,000 に固定。 +- 「再生成」を押すとダイアログを開き、その時点で世界サイズ(128 / 160 / 220 / 280)を選択する方式に変更。 +- 反乱ロジックを変更。 + - 忠誠度 30% 未満の都市が1つでもあれば反乱を自動発生。 + - 反乱が発生した場合、首都を除く忠誠度 50% 未満の全都市が同時に同一反乱勢力へ参加。 + - 30〜50%の範囲では従来のランダム性を残しつつ、忠誠度・不安定度・国家年齢から年次発生確率を算出。 +- 「災害発生」「都市建設」「交通接続」の3ボタンを追加。 + - 災害発生: 地図上の指定地点を中心に即時発生。 + - 都市建設: 地図上のクリック地点へ都市を即時建設。ユーザー建設都市は都市間隔・自動都市数上限を無視するが、水域には建設できない。 + - 交通接続: 都市を2つ順にクリックして交易路を作成。 +- ユーザー作成交易路に内部保護期間を追加し、期間終了後は通常管理へ戻るよう変更。 +- 戦争による都市人口損失と戦場タイル人口損失を従来の約 65% に軽減。 + - 戦争発生頻度・勝敗・占領判定そのものは変更していない。 +- 水域の肥沃度を計算対象外にし、常に 0 とするよう変更。 +- 既存コードの堅牢性修正。 + - `getCityById` / `getPolityById` がキャッシュ未初期化のテスト・部分実行でも動作するよう修正。 + - 完全に囲まれた小さな中立穴を領土平滑化で埋められるよう修正。 + +## 追加修正(連続介入・ヘルプ) + +- 災害発生・都市建設・交通接続が、1回成功するたびに解除されていた挙動を修正。選択した介入は同じボタンを再度押すまで継続する。 +- 交通接続は1本作成後、同じモードのまま次の1つ目の都市選択へ戻る。 +- ヘルプを現行仕様に合わせて更新し、説明量を圧縮。 +- 交通接続完了メッセージから保護期間に関する説明を削除。 + +## 追加修正(砂漠交易路・手動都市民族・征服後反乱) + +- 砂漠横断交易路の成立条件を改善。 + - 交易路経路探索では砂漠の移動コストを通常移動より軽く評価し、隊商路として横断しやすくした。 + - 都市間の直線上に砂漠が多い場合、交易路の有効距離上限を段階的に拡張する。 + - 砂漠を多く通る経路では、新規交易路に必要なフェロモン閾値と建設コストを緩和する。 + - 水域は従来どおり交易路不可。山地の高コストも維持。 +- ユーザー建設都市の民族構成を修正。 + - 建設地点の民族構成を優先して継承。 + - 地点に民族が存在しない場合は最寄りの民族構成を継承。 + - 世界内に参照可能な民族がない場合も、新しい現地民族を生成して民族構成が空にならないようにした。 +- 征服された都市に20年間の反乱猶予期間を追加。 + - 戦争占領、国境戦争、軍事併合、講和による都市移譲、遊牧民征服を対象とする。 + - 猶予期間中は忠誠度が30%未満でも反乱を自動発生せず、他都市の反乱にも参加しない。 + - 猶予終了後は通常の反乱判定へ戻る。 + +## 追加修正(国家ツールチップ・制度老朽化・制度改革) + +- 国家領土または国家所属都市にカーソルを合わせた際、ツールチップに「国家データ」を表示。 + - 国力、人口、都市数、国庫、正統性 / 結束、制度老朽化、忠誠度上限を表示する。 +- 国家に `institutionalAging`(制度老朽化度、0~1)を追加。 + - 国家成立後、年次更新ごとに徐々に増加する。 + - 忠誠度上限を `1 - institutionalAging` とし、首都を含む所属都市の忠誠度はこの上限を超えない。 +- 「制度改革」イベントを追加。 + - 老朽化度が30%を超えてから発生候補となり、老朽化が高いほど年次発生確率が上昇する。 + - 発生時は現在の老朽化度に対して -50%~+30% の相対変化をランダムに適用する。改革が逆効果になる場合もある。 + - 同一国家での連続発生を抑えるため8年間のクールダウンを設ける。 + - 国家興亡グラフへ専用の「制度改革」マーカーを記録し、老朽化度の変化前後と変化率を表示する。 + - 国家履歴サンプルにも制度老朽化度と忠誠度上限を保存する。 + +## 追加整理(不要処理・ツールチップ・回帰試験) + +- 都市ツールチップを「都市データ」と「国家データ」に分離し、民族構成の割合表示を都市データ内へ移動。 +- 国家人口表示は既存の `totalPolityPopulation()` を利用し、ツールチップ独自の人口集計を廃止。 +- 未参照の設定値11個を `config.js` から削除。 + - 人口: `softCapStart`, `hardCapScale`, `pressurePenaltyBase`, `pressureReproductionPenalty` + - 都市: `agingRestoreMinWear`, `agingRestoreCostPerWear`, `agingRestoreBudgetShare` + - 技術: `cityDiffusion` + - 文化: `spreadRadius`, `cityWeight`, `routeWeight` +- 未参照の描画/共通ヘルパー6個を削除。 +- 未参照または明示的に停止済みだったSimulationメソッド12個と、それ専用だった文化スクラッチ状態を削除。 + - 特に先頭で `return` していた旧 `expandPolities()` / `absorbIndependentCities()` を完全削除。 + - 旧地域文化更新・旧収容力単発計算・旧都市老朽化補助など、現行処理と責務が重複したコードを除去。 +- `annexation_policy_probe.html` を削除。内容は `tests/simulation.test.cjs` のNode回帰試験へ統合し、ブラウザ専用の古いモックを廃止。 +- 静的棚卸しで、`SimConfig` の未参照leaf設定値と明白な未参照関数/メソッドが0件であることを確認。 +- 制度改革の発生開始を「制度老朽化30%超」に変更。30%ちょうどでは発生しない。 + + +## 追加修正(ヘルプ・国庫上限・性能) + +- ヘルプに主要パラメータの短い説明を追加。 + - 人口 / 人口圧力、資源 / 肥沃度、農業 / 冶金、交通量、忠誠度、国庫、正統性 / 結束、制度老朽化、国力を対象とする。 +- 国家国庫に人口比例の動的上限を追加。 + - 上限は `国家人口 × 0.30`。人口増減に応じて毎年再評価する。 + - 国家ツールチップは重複表示を避けるため国庫残高のみを1行で表示する。動的上限は内部会計ロジックとヘルプで説明する。 + - 反乱時は、反乱人口比に応じて旧国家の国庫の一部を反乱国へ移す。移転額は `旧国庫 × 反乱人口比 × 0.65`。 + - 反乱後は旧国家・反乱国家とも、それぞれの新しい人口に応じた国庫上限へ収める。 +- 計算負荷をプロファイルし、結果を変えない範囲を優先して軽量化。 + - 世界生成の湿度計算を、各タイル半径10の総当たり探索から2パスのマンハッタン距離変換へ変更。旧方式との比較で浮動小数点誤差範囲まで一致することを確認。 + - 沿岸肥沃度補正は49マスの矩形走査から、実際に寄与するマンハッタン距離3以内のオフセットだけを走査する方式へ変更。 + - 人口収容力のうち、地形・肥沃度・鉱物由来の静的成分を初期化時にキャッシュ。年次処理で不要な全マップ再計算も削減し、人口だけが変わる段階では収容力の再計算をせず、圧力更新に限定。 + +## 追加修正(表示整理・都市間隔・追加軽量化) + +- 国家ツールチップの国庫表示を1行へ統一し、同じ国庫情報が二重に見える表示を解消。 +- 国家興亡グラフに「出来事アイコン」切替を追加。制度改革を含むイベントマーカーと凡例をまとめて表示 / 非表示にできる。 +- 自然生成都市の既存都市からの必要距離を従来のおおむね半分へ縮小。 + - 自然生成の最小都市間隔は `6`、競合評価半径は `9` とする。 +- ユーザー建設都市は通常の都市間隔・自動都市数上限制限を無視する。 + - ただし水域への建設は禁止し、陸地の指定座標へ直接建設する。 +- 追加プロファイルに基づき、通常進行をさらに軽量化。 + - 毎週相当で全世界を走査していた世界フィールド処理を月次へ集約。 + - 交易フェロモンは全タイル走査をやめ、値が存在するタイルと隣接域だけを疎更新。 + - 資源再生は四半期ごとにまとめ、年間再生量を維持。 + - 人口収容力は必要な変化時と年次処理に限定して再計算。 + - 民族統計の毎週更新を廃止し、履歴記録・年次処理で必要な時だけ更新。 + - 旧互換用で実利用されていなかった人口 `pressure` 配列と更新処理を削除。 + - 年次更新で重複呼び出しされていた国家危機判定を1回へ整理し、負荷とイベント判定回数の重複を解消。 + +## 追加修正(都市数上限・手動都市の水域制限) + +- 自動生成都市数の上限を `300` から `1200` へ引き上げ。 + - 自然発生都市・遠征による前哨都市など、自動生成側の共通上限として適用する。 + - ユーザー建設都市はこの自動上限を引き続き無視する。 +- ユーザー建設都市の水域建設を禁止。 + - 水域をクリックした場合は都市を生成せず、陸地を選択するようUIへ案内する。 + - 都市間隔制限は引き続き無視できるため、陸地上では既存都市の隣接地点にも建設可能。 + +## v9: 自然都市数の成長停滞を修正 + +### 原因 + +自然都市の創設判定が20年に1回で、1000年までは1回につき最大1都市に固定されていた。最大280×280マップでも創設流量が面積に比例せず、都市上限を1200へ増やしても約60都市前後で成長が鈍化しやすかった。 + +### 変更 + +- 自然都市の創設判定周期を20年から5年へ短縮。 +- 1回の創設可能数を世界面積に応じて増加。160×160で1、220×220で2、280×280で4都市まで。 +- 人口・定住性・資源・地形・既存都市との距離・創設確率は従来どおり判定し、無条件生成にはしない。 +- 面積スケーリングは `naturalCityFoundingLimit()` に集約し、設定値から調整できるようにした。 + +最大280×280では、同じ期間あたりの理論上の自然都市創設流量は旧仕様の最大16倍になる。実際の都市数は候補地、人口、都市消滅、戦争などで変動する。 + + +## v10: 都市人口・都市アイコン・民族動態の調整 + +- 再生成ダイアログから不要になった初期人口固定の案内を削除。 +- 新規都市の初期人口を自然生成・ユーザー建設ともに400へ統一。 +- 都市は人口200未満で消滅する。200ちょうどは存続可能。 +- 都市アイコンを人口帯で4段階に固定。 + - 200–999: 最小 + - 1000–3999: 中 + - 4000–5999: 大 + - 6000以上: 最大 +- 民族の新規分化速度を従来の1/4へ低下。分化判定間隔を4倍にし、分化条件そのものは維持する。 +- 総人口30未満の極小民族は年次処理で周辺民族へ吸収する。 + - 同一地点・隣接地点・同一都市で接触している民族を優先する。 + - 周辺に候補がない場合は親系統、さらに最寄りの存続民族へ統合する。 + - タイル人口、都市民族構成、遊牧集団の所属を移し替えるため、吸収によって総人口は減らない。 +- 旧仕様の「都市人口を populationScale に合わせて10倍化する」ことだけを確認していた回帰試験を削除し、現行仕様を直接検査する試験へ置換。 + +## v11: 都市表示・制度改革クールダウン・国庫都市建設 + +- 都市アイコンの人口帯を変更。 + - 200–999: 最小 + - 1000–4999: 中 + - 5000–9999: 大 + - 10000以上: 最大 +- 制度改革の再発クールダウンを50年へ変更。制度老朽化30%超という発生開始条件は維持する。 +- 国庫が人口比例上限に達した国家は、年次14%の確率で都市建設を試みる。 + - 建設成功時は、その時点の国庫上限の35%を消費する。 + - 建設候補は自国領土内の陸地に限定し、自然都市と同じ最小都市間隔を維持する。 + - 候補探索は全世界走査ではなく既存都市の周辺領土に限定し、国家数増加時の追加負荷を抑える。 + - 新都市は人口400で成立し、周辺の民族構成を継承する。 +- ヘルプの国庫・制度老朽化説明を新仕様へ同期。 + +## 主な設定値 + +`config.js` で変更可能。 + +- `SimConfig.world.initialPopulation`: 50000 +- `SimConfig.world.defaultSize`: 160 +- `SimConfig.city.maxCities`: 1200 +- `SimConfig.city.initialPopulation`: 400 +- `SimConfig.city.abandonmentPopulation`: 200 +- `SimConfig.city.naturalFoundingMinDistance`: 6 +- `SimConfig.city.naturalFoundingStartYears`: 160 +- `SimConfig.city.naturalFoundingIntervalYears`: 5 +- `SimConfig.city.naturalFoundingReferenceArea`: 25600 +- `SimConfig.city.naturalFoundingMaxPerCycle`: 8 +- `SimConfig.city.foundingCompetitionRadius`: 9 +- `SimConfig.culture.divergenceRateMultiplier`: 0.25 +- `SimConfig.culture.absorptionPopulationThreshold`: 30 +- `SimConfig.rebellion.participationLoyalty`: 0.50 +- `SimConfig.rebellion.automaticLoyalty`: 0.30 +- `SimConfig.rebellion.conquestGraceYears`: 20 +- `SimConfig.rebellion.treasuryTransferRate`: 0.65 +- `SimConfig.route.manualProtectionYears`: 25 +- `SimConfig.route.manualRouteMaxLength`: 120 +- `SimConfig.route.desertTraversalCostFactor`: 0.68 +- `SimConfig.route.desertRouteLengthBonus`: 30 +- `SimConfig.route.desertAdaptiveMaxRouteLength`: 96 +- `SimConfig.war.damageMultiplier`: 0.65 +- `SimConfig.polity.institutionalAgingAnnualIncrease`: 0.0015 +- `SimConfig.polity.institutionalReformThreshold`: 0.30 +- `SimConfig.polity.institutionalReformBaseChance`: 0.008 +- `SimConfig.polity.institutionalReformChanceScale`: 0.10 +- `SimConfig.polity.institutionalReformMaxChance`: 0.12 +- `SimConfig.polity.institutionalReformCooldownYears`: 50 +- `SimConfig.polity.institutionalReformMinChange`: -0.50 +- `SimConfig.polity.institutionalReformMaxChange`: 0.30 +- `SimConfig.polity.treasuryCapPerPopulation`: 0.30 +- `SimConfig.polity.treasuryCityFoundingChance`: 0.14 +- `SimConfig.polity.treasuryCityFoundingCostRatio`: 0.35 +- `SimConfig.polity.treasuryCityFoundingSearchRadius`: 20 + +## 検査結果 + +### 構文検査 + +`config.js`, `app-state.js`, `engine.js`, `render.js`, `boot.js` を `node --check` で確認し、構文エラーなし。 + +### 自動テスト + +実行コマンド: + +```bash +node --test tests/simulation.test.cjs tests/modifications.test.cjs +``` + +結果: **48 / 48 PASS**。 + +追加テストでは以下を個別確認した。 + +- 1週進行・初期人口スライダーがUIから削除されていること。 +- 再生成ダイアログと3種の介入ボタンが存在すること。 +- 水域肥沃度が全て0であること。 +- 忠誠度30%未満による自動反乱と50%未満都市の同時参加。 +- 手動都市建設。 +- 手動交易路の保護期間と保護終了。 +- 戦争人口損害が約65%へ軽減されていること。 +- 手動災害が履歴へ記録されること。 +- 砂漠横断交易路の距離上限・通行コスト・建設コストが緩和されること。 +- ユーザー建設都市に必ず民族構成が設定されること。 +- 征服都市が20年間反乱から保護され、期間終了後に通常判定へ戻ること。 +- 他都市で反乱が発生しても、征服保護中の都市が参加しないこと。 +- 制度老朽化が年次で増加し、同じ割合だけ忠誠度上限が低下すること。 +- 制度改革が老朽化度を -50%~+30% の範囲で相対変化させ、国家履歴イベントへ記録すること。 +- 国家ツールチップに制度老朽化・忠誠度上限を含む国家データが存在すること。 +- 国家興亡グラフに制度改革イベント種別と説明が存在すること。 +- 制度改革が30%ちょうどでは発生せず、30%超でのみ候補になること。 +- 都市の民族構成割合が「都市データ」内かつ「国家データ」より前に表示されること。 +- 旧ブラウザ併合試験をNode回帰試験へ統合し、平和併合/武力併合/他国所属都市拒否を確認すること。 +- ヘルプに主要パラメータの簡単な説明が存在すること。 +- 国庫上限が国家人口に比例し、人口減少時も上限へ収まること。 +- 反乱人口比に応じて国庫の一部が反乱国へ移転すること。 +- 国家ツールチップの国庫表示が1回だけであること。 +- 国家興亡グラフの出来事アイコンを表示 / 非表示に切り替えられること。 +- 自然生成都市の最小間隔が縮小されていること。 +- ユーザー建設都市が都市間隔・自動都市数上限を無視でき、水域では拒否されること。 +- 疎フェロモン更新が減衰・伝播を維持し、不要な全世界走査を行わないこと。 +- 自然都市の創設周期が5年となり、世界面積に応じて1回あたりの創設可能数が増えること。 +- 新規都市が人口400で成立し、199では消滅、200では存続すること。 +- 都市アイコンが指定された4つの人口帯で切り替わること。 +- 民族分化の時間スケールが1/4になり、総人口30未満の民族が隣接民族へ人口を失わず統合されること。 +- 再生成ダイアログに旧「初期人口は標準値で固定」案内が残っていないこと。 +- 都市アイコンが1000/5000/10000の新しい境界で切り替わること。 +- 制度改革が前回改革から50年間再発せず、50年経過後に再び候補になること。 +- 国庫が上限未満では都市を建設せず、上限到達時は確率判定後に上限の35%を都市建設へ投資すること。 +- 国家都市建設の投資額が大きいほど、初期人口と初期備蓄が単調に増えること。 +- 国家都市建設の候補地が広い適地群から加重抽選され、特定地点へ固定されないこと。 +- 都市老朽化のパラメータ・収容力ペナルティ・経年摩耗処理が残っていないこと。 +- 人口が過去ピークから落ちた都市ほど、食料余力がある場合に回復成長率が上がること。 + +### 長期スモークテスト + +280×280 / 初期人口50,000で180年進行し、例外なし。終了時は都市16、最小都市人口442、民族9、総人口30未満の存続民族0を確認した。都市創設は160年以降に始まるため、この試験は主に新しい消滅閾値と民族吸収処理の長期整合性確認を目的とする。 + +### 簡易性能計測 + +同一実行環境・固定乱数シードでの参考値。ブラウザ・CPUにより変動する。 + +| 計測 | v6 | v7 | 変化 | +| --- | ---: | ---: | ---: | +| 160×160 / 初期人口50,000 / 240週(通常状態) | 約 1.93 秒 | 約 0.19 秒 | 約 90%短縮 | +| 160×160 / 240週(交易フェロモン120地点を事前投入) | 約 1.91 秒 | 約 0.51 秒 | 約 73%短縮 | +| 160×160 初期化 | 約 0.66 秒 | 約 0.65 秒 | ほぼ同等 | + +今回の追加軽量化では、`updateWorldFields()` を毎週の全タイル走査から月次・疎更新へ変更した。交易フェロモンは値が存在するタイルと隣接域のみ更新し、資源再生は年間総量を維持したまま四半期単位へ集約した。人口収容力は毎回の全再計算を避け、必要な月次変化時と年次処理に限定した。民族統計も履歴記録・年次処理に必要な時点だけ更新する。 + +現状の残存コストは主に `updateCities()` と `updatePopulationCapacity()`、四半期の `regenerateResources()`。これ以上の更新頻度低下は都市成立・人口圧力・資源変動の時間解像度を変えるため、今回は行っていない。 + +### v11 追加スモーク / 性能確認 + +- 160×160 / 初期人口50,000で220年進行し、例外なし。終了時の都市人口に非有限値・負値なし。 +- 160×160 / 240週の参考計測は初期化約0.68秒、進行約0.19秒で、v7以降の軽量化水準を維持。 +- 国庫都市建設の候補探索は国庫上限到達・確率判定通過時のみ行い、通常年次では追加の全マップ走査を行わない。 + + +## v12 追加修正(都市建設分散・投資規模・長期人口) + +- 国庫都市建設の投資額を初期状態へ反映。 + - 初期人口は標準400人を下限に、投資額に比例して増加する。 + - 初期備蓄も投資額と増加した初期人口に比例して増加する。 + - 現在の係数は `treasuryCityPopulationPerCost = 0.18`、`treasuryCityReservePerCost = 0.45`。 +- 都市建設候補の偏りを緩和。 + - 自然都市は最上位付近の適地をランダム化して順位付けする。 + - 国家による国庫都市建設は、最高点から6.0点以内の適地を固定件数で切らず、候補全体から適性加重抽選する。 + - 適地評価そのもの(肥沃度、資源、人口圧力、農地、統制、交易、移動コスト)は維持する。 +- **都市老朽化を削除**。 + - `ageWear`、経年摩耗更新、老朽化による収容力低下、関連設定値を削除した。 + - 国家の「制度老朽化」は別概念なので維持する。 +- 長期都市人口の一方向減少を抑制。 + - 災害・戦争などで過去ピーク人口から落ちた都市に回復成長を付与する。 + - 回復中のみ必要備蓄・供給ストレス条件を緩めるが、人口は都市収容力を超えて増えない。 + - `postShockRecoveryGrowth = 0.045`、過剰収容時の減衰率は `overCapacityAttrition = 0.020`。 + +### v12 長期確認 + +固定乱数・32×32・初期人口8,000で500年進行した参考値: + +- 200年: 7都市 / 都市人口3,306 +- 300年: 23都市 / 都市人口14,478 +- 400年: 23都市 / 都市人口13,646 +- 500年: 28都市 / 都市人口18,545 + +短期的な減少は災害・戦争により残るが、都市老朽化による恒久的な下押しはなく、被害後に再成長できることを確認した。 + +## v13 追加修正(占領人口・占領確率・領域外人口) + +### 実装意図 + +- 都市占領の影響を都市人口だけに限定せず、周辺の地域人口にも反映する。 +- 戦争で都市が短期間に連続占領されすぎるのを抑えるため、都市占領判定だけを半減する。 +- 国家領域外の人口が恒久的な人口供給源として増え続けないよう、国家成立後の未所属セルに人口減少圧力を与える。 + +### 変更内容 + +- `SimConfig.war.captureChanceMultiplier = 0.50` を追加。 + - `applyWarPressure()` の既存都市占領確率へ0.5倍を適用。 + - 戦争開始率・戦争損害率・戦争終結条件は変更していない。 +- 征服系都市移転に `applyOccupationPopulationShock()` を追加。 + - 対象: `warCapture` / `borderWar` / `militaryAnnexation` / `settlement` / `nomadConquest`。 + - 都市中心セルは都市人口損害との二重計上を避け、周辺半径4セルの地域人口だけを距離減衰付きで減少させる。 + - 基礎損失率は0.08で、既存の戦争損害倍率0.65も適用する。 + - 通常の戦場タイル損害とは責務を分離した。 +- `SimConfig.population.unclaimedTerritoryGrowthPenalty = 0.03` を追加。 + - 国家が一つ以上成立した後、`territoryOwner < 0` のセルに年次人口成長ペナルティを適用。 + - 現在の地域人口の最大基礎成長率を上回るため、未所属セルは長期的に減少する。 + - 国家領域内セルにはこのペナルティを適用しない。 + +### 回帰テスト + +`tests/modifications.test.cjs` に以下を追加。 + +- 占領時に都市中心とは別に周辺地域人口が減少すること。 +- 旧占領確率なら成立する乱数値でも、0.5倍後は失敗し、より低い乱数値では成立すること。 +- 国家成立後は未所属セル人口が減少し、領域内セルには同じペナルティを掛けないこと。 + +全回帰テスト: **51/51成功**。 + +### 長期・性能確認 + +- 32×32 / 初期人口8,000 / 500年スモークテスト: 例外なし。 + - 500年時点の地域人口 約83,465。 + - 国家領域内 約82,896、未所属 約569。 + - 未所属人口だけが縮小し、国家領域内人口まで一律に減らさないことを確認。 +- 160×160 / 初期人口50,000: + - 初期化 約0.666秒。 + - 240週進行 約0.205秒。 + - 今回の変更による目立った性能劣化なし。 + +新規ソースファイルは作成していない。既存の `config.js` / `engine.js` / `tests/modifications.test.cjs` と本記録のみ更新した。 + +## v14 追加修正(領域離脱人口・災害地域損害・処理整理) + +### 仕様変更 +- 国家領域外セルへの恒常的な人口減少を廃止。 +- セルが **国家所属(owner >= 0)から未所属(owner < 0)へ変わった瞬間だけ**、地域人口を12%減少させる。 + - `SimConfig.population.territoryExitPopulationLossRate = 0.12` + - 未所属のまま時間が経過しても、この理由による追加減少は発生しない。 + - 領土再計算中の一時的な所有状態変更では二重に損害を出さず、再計算前後で実際に「所属→未所属」になった人口セルだけを対象とする。 +- 災害が地域セル人口にも直接損害を与えるよう変更。 + - 地域セル人口損失率: `damage * 0.42`、上限65%。 + - 都市人口の基準 `damage * 0.25` より大きく設定。 + - 災害履歴に `regionalPopulationLoss` を記録する。 + +### 整理・軽量化 +- `unclaimedTerritoryGrowthPenalty` と、未所属状態そのものに毎年人口減少を掛ける処理を削除。 +- 資源再生の全マップ走査を四半期4回から年1回へ集約。 + - 年間再生量48週分は維持し、次回年次経済処理の直前にまとめて反映する。 +- `updateCities()` 冒頭の重複した `updatePopulationCapacity()` を削除。 + - 農地・都市圏をゼロクリアした直後に全マップ再計算していたため、負荷が大きく、直前まで有効だった都市・農地ボーナスも一時的に捨てていた。 + - 新しい農地・都市圏を再構築した後の1回だけ再計算する。 +- 単なる委譲だけだった `updateTradeRoutesFieldBased()` を削除し、`updateTradeRoutes(true)` を直接呼ぶ。 +- `updatePolities()` 内の「常に存在するメソッドに対する存在確認」を削除し、責務と呼出関係を明確化。 +- 静的参照確認では、現行 `engine.js` に定義のみで参照されないメソッドは残っていない。 + +### 検査 +- Node回帰テスト: **53/53 成功**。 +- 追加確認: + - 所属→未所属の瞬間だけ12%人口減少。 + - 未所属のままでは恒常減少せず、通常条件なら人口成長可能。 + - 災害中心セルで地域人口42%損失(都市基準25%より大きい)。 + - 32×32・500年スモークテスト完走。人口・都市・国家・災害履歴に非有限値や例外なし。 +- 160×160・240週、同一ベンチマークを各5回実行した中央値: + - v13: **178.5 ms** + - v14: **93.6 ms** + - 約47%短縮。 + +### 旧仕様の扱い +v13で導入した「国家成立後、未所属セルへ毎年人口減少圧力を掛ける」仕様と `unclaimedTerritoryGrowthPenalty` は、v14で完全に廃止した。v13節の記録は変更履歴としてのみ残す。 + +## v15 追加修正(人口希薄セル・年次負荷分散・ヘルプ更新) + +### 仕様変更 +- `SimConfig.population.humanDataMinPopulation = 1.0` を追加。 +- 地域人口が1未満のセルでは、人口に付随する人文データを常にゼロ扱いにする。 + - 農業知識: 0 + - 冶金知識: 0 + - 文化多様性: 0 + - 主民族: なし(内部値 -1) + - タイル民族構成も削除する。 +- 国家領域・支配・交易路・交通量・農地など、人口希薄でも成立し得る政治・インフラ情報はゼロ化対象にしない。 + +### 年次更新の軽量化 +- 年境界に集中していた周期処理を、周期自体は維持したまま年内の別週へ分散。 + - 地域人口移動: 四半期4コホートに分割し、各セルは年1回処理。 + - 国家更新: 年境界から1か月後へ移動。 + - 技術更新: 2年周期のまま年内へオフセット。 + - 文化同化: 3年周期のまま年内へオフセット。 + - 文化伝播: 年1回のまま年後半へ移動。 + - 交易路更新: 5年周期のまま年内へオフセット。 + - 民族吸収・分化: 年1回のまま年境界から分離。 +- フェロモン変化のたびに全マップの人口収容力を再計算する処理を廃止し、四半期更新へ整理。 +- 国家所属都市一覧を更新単位でキャッシュし、同一更新内での反復構築を削減。 +- 国庫都市候補の選定で全候補ソートを廃止し、最高得点探索+許容帯の加重抽選へ変更。 +- `updatePolities()` 内に残っていた不要な `syncTilePopulationCulture()` を削除。 +- 未参照だった `emptyTraitSums()` / `averageTraits()` / `updatePopulationCapacityTiles()` を削除。 + +### ヘルプ更新 +- 民族説明から「分化は抑制され、」を削除。 +- 国庫説明を「国庫で戦争や都市建設を行います。反乱時はいくらかが奪取されます。」へ変更。 +- 制度老朽化説明を「老朽化が進むと制度改革が起こり、制度が新陳代謝されることもありますが、もしかしたら失敗するかもしれません。」へ変更。 + +### 検査・性能 +- Node回帰テスト: **56/56成功**。 +- 固定シード96×96、180年まで進行後の20年間を計測: + - v14 年境界平均: 約41.1 ms / 最大: 約62.6 ms + - v15 年境界平均: 約14.4 ms / 最大: 約26.2 ms + - 年境界平均は約65%短縮。 +- 本体JavaScriptの静的参照確認で、定義のみで未参照のメソッドが残っていないことを確認。 diff --git a/README.md b/README.md index e69de29..148f52d 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,11 @@ +# 文明シミュレータ + +`index.html` をブラウザで開いて実行する文明シミュレータです。 + +今回の改修内容、設定値、検査結果は `MODIFICATION_NOTES.md` を参照してください。 + +テスト: + +```bash +node --test tests/simulation.test.cjs tests/modifications.test.cjs +``` diff --git a/app-state.js b/app-state.js new file mode 100644 index 0000000..5000340 --- /dev/null +++ b/app-state.js @@ -0,0 +1,56 @@ +const elementIds = [ +"world", "toggleRun", "resetWorld", +"speed", "worldSize", "viewMode", "year", "activeGroups", "urbanPopulation", +"ethnicities", "cities", "polities", "wars", "routes", "farmingKnowledge", "metallurgyKnowledge", +"deaths", "frameCost", "ethnicityList", "fpsCounter", "legend", "tooltip", "worldTrendGraph", "stateGraph", "historyRange", +"currentDate", "historyFilter", "historySort", "historyMetric", "toggleActiveStates", "togglePastStates", "toggleEventIcons", +"stateGraphInfo", "stateGraphTooltip", "eventLegend", +"regenerateDialog", "confirmRegenerate", "cancelRegenerate", +"triggerDisaster", "buildCity", "connectCities", "interventionStatus" +]; +const els = Object.fromEntries(elementIds.map(id => [id === "world" ? "canvas" : id, document.getElementById(id)])); +els.sim = document.querySelector(".sim"); +const ctx = els.canvas.getContext("2d", { alpha: false }); +let sim; +let running = true; +let lastStatsAt = 0; +let lastGraphRenderAt = 0; +let lastRenderAt = 0; +let lastSimTickAt = 0; +let renderDirty = true; +let loopTimer = null; +let loopScheduled = false; +let fpsWindowStartedAt = 0; +let fpsFrames = 0; +let hoverState = null; +let renderImage = null; +let renderImageSize = 0; +const LoopConfig = Object.freeze({ +simTickMs: 33, +renderMs: 66, +stepBudgetMs: 8, +idlePollMs: 180, +hiddenPollMs: 1000 +}); +const graphState = { +filter: "all", +sort: "oldest", +metric: "power", +showActive: true, +showPast: true, +showEventIcons: true, +hoverPolityId: null, +rows: [], +markers: [], +scale: null +}; +const legendByMode = { +terrain: () => terrainInfo.map(t => `${t.name}`).join(""), +ethnicity: () => "民族の色・濃さは人口と優勢度", +pressure: () => "赤いほど人口圧力が高い", +polities: () => "", +nomads: () => "遊牧集団", +technology: () => "緑:農業 / 黄土色:冶金", +pheromone: () => "明るい黄色ほど交通量が多い", +resources: () => "明るい緑ほど資源が豊富" +}; diff --git a/boot.js b/boot.js new file mode 100644 index 0000000..9408b8a --- /dev/null +++ b/boot.js @@ -0,0 +1,228 @@ +function requestRender() { +renderDirty = true; +} +function drawFrame(forceStats = false) { +render(); +updateStats(forceStats); +renderTooltip(); +renderDirty = false; +lastRenderAt = performance.now(); +if (!fpsWindowStartedAt) fpsWindowStartedAt = lastRenderAt; +fpsFrames++; +const fpsElapsed = lastRenderAt - fpsWindowStartedAt; +if (fpsElapsed >= 500) { +if (els.fpsCounter) els.fpsCounter.textContent = `FPS ${Math.round(fpsFrames * 1000 / fpsElapsed)}`; +fpsWindowStartedAt = lastRenderAt; +fpsFrames = 0; +} +} +function scheduleLoop() { +if (loopScheduled) return; +loopScheduled = true; +const delay = document.hidden +? LoopConfig.hiddenPollMs +: running ? 0 : LoopConfig.idlePollMs; +if (delay > 0) { +loopTimer = setTimeout(() => { +loopTimer = null; +requestAnimationFrame(loop); +}, delay); +} else { +requestAnimationFrame(loop); +} +} +function loop(now = performance.now()) { +loopScheduled = false; +if (running && !document.hidden && now - lastSimTickAt >= LoopConfig.simTickMs) { +lastSimTickAt = now; +const steps = Number(els.speed.value); +const stepBudget = LoopConfig.stepBudgetMs; +const loopStart = performance.now(); +let completedSteps = 0; +for (let i = 0; i < steps; i++) { +sim.step(); +completedSteps++; +if (completedSteps > 0 && performance.now() - loopStart > stepBudget) break; +} +if (completedSteps > 0) requestRender(); +} +const shouldRender = !document.hidden && renderDirty && +(!running || now - lastRenderAt >= LoopConfig.renderMs); +if (shouldRender) drawFrame(); +scheduleLoop(); +} + +let interventionMode = null; +let manualRouteStartCityId = null; + +function setInterventionStatus(text) { +if (els.interventionStatus) els.interventionStatus.textContent = text; +} +function setInterventionMode(mode) { +interventionMode = interventionMode === mode ? null : mode; +manualRouteStartCityId = null; +const buttons = [ +[els.triggerDisaster, "disaster"], +[els.buildCity, "city"], +[els.connectCities, "route"] +]; +for (const [button, buttonMode] of buttons) button?.setAttribute("aria-pressed", String(interventionMode === buttonMode)); +if (interventionMode === "disaster") setInterventionStatus("災害を発生させる地点を地図上でクリックしてください。"); +else if (interventionMode === "city") setInterventionStatus("都市を建設する地点を地図上でクリックしてください。"); +else if (interventionMode === "route") setInterventionStatus("接続する1つ目の都市をクリックしてください。"); +else setInterventionStatus("ボタンを選び、地図上をクリックして実行します。"); +} +function eventToWorldCoordinates(event) { +const rect = els.canvas.getBoundingClientRect(); +return { +x: Math.floor((event.clientX - rect.left) / rect.width * sim.world.size), +y: Math.floor((event.clientY - rect.top) / rect.height * sim.world.size) +}; +} +function handleMapIntervention(event) { +if (!interventionMode || !sim) return; +const { x, y } = eventToWorldCoordinates(event); +if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) return; +if (interventionMode === "disaster") { +const result = sim.triggerManualDisasterAt(x, y); +setInterventionStatus(`災害を発生させました(中心 ${result.x}, ${result.y}/半径 ${Math.round(result.radius)})。続けて地点をクリックできます。`); +requestRender(); +drawFrame(true); +renderStateGraph(); +return; +} +if (interventionMode === "city") { +const result = sim.buildCityAt(x, y); +if (!result.ok) { +setInterventionStatus(result.reason === "water" +? "水域には都市を建設できません。陸地をクリックしてください。" +: "この地点には都市を建設できません。"); +return; +} +setInterventionStatus(`都市${result.city.id}を建設しました(${result.city.x}, ${result.city.y})。続けて建設地点をクリックできます。`); +requestRender(); +drawFrame(true); +renderStateGraph(); +return; +} +if (interventionMode === "route") { +const city = cityAtDisplayTile(x, y); +if (!city) { +setInterventionStatus("都市をクリックしてください。都市アイコンの範囲内で選択できます。"); +return; +} +if (manualRouteStartCityId == null) { +manualRouteStartCityId = city.id; +setInterventionStatus(`都市${city.id}を選択しました。接続する2つ目の都市をクリックしてください。`); +return; +} +if (manualRouteStartCityId === city.id) { +setInterventionStatus("同じ都市は接続できません。別の都市をクリックしてください。"); +return; +} +const startId = manualRouteStartCityId; +const result = sim.createManualTradeRoute(startId, city.id); +manualRouteStartCityId = null; +if (!result.ok) { +const message = result.reason === "tooFar" ? `都市間が遠すぎます(上限 ${result.maxLength} タイル)。` +: result.reason === "noPath" ? "陸路で接続できる経路が見つかりませんでした。" +: "交通接続を作成できませんでした。"; +setInterventionStatus(`${message} 1つ目の都市から選び直してください。`); +return; +} +setInterventionStatus(`都市${startId}と都市${city.id}を接続しました。続けて1つ目の都市をクリックできます。`); +requestRender(); +drawFrame(true); +} +} + +function reset(size = SimConfig.world?.defaultSize ?? 160) { +const initialPopulation = SimConfig.world?.initialPopulation ?? 50000; +fpsWindowStartedAt = 0; +fpsFrames = 0; +if (els.fpsCounter) els.fpsCounter.textContent = "FPS --"; +els.canvas.width = size; +els.canvas.height = size; +renderImage = null; +renderImageSize = 0; +sim = new Simulation(size, initialPopulation); +if (els.worldSize) els.worldSize.value = String(size); +interventionMode = null; +manualRouteStartCityId = null; +for (const button of [els.triggerDisaster, els.buildCity, els.connectCities]) button?.setAttribute("aria-pressed", "false"); +setInterventionStatus("ボタンを選び、地図上をクリックして実行します。"); +setLegend(); +drawFrame(true); +renderStateGraph(); +} +function openRegenerateDialog() { +if (els.worldSize && sim) els.worldSize.value = String(sim.world.size); +if (els.regenerateDialog?.showModal) els.regenerateDialog.showModal(); +} +function refreshStateGraph() { +renderStateGraph(); +clearStateGraphInfo(); +} +els.toggleRun.addEventListener("click", () => { +running = !running; +els.toggleRun.textContent = running ? "一時停止" : "再開"; +lastSimTickAt = 0; +if (running && loopTimer) { +clearTimeout(loopTimer); +loopTimer = null; +loopScheduled = false; +scheduleLoop(); +} else if (running) { +scheduleLoop(); +} +}); +els.resetWorld.addEventListener("click", openRegenerateDialog); +els.confirmRegenerate?.addEventListener("click", () => { +const size = Number(els.worldSize?.value || SimConfig.world?.defaultSize || 160); +els.regenerateDialog?.close(); +reset(size); +}); +els.cancelRegenerate?.addEventListener("click", () => els.regenerateDialog?.close()); +els.triggerDisaster?.addEventListener("click", () => setInterventionMode("disaster")); +els.buildCity?.addEventListener("click", () => setInterventionMode("city")); +els.connectCities?.addEventListener("click", () => setInterventionMode("route")); +els.viewMode.addEventListener("change", () => { +setLegend(); +requestRender(); +drawFrame(); +}); +els.canvas.addEventListener("mousemove", showTooltip); +els.canvas.addEventListener("mouseleave", hideTooltip); +els.canvas.addEventListener("click", handleMapIntervention); +for (const control of [els.historyFilter, els.historySort, els.historyMetric]) { +control?.addEventListener("change", refreshStateGraph); +} +els.toggleActiveStates?.addEventListener("click", () => { +graphState.showActive = !graphState.showActive; +els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive)); +refreshStateGraph(); +}); +els.togglePastStates?.addEventListener("click", () => { +graphState.showPast = !graphState.showPast; +els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast)); +refreshStateGraph(); +}); +els.toggleEventIcons?.addEventListener("click", () => { +graphState.showEventIcons = !graphState.showEventIcons; +els.toggleEventIcons.setAttribute("aria-pressed", String(graphState.showEventIcons)); +refreshStateGraph(); +}); +els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo); +els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo); +document.addEventListener("visibilitychange", () => { +lastSimTickAt = 0; +requestRender(); +if (!document.hidden && loopTimer) { +clearTimeout(loopTimer); +loopTimer = null; +loopScheduled = false; +scheduleLoop(); +} +}); +reset(SimConfig.world?.defaultSize ?? 160); +loop(); diff --git a/config.js b/config.js new file mode 100644 index 0000000..93709bb --- /dev/null +++ b/config.js @@ -0,0 +1,254 @@ +const Terrain = Object.freeze({ +PLAINS: 0, +FOREST: 1, +MOUNTAIN: 2, +WATER: 3, +DESERT: 4 +}); +const terrainInfo = [ +{ name: "平原", color: [82, 102, 74], move: 1.0, fertility: 0.55, mineral: 0.05, regen: 0.025 }, +{ name: "森林", color: [58, 91, 68], move: 1.3, fertility: 0.62, mineral: 0.08, regen: 0.038 }, +{ name: "山地", color: [104, 105, 101], move: 2.6, fertility: 0.18, mineral: 0.78, regen: 0.012 }, +{ name: "水域", color: [52, 78, 103], move: 7.0, fertility: 0, mineral: 0.02, regen: 0.004 }, +{ name: "砂漠", color: [139, 123, 86], move: 1.55, fertility: 0.16, mineral: 0.16, regen: 0.014 } +]; +const WEEKS_PER_MONTH = 4; +const MONTHS_PER_YEAR = 12; +const WEEKS_PER_YEAR = MONTHS_PER_YEAR * WEEKS_PER_MONTH; +const SimConfig = Object.freeze({ +world: Object.freeze({ +initialPopulation: 50000, +defaultSize: 160 +}), +population: Object.freeze({ +populationScale: 10, +carryingCapacityBase: 2.4, +carryingCapacityFertility: 7.8, +carryingCapacityMineral: 1.8, +carryingCapacityFarmland: 5.5, +territoryExitPopulationLossRate: 0.12, +humanDataMinPopulation: 1.0 +}), +city: Object.freeze({ +maxCities: 1200, +initialPopulation: 400, +abandonmentPopulation: 200, +populationScale: 10, +foodProductionScale: 10, +postShockRecoveryGrowth: 0.045, +overCapacityAttrition: 0.020, +naturalFoundingMinDistance: 6, +naturalFoundingStartYears: 160, +naturalFoundingIntervalYears: 5, +naturalFoundingReferenceArea: 160 * 160, +naturalFoundingMaxPerCycle: 8, +foundingCompetitionRadius: 9, +}), +render: Object.freeze({ +graphThrottleMs: 1400, +statsThrottleMs: 900, +historyWindowYears: 2000, +historySamplePaddingYears: 240 +}), +technology: Object.freeze({ +tradeDiffusion: 0.0032, +cityInnovation: 0.00045 +}), +polity: Object.freeze({ +minimumPerCapitaFood: 0.06, +logisticsDistance: 34, +charismaMin: 0.5, +charismaMax: 1.5, +charismaAverage: 1.0, +leaderTenureMinYears: 22, +leaderTenureMaxYears: 72, +foundingProtectionYears: 12, +warCapitalDistanceStart: 18, +warCapitalDistanceScale: 42, +warDisconnectedTerritoryFactor: 0.42, +institutionalAgingAnnualIncrease: 0.0015, +institutionalReformThreshold: 0.30, +institutionalReformBaseChance: 0.008, +institutionalReformChanceScale: 0.10, +institutionalReformMaxChance: 0.12, +institutionalReformCooldownYears: 50, +institutionalReformMinChange: -0.50, +institutionalReformMaxChange: 0.30, +treasuryCapPerPopulation: 0.30, +treasuryCityFoundingChance: 0.14, +treasuryCityFoundingCostRatio: 0.35, +treasuryCityPopulationPerCost: 0.18, +treasuryCityReservePerCost: 0.45, +treasuryCityFoundingSearchRadius: 20, +treasuryCitySiteScoreWindow: 6.0 +}), +territory: Object.freeze({ +baseCityReach: 9, +radiusScale: 0.5, +cityPowerWeight: 1.8, +capitalReachBonus: 1.5, +ownershipSwitchMargin: 1.5, +minimumInfluence: 2.4, +fullMaintenanceCityPopulation: 2400, +minimumPopulationSupport: 0.28, +maintenancePressure: 4, +supportSmoothing: 0.18, +smallStateSwitchPenalty: 2.5 +}), +rebellion: Object.freeze({ +participationLoyalty: 0.50, +automaticLoyalty: 0.30, +chanceScale: 0.20, +maxAnnualChance: 0.16, +conquestGraceYears: 20, +treasuryTransferRate: 0.65 +}), +campaign: Object.freeze({ +checkIntervalYears: 3, +baseChance: 0.18, +minCooldownYears: 12, +maxCooldownYears: 45, +maxActiveCampaigns: 12, +frontierTargetChance: 0.35, +nonStateTargetChance: 0.30, +independentCityTargetChance: 0.20, +polityTargetChance: 0, +independentCityPeacefulShare: 0.4, +baseDurationYears: 8, +maxDurationYears: 25, +claimRadius: 8, +maxTargetDistance: 48 +}), +nomad: Object.freeze({ +enabled: true, +maxBands: 24, +enabledAfterYears: 120, +spawnCheckIntervalYears: 25, +baseSpawnChance: 0.28, +minPopulation: 80, +maxPopulation: 420, +minRadius: 5, +maxRadius: 14, +movementIntervalYears: 2, +maxMoveDistance: 9, +grazingGain: 0.035, +overgrazingDamage: 0.018, +resourceConsumption: 0.012, +herdSoftCapPerPop: 2.4, +herdHardCapPerPop: 4.0, +resourceSoftCapPerPop: 1.2, +resourceHardCapPerPop: 2.5, +grazingDiminishingPower: 1.35, +urbanAbsorptionControl: 0.55, +cityInteractionRadius: 8, +tradeChance: 0.22, +raidChance: 0.14, +invasionChance: 0.025, +confederationPopulation: 900, +confederationPrestige: 1.8, +splitPopulation: 1200, +sedentarizationChance: 0.035, +collapseCohesion: 0.12, +influenceCulturalTrace: 0.006, +influenceMobilePopulationTrace: 0.004, +visualDurationYears: 20 +}), +polityAccess: Object.freeze({ +enabled: true, +maxSearchDepth: 8, +indirectPenalty: 0.012, +perHopPenalty: 0.006, +noAccessPenalty: 0.045, +lowLoyaltyTransitPenalty: 0.012, +foreignTransitPenalty: 0.008 +}), +culture: Object.freeze({ +minimumInfluence: 0.18, +divergenceRateMultiplier: 0.25, +absorptionPopulationThreshold: 30, +urbanSpreadRadius: 5, +cityBlendRate: 0.045, +routeBlendRate: 0.035, +routeNeighborFactor: 0.35, +cityTradeBlendRate: 0.015 +}), +route: Object.freeze({ +maxRouteLength: 42, +adaptiveMaxRouteLength: 72, +isolatedCityBootstrap: true, +isolatedRouteSearchLimit: 8, +isolatedRouteRadius: 4, +newRoutePheromoneSeed: 2.5, +newRouteGraceBoost: 18, +isolatedRouteMinStrength: 0.12, +pheromoneDecay: 0.996, +pheromoneDiffusion: 0.045, +maxPheromone: 18, +routePheromoneBuild: 0.18, +routePheromoneMaintain: 0.12, +routeUpkeepPerTile: 0.018, +routeWeakThreshold: 8, +routeUnsupportedDecay: 3, +routeMaintainedBoost: 10, +manualProtectionYears: 25, +manualProtectedStrength: 32, +manualRouteMaxLength: 120, +desertTraversalCostFactor: 0.68, +desertRouteLengthBonus: 30, +desertAdaptiveMaxRouteLength: 96, +desertPheromoneThresholdFactor: 0.45, +desertConstructionCostFactor: 0.72 +}), +war: Object.freeze({ +damageMultiplier: 0.65, +tickWeeks: 4, +startChanceMultiplier: 3, +maxActiveWars: 12, +maxStartsPerTick: 4, +minDurationWeeks: 12, +idleEndWeeks: 24, +cooldownWeeks: 24, +decisiveScore: 6, +pressureMemoryDecay: 0.88, +captureChanceMultiplier: 0.50, +occupationPopulationLossRadius: 4, +occupationPopulationLossRate: 0.08, +cityDamageMinRate: 0.025, +cityDamageMaxRate: 0.30, +captureLossBase: 0.22, +captureLossMax: 0.40, +exhaustionPopulationLossBase: 0.003, +exhaustionPopulationLossMax: 0.03, +warZoneTileLossRate: 0.025, +majorSettlementThreshold: 1.25 +}), +disaster: Object.freeze({ +damageScale: 1.5, +majorLossRate: 0.05, +regionalPopulationLossRate: 0.42, +regionalPopulationLossMax: 0.65, +checkIntervalYears: 8, +baseChance: 0.76, +minRadius: 6, +maxRadius: 34, +rareLargeChance: 0.10, +largeRadiusMin: 28, +largeRadiusMax: 54, +minIntensity: 0.25, +maxIntensity: 0.95, +visualDurationYears: 18, +maxActiveVisuals: 12, +maxHistory: 160 +}), +frontierWave: Object.freeze({ +enabledAfterYears: 300, +intervalYears: 80, +chance: 0.45, +minPopulation: 60, +maxPopulation: 140, +edgeBand: 4 +}) +}); +function years(value) { +return value * WEEKS_PER_YEAR; +} diff --git a/engine.js b/engine.js new file mode 100644 index 0000000..25ed039 --- /dev/null +++ b/engine.js @@ -0,0 +1,6732 @@ +class Rng { +constructor(seed) { +this.seed = seed >>> 0; +} +next() { +this.seed = (1664525 * this.seed + 1013904223) >>> 0; +return this.seed / 4294967296; +} +range(min, max) { +return min + (max - min) * this.next(); +} +int(max) { +return Math.floor(this.next() * max); +} +} +class World { +constructor(size, rng) { +this.size = size; +this.count = size * size; +this.rng = rng; +this.terrain = new Uint8Array(this.count); +this.resource = new Float32Array(this.count); +this.regen = new Float32Array(this.count); +this.move = new Float32Array(this.count); +this.fertility = new Float32Array(this.count); +this.mineral = new Float32Array(this.count); +this.temperature = new Float32Array(this.count); +this.humidity = new Float32Array(this.count); +this.pheromone = new Float32Array(this.count); +this.tradeRoute = new Uint8Array(this.count); +this.farmland = new Float32Array(this.count); +this.cityPull = new Float32Array(this.count); +this.city = new Int32Array(this.count); +this.population = new Float32Array(this.count); +this.settledPopulation = new Float32Array(this.count); +this.mobilePopulation = new Float32Array(this.count); +this.populationCapacity = new Float32Array(this.count); +this.populationPressure = new Float32Array(this.count); +this.farmingKnowledge = new Float32Array(this.count); +this.metallurgyKnowledge = new Float32Array(this.count); +this.nomadInfluence = new Float32Array(this.count); +this.nomadBand = new Int32Array(this.count); +this.territoryOwner = new Int32Array(this.count); +this.polity = this.territoryOwner; +this.control = new Float32Array(this.count); +this.claim = new Float32Array(this.count); +this.contested = new Uint8Array(this.count); +this.dominantEthnicity = new Int32Array(this.count); +this.cultureDiversity = new Float32Array(this.count); +this.city.fill(-1); +this.nomadBand.fill(-1); +this.territoryOwner.fill(-1); +this.polity.fill(-1); +this.dominantEthnicity.fill(-1); +this.generate(); +this.buildLandNeighbors(); +} +buildLandNeighbors() { +// Terrain is fixed after generation. Keep the original dy/dx traversal order. +const starts = new Int32Array(this.count + 1); +const tiles = []; +for (let i = 0; i < this.count; i++) { +starts[i] = tiles.length; +if (this.terrain[i] === Terrain.WATER) continue; +const x = i % this.size; +const y = Math.floor(i / this.size); +for (let dy = -1; dy <= 1; dy++) { +for (let dx = -1; dx <= 1; dx++) { +if (!dx && !dy) continue; +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= this.size || ty >= this.size) continue; +const tile = this.idx(tx, ty); +if (this.terrain[tile] !== Terrain.WATER) tiles.push(tile); +} +} +} +starts[this.count] = tiles.length; +this.landNeighborStarts = starts; +this.landNeighbors = Int32Array.from(tiles); +} +idx(x, y) { +return y * this.size + x; +} +generate() { +const s = this.size; +const centers = Array.from({ length: 18 }, () => ({ +x: this.rng.range(0, s), +y: this.rng.range(0, s), +kind: this.rng.next() +})); +const height = new Float32Array(this.count); +const moistureMap = new Float32Array(this.count); +const mineralMap = new Float32Array(this.count); +for (let y = 0; y < s; y++) { +for (let x = 0; x < s; x++) { +const i = this.idx(x, y); +const nx = x / s - 0.5; +const ny = y / s - 0.5; +const latitude = Math.abs(ny) * 0.18; +let elevation = 0.48 - Math.hypot(nx, ny) * 0.62 + this.smoothNoise(x, y, 58) * 0.42 + this.smoothNoise(x + 900, y - 300, 31) * 0.12; +let moisture = this.smoothNoise(x + 500, y - 330, 54) * 0.68 + this.smoothNoise(x, y, 24) * 0.24 - latitude; +let minerals = this.smoothNoise(x - 290, y + 120, 20); +for (const c of centers) { +const d = Math.hypot(x - c.x, y - c.y) / s; +if (c.kind < 0.3) elevation += Math.max(0, 0.24 - d) * 0.85; +if (c.kind > 0.72) moisture += Math.max(0, 0.22 - d) * 1.0; +} +height[i] = elevation; +moistureMap[i] = moisture; +mineralMap[i] = minerals; +} +} +this.smoothField(height, 2); +this.smoothField(moistureMap, 1); +const seaLevel = this.percentile(height, 0.34); +for (let y = 0; y < s; y++) { +for (let x = 0; x < s; x++) { +const i = this.idx(x, y); +const latitude = Math.abs((y / (s - 1)) * 2 - 1); +const altitudeCooling = clamp((height[i] - seaLevel) * 0.25, 0, 0.18); +this.temperature[i] = clamp(1 - latitude * 0.92 - altitudeCooling + this.smoothNoise(x + 1700, y - 80, 70) * 0.08, 0, 1); +this.terrain[i] = height[i] < seaLevel ? Terrain.WATER : Terrain.PLAINS; +} +} +this.computeHumidityFromWater(moistureMap); +for (let i = 0; i < this.count; i++) { +const elevation = height[i]; +const minerals = mineralMap[i]; +const temp = this.temperature[i]; +const humid = this.humidity[i]; +let t = this.terrain[i]; +if (t !== Terrain.WATER) { +if (elevation > seaLevel + 0.42) t = Terrain.MOUNTAIN; +else if (humid < 0.15 && temp > 0.58) t = Terrain.DESERT; +else if (humid > 0.5 && temp > 0.18) t = Terrain.FOREST; +else t = Terrain.PLAINS; +} +const info = terrainInfo[t]; +this.terrain[i] = t; +// Water does not participate in fertility simulation. Keep it explicitly at zero +// so later systems never spend work interpreting a meaningless fertility value. +this.fertility[i] = t === Terrain.WATER +? 0 +: clamp(info.fertility + humid * 0.16 - Math.abs(temp - 0.58) * 0.08 + this.rng.range(-0.03, 0.03), 0, 1); +this.mineral[i] = clamp(info.mineral + minerals * 0.16, 0, 1); +this.regen[i] = info.regen * (0.7 + this.fertility[i]); +this.move[i] = info.move; +this.resource[i] = this.terrain[i] === Terrain.WATER ? 0 : this.rng.range(4, 18) * (0.5 + this.fertility[i] + this.mineral[i] * 0.35); +} +this.smoothTerrainTypes(2); +this.ensureDesertPatches(); +this.enrichWaterMargins(); +} +enrichWaterMargins() { +const s = this.size; +const nextFertility = new Float32Array(this.fertility); +const offsets = []; +for (let dy = -3; dy <= 3; dy++) { +for (let dx = -3; dx <= 3; dx++) { +const d = Math.abs(dx) + Math.abs(dy); +if (!d || d > 3) continue; +offsets.push([dx, dy, (4 - d) / 4]); +} +} +for (let y = 0; y < s; y++) { +for (let x = 0; x < s; x++) { +const i = y * s + x; +if (this.terrain[i] === Terrain.WATER) continue; +let waterScore = 0; +for (const [dx, dy, weight] of offsets) { +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue; +if (this.terrain[ty * s + tx] === Terrain.WATER) waterScore += weight; +} +if (waterScore <= 0) continue; +const boost = Math.min(0.32, waterScore * 0.08); +nextFertility[i] = clamp(this.fertility[i] + boost, 0, 1); +if (this.terrain[i] === Terrain.DESERT && waterScore > 1.2) this.terrain[i] = Terrain.PLAINS; +} +} +for (let i = 0; i < this.count; i++) { +const info = terrainInfo[this.terrain[i]]; +if (this.terrain[i] === Terrain.WATER) { +this.fertility[i] = 0; +this.regen[i] = info.regen * 0.7; +this.resource[i] = 0; +} else { +this.fertility[i] = nextFertility[i]; +this.regen[i] = info.regen * (0.7 + this.fertility[i]); +this.resource[i] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25)); +} +this.move[i] = info.move; +} +} +smoothNoise(x, y, scale) { +const x0 = Math.floor(x / scale); +const y0 = Math.floor(y / scale); +const fx = smoothstep((x / scale) - x0); +const fy = smoothstep((y / scale) - y0); +const a = this.gridNoise(x0, y0); +const b = this.gridNoise(x0 + 1, y0); +const c = this.gridNoise(x0, y0 + 1); +const d = this.gridNoise(x0 + 1, y0 + 1); +return lerp(lerp(a, b, fx), lerp(c, d, fx), fy); +} +gridNoise(x, y) { +const v = Math.sin(x * 127.1 + y * 311.7 + this.rng.seed * 0.000001) * 43758.5453123; +return v - Math.floor(v); +} +computeHumidityFromWater(moistureMap) { +const s = this.size; +const radius = 10; +const unreachable = radius + 1; +const distance = new Uint8Array(this.count); +distance.fill(unreachable); +for (let i = 0; i < this.count; i++) { +if (this.terrain[i] === Terrain.WATER) distance[i] = 0; +} +// Exact city-block distance transform. The former implementation scanned the +// full radius around every tile; two linear passes produce the same nearest- +// water distance with O(tile count) work. +for (let y = 0; y < s; y++) { +for (let x = 0; x < s; x++) { +const i = y * s + x; +if (distance[i] === 0) continue; +let d = distance[i]; +if (x > 0) d = Math.min(d, distance[i - 1] + 1); +if (y > 0) d = Math.min(d, distance[i - s] + 1); +distance[i] = Math.min(unreachable, d); +} +} +for (let y = s - 1; y >= 0; y--) { +for (let x = s - 1; x >= 0; x--) { +const i = y * s + x; +if (distance[i] === 0) continue; +let d = distance[i]; +if (x + 1 < s) d = Math.min(d, distance[i + 1] + 1); +if (y + 1 < s) d = Math.min(d, distance[i + s] + 1); +distance[i] = Math.min(unreachable, d); +} +} +for (let i = 0; i < this.count; i++) { +if (this.terrain[i] === Terrain.WATER) { +this.humidity[i] = 1; +continue; +} +const d = distance[i]; +const waterInfluence = d <= radius ? 1 - d / (radius + 1) : 0; +this.humidity[i] = clamp(waterInfluence * 0.78 + moistureMap[i] * 0.22, 0, 1); +} +this.smoothField(this.humidity, 1); +} +smoothTerrainTypes(passes) { +const s = this.size; +let source = new Uint8Array(this.terrain); +let target = new Uint8Array(this.terrain.length); +for (let pass = 0; pass < passes; pass++) { +for (let y = 0; y < s; y++) { +for (let x = 0; x < s; x++) { +const counts = new Uint8Array(terrainInfo.length); +for (let dy = -1; dy <= 1; dy++) { +for (let dx = -1; dx <= 1; dx++) { +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue; +counts[source[this.idx(tx, ty)]]++; +} +} +let bestTerrain = source[this.idx(x, y)]; +let bestCount = counts[bestTerrain]; +for (let t = 0; t < counts.length; t++) { +if (counts[t] > bestCount) { +bestTerrain = t; +bestCount = counts[t]; +} +} +target[this.idx(x, y)] = bestCount >= 4 ? bestTerrain : source[this.idx(x, y)]; +} +} +const swap = source; +source = target; +target = swap; +} +this.terrain.set(source); +} +ensureDesertPatches() { +const candidates = []; +let desertCount = 0; +for (let i = 0; i < this.count; i++) { +if (this.terrain[i] === Terrain.DESERT) desertCount++; +if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.55 && this.humidity[i] < 0.28) { +candidates.push(i); +} +} +const target = Math.max(12, Math.floor(this.count * 0.012)); +if (desertCount >= target || !candidates.length) return; +candidates.sort((a, b) => { +const dryA = this.temperature[a] * (1 - this.humidity[a]); +const dryB = this.temperature[b] * (1 - this.humidity[b]); +return dryB - dryA; +}); +const needed = Math.min(target - desertCount, candidates.length); +for (let n = 0; n < needed; n++) { +const center = candidates[n]; +const cx = center % this.size; +const cy = Math.floor(center / this.size); +for (let dy = -2; dy <= 2; dy++) { +for (let dx = -2; dx <= 2; dx++) { +if (Math.abs(dx) + Math.abs(dy) > 2) continue; +const x = cx + dx; +const y = cy + dy; +if (x < 0 || y < 0 || x >= this.size || y >= this.size) continue; +const i = this.idx(x, y); +if (this.terrain[i] !== Terrain.WATER && this.temperature[i] > 0.5 && this.humidity[i] < 0.34) { +this.terrain[i] = Terrain.DESERT; +} +} +} +} +} +smoothField(field, passes) { +const s = this.size; +let source = field; +let target = new Float32Array(field.length); +for (let pass = 0; pass < passes; pass++) { +for (let y = 0; y < s; y++) { +for (let x = 0; x < s; x++) { +let sum = 0; +let count = 0; +for (let dy = -1; dy <= 1; dy++) { +for (let dx = -1; dx <= 1; dx++) { +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue; +sum += source[this.idx(tx, ty)]; +count++; +} +} +target[this.idx(x, y)] = sum / count; +} +} +const nextSource = target; +target = source === field ? new Float32Array(field.length) : field; +source = nextSource; +} +if (source !== field) field.set(source); +} +percentile(field, ratio) { +const values = Array.from(field).sort((a, b) => a - b); +return values[Math.floor(clamp(ratio, 0, 1) * (values.length - 1))]; +} +} +class Simulation { +constructor(size, initialPopulation = SimConfig.world?.initialPopulation ?? 50000) { +this.rng = new Rng(Date.now()); +this.world = new World(size, this.rng); +this.ethnicities = new Map(); +this.cities = []; +this.cityById = new Map(); +this.cityBuckets = new Map(); +this.polityCitiesCache = new Map(); +this.polities = []; +this.polityById = new Map(); +this.wars = []; +this.nomadBands = []; +this.disasters = []; +this.disasterHistory = []; +this.graphEvents = []; +this.worldHistory = []; +this.polityHistory = new Map(); +this.deadPolityHistories = []; +this.tradeLinks = []; +this.activeTradeRouteTiles = new Set(); +this.activePheromoneTiles = new Set(); +this.nextPheromoneTiles = new Set(); +this.pheromoneTouchedScratch = []; +this.manualTradeRoutes = []; +this.manualRouteProtection = new Map(); +this.dominantEthnicityCache = new Map(); +this.nextEthnicity = 1; +this.nextCity = 1; +this.nextPolity = 1; +this.nextWar = 1; +this.nextNomadBand = 1; +this.nextDisaster = 1; +this.nextCampaign = 1; +this.year = 0; +this.deaths = 0; +this.campaigns = []; +this.campaignHistory = []; +this.territorialClaims = new Map(); +this.territoryDebug = { +disconnectedTerritoryRemoved: 0, +independentCitiesAbsorbedByTerritory: 0, +cityTerritoryMismatchesFixed: 0, +invalidCampaignTargetsRejected: 0 +}; +this.populationFieldInitialized = false; +this.tileEthnicMix = new Map(); +this.tileCultures = this.tileEthnicMix; +this.activePopulationTiles = new Set(); +this.radiusOffsetCache = new Map(); +this.scratchFloatA = new Float32Array(this.world.count); +this.scratchFloatB = new Float32Array(this.world.count); +this.scratchFloatC = new Float32Array(this.world.count); +this.populationCapacityStatic = new Float32Array(this.world.count); +this.populationCapacityDynamicScale = new Float32Array(this.world.count); +this.initializePopulationCapacityCache(); +this.territoryBestSupport = new Float32Array(this.world.count); +this.territoryIncumbentSupport = new Float32Array(this.world.count); +this.scratchIntA = new Int32Array(this.world.count); +this.scratchIntB = new Int32Array(this.world.count); +this.scratchUintA = new Uint8Array(this.world.count); +this.visitStamp = new Int32Array(this.world.count); +this.nextVisitStamp = 1; +this.cityScoreScratch = []; +this.pairCandidateScratch = []; +this.routeCandidateScratch = []; +this.supportedRouteScratch = new Set(); +this.populationMoveScratch = []; +this.activePopulationScratch = []; +this.territoryExitCandidates = []; +this.suppressTerritoryExitShock = false; +this.technologySourceScratch = []; +this.routeCostScratch = new Float32Array(this.world.count); +this.routePrevScratch = new Int32Array(this.world.count); +this.routeHeapScratch = []; +this.seedInitialPopulationFieldDirectly(initialPopulation); +this.recomputeTerritories({ allowOwnershipChanges: true, reason: "initial" }); +this.updateEthnicStats(); +this.sampleWorldHistory(); +} +markPopulationTile(tile) { +if (tile >= 0 && tile < this.world.count) this.activePopulationTiles.add(tile); +} +cleanupPopulationTile(tile) { +if (tile < 0 || tile >= this.world.count) return; +if ((this.world.population[tile] || 0) > 0.01 || this.tileEthnicMix.has(tile)) return; +this.activePopulationTiles.delete(tile); +} +rebuildActivePopulationTiles() { +const w = this.world; +for (const tile of this.activePopulationTiles) { +if ((w.population[tile] || 0) <= 0.01 && !this.tileEthnicMix.has(tile)) this.activePopulationTiles.delete(tile); +} +for (const tile of this.tileEthnicMix.keys()) { +if ((w.population[tile] || 0) > 0.01 || this.tileEthnicMix.get(tile)?.size) this.activePopulationTiles.add(tile); +} +} +activePopulationSnapshot() { +this.activePopulationScratch.length = 0; +for (const tile of this.activePopulationTiles) this.activePopulationScratch.push(tile); +return this.activePopulationScratch; +} +getRadiusOffsets(radius, includeCenter = true) { +const key = `${radius}:${includeCenter ? 1 : 0}`; +let offsets = this.radiusOffsetCache.get(key); +if (offsets) return offsets; +offsets = []; +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const distance = Math.abs(dx) + Math.abs(dy); +if (distance > radius || (!includeCenter && distance === 0)) continue; +offsets.push({ dx, dy, distance }); +} +} +this.radiusOffsetCache.set(key, offsets); +return offsets; +} +forCardinalNeighbors(tile, visitor) { +const w = this.world; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +if (x > 0 && visitor(tile - 1, x - 1, y) === false) return false; +if (x < w.size - 1 && visitor(tile + 1, x + 1, y) === false) return false; +if (y > 0 && visitor(tile - w.size, x, y - 1) === false) return false; +if (y < w.size - 1 && visitor(tile + w.size, x, y + 1) === false) return false; +return true; +} +nextVisitMarker() { +if (this.nextVisitStamp >= 2147483640) { +this.visitStamp.fill(0); +this.nextVisitStamp = 1; +} +return this.nextVisitStamp++; +} +topHeapPush(heap, item, limit, scoreKey) { +if (limit <= 0) return; +if (heap.length < limit) { +heap.push(item); +this.heapSiftUp(heap, heap.length - 1, scoreKey); +return; +} +if (item[scoreKey] <= heap[0][scoreKey]) return; +heap[0] = item; +this.heapSiftDown(heap, 0, scoreKey); +} +heapSiftUp(heap, index, scoreKey) { +while (index > 0) { +const parent = (index - 1) >> 1; +if (heap[parent][scoreKey] <= heap[index][scoreKey]) break; +const swap = heap[parent]; +heap[parent] = heap[index]; +heap[index] = swap; +index = parent; +} +} +heapSiftDown(heap, index, scoreKey) { +for (;;) { +let smallest = index; +const left = index * 2 + 1; +const right = left + 1; +if (left < heap.length && heap[left][scoreKey] < heap[smallest][scoreKey]) smallest = left; +if (right < heap.length && heap[right][scoreKey] < heap[smallest][scoreKey]) smallest = right; +if (smallest === index) return; +const swap = heap[index]; +heap[index] = heap[smallest]; +heap[smallest] = swap; +index = smallest; +} +} +heapToDescending(heap, scoreKey) { +return heap.sort((a, b) => b[scoreKey] - a[scoreKey]); +} +createEthnicity(parent, climate = null) { +const id = this.nextEthnicity++; +this.ethnicities.set(id, { +id, +parent, +born: this.year, +population: 0, +diversity: 0, +climateTemp: climate?.temperature ?? 0.55, +climateHumidity: climate?.humidity ?? 0.45, +color: hslToRgb((id * 0.61803398875) % 1, 0.55, 0.56), +centroidX: 0, +centroidY: 0, +nextDivergenceCheck: this.year + years(this.rng.range(18, 72) / clamp(SimConfig.culture?.divergenceRateMultiplier ?? 0.25, 0.05, 1)) +}); +return id; +} +randomTraits() { +return { +mobility: this.rng.range(0.18, 0.85), +resourceAttraction: this.rng.range(0.55, 1), +assimilation: this.rng.range(0.03, 0.34), +ethnocentrism: this.rng.range(0.12, 0.78), +reproductionThreshold: this.rng.range(19, 34), +sedentary: this.rng.range(0.08, 0.82) +}; +} +desertFounderTraits() { +const traits = this.randomTraits(); +traits.mobility = this.rng.range(0.48, 0.9); +traits.resourceAttraction = this.rng.range(0.82, 1.12); +traits.reproductionThreshold = this.rng.range(24, 38); +traits.sedentary = this.rng.range(0.18, 0.58); +return traits; +} +findHabitableTile(preferredTerrain = null) { +if (preferredTerrain !== null) { +const exact = []; +let best = null; +let bestScore = -Infinity; +for (let i = 0; i < this.world.count; i++) { +const x = i % this.world.size; +const y = Math.floor(i / this.world.size); +if (this.world.terrain[i] === preferredTerrain) { +exact.push({ x, y }); +continue; +} +if (preferredTerrain === Terrain.DESERT && this.world.terrain[i] !== Terrain.WATER) { +const score = this.world.temperature[i] * (1 - this.world.humidity[i]); +if (score > bestScore) { +bestScore = score; +best = { x, y }; +} +} +} +if (exact.length) return exact[this.rng.int(exact.length)]; +if (best) return best; +} +for (let tries = 0; tries < 5000; tries++) { +const x = this.rng.int(this.world.size); +const y = this.rng.int(this.world.size); +const i = this.world.idx(x, y); +if (this.world.terrain[i] !== Terrain.WATER && this.world.fertility[i] + this.world.mineral[i] > 0.5) { +return { x, y }; +} +} +return { x: this.world.size >> 1, y: this.world.size >> 1 }; +} +frontierSpawnTile(side, edgeBand = 4) { +const w = this.world; +const band = Math.max(1, Math.floor(edgeBand)); +let best = null; +let bestScore = -Infinity; +for (let tries = 0; tries < 160; tries++) { +let x = this.rng.int(w.size); +let y = this.rng.int(w.size); +if (side === 0) y = this.rng.int(band); +else if (side === 1) y = w.size - 1 - this.rng.int(band); +else if (side === 2) x = this.rng.int(band); +else x = w.size - 1 - this.rng.int(band); +const i = w.idx(x, y); +if (w.terrain[i] === Terrain.WATER) continue; +const score = w.resource[i] * 0.04 + w.fertility[i] * 0.85 + w.mineral[i] * 0.25 - w.move[i] * 0.12; +if (score > bestScore) { +best = { x, y }; +bestScore = score; +} +} +return best || this.findHabitableTile(); +} +maybeSpawnFrontierWave() { +const cfg = SimConfig.frontierWave; +if (!cfg) return; +if (this.year < years(cfg.enabledAfterYears ?? 300)) return; +const interval = years(cfg.intervalYears ?? 80); +if (!interval || this.year % interval !== 0) return; +if (this.rng.next() > (cfg.chance ?? 0.45)) return; +if (SimConfig.nomad?.enabled && this.nomadBands.length < (SimConfig.nomad.maxBands ?? 24) && this.rng.next() < 0.55) { +const tile = this.findNomadSpawnTile(); +if (tile != null && this.spawnNomadBand(tile)) return; +} +this.spawnFrontierPopulationWave(); +} +spawnFrontierPopulationWave() { +const cfg = SimConfig.frontierWave || {}; +const side = this.rng.int(4); +const origin = this.frontierSpawnTile(side, cfg.edgeBand ?? 4); +const originTile = this.world.idx(origin.x, origin.y); +const ethnicity = this.createEthnicity(0, { +temperature: this.world.temperature[originTile], +humidity: this.world.humidity[originTile] +}); +const traits = this.randomTraits(); +traits.mobility = this.rng.range(0.65, 1.0); +traits.sedentary = this.rng.range(0.02, 0.28); +traits.ethnocentrism = this.rng.range(0.42, 0.95); +traits.assimilation = this.rng.range(0.02, 0.22); +traits.resourceAttraction = this.rng.range(0.78, 1.15); +const lineage = this.ethnicities.get(ethnicity); +if (lineage) lineage.averageTraits = traits; +const total = this.rng.range(cfg.minPopulation ?? 60, cfg.maxPopulation ?? 140); +let seeded = 0; +for (let n = 0; n < 7; n++) { +const spawn = this.frontierSpawnTile(side, (cfg.edgeBand ?? 4) + 3); +const tile = this.world.idx(spawn.x, spawn.y); +if (this.world.terrain[tile] === Terrain.WATER) continue; +if (this.world.control[tile] > 0.65 && this.world.polity[tile] >= 0) continue; +const amount = total * this.rng.range(0.08, 0.22); +this.seedEthnicPopulationPatch(tile, ethnicity, traits, amount, 2); +seeded += amount; +} +if (seeded <= 0) { +this.ethnicities.delete(ethnicity); +return; +} +this.syncTilePopulationCulture(); +this.addGraphEvent("newEthnicity", this.year, { +ethnicity, +population: Math.round(seeded) +}); +} +maybeSpawnNomadBand() { +const cfg = SimConfig.nomad || {}; +if (!cfg.enabled) return; +if (this.year < years(cfg.enabledAfterYears ?? 120)) return; +const interval = years(cfg.spawnCheckIntervalYears ?? 25); +if (!interval || this.year % interval !== 0) return; +if (this.nomadBands.length >= (cfg.maxBands ?? 24)) return; +if (this.rng.next() > (cfg.baseSpawnChance ?? 0.28)) return; +const tile = this.findNomadSpawnTile(); +if (tile == null) return; +this.spawnNomadBand(tile); +} +findNomadSpawnTile() { +const w = this.world; +let best = null; +let bestScore = -Infinity; +for (let tries = 0; tries < 240; tries++) { +const edgeBias = this.rng.next() < 0.55; +let x = this.rng.int(w.size); +let y = this.rng.int(w.size); +if (edgeBias) { +const side = this.rng.int(4); +const band = Math.max(3, Math.floor(w.size * 0.12)); +if (side === 0) y = this.rng.int(band); +else if (side === 1) y = w.size - 1 - this.rng.int(band); +else if (side === 2) x = this.rng.int(band); +else x = w.size - 1 - this.rng.int(band); +} +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER || w.city[tile] >= 0) continue; +if (w.control[tile] > 0.55 && w.polity[tile] >= 0) continue; +if (this.getCitiesNear(x, y, 8).length) continue; +if ((w.cityPull[tile] || 0) > 0.35) continue; +if ((w.settledPopulation[tile] || 0) > 8 || ((w.settledPopulation[tile] || 0) > (w.mobilePopulation[tile] || 0) * 1.8 && (w.settledPopulation[tile] || 0) > 4)) continue; +if ((w.control[tile] || 0) > 0.45 && (w.populationPressure[tile] || 0) > 0.4) continue; +const dryland = w.terrain[tile] === Terrain.DESERT ? 0.9 : 0; +const plain = w.terrain[tile] === Terrain.PLAINS ? 0.8 : w.terrain[tile] === Terrain.FOREST ? 0.15 : 0; +const frontier = w.polity[tile] < 0 ? 0.55 : (w.control[tile] < 0.35 ? 0.25 : -0.35); +const mobile = clamp((w.mobilePopulation[tile] || 0) / 20, 0, 1.2); +const route = w.tradeRoute[tile] ? 0.25 : clamp(w.pheromone[tile] / 18, 0, 0.25); +const pressure = clamp(w.populationPressure[tile] || 0, 0, 2); +const score = dryland + plain + frontier + mobile + route + w.fertility[tile] * 0.55 + clamp(w.resource[tile] / 35, 0, 0.8) - pressure * 0.8 - w.move[tile] * 0.35 + this.rng.range(0, 0.6); +if (score > bestScore) { +best = tile; +bestScore = score; +} +} +return best; +} +spawnNomadBand(originTile, options = {}) { +const cfg = SimConfig.nomad || {}; +const w = this.world; +if (originTile == null || w.terrain[originTile] === Terrain.WATER) return null; +let ethnicityId = options.ethnicityId; +if (!ethnicityId) { +ethnicityId = this.createEthnicity(0, { +temperature: w.temperature[originTile], +humidity: w.humidity[originTile] +}); +const ethnicity = this.ethnicities.get(ethnicityId); +if (ethnicity) { +ethnicity.averageTraits = { +...this.randomTraits(), +mobility: this.rng.range(0.72, 1.0), +sedentary: this.rng.range(0.02, 0.24), +ethnocentrism: this.rng.range(0.35, 0.9), +assimilation: this.rng.range(0.02, 0.18) +}; +} +} +const population = options.population ?? Math.round(this.rng.range(cfg.minPopulation ?? 80, cfg.maxPopulation ?? 420)); +const band = { +id: this.nextNomadBand++, +name: `遊牧集団${this.nextNomadBand - 1}`, +ethnicityId, +x: originTile % w.size, +y: Math.floor(originTile / w.size), +radius: Math.round(this.rng.range(cfg.minRadius ?? 5, cfg.maxRadius ?? 14)), +population, +herds: options.herds ?? population * this.rng.range(0.8, 1.8), +resources: options.resources ?? population * this.rng.range(0.10, 0.28), +cohesion: this.rng.range(0.42, 0.86), +prestige: this.rng.range(0.15, 0.75), +charisma: this.rng.range(0.55, 1.55), +aggression: this.rng.range(0.12, 0.82), +tradeAffinity: this.rng.range(0.18, 0.86), +raidAffinity: this.rng.range(0.12, 0.88), +migrationPressure: this.rng.range(0.18, 0.72), +sedentarization: this.rng.range(0.02, 0.22), +targetTile: originTile, +targetCityId: null, +mode: "roaming", +born: this.year, +lastMoveYear: -Infinity, +lastRaidYear: -Infinity, +lastTradeYear: -Infinity, +lastSplitYear: -Infinity, +trailTiles: [originTile], +influenceTiles: [], +memory: {} +}; +this.nomadBands.push(band); +this.seedEthnicPopulationPatch(originTile, ethnicityId, this.ethnicities.get(ethnicityId)?.averageTraits || this.randomTraits(), population * 0.22, 2); +this.addGraphEvent("nomadBand", this.year, { bandId: band.id, ethnicity: ethnicityId, population }, 1); +this.updateNomadInfluence(); +return band; +} +clearNomadInfluence() { +const w = this.world; +if (!w.nomadInfluence || !w.nomadBand) return; +w.nomadInfluence.fill(0); +w.nomadBand.fill(-1); +} +updateNomadInfluence() { +const w = this.world; +const cfg = SimConfig.nomad || {}; +this.clearNomadInfluence(); +for (const band of this.nomadBands) { +band.influenceTiles = []; +const cx = Math.round(band.x); +const cy = Math.round(band.y); +const popScale = clamp(Math.log1p(band.population) / Math.log(1200), 0.2, 1.3); +const strength = popScale * clamp(band.cohesion, 0.15, 1) * (0.65 + band.prestige * 0.22); +for (const offset of this.getRadiusOffsets(band.radius)) { +const dx = offset.dx; +const dy = offset.dy; +const distance = offset.distance; +const x = cx + dx; +const y = cy + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const local = strength * (1 - distance / (band.radius + 1)); +if (local > w.nomadInfluence[tile]) { +w.nomadInfluence[tile] = local; +w.nomadBand[tile] = band.id; +} +band.influenceTiles.push(tile); +if (local > 0.18 && this.rng.next() < (cfg.influenceCulturalTrace ?? 0.006)) { +const amount = Math.min(0.06, band.population * (cfg.influenceMobilePopulationTrace ?? 0.004) * local); +this.addNomadTrace(tile, band.ethnicityId, amount); +} +} +} +} +addNomadTrace(tile, ethnicityId, amount) { +if (amount <= 0 || ethnicityId == null) return; +const w = this.world; +w.population[tile] += amount; +w.mobilePopulation[tile] += amount; +this.markPopulationTile(tile); +let mix = this.tileEthnicMix.get(tile); +if (!mix) { +mix = new Map(); +this.tileEthnicMix.set(tile, mix); +} +mix.set(ethnicityId, (mix.get(ethnicityId) || 0) + amount); +this.updateCultureTile(tile); +} +updateNomadBands() { +const cfg = SimConfig.nomad || {}; +if (!cfg.enabled) return; +this.maybeSpawnNomadBand(); +const interval = years(cfg.movementIntervalYears ?? 2); +for (const band of [...this.nomadBands]) { +if (!this.nomadBands.includes(band)) continue; +this.sanitizeNomadBand(band); +if (!this.nomadBands.includes(band)) continue; +if (this.year - (band.lastMoveYear ?? -Infinity) >= interval) { +this.chooseNomadTarget(band); +this.moveNomadBandTowardTarget(band); +band.lastMoveYear = this.year; +} +this.grazeNomadBand(band); +this.sanitizeNomadBand(band); +if (!this.nomadBands.includes(band)) continue; +this.interactNomadsWithCities(band); +const urbanPressure = this.nomadUrbanPressure(band); +if (urbanPressure > (cfg.urbanAbsorptionControl ?? 0.55)) this.maybeAbsorbNomadIntoUrbanRegion(band, urbanPressure); +if (!this.nomadBands.includes(band) || band.population < 25) continue; +this.maybeNomadRaidOrInvasion(band); +this.maybeFormNomadConfederation(band); +this.maybeMergeWeakNomadBand(band); +if (!this.nomadBands.includes(band)) continue; +this.splitNomadBand(band); +this.sanitizeNomadBand(band); +} +this.nomadBands = this.nomadBands.filter(band => band.population >= 25 && band.cohesion > (cfg.collapseCohesion ?? 0.12)); +this.updateNomadInfluence(); +} +chooseNomadTarget(band) { +const w = this.world; +const cfg = SimConfig.nomad || {}; +let bestTile = w.idx(Math.round(band.x), Math.round(band.y)); +let bestScore = -Infinity; +const range = cfg.maxMoveDistance ?? 9; +for (let tries = 0; tries < 80; tries++) { +const x = clamp(Math.round(band.x) + this.rng.int(range * 2 + 1) - range, 0, w.size - 1); +const y = clamp(Math.round(band.y) + this.rng.int(range * 2 + 1) - range, 0, w.size - 1); +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +const urbanPressure = this.nomadUrbanPressureAtTile(tile, band); +const terrainSuitability = w.terrain[tile] === Terrain.PLAINS ? 1.1 : w.terrain[tile] === Terrain.DESERT ? 0.85 : w.terrain[tile] === Terrain.FOREST ? 0.25 : -0.8; +const statePenalty = clamp(w.control[tile] || 0, 0, 1) * (band.aggression > 0.65 ? 0.35 : 1.25); +const cityPenalty = city ? (band.mode === "raiding" || band.mode === "trading" ? -0.25 : 1.2) : 0; +const urbanPenalty = urbanPressure * (band.mode === "raiding" || band.mode === "invading" ? 0.75 : 2.4); +const sameTrace = w.dominantEthnicity[tile] === band.ethnicityId ? 0.55 : 0; +const raidAttraction = band.aggression * band.raidAffinity > 0.42 && city ? clamp((city.storedResources || 0) / 120, 0, 1.4) : 0; +const score = +terrainSuitability + +w.fertility[tile] * 0.7 + +clamp(w.resource[tile] / 35, 0, 1.2) + +(w.tradeRoute[tile] ? 0.45 : 0) + +sameTrace + +raidAttraction - +statePenalty - +cityPenalty - +urbanPenalty - +(w.populationPressure[tile] || 0) * 0.55 - +(w.terrain[tile] === Terrain.MOUNTAIN ? 1.2 : 0) + +this.rng.range(0, 0.5); +if (score > bestScore) { +bestScore = score; +bestTile = tile; +} +} +band.targetTile = bestTile; +band.mode = bestScore > 1.2 ? "grazing" : "roaming"; +} +moveNomadBandTowardTarget(band) { +const w = this.world; +const target = band.targetTile; +if (target == null) return; +const tx = target % w.size; +const ty = Math.floor(target / w.size); +let x = Math.round(band.x); +let y = Math.round(band.y); +const steps = Math.max(1, Math.min(3, Math.abs(tx - x) + Math.abs(ty - y))); +for (let step = 0; step < steps; step++) { +let best = w.idx(x, y); +let bestScore = Infinity; +for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [0, 0]]) { +const nx = x + dx; +const ny = y + dy; +if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue; +const tile = w.idx(nx, ny); +if (w.terrain[tile] === Terrain.WATER) continue; +const urbanPenalty = this.nomadUrbanPressureAtTile(tile, band) * (band.mode === "raiding" || band.mode === "invading" ? 0.5 : 1.35); +const score = Math.abs(nx - tx) + Math.abs(ny - ty) + w.move[tile] * 0.35 + clamp(w.control[tile], 0, 1) * 0.55 + urbanPenalty; +if (score < bestScore) { +bestScore = score; +best = tile; +} +} +x = best % w.size; +y = Math.floor(best / w.size); +} +band.x = x; +band.y = y; +const tile = w.idx(x, y); +band.trailTiles.push(tile); +while (band.trailTiles.length > 32) band.trailTiles.shift(); +} +grazeNomadBand(band) { +const w = this.world; +const cfg = SimConfig.nomad || {}; +const tile = w.idx(Math.round(band.x), Math.round(band.y)); +if (w.terrain[tile] === Terrain.WATER) return; +const terrainMultiplier = +w.terrain[tile] === Terrain.PLAINS ? 1.15 : +w.terrain[tile] === Terrain.DESERT ? 0.75 : +w.terrain[tile] === Terrain.FOREST ? 0.45 : +w.terrain[tile] === Terrain.MOUNTAIN ? 0.25 : 0; +const urbanPressure = this.nomadUrbanPressure(band); +const pasture = band.population * terrainMultiplier * (0.8 + w.fertility[tile] * 1.4 + clamp(w.resource[tile] / 35, 0, 1) * 0.8) * clamp(1 - urbanPressure * 0.22, 0.35, 1); +const herdSoftCap = band.population * (cfg.herdSoftCapPerPop ?? 2.4); +const herdHardCap = band.population * (cfg.herdHardCapPerPop ?? 4.0); +const resourceSoftCap = band.population * (cfg.resourceSoftCapPerPop ?? 1.2); +const resourceHardCap = band.population * (cfg.resourceHardCapPerPop ?? 2.5); +const herdPressure = band.herds / Math.max(1, pasture); +const herdGrowthRate = (cfg.grazingGain ?? 0.035) * Math.max(0, 1 - Math.pow(herdPressure, cfg.grazingDiminishingPower ?? 1.35)); +band.herds += band.herds * herdGrowthRate; +band.herds -= band.population * 0.008; +if (band.herds > herdSoftCap) band.herds -= (band.herds - herdSoftCap) * 0.06; +band.herds = clamp(band.herds, 0, herdHardCap); +const produced = Math.min(band.herds * 0.0035, band.population * 0.035) * clamp(1 - urbanPressure * 0.18, 0.4, 1); +const upkeep = band.population * (cfg.resourceConsumption ?? 0.012); +band.resources += produced - upkeep; +if (band.resources > resourceSoftCap) band.resources -= (band.resources - resourceSoftCap) * 0.08; +band.resources = clamp(band.resources, 0, resourceHardCap); +w.resource[tile] = Math.max(0, w.resource[tile] - band.population * (cfg.overgrazingDamage ?? 0.018) * 0.02); +if (band.resources < band.population * 0.03) band.cohesion = clamp(band.cohesion - 0.012, 0, 1); +else band.cohesion = clamp(band.cohesion + 0.003, 0, 1); +} +sanitizeNomadBand(band) { +const cfg = SimConfig.nomad || {}; +if (!Number.isFinite(band.population)) band.population = cfg.minPopulation ?? 80; +if (!Number.isFinite(band.herds)) band.herds = Math.max(0, band.population * 1.1); +if (!Number.isFinite(band.resources)) band.resources = Math.max(0, band.population * 0.18); +band.population = Math.max(0, band.population); +band.herds = clamp(Math.max(0, band.herds), 0, Math.max(1, band.population) * (cfg.herdHardCapPerPop ?? 4.0)); +band.resources = clamp(Math.max(0, band.resources), 0, Math.max(1, band.population) * (cfg.resourceHardCapPerPop ?? 2.5)); +if (band.population < 25) this.nomadBands = this.nomadBands.filter(candidate => candidate !== band); +return band; +} +nomadUrbanPressure(band) { +if (!band) return 0; +const w = this.world; +const tile = w.idx(clamp(Math.round(band.x), 0, w.size - 1), clamp(Math.round(band.y), 0, w.size - 1)); +return this.nomadUrbanPressureAtTile(tile, band); +} +nomadUrbanPressureAtTile(tile, band = null) { +const w = this.world; +if (tile == null || tile < 0 || tile >= w.count || w.terrain[tile] === Terrain.WATER) return 0; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +const cities = this.getCitiesNear(x, y, 12); +let cityPopulation = 0; +let nearest = Infinity; +for (const city of cities) { +cityPopulation += city.population || 0; +nearest = Math.min(nearest, Math.abs(city.x - x) + Math.abs(city.y - y)); +} +const localPopulation = w.population[tile] || 0; +const settled = w.settledPopulation[tile] || 0; +const mobile = w.mobilePopulation[tile] || 0; +const settledRatio = localPopulation > 0 ? settled / Math.max(1, localPopulation) : 0; +const urbanCore = nearest <= 4 ? 0.35 : nearest <= 8 ? 0.18 : 0; +const score = +clamp(cityPopulation / 650, 0, 1.8) * 0.42 + +clamp(cities.length / 4, 0, 1.5) * 0.34 + +clamp(w.cityPull[tile] || 0, 0, 1) * 0.58 + +clamp(w.control[tile] || 0, 0, 1) * 0.36 + +clamp(w.populationPressure[tile] || 0, 0, 2) * 0.22 + +clamp(settledRatio, 0, 1) * 0.34 + +clamp(settled / Math.max(1, mobile + 3), 0, 3) * 0.12 + +urbanCore; +return clamp(score, 0, 2.2); +} +maybeAbsorbNomadIntoUrbanRegion(band, urbanPressure = this.nomadUrbanPressure(band)) { +const cfg = SimConfig.nomad || {}; +const radius = urbanPressure > 1.1 ? 12 : 10; +const city = this.findCityNear(Math.round(band.x), Math.round(band.y), radius); +if (!city) { +band.targetTile = null; +band.mode = "leaving"; +band.cohesion = clamp(band.cohesion - urbanPressure * 0.008, 0, 1); +return false; +} +const absorbRate = clamp(0.08 + (urbanPressure - (cfg.urbanAbsorptionControl ?? 0.55)) * 0.12, 0.08, 0.25); +let migrants = Math.max(4, band.population * absorbRate); +const dissolve = band.population < 40 || urbanPressure > 1.35; +if (dissolve) migrants = band.population; +this.transferNomadsToCity(band, city, migrants, "urbanAbsorption"); +if (dissolve || band.population < 25) this.nomadBands = this.nomadBands.filter(candidate => candidate !== band); +return true; +} +transferNomadsToCity(band, city, migrants, reason = "sedentarization") { +if (!band || !city || migrants <= 0 || band.population <= 0) return 0; +const moved = Math.min(band.population, migrants); +const share = moved / Math.max(1, band.population); +band.population -= moved; +band.herds = Math.max(0, band.herds * (1 - share * 0.82)); +const resourceTransfer = band.resources * share * 0.45; +band.resources = Math.max(0, band.resources - resourceTransfer); +city.population += moved; +city.storedResources += resourceTransfer; +city.ethnicityComposition.set(band.ethnicityId, (city.ethnicityComposition.get(band.ethnicityId) || 0) + moved); +const tile = this.world.idx(city.x, city.y); +this.world.population[tile] += moved * 0.15; +this.world.settledPopulation[tile] += moved; +this.markPopulationTile(tile); +this.addNomadTrace(tile, band.ethnicityId, moved * 0.10); +band.sedentarization = clamp((band.sedentarization || 0) + 0.05, 0, 1); +band.mode = "settling"; +if (this.year - (band.lastUrbanAbsorptionYear ?? -Infinity) > years(18) && (moved >= 30 || reason === "sedentarization")) { +this.addGraphEvent("sedentarization", this.year, { bandId: band.id, cityId: city.id, population: Math.round(moved), reason }, 1); +band.lastUrbanAbsorptionYear = this.year; +} +return moved; +} +interactNomadsWithCities(band) { +const cfg = SimConfig.nomad || {}; +const cities = this.getCitiesNear(Math.round(band.x), Math.round(band.y), cfg.cityInteractionRadius ?? 8); +if (!cities.length) return; +for (const city of cities.slice(0, 3)) { +const exposed = city.polityId === null ? 0.7 : clamp(1 - (this.getPolityById(city.polityId)?.cohesion ?? 0.6), 0, 0.8) + clamp(1 - city.loyalty, 0, 1) * 0.5; +const hungry = band.resources < band.population * 0.08 ? 0.25 : 0; +if (this.year - (band.lastTradeYear ?? -Infinity) > years(6) && this.rng.next() < (cfg.tradeChance ?? 0.22) * band.tradeAffinity) { +this.nomadTradeWithCity(band, city); +} +if (this.year - (band.lastRaidYear ?? -Infinity) > years(8) && this.rng.next() < (cfg.raidChance ?? 0.14) * (band.raidAffinity + hungry + exposed * 0.4)) { +this.nomadRaidCity(band, city, false); +} +if (this.rng.next() < (cfg.sedentarizationChance ?? 0.035) * band.sedentarization) this.sedentarizeNomadsIntoCity(band, city); +} +} +nomadTradeWithCity(band, city) { +const value = Math.min(city.storedResources * 0.05, Math.max(2, band.population * 0.025)); +city.storedResources += value * 0.35; +band.resources += value; +band.prestige = clamp(band.prestige + 0.025, 0, 5); +band.mode = "trading"; +band.lastTradeYear = this.year; +const path = this.findTerrainRoute(Math.round(band.x), Math.round(band.y), city.x, city.y); +if (this.isValidRoutePath(path, city.x, city.y)) this.depositRoutePheromone(path, 0.9); +this.addGraphEvent("nomadTrade", this.year, { bandId: band.id, cityId: city.id }, 1); +} +nomadRaidCity(band, city, severe = false) { +const loot = Math.min(city.storedResources * (severe ? 0.38 : 0.18), band.population * (severe ? 0.16 : 0.08)); +city.storedResources = Math.max(0, city.storedResources - loot); +const loss = Math.min(city.population * (severe ? 0.10 : 0.035), band.population * 0.035); +if (loss > 0) { +city.population = Math.max(0, city.population - loss); +removeFromComposition(city.ethnicityComposition, loss); +} +city.loyalty = clamp((city.loyalty ?? 0.5) - (severe ? 0.10 : 0.035), 0, 1); +if (city.polityId !== null) { +const polity = this.getPolityById(city.polityId); +if (polity) { +polity.cohesion = clamp((polity.cohesion ?? 0.6) - (severe ? 0.035 : 0.012), 0, 1); +polity.treasury = Math.max(0, (polity.treasury || 0) - loot * 0.18); +} +} +band.resources += loot; +band.prestige = clamp(band.prestige + (severe ? 0.18 : 0.06), 0, 5); +band.mode = severe ? "invading" : "raiding"; +band.lastRaidYear = this.year; +this.addGraphEvent("nomadRaid", this.year, { bandId: band.id, cityId: city.id, severe }, severe ? 2 : 1); +} +sedentarizeNomadsIntoCity(band, city) { +if (band.population < 60) return; +const migrants = Math.min(band.population * this.rng.range(0.08, 0.22), Math.max(8, city.population * 0.12)); +this.transferNomadsToCity(band, city, migrants); +} +maybeNomadRaidOrInvasion(band) { +const cfg = SimConfig.nomad || {}; +if (band.population < (cfg.confederationPopulation ?? 900) * 0.55) return; +if (band.cohesion < 0.55 || band.prestige < 1.0 || band.aggression < 0.55) return; +if (this.rng.next() > (cfg.invasionChance ?? 0.025) * band.aggression * band.raidAffinity) return; +const cities = this.getCitiesNear(Math.round(band.x), Math.round(band.y), cfg.cityInteractionRadius ?? 8) +.filter(city => city.population > 20) +.sort((a, b) => this.nomadCityWeaknessScore(band, b) - this.nomadCityWeaknessScore(band, a)); +if (cities.length) this.resolveNomadInvasion(band, cities[0]); +} +nomadCityWeaknessScore(band, city) { +const polity = city.polityId !== null ? this.getPolityById(city.polityId) : null; +const crisis = polity ? (polity.crisis || 0) + clamp(1 - (polity.cohesion ?? 0.6), 0, 1) : 0.7; +return crisis + clamp(1 - (city.loyalty ?? 0.5), 0, 1) + clamp((city.storedResources || 0) / 160, 0, 1.2) - Math.sqrt(city.population || 1) / 80; +} +resolveNomadInvasion(band, city) { +const score = band.population * band.cohesion * (0.5 + band.prestige * 0.25) * band.aggression; +const defense = Math.max(20, city.population * (0.55 + (city.loyalty ?? 0.5)) + (city.polityId !== null ? this.polityPower(this.getPolityById(city.polityId)) * 0.6 : 0)); +if (this.rng.next() > clamp(score / Math.max(1, score + defense), 0.08, 0.72)) { +band.cohesion = clamp(band.cohesion - 0.08, 0, 1); +band.resources = Math.max(0, band.resources - band.population * 0.06); +return; +} +const outcomeRoll = this.rng.next(); +if (outcomeRoll < 0.45) { +this.nomadRaidCity(band, city, true); +} else if (outcomeRoll < 0.75) { +const oldPolityId = city.polityId; +if (city.polityId !== null) this.removeCityFromPolity(city); +this.releaseCityTerritory(city, oldPolityId, 5, "nomadConquest"); +this.protectCityFromRebellion(city); +city.loyalty = 0.22; +band.prestige = clamp(band.prestige + 0.28, 0, 5); +this.addGraphEvent("nomadConquest", this.year, { bandId: band.id, cityId: city.id, outcome: "puppetIndependent" }, 3); +} else { +const oldPolityId = city.polityId; +if (city.polityId !== null) this.removeCityFromPolity(city); +this.releaseCityTerritory(city, oldPolityId, 5, "nomadConquest"); +this.protectCityFromRebellion(city); +const settlers = Math.min(band.population * 0.28, Math.max(30, city.population * 0.35)); +band.population -= settlers; +city.population += settlers; +city.ethnicityComposition.set(band.ethnicityId, (city.ethnicityComposition.get(band.ethnicityId) || 0) + settlers); +const polity = this.createPolity(city); +if (polity) { +polity.origin = "nomad"; +polity.type = "nomadic_dynasty"; +} +band.prestige = clamp(band.prestige + 0.45, 0, 5); +band.mode = "invading"; +this.addGraphEvent("nomadConquest", this.year, { bandId: band.id, cityId: city.id, outcome: "nomadicDynasty" }, 4); +} +} +maybeFormNomadConfederation(band) { +const cfg = SimConfig.nomad || {}; +if (band.population < (cfg.confederationPopulation ?? 900) || band.prestige < (cfg.confederationPrestige ?? 1.8) || band.cohesion < 0.58) return; +band.mode = "confederating"; +band.radius = Math.min((cfg.maxRadius ?? 14) + 8, band.radius + 1); +band.charisma = clamp(band.charisma + 0.05, 0.5, 2.4); +for (const other of [...this.nomadBands]) { +if (other === band || other.population > band.population * 0.55) continue; +const distance = Math.abs(other.x - band.x) + Math.abs(other.y - band.y); +if (distance > band.radius + other.radius + 8) continue; +if (this.rng.next() < 0.18 * band.charisma) this.mergeNomadBands(band, other); +} +this.addGraphEvent("nomadConfederation", this.year, { bandId: band.id, population: Math.round(band.population) }, 2); +} +mergeNomadBands(leaderBand, subordinateBand) { +leaderBand.population += subordinateBand.population; +leaderBand.herds += subordinateBand.herds; +leaderBand.resources += subordinateBand.resources * 0.7; +leaderBand.prestige = clamp(leaderBand.prestige + subordinateBand.prestige * 0.18, 0, 5); +leaderBand.cohesion = clamp((leaderBand.cohesion + subordinateBand.cohesion) * 0.48, 0, 1); +this.nomadBands = this.nomadBands.filter(band => band !== subordinateBand); +this.sanitizeNomadBand(leaderBand); +} +maybeMergeWeakNomadBand(band) { +if (!this.nomadBands.includes(band) || band.population <= 0) return false; +let strongest = null; +let strongestDistance = Infinity; +for (const other of this.nomadBands) { +if (other === band || other.population <= band.population) continue; +const distance = Math.abs(other.x - band.x) + Math.abs(other.y - band.y); +if (distance > band.radius + other.radius + 6) continue; +if (band.population >= other.population * 0.45) continue; +const sameEthnicity = other.ethnicityId === band.ethnicityId; +const compatible = sameEthnicity || Math.abs((other.sedentarization || 0) - (band.sedentarization || 0)) < 0.18 || Math.abs((other.aggression || 0) - (band.aggression || 0)) < 0.22; +if (!compatible) continue; +if (distance < strongestDistance) { +strongest = other; +strongestDistance = distance; +} +} +if (!strongest) return false; +const important = band.population >= 120 || strongest.population + band.population >= (SimConfig.nomad.confederationPopulation ?? 900); +this.mergeNomadBands(strongest, band); +if (important) this.addGraphEvent("nomadMerge", this.year, { bandId: strongest.id, mergedBandId: band.id, population: Math.round(strongest.population) }, 1); +return true; +} +splitNomadBand(band) { +const cfg = SimConfig.nomad || {}; +if (this.nomadBands.length >= (cfg.maxBands ?? 24)) return null; +if (this.year - (band.lastSplitYear ?? -Infinity) < years(60)) return null; +if (band.population < (cfg.splitPopulation ?? 1200) && band.cohesion > 0.22) return null; +const split = band.population * this.rng.range(0.24, 0.42); +if (split < 80) return null; +band.population -= split; +band.herds *= 0.72; +band.resources *= 0.78; +band.cohesion = clamp(band.cohesion - 0.10, 0, 1); +band.lastSplitYear = this.year; +const tile = this.world.idx(Math.round(band.x), Math.round(band.y)); +const child = this.spawnNomadBand(tile, { +ethnicityId: band.ethnicityId, +population: split, +herds: band.herds * 0.35, +resources: band.resources * 0.25 +}); +if (child) { +child.mode = "roaming"; +child.prestige = Math.max(0.1, band.prestige * 0.35); +this.addGraphEvent("nomadSplit", this.year, { bandId: band.id, newBandId: child.id, population: Math.round(split) }, 1); +} +return child; +} +step() { +for (const city of this.cities) city.activeVisitors = 0; +this.dominantEthnicityCache.clear(); +this.rebuildIndexes(); +const annualTick = this.year % years(1) === 0; +const weekOfYear = this.year % WEEKS_PER_YEAR; +const fieldTick = this.year % WEEKS_PER_MONTH === 0; +const resourceTick = weekOfYear === WEEKS_PER_YEAR - 1; +// Pheromone changes update capacity only on touched cells inside updateWorldFields(). +// This avoids a full-map capacity pass every simulated month. +if (fieldTick) this.updateWorldFields(WEEKS_PER_MONTH); +// Resources are consumed by the annual economy. Batch the same 48-week +// regeneration immediately before the next annual tick. +if (resourceTick) this.regenerateResources(WEEKS_PER_YEAR); +this.maybeSpawnDisaster(); +if (annualTick) this.maybeSpawnFrontierWave(); +if (annualTick) { +this.updateCities(); +this.updateNomadBands(); +this.consumeTileResources(); +this.growTilePopulation(); +this.updatePopulationPressure(); +this.updateNomadInfluence(); +this.clearSparseHumanData(); +if (this.year % years(100) === 0 && typeof console !== "undefined" && console.debug) { +const d = this.territoryDebug; +if (d.disconnectedTerritoryRemoved || d.independentCitiesAbsorbedByTerritory || d.cityTerritoryMismatchesFixed || d.invalidCampaignTargetsRejected) { +console.debug("territory topology", { year: Math.floor(this.year / WEEKS_PER_YEAR), ...d }); +} +} +} +// Spread expensive annual/periodic field work across the year instead of +// stacking it all on the year boundary. Each population cell still migrates +// once per year, in one of four stable quarterly cohorts. +if (weekOfYear % 12 === 8) { +const cohort = Math.floor(weekOfYear / 12) % 4; +this.updatePopulationCapacity(); +this.diffusePopulation(cohort, 4); +this.updatePopulationPressure(); +} +if (this.year % years(2) === 12) { +this.updateTileTechnology(); +this.diffuseTileTechnology(); +this.cityTechnologyExchange(); +this.clearSparseHumanData(); +} +if (this.year % years(3) === 24) this.updateTileAssimilation(); +if (weekOfYear === 36) this.spreadCulturesFromCitiesAndTrade(); +if (this.year % years(5) === 20) this.updateTradeRoutes(true); +// State accounting follows the annual city economy, but is offset by one +// month so the two largest annual workloads do not land on the same frame. +if (weekOfYear === 4) this.updatePolities(true); +if (this.year % SimConfig.war.tickWeeks === 0) { +this.maybeStartWars(); +this.updateCampaigns(); +this.updateWars(); +} +const historyTick = this.year % 12 === 0; +if (historyTick) this.updateEthnicStats(); +// Ethnic maintenance is annual in frequency but offset from the year boundary. +if (this.year > 0 && weekOfYear === 40) { +this.updateEthnicStats(); +this.absorbSmallEthnicities(); +this.updateEthnicStats(); +this.splitDivergentEthnicities(); +this.updateEthnicStats(); +} +if (historyTick) this.sampleWorldHistory(); +this.year++; +} +rebuildIndexes() { +this.cityById.clear(); +this.polityCitiesCache?.clear(); +for (const city of this.cities) this.cityById.set(city.id, city); +this.polityById.clear(); +for (const polity of this.polities) this.polityById.set(polity.id, polity); +this.cityBuckets.clear(); +for (const city of this.cities) { +const key = this.cityBucketKey(city.x, city.y); +let bucket = this.cityBuckets.get(key); +if (!bucket) { +bucket = []; +this.cityBuckets.set(key, bucket); +} +bucket.push(city); +} +} +cityBucketKey(x, y) { +return Math.floor(x / 8) + Math.floor(y / 8) * 4096; +} +getCitiesNear(x, y, radius) { +const cities = []; +const minBx = Math.floor((x - radius) / 8); +const maxBx = Math.floor((x + radius) / 8); +const minBy = Math.floor((y - radius) / 8); +const maxBy = Math.floor((y + radius) / 8); +for (let by = minBy; by <= maxBy; by++) { +for (let bx = minBx; bx <= maxBx; bx++) { +const bucket = this.cityBuckets.get(bx + by * 4096); +if (!bucket) continue; +for (const city of bucket) { +if (Math.abs(city.x - x) + Math.abs(city.y - y) <= radius) cities.push(city); +} +} +} +return cities; +} +seedInitialPopulationFieldDirectly(initialPopulation) { +const w = this.world; +w.population.fill(0); +w.settledPopulation.fill(0); +w.mobilePopulation.fill(0); +w.populationPressure.fill(0); +this.tileEthnicMix.clear(); +this.activePopulationTiles.clear(); +const founders = Math.max(6, Math.min(18, Math.round(initialPopulation / (650 * SimConfig.population.populationScale)))); +const desertFounders = Math.max(2, Math.floor(founders * 0.25)); +for (let e = 0; e < founders; e++) { +const desertFounder = e < desertFounders; +const origin = this.findFounderRegionTile(desertFounder); +const id = this.createEthnicity(0, { +temperature: w.temperature[origin], +humidity: w.humidity[origin] +}); +const traits = desertFounder ? this.desertFounderTraits() : this.randomTraits(); +const ethnicity = this.ethnicities.get(id); +if (ethnicity) ethnicity.averageTraits = traits; +const share = initialPopulation / founders * this.rng.range(0.72, 1.28); +this.seedEthnicPopulationPatch(origin, id, traits, share, desertFounder ? 5 : 4); +} +this.syncTilePopulationCulture(); +this.updatePopulationCapacity(); +this.populationFieldInitialized = true; +} +findFounderRegionTile(desertFounder = false) { +const w = this.world; +let best = 0; +let bestScore = -Infinity; +const tries = Math.max(120, Math.floor(w.size * 1.8)); +for (let n = 0; n < tries; n++) { +const tile = this.rng.int(w.count); +if (w.terrain[tile] === Terrain.WATER) continue; +const water = this.localWaterScore(tile, 5); +const desertFit = desertFounder ? (w.terrain[tile] === Terrain.DESERT ? 1.3 : -0.6) : (w.terrain[tile] === Terrain.DESERT ? -0.4 : 0); +const score = +w.fertility[tile] * 2.2 + +clamp(w.resource[tile] / 24, 0, 1.6) + +w.mineral[tile] * 0.45 + +water * 0.9 - +w.move[tile] * 0.55 + +desertFit + +this.rng.range(0, 0.55); +if (score > bestScore) { +best = tile; +bestScore = score; +} +} +if (bestScore === -Infinity) { +const fallback = this.findHabitableTile(desertFounder ? Terrain.DESERT : null); +return w.idx(fallback.x, fallback.y); +} +return best; +} +localWaterScore(tile, radius = 4) { +const w = this.world; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +let score = 0; +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const d = Math.abs(dx) + Math.abs(dy); +if (!d || d > radius) continue; +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; +if (w.terrain[w.idx(tx, ty)] === Terrain.WATER) score += (radius + 1 - d) / radius; +} +} +return score; +} +seedEthnicPopulationPatch(centerTile, ethnicityId, traits, totalPopulation, radius = 4) { +const w = this.world; +const cx = centerTile % w.size; +const cy = Math.floor(centerTile / w.size); +const tiles = []; +let weightTotal = 0; +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; +const d = offset.distance; +const x = cx + dx; +const y = cy + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const suitability = 0.35 + w.fertility[tile] + clamp(w.resource[tile] / 30, 0, 1) * 0.6 + w.mineral[tile] * 0.2; +const weight = Math.max(0.05, suitability * (radius - d + 1)); +tiles.push({ tile, weight }); +weightTotal += weight; +} +const settledShare = clamp(traits.sedentary ?? 0.45, 0.08, 0.9); +for (const entry of tiles) { +const amount = totalPopulation * entry.weight / Math.max(0.001, weightTotal); +if (amount <= 0.01) continue; +w.population[entry.tile] += amount; +w.settledPopulation[entry.tile] += amount * settledShare; +w.mobilePopulation[entry.tile] += amount * (1 - settledShare); +this.markPopulationTile(entry.tile); +let mix = this.tileEthnicMix.get(entry.tile); +if (!mix) { +mix = new Map(); +this.tileEthnicMix.set(entry.tile, mix); +} +mix.set(ethnicityId, (mix.get(ethnicityId) || 0) + amount); +this.updateCultureTile(entry.tile); +} +} +syncTilePopulationCulture() { +const w = this.world; +this.tileCultures = this.tileEthnicMix; +for (const tile of this.tileEthnicMix.keys()) this.updateCultureTile(tile); +this.rebuildActivePopulationTiles(); +for (const tile of this.activePopulationTiles) { +if (w.population[tile] <= 0 && !this.tileEthnicMix.has(tile)) { +w.dominantEthnicity[tile] = -1; +w.cultureDiversity[tile] *= 0.985; +} +} +} +projectCityPopulationToTiles(city) { +if (!city || city.population <= 0) return; +const w = this.world; +const radius = clamp(Math.ceil((city.agriculturalRadius || 2) * 0.75), 1, 8); +let totalWeight = 0; +const tiles = []; +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; +const distance = offset.distance; +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const weight = (radius - distance + 1) * (tile === w.idx(city.x, city.y) ? 1.8 : 1); +tiles.push({ tile, weight }); +totalWeight += weight; +} +if (!tiles.length || totalWeight <= 0) return; +const cityTotal = compositionTotal(city.ethnicityComposition); +const fallbackEthnicity = dominantComposition(city.ethnicityComposition); +for (const entry of tiles) { +const share = entry.weight / totalWeight; +const localPopulation = city.population * share; +w.population[entry.tile] += localPopulation; +w.settledPopulation[entry.tile] += localPopulation; +this.markPopulationTile(entry.tile); +let culture = this.tileEthnicMix.get(entry.tile); +if (!culture) { +culture = new Map(); +this.tileEthnicMix.set(entry.tile, culture); +} +if (cityTotal > 0) { +for (const [id, count] of city.ethnicityComposition) { +const amount = localPopulation * count / cityTotal; +if (amount > 0) culture.set(id, (culture.get(id) || 0) + amount); +} +} else if (fallbackEthnicity !== null) { +culture.set(fallbackEthnicity, (culture.get(fallbackEthnicity) || 0) + localPopulation); +} +} +} +updateCultureTile(tile) { +const w = this.world; +const sparseThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1); +if ((w.population[tile] || 0) < sparseThreshold) { +w.dominantEthnicity[tile] = -1; +w.cultureDiversity[tile] = 0; +w.farmingKnowledge[tile] = 0; +w.metallurgyKnowledge[tile] = 0; +this.tileEthnicMix.delete(tile); +return; +} +const counts = this.tileEthnicMix.get(tile); +if (!counts || !counts.size) { +w.dominantEthnicity[tile] = -1; +w.cultureDiversity[tile] = 0; +return; +} +let total = 0; +let dominant = -1; +let dominantCount = 0; +for (const [id, count] of counts) { +total += count; +if (count > dominantCount) { +dominant = id; +dominantCount = count; +} +} +this.world.dominantEthnicity[tile] = dominant; +this.world.cultureDiversity[tile] = total > 0 ? 1 - dominantCount / total : 0; +} +diffusePopulation(cohortIndex = 0, cohortCount = 1) { +const w = this.world; +const moves = this.populationMoveScratch; +moves.length = 0; +const tiles = this.activePopulationSnapshot(); +for (const tile of tiles) { +if (cohortCount > 1 && tile % cohortCount !== cohortIndex) continue; +const population = w.population[tile]; +const mobile = w.mobilePopulation[tile] || 0; +if (population < 1) continue; +const pressure = w.populationPressure[tile] || 0; +if (pressure <= 1.05 && (population <= 3 || mobile <= 0.5)) continue; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +let bestTile = -1; +let bestScore = -Infinity; +const radius = mobile > population * 0.35 || mobile > 3 ? 3 : 2; +for (const { dx, dy } of this.getRadiusOffsets(radius, false)) { +const tx = x + dx; +const ty = y + dy; +if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue; +const target = w.idx(tx, ty); +const score = this.populationDestinationScore(tile, target); +if (score > bestScore) { +bestScore = score; +bestTile = target; +} +} +if (bestTile < 0 || bestScore <= 0) continue; +const excess = Math.max(0, population - w.populationCapacity[tile]); +const frontierPush = mobile > 0.5 ? mobile * 0.012 : 0; +const excessPush = excess * 0.18; +const amount = Math.min(population * 0.055, excessPush + frontierPush); +if (amount >= 0.05) moves.push({ from: tile, to: bestTile, amount }); +} +for (const move of moves) this.moveEthnicPopulation(move.from, move.to, move.amount); +} +populationDestinationScore(fromTile, toTile) { +const w = this.world; +if (w.terrain[toTile] === Terrain.WATER) return -Infinity; +const fromDominant = w.dominantEthnicity[fromTile]; +const toDominant = w.dominantEthnicity[toTile]; +const sameEthnicity = fromDominant >= 0 && toDominant >= 0 && fromDominant === toDominant ? 1.1 : 0; +const frontier = (w.polity[toTile] < 0 ? 0.65 : 0) + ((w.population[toTile] || 0) < 1 ? 0.25 : 0); +const routeScore = (w.tradeRoute[toTile] ? 1.25 : 0) + clamp(w.pheromone[toTile] / 8, 0, 1.4); +const overcapacity = Math.max(0, w.populationPressure[toTile] - 0.85); +const foreignPolity = w.polity[toTile] >= 0 && w.polity[fromTile] >= 0 && w.polity[toTile] !== w.polity[fromTile] ? 1.25 + clamp(w.control[toTile], 0, 1) * 1.1 : 0; +return w.resource[toTile] * 0.06 + +w.fertility[toTile] * 1.4 + +w.mineral[toTile] * 0.35 + +w.cityPull[toTile] * 1.3 + +routeScore + +sameEthnicity + +frontier - +w.move[toTile] * 0.62 - +overcapacity * 2.4 - +foreignPolity; +} +moveEthnicPopulation(fromTile, toTile, amount) { +const w = this.world; +const fromMix = this.tileEthnicMix.get(fromTile); +if (!fromMix || amount <= 0 || w.population[fromTile] <= 0) return 0; +const moved = Math.min(amount, w.population[fromTile]); +let toMix = this.tileEthnicMix.get(toTile); +if (!toMix) { +toMix = new Map(); +this.tileEthnicMix.set(toTile, toMix); +} +const total = compositionTotal(fromMix); +if (total <= 0) return 0; +for (const [id, count] of [...fromMix]) { +const ethnicMove = Math.min(count, moved * count / total); +if (ethnicMove <= 0) continue; +const remaining = count - ethnicMove; +if (remaining > 0.01) fromMix.set(id, remaining); +else fromMix.delete(id); +toMix.set(id, (toMix.get(id) || 0) + ethnicMove); +} +if (!fromMix.size) this.tileEthnicMix.delete(fromTile); +const settledShare = w.population[fromTile] > 0 ? w.settledPopulation[fromTile] / w.population[fromTile] : 0; +const settledMove = moved * clamp(settledShare, 0, 1); +const mobileMove = moved - settledMove; +w.population[fromTile] = Math.max(0, w.population[fromTile] - moved); +w.population[toTile] += moved; +w.settledPopulation[fromTile] = Math.max(0, w.settledPopulation[fromTile] - settledMove); +w.mobilePopulation[fromTile] = Math.max(0, w.mobilePopulation[fromTile] - mobileMove); +w.settledPopulation[toTile] += settledMove; +w.mobilePopulation[toTile] += mobileMove; +this.markPopulationTile(toTile); +this.updateCultureTile(fromTile); +this.updateCultureTile(toTile); +this.cleanupPopulationTile(fromTile); +return moved; +} +growTilePopulation() { +const w = this.world; +const tiles = this.activePopulationSnapshot(); +for (const tile of tiles) { +const population = w.population[tile]; +if (population <= 0 || w.terrain[tile] === Terrain.WATER) continue; +const pressure = w.populationPressure[tile]; +const baseGrowth = 0.004 + w.fertility[tile] * 0.010 + w.farmland[tile] * 0.007 + clamp(w.resource[tile] / 40, 0, 1) * 0.004; +const pressurePenalty = pressure > 1 ? (pressure - 1) * 0.020 : 0; +const warPenalty = w.contested[tile] ? 0.006 : 0; +const rate = clamp(baseGrowth - pressurePenalty - warPenalty, -0.045, 0.035); +const change = population * rate; +if (Math.abs(change) < 0.01) continue; +if (change > 0) this.addEthnicPopulationProportionally(tile, change); +else this.removeEthnicPopulationProportionally(tile, -change); +} +} +addEthnicPopulationProportionally(tile, amount) { +const w = this.world; +let mix = this.tileEthnicMix.get(tile); +if (!mix) { +const dominant = w.dominantEthnicity[tile]; +if (dominant < 0) return; +mix = new Map([[dominant, amount]]); +this.tileEthnicMix.set(tile, mix); +w.population[tile] += amount; +w.settledPopulation[tile] += amount; +this.markPopulationTile(tile); +this.updateCultureTile(tile); +return; +} +const total = Math.max(0.001, compositionTotal(mix)); +for (const [id, count] of [...mix]) mix.set(id, count + amount * count / total); +w.population[tile] += amount; +w.settledPopulation[tile] += amount; +this.markPopulationTile(tile); +this.updateCultureTile(tile); +} +removeEthnicPopulationProportionally(tile, amount) { +const w = this.world; +const mix = this.tileEthnicMix.get(tile); +if (!mix) return 0; +const removed = Math.min(amount, w.population[tile]); +const total = Math.max(0.001, compositionTotal(mix)); +for (const [id, count] of [...mix]) { +const loss = removed * count / total; +const remaining = count - loss; +if (remaining > 0.01) mix.set(id, remaining); +else mix.delete(id); +} +if (!mix.size) this.tileEthnicMix.delete(tile); +const settledShare = w.population[tile] > 0 ? w.settledPopulation[tile] / w.population[tile] : 0; +w.population[tile] = Math.max(0, w.population[tile] - removed); +w.settledPopulation[tile] = Math.max(0, w.settledPopulation[tile] - removed * settledShare); +w.mobilePopulation[tile] = Math.max(0, w.mobilePopulation[tile] - removed * (1 - settledShare)); +this.updateCultureTile(tile); +this.cleanupPopulationTile(tile); +return removed; +} +consumeTileResources() { +const w = this.world; +const tiles = this.activePopulationSnapshot(); +for (const tile of tiles) { +const population = w.population[tile]; +if (population <= 0 || w.terrain[tile] === Terrain.WATER) continue; +const pressure = w.populationPressure[tile] || 0; +const demand = (population / SimConfig.population.populationScale) * (0.0045 + Math.max(0, pressure - 1) * 0.0035); +w.resource[tile] = Math.max(0, w.resource[tile] - demand); +} +} +clearSparseHumanData() { +const w = this.world; +const threshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1); +if (threshold <= 0) return 0; +let cleared = 0; +for (const tile of this.activePopulationSnapshot()) { +if ((w.population[tile] || 0) >= threshold) continue; +const hadData = w.farmingKnowledge[tile] !== 0 || w.metallurgyKnowledge[tile] !== 0 || +w.dominantEthnicity[tile] >= 0 || w.cultureDiversity[tile] !== 0 || this.tileEthnicMix.has(tile); +if (!hadData) continue; +w.farmingKnowledge[tile] = 0; +w.metallurgyKnowledge[tile] = 0; +w.dominantEthnicity[tile] = -1; +w.cultureDiversity[tile] = 0; +this.tileEthnicMix.delete(tile); +cleared++; +} +return cleared; +} +updateTileAssimilation() { +const w = this.world; +for (const [tile, mix] of [...this.tileEthnicMix]) { +if (!mix || mix.size <= 1) continue; +const total = compositionTotal(mix); +if (total <= 0.5) continue; +let dominant = null; +let dominantCount = 0; +for (const [id, count] of mix) { +if (count > dominantCount) { +dominant = id; +dominantCount = count; +} +} +if (dominant == null || dominantCount <= 0) continue; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +const nearCity = this.getCitiesNear(x, y, 5).length ? 1 : 0; +const control = clamp(w.control[tile] || 0, 0, 1); +const route = w.tradeRoute[tile] ? 1 : 0; +const diversity = clamp(w.cultureDiversity[tile] || 0, 0, 1); +const dominantTraits = this.ethnicities.get(dominant)?.averageTraits; +const assimilation = dominantTraits?.assimilation ?? 0.12; +const baseRate = (0.0004 + nearCity * 0.00045 + control * 0.00035 + route * 0.00035 + diversity * 0.00025) * (0.5 + assimilation); +let gained = 0; +for (const [id, count] of [...mix]) { +if (id === dominant || count <= 0) continue; +const minorityTraits = this.ethnicities.get(id)?.averageTraits; +const resistance = minorityTraits?.ethnocentrism ?? 0.35; +const converted = Math.min(count, count * baseRate * clamp(1.15 - resistance * 0.55, 0.2, 1.15)); +if (converted <= 0.001) continue; +const remaining = count - converted; +if (remaining > 0.01) mix.set(id, remaining); +else mix.delete(id); +gained += converted; +} +if (gained > 0) { +mix.set(dominant, (mix.get(dominant) || 0) + gained); +this.updateCultureTile(tile); +} +} +} +blendEthnicComposition(target, targetTotal, source, rate) { +if (!target || !source?.size || targetTotal <= 0 || rate <= 0) return false; +const sourceTotal = compositionTotal(source); +if (sourceTotal <= 0) return false; +const blendRate = clamp(rate, 0, 0.25); +const ids = new Set([...target.keys(), ...source.keys()]); +for (const id of ids) { +const current = target.get(id) || 0; +const desired = targetTotal * (source.get(id) || 0) / sourceTotal; +const next = current + (desired - current) * blendRate; +if (next > 0.01) target.set(id, next); +else target.delete(id); +} +return true; +} +blendTileEthnicComposition(tile, source, rate) { +const population = this.world.population[tile] || 0; +if (population <= 0.01 || this.world.terrain[tile] === Terrain.WATER || !source?.size) return false; +let target = this.tileEthnicMix.get(tile); +if (!target?.size) { +target = new Map(); +const sourceTotal = compositionTotal(source); +if (sourceTotal <= 0) return false; +for (const [id, count] of source) target.set(id, population * count / sourceTotal); +this.tileEthnicMix.set(tile, target); +this.updateCultureTile(tile); +return true; +} +const targetTotal = compositionTotal(target); +const changed = this.blendEthnicComposition(target, targetTotal, source, rate); +if (changed) this.updateCultureTile(tile); +return changed; +} +spreadCulturesFromCitiesAndTrade() { +const w = this.world; +const cfg = SimConfig.culture || {}; +const urbanRadius = cfg.urbanSpreadRadius ?? 5; +const cityRate = cfg.cityBlendRate ?? 0.045; +for (const city of this.cities) { +if (!city.ethnicityComposition?.size || city.population <= 0) continue; +const populationStrength = clamp(Math.sqrt(city.population / (SimConfig.city.populationScale || 1)) / 18, 0.35, 1.4); +for (const { dx, dy, distance } of this.getRadiusOffsets(urbanRadius)) { +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER || (w.population[tile] || 0) <= 0.01) continue; +const falloff = 1 - distance / (urbanRadius + 1); +const routeBoost = w.tradeRoute[tile] ? 1.35 : 1; +this.blendTileEthnicComposition(tile, city.ethnicityComposition, cityRate * populationStrength * falloff * routeBoost); +} +} +const routeRate = cfg.routeBlendRate ?? 0.035; +const neighborFactor = cfg.routeNeighborFactor ?? 0.35; +const cityTradeRate = cfg.cityTradeBlendRate ?? 0.015; +for (const link of this.tradeLinks || []) { +const a = this.getCityById(link.from); +const b = this.getCityById(link.to); +if (!a?.ethnicityComposition?.size || !b?.ethnicityComposition?.size) continue; +const aSource = new Map(a.ethnicityComposition); +const bSource = new Map(b.ethnicityComposition); +this.blendEthnicComposition(a.ethnicityComposition, compositionTotal(a.ethnicityComposition), bSource, cityTradeRate * (0.5 + (link.strength || 0))); +this.blendEthnicComposition(b.ethnicityComposition, compositionTotal(b.ethnicityComposition), aSource, cityTradeRate * (0.5 + (link.strength || 0))); +const routeCulture = new Map(); +for (const [id, count] of aSource) routeCulture.set(id, (routeCulture.get(id) || 0) + count / Math.max(1, a.population)); +for (const [id, count] of bSource) routeCulture.set(id, (routeCulture.get(id) || 0) + count / Math.max(1, b.population)); +const localRate = routeRate * clamp(0.6 + (link.strength || 0), 0.6, 1.8); +for (const tile of link.path || []) { +if ((w.population[tile] || 0) > 0.01) this.blendTileEthnicComposition(tile, routeCulture, localRate); +this.forCardinalNeighbors(tile, neighbor => { +if ((w.population[neighbor] || 0) > 0.01) this.blendTileEthnicComposition(neighbor, routeCulture, localRate * neighborFactor); +}); +} +} +} +farmabilityAt(tile) { +const t = this.world.terrain[tile]; +if (t === Terrain.PLAINS) return 0.75; +if (t === Terrain.FOREST) return 0.55; +if (t === Terrain.DESERT) return 0.25; +return 0.0; +} +initializePopulationCapacityCache() { +const w = this.world; +const { carryingCapacityBase, carryingCapacityFertility, carryingCapacityMineral } = SimConfig.population; +const populationScale = SimConfig.population.populationScale; +for (let i = 0; i < w.count; i++) { +if (w.terrain[i] === Terrain.WATER) { +this.populationCapacityStatic[i] = 0.35 * populationScale; +this.populationCapacityDynamicScale[i] = 0; +continue; +} +const terrainFactor = +w.terrain[i] === Terrain.MOUNTAIN ? 0.62 : +w.terrain[i] === Terrain.DESERT ? 0.55 : +w.terrain[i] === Terrain.FOREST ? 0.92 : +1; +const scale = terrainFactor * populationScale; +this.populationCapacityStatic[i] = ( +carryingCapacityBase + +w.fertility[i] * carryingCapacityFertility + +w.mineral[i] * carryingCapacityMineral +) * scale; +this.populationCapacityDynamicScale[i] = scale; +} +} +updatePopulationPressure() { +const w = this.world; +for (let i = 0; i < w.count; i++) { +const capacity = w.populationCapacity[i]; +w.populationPressure[i] = capacity > 0 ? w.population[i] / capacity : 0; +} +} +updatePopulationCapacityTile(i) { +const w = this.world; +const scale = this.populationCapacityDynamicScale[i]; +const staticCapacity = this.populationCapacityStatic[i]; +if (scale === 0) { +w.populationCapacity[i] = staticCapacity; +w.populationPressure[i] = staticCapacity > 0 ? w.population[i] / staticCapacity : 0; +return; +} +const maxPheromone = Math.max(1, SimConfig.route.maxPheromone); +const routeBonus = w.tradeRoute[i] ? 1.6 : Math.min(1, Math.max(0, w.pheromone[i] / maxPheromone)) * 0.8; +const cityBonus = w.city[i] >= 0 ? 3.5 : w.cityPull[i] * 2.2; +const capacity = staticCapacity + ( +w.farmland[i] * SimConfig.population.carryingCapacityFarmland + routeBonus + cityBonus +) * scale; +w.populationCapacity[i] = capacity; +w.populationPressure[i] = capacity > 0 ? w.population[i] / capacity : 0; +} +updatePopulationCapacity() { +for (let i = 0; i < this.world.count; i++) this.updatePopulationCapacityTile(i); +} +updateTileTechnology() { +const w = this.world; +const tiles = this.activePopulationSnapshot(); +const humanThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1); +for (const tile of tiles) { +if (w.population[tile] < humanThreshold || w.terrain[tile] === Terrain.WATER) { +w.farmingKnowledge[tile] = 0; +w.metallurgyKnowledge[tile] = 0; +continue; +} +const settled = w.settledPopulation[tile] || 0; +const density = clamp(Math.log1p(settled) / Math.log(80), 0, 1.4); +const cityPull = w.city[tile] >= 0 ? 1 : clamp(w.cityPull[tile], 0, 1); +const route = w.tradeRoute[tile] ? 0.35 : clamp(w.pheromone[tile] / SimConfig.route.maxPheromone, 0, 0.25); +const farmingGain = SimConfig.technology.cityInnovation * 0.45 * density * (0.2 + this.farmabilityAt(tile) + w.farmland[tile] * 0.8 + cityPull * 0.45 + route); +const metalTerrain = w.terrain[tile] === Terrain.MOUNTAIN ? 1.45 : 1; +const metallurgyGain = SimConfig.technology.cityInnovation * 0.32 * density * (0.15 + w.mineral[tile] * metalTerrain + cityPull * 0.30 + route); +w.farmingKnowledge[tile] = clamp(w.farmingKnowledge[tile] + farmingGain, 0, 1); +w.metallurgyKnowledge[tile] = clamp(w.metallurgyKnowledge[tile] + metallurgyGain, 0, 1); +} +} +diffuseTileTechnology() { +const w = this.world; +const nextFarming = this.scratchFloatA; +const nextMetallurgy = this.scratchFloatB; +const humanThreshold = Math.max(0, SimConfig.population.humanDataMinPopulation ?? 1); +nextFarming.set(w.farmingKnowledge); +nextMetallurgy.set(w.metallurgyKnowledge); +const sources = this.technologySourceScratch; +sources.length = 0; +const marker = this.nextVisitMarker(); +for (const tile of this.activePopulationTiles) { +this.visitStamp[tile] = marker; +sources.push(tile); +} +for (const tile of this.activeTradeRouteTiles) { +if (this.visitStamp[tile] === marker) continue; +this.visitStamp[tile] = marker; +sources.push(tile); +} +for (const tile of sources) { +if ((w.population[tile] || 0) < humanThreshold) { +nextFarming[tile] = 0; +nextMetallurgy[tile] = 0; +continue; +} +this.forCardinalNeighbors(tile, n => { +if (w.terrain[n] === Terrain.WATER || (w.population[n] || 0) < humanThreshold) { +nextFarming[n] = 0; +nextMetallurgy[n] = 0; +return; +} +const routeBoost = w.tradeRoute[tile] || w.tradeRoute[n] ? 1.9 : 1; +const popBoost = clamp(Math.log1p(w.population[n]) / Math.log(60), 0.2, 1.2); +const rate = clamp((0.010 + (w.cityPull[tile] + w.cityPull[n]) * 0.004) * routeBoost * popBoost, 0.002, 0.035); +nextFarming[n] = clamp(nextFarming[n] + (w.farmingKnowledge[tile] - w.farmingKnowledge[n]) * rate, 0, 1); +nextMetallurgy[n] = clamp(nextMetallurgy[n] + (w.metallurgyKnowledge[tile] - w.metallurgyKnowledge[n]) * rate, 0, 1); +}); +} +w.farmingKnowledge.set(nextFarming); +w.metallurgyKnowledge.set(nextMetallurgy); +} +cityTechnologyExchange() { +const w = this.world; +for (const city of this.cities) { +city.knowledge ??= { farming: 0, metallurgy: 0 }; +const radius = Math.max(1, city.agriculturalRadius || 2); +let farming = 0; +let metallurgy = 0; +let total = 0; +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; +const d = offset.distance; +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +const weight = (radius - d + 1) * Math.max(0.2, w.population[tile]); +farming += w.farmingKnowledge[tile] * weight; +metallurgy += w.metallurgyKnowledge[tile] * weight; +total += weight; +} +if (total > 0) { +city.knowledge.farming = clamp(Math.max(city.knowledge.farming, farming / total * 0.86), 0, 1); +city.knowledge.metallurgy = clamp(Math.max(city.knowledge.metallurgy, metallurgy / total * 0.82), 0, 1); +} +const cityTile = w.idx(city.x, city.y); +w.farmingKnowledge[cityTile] = clamp(Math.max(w.farmingKnowledge[cityTile], city.knowledge.farming * 0.72), 0, 1); +w.metallurgyKnowledge[cityTile] = clamp(Math.max(w.metallurgyKnowledge[cityTile], city.knowledge.metallurgy * 0.68), 0, 1); +for (const link of this.tradeLinks || []) { +if (link.from !== city.id && link.to !== city.id) continue; +const other = this.getCityById(link.from === city.id ? link.to : link.from); +if (!other?.knowledge) continue; +const rate = clamp(SimConfig.technology.tradeDiffusion * (0.5 + (link.strength || 0.2)), 0, 0.012); +city.knowledge.farming = clamp(city.knowledge.farming + (other.knowledge.farming - city.knowledge.farming) * rate, 0, 1); +city.knowledge.metallurgy = clamp(city.knowledge.metallurgy + (other.knowledge.metallurgy - city.knowledge.metallurgy) * rate, 0, 1); +} +} +} +climateMismatch(ethnicityId, tile) { +const ethnicity = this.ethnicities.get(ethnicityId); +if (!ethnicity) return 0; +const tempDiff = Math.abs(this.world.temperature[tile] - ethnicity.climateTemp); +const humidDiff = Math.abs(this.world.humidity[tile] - ethnicity.climateHumidity); +return tempDiff * 0.58 + humidDiff * 0.42; +} +updateWorldFields(scale = 1) { +// Preserve the former biweekly pheromone diffusion semantics while keeping +// pheromone work sparse. Resource regeneration is batched separately by step(). +let remaining = Math.max(0, scale); +let pheromoneChanged = false; +while (remaining > 0) { +const chunk = Math.min(2, remaining); +if (this.updatePheromoneField(chunk)) pheromoneChanged = true; +remaining -= chunk; +} +return pheromoneChanged; +} +regenerateResources(scale = 1) { +const w = this.world; +for (let i = 0; i < w.count; i++) { +w.resource[i] = Math.min(58, w.resource[i] + w.regen[i] * scale * (1 + w.farmland[i] * 1.15)); +} +} +updatePheromoneField(scale = 1) { +const w = this.world; +const active = this.activePheromoneTiles; +if (!active?.size) return false; +const decay = Math.pow(SimConfig.route.pheromoneDecay, scale); +const diffusion = SimConfig.route.pheromoneDiffusion * scale; +const maxPheromone = SimConfig.route.maxPheromone; +const touched = this.pheromoneTouchedScratch; +touched.length = 0; +const marker = this.nextVisitMarker(); +const starts = w.landNeighborStarts; +const neighbors = w.landNeighbors; +const markTouched = tile => { +if (this.visitStamp[tile] === marker) return; +this.visitStamp[tile] = marker; +touched.push(tile); +}; +for (const tile of active) { +if (tile < 0 || tile >= w.count) continue; +markTouched(tile); +if (w.terrain[tile] === Terrain.WATER) continue; +for (let n = starts[tile]; n < starts[tile + 1]; n++) markTouched(neighbors[n]); +} +const decayed = this.scratchFloatC; +for (const tile of touched) decayed[tile] = w.pheromone[tile] * decay; +const nextActive = this.nextPheromoneTiles; +nextActive.clear(); +for (const tile of touched) { +let value; +if (w.terrain[tile] === Terrain.WATER) { +value = Math.min(maxPheromone, decayed[tile] * 0.85); +} else { +let neighborSum = 0; +const begin = starts[tile]; +const end = starts[tile + 1]; +for (let n = begin; n < end; n++) neighborSum += decayed[neighbors[n]]; +const neighborCount = end - begin; +const neighborAverage = neighborCount ? neighborSum / neighborCount : decayed[tile]; +value = Math.min(maxPheromone, lerp(decayed[tile], neighborAverage, diffusion)); +} +if (value < 0.0005) value = 0; +w.pheromone[tile] = value; +if (value > 0) nextActive.add(tile); +} +this.activePheromoneTiles = nextActive; +this.nextPheromoneTiles = active; +return touched.length > 0; +} + +updateCities() { +const w = this.world; +w.city.fill(-1); +w.farmland.fill(0); +w.cityPull.fill(0); +const candidates = new Map(); +// Keep the previous valid capacity while rebuilding farmland/city influence. +// Recomputing here would scan the whole map only to discard both bonuses; the +// refreshed capacity is calculated once after the rebuilt fields are ready. +const activeTiles = this.activePopulationSnapshot(); +for (const tile of activeTiles) { +if (w.terrain[tile] === Terrain.WATER) continue; +const population = w.population[tile] || 0; +const settled = w.settledPopulation[tile] || 0; +if (settled < 5 && population < 9) continue; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +const foundingSpacing = SimConfig.city.naturalFoundingMinDistance ?? 6; +if (this.getCitiesNear(x, y, Math.max(1, foundingSpacing - 1)).length) continue; +const cx = clamp(Math.round(x / 4) * 4, 0, w.size - 1); +const cy = clamp(Math.round(y / 4) * 4, 0, w.size - 1); +const i = w.idx(cx, cy); +let group = candidates.get(i); +if (!group) { +group = { count: 0, resources: 0, sedentary: 0, farming: 0, metallurgy: 0, techCount: 0, ethnicities: new Map(), sourceTiles: [] }; +candidates.set(i, group); +} +const localWeight = 0.65 + w.fertility[tile] * 0.35 + w.cityPull[tile] * 0.25 + (w.tradeRoute[tile] ? 0.2 : 0); +group.count += population * localWeight; +group.resources += Math.max(0, w.resource[tile]) * Math.max(0.25, population * 0.06); +group.sedentary += settled; +group.farming += w.farmland[tile] * settled; +group.metallurgy += w.mineral[tile] * population; +group.techCount += Math.max(1, population); +group.sourceTiles.push(tile); +const mix = this.tileEthnicMix.get(tile); +if (mix) { +for (const [id, count] of mix) group.ethnicities.set(id, (group.ethnicities.get(id) || 0) + count); +} +} +let foundedThisTick = 0; +const foundingStartYears = SimConfig.city.naturalFoundingStartYears ?? 160; +const foundingIntervalYears = Math.max(1, SimConfig.city.naturalFoundingIntervalYears ?? 5); +const canFoundCities = this.year >= years(foundingStartYears) && this.year % years(foundingIntervalYears) === 0; +const foundingLimit = canFoundCities ? this.naturalCityFoundingLimit() : 0; +const scoredCandidates = [...candidates].map(([tile, group]) => ({ +tile, +group, +score: this.cityFoundingScore(tile, group) +})); +const bestFoundingScore = scoredCandidates.reduce((best, entry) => Math.max(best, entry.score), -Infinity); +// Near-equivalent sites should not always resolve to the exact same maximum. +// Keep strong suitability preference, but randomize within the broad top band. +for (const entry of scoredCandidates) { +const nearBest = entry.score >= bestFoundingScore - 4.0; +entry.priority = nearBest ? bestFoundingScore + this.rng.range(0, 4.0) : entry.score; +} +scoredCandidates.sort((a, b) => b.priority - a.priority); +const candidateEntries = scoredCandidates.map(entry => [entry.tile, entry.group]); +for (const [i, group] of candidateEntries) { +if (group.count < 24) continue; +const avgSedentary = clamp(group.sedentary / Math.max(1, group.count), 0, 1); +const foundingSpacing = SimConfig.city.naturalFoundingMinDistance ?? 6; +let city = this.getCitiesNear(i % w.size, Math.floor(i / w.size), foundingSpacing)[0] || null; +if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) { +const foundingScore = this.cityFoundingScore(i, group); +const foundingChance = clamp((foundingScore - 12) * 0.030, 0.01, 0.38); +if (avgSedentary < 0.38 || foundingScore < 12 || this.rng.next() > foundingChance) continue; +city = this.createCity(i % w.size, Math.floor(i / w.size), group); +this.cities.push(city); +this.cityById.set(city.id, city); +const bucketKey = this.cityBucketKey(city.x, city.y); +if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []); +this.cityBuckets.get(bucketKey).push(city); +this.assignNewCityToTerritoryOwner(city, i); +foundedThisTick++; +} +if (city) { +city.activeVisitors += group.count; +city.storedResources += group.resources * 0.08; +city.sedentaryCulture = city.sedentaryCulture * 0.98 + avgSedentary * 0.02; +city.knowledge ??= { farming: 0, metallurgy: 0 }; +city.knowledge.farming = Math.max(city.knowledge.farming * 0.998, group.farming / Math.max(1, group.techCount)); +city.knowledge.metallurgy = Math.max(city.knowledge.metallurgy * 0.998, group.metallurgy / Math.max(1, group.techCount)); +const urbanWeight = clamp((avgSedentary - 0.18) * 1.45, 0.08, 1); +for (const [id, count] of group.ethnicities) { +const urbanCount = this.weightedUrbanContribution(count * 0.2 * SimConfig.city.populationScale / SimConfig.population.populationScale, urbanWeight); +if (urbanCount > 0) city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + urbanCount); +} +city.strength = city.strength * 0.96 + group.count * 0.05; +} +} +this.absorbUrbanPopulation(); +this.rebuildIndexes(); +this.processCityEconomies(); +const removedCities = []; +this.cities = this.cities.filter(c => { +c.age++; +c.strength *= 0.992; +const foodPerCapita = c.storedResources / Math.max(1, c.population); +if (c.age > 20) { +if (c.activeVisitors < 2 && foodPerCapita < 0.04) c.strength -= 0.035; +else if (c.activeVisitors < 5 && foodPerCapita < 0.02) c.strength -= 0.015; +if (c.population < 25 && foodPerCapita < 0.05) c.strength -= 0.018; +} +if (c.population < (SimConfig.city.abandonmentPopulation ?? 200) || c.strength <= 0.06) { +removedCities.push(c); +return false; +} +const radius = c.agriculturalRadius; +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; +const x = clamp(c.x + dx, 0, w.size - 1); +const y = clamp(c.y + dy, 0, w.size - 1); +const d = offset.distance; +const tile = w.idx(x, y); +if (w.terrain[tile] !== Terrain.WATER) { +w.farmland[tile] = Math.max(w.farmland[tile], (radius - d + 1) / (radius + 1)); +w.cityPull[tile] = Math.max(w.cityPull[tile], (radius - d + 1) / (radius + 1)); +} +} +w.city[w.idx(c.x, c.y)] = c.id; +return true; +}); +for (const city of removedCities) { +const oldPolityId = city.polityId; +if (oldPolityId !== null) this.removeCityFromPolity(city); +const tile = w.idx(city.x, city.y); +if (w.city[tile] === city.id) w.city[tile] = -1; +if (oldPolityId !== null) this.releaseCityTerritory(city, oldPolityId, 5, "cityAbandoned"); +} +this.rebuildIndexes(); +this.updatePopulationCapacity(); +if (removedCities.length) this.pruneOrphanTradeRoutes(); +} +naturalCityFoundingLimit() { +const referenceArea = Math.max(1, SimConfig.city.naturalFoundingReferenceArea ?? (160 * 160)); +const maxPerCycle = Math.max(1, SimConfig.city.naturalFoundingMaxPerCycle ?? 8); +return clamp(Math.ceil(this.world.count / referenceArea), 1, maxPerCycle); +} +cityFoundingScore(tile, group) { +const w = this.world; +const settledShare = group.count > 0 ? group.sedentary / group.count : 0; +const pressure = w.populationPressure[tile] || 0; +const route = w.tradeRoute[tile] ? 1.4 : clamp(w.pheromone[tile] / 10, 0, 1.2); +const spacing = SimConfig.city.naturalFoundingMinDistance ?? 6; +const nearbyPenalty = this.getCitiesNear(tile % w.size, Math.floor(tile / w.size), SimConfig.city.foundingCompetitionRadius ?? spacing + 3).length * 11; +return group.count * (0.45 + settledShare) + +pressure * 2.2 + +w.fertility[tile] * 3.4 + +clamp(w.resource[tile] / 18, 0, 3) + +route + +w.mineral[tile] * 0.9 - +w.move[tile] * 1.1 - +nearbyPenalty; +} +assignNewCityToTerritoryOwner(city, tile, options = {}) { +if (!options.explicit) return false; +const w = this.world; +const ownerId = w.territoryOwner?.[tile] ?? -1; +if (!city || ownerId < 0) return false; +const polity = this.getPolityById(ownerId); +if (!polity) return false; +if (city.polityId != null && city.polityId !== ownerId) return false; +const control = clamp(w.control?.[tile] || 0, 0, 1); +const contested = !!w.contested?.[tile]; +if (contested && control < 0.35) return false; +const cityEthnicity = this.dominantCityEthnicity(city); +const nearest = this.nearestPolityCityToTile(polity, tile); +const core = nearest || this.getCityById(polity.centerCityId); +const coreEthnicity = this.dominantCityEthnicity(core); +const sameEthnicity = cityEthnicity !== null && coreEthnicity !== null && cityEthnicity === coreEthnicity; +const differentEthnicity = cityEthnicity !== null && coreEthnicity !== null && cityEthnicity !== coreEthnicity; +const distance = nearest ? Math.abs(nearest.x - city.x) + Math.abs(nearest.y - city.y) : 24; +const loyalty = 0.38 + control * 0.34 - (contested ? 0.18 : 0) + (sameEthnicity ? 0.10 : 0) - (differentEthnicity ? 0.10 : 0) - clamp((distance - 18) * 0.004, 0, 0.10); +this.addCityToPolity(city, polity, clamp(loyalty, 0.18, 0.82)); +return true; +} +nearestPolityCityToTile(polity, tile) { +const w = this.world; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +let best = null; +let bestDistance = Infinity; +for (const city of this.getPolityCities(polity)) { +const distance = Math.abs(city.x - x) + Math.abs(city.y - y); +if (distance < bestDistance) { +best = city; +bestDistance = distance; +} +} +return best; +} +drawPopulationFromTilesForCity(city, group, amount) { +const w = this.world; +let remaining = Math.max(0, amount); +for (const tile of group?.sourceTiles || [w.idx(city.x, city.y)]) { +if (remaining <= 0) break; +const available = Math.max(0, w.settledPopulation[tile] || w.population[tile] || 0); +if (available <= 0) continue; +const take = Math.min(available, remaining); +this.removeEthnicPopulationProportionally(tile, take); +remaining -= take; +} +} +createCity(x, y, seedGroup = null, options = {}) { +const scale = SimConfig.city.populationScale; +const seedPopulation = Math.max(1, Math.round(options.initialPopulation ?? SimConfig.city.initialPopulation ?? 400)); +const seedSedentary = seedGroup ? seedGroup.sedentary / Math.max(1, seedGroup.count) : 0.5; +const composition = new Map(); +if (seedGroup) { +const urbanWeight = clamp((seedSedentary - 0.18) * 1.45, 0.08, 1); +for (const [id, count] of seedGroup.ethnicities) { +const urbanCount = this.weightedUrbanContribution(count * 0.42 * scale / SimConfig.population.populationScale, urbanWeight); +if (urbanCount > 0) composition.set(id, urbanCount); +} +} +const city = { +id: this.nextCity++, +x, +y, +population: seedPopulation, +storedResources: options.initialResources ?? (34 + (seedGroup?.resources || 0) * 0.55) * scale, +ethnicityComposition: composition, +pheromoneOutput: 0, +agriculturalRadius: 2, +tradeLinks: new Set(), +activeVisitors: 0, +age: 0, +peakPopulation: seedPopulation, +strength: 3, +sedentaryCulture: seedSedentary, +knowledge: { farming: 0, metallurgy: 0 }, +supplyStress: 0, +polityId: null, +loyalty: 0.5, +receivedAid: false, +tradeValue: 0, +tradeReach: 0, +rebellionProtectedUntil: 0 +}; +if (seedGroup) this.drawPopulationFromTilesForCity(city, seedGroup, seedPopulation); +this.projectCityPopulationToTiles(city); +this.syncTilePopulationCulture(); +return city; +} +cityCarryingCapacity(city) { +const w = this.world; +if (!city) return 1; +const radius = Math.max(2, city.agriculturalRadius || 2); +let farmlandSum = 0; +let fertilitySum = 0; +let resourceSum = 0; +let weightSum = 0; +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; +const distance = offset.distance; +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const weight = (radius - distance + 1) / (radius + 1); +farmlandSum += (w.farmland[tile] || 0) * weight; +fertilitySum += (w.fertility[tile] || 0) * weight; +resourceSum += clamp((w.resource[tile] || 0) / 35, 0, 1) * weight; +weightSum += weight; +} +const hinterlandFertility = weightSum > 0 ? fertilitySum / weightSum : 0; +const resourceSupport = weightSum > 0 ? resourceSum / weightSum : 0; +let capacity = +35 + +hinterlandFertility * 120 + +resourceSupport * 55 + +farmlandSum * 18 + +(city.tradeValue || 0) * 45 + +(city.knowledge?.farming || 0) * 90 + +Math.sqrt(Math.max(0, city.storedResources || 0)) * 3; +return Math.max(20, capacity) * SimConfig.city.populationScale; +} +absorbUrbanPopulation() { +// Population is already absorbed into cities when field-founded settlements draw +// from nearby tile population. This hook remains for the city update pipeline. +} +weightedUrbanContribution(amount, weight) { +const value = amount * weight; +const whole = Math.floor(value); +return whole + (this.rng.next() < value - whole ? 1 : 0); +} +cityRecoveryGrowthBonus(city) { +if (!city || city.population <= 0) return 0; +const peak = Math.max(city.population, city.peakPopulation || city.population); +if (peak <= 0) return 0; +const recoveryGap = clamp((peak - city.population) / peak, 0, 0.75); +return recoveryGap * (SimConfig.city.postShockRecoveryGrowth ?? 0.045); +} +processCityEconomies() { +const w = this.world; +for (const city of this.cities) { +city.peakPopulation = Math.max(city.peakPopulation || 0, city.population || 0); +const populationScale = SimConfig.city.populationScale; +city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population / populationScale) / 4.5), 2, 14); +city.pheromoneOutput = clamp(Math.log2(city.population + 1) * 0.09, 0.15, 1.8); +city.knowledge ??= { farming: 0, metallurgy: 0 }; +let harvested = 0; +const radius = city.agriculturalRadius; +for (const offset of this.getRadiusOffsets(radius)) { +const dx = offset.dx; +const dy = offset.dy; +const distance = offset.distance; +const x = clamp(city.x + dx, 0, w.size - 1); +const y = clamp(city.y + dy, 0, w.size - 1); +const i = w.idx(x, y); +if (w.terrain[i] === Terrain.WATER) continue; +const pull = (radius - distance + 1) / (radius + 1); +const farmingYield = 1 + city.knowledge.farming * 0.85; +const metallurgyYield = 1 + city.knowledge.metallurgy * w.mineral[i] * 0.35; +const extraction = Math.min(w.resource[i], (0.07 + w.fertility[i] * 0.18 * farmingYield + w.mineral[i] * 0.045 * metallurgyYield) * pull * SimConfig.city.foodProductionScale); +w.resource[i] -= extraction; +w.farmland[i] = Math.max(w.farmland[i], pull); +this.addPheromone(i, city.pheromoneOutput * pull * 0.09); +harvested += extraction; +} +city.storedResources += harvested; +const trade = this.cityTradeProfile(city); +const tradeIncome = trade.value * (0.18 + Math.sqrt(Math.max(0, city.population / populationScale)) * 0.018) * populationScale; +city.storedResources += tradeIncome; +const supportRatio = (city.activeVisitors / SimConfig.population.populationScale) / Math.max(1, city.population / populationScale); +const knowledgeMaintenance = city.population * (city.knowledge.farming * 0.0008 + city.knowledge.metallurgy * 0.0012); +const tradeRelief = clamp(trade.value * 0.09, 0, 0.32); +const upkeep = (city.population * (0.010 + Math.max(0, 0.025 - supportRatio) * 0.09)) * (1 - tradeRelief) + knowledgeMaintenance; +city.storedResources -= upkeep; +const foodPerCapita = city.storedResources / Math.max(1, city.population); +city.supplyStress = clamp((0.11 - foodPerCapita) * 8 + Math.max(0, 0.02 - supportRatio) * 8 - trade.value * 0.12, 0, 1.8); +this.innovateCityKnowledge(city, harvested); +if (trade.value > 0) { +city.knowledge.farming = clamp(city.knowledge.farming + trade.value * 0.00018, 0, 1); +city.knowledge.metallurgy = clamp(city.knowledge.metallurgy + trade.value * 0.00024, 0, 1); +city.strength += clamp(trade.value * 0.018, 0, 0.08); +city.tradeValue = trade.value; +city.tradeReach = trade.reach; +} else { +city.tradeValue = 0; +city.tradeReach = 0; +} +const capacity = this.cityCarryingCapacity(city); +const recoveryBonus = this.cityRecoveryGrowthBonus(city); +const recoveryMode = recoveryBonus > 0.002; +const growthReserveRatio = recoveryMode ? 0.06 : 0.11; +const growthStressLimit = recoveryMode ? 1.20 : 0.90; +if (city.storedResources > city.population * growthReserveRatio && city.population > 0 && city.population < capacity && city.supplyStress < growthStressLimit) { +const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8); +const birthRoom = Math.max(0, Math.floor(capacity - city.population)); +const births = Math.min(birthRoom, Math.max(1, Math.floor(city.population * ( +0.012 + +prosperity * 0.012 + +clamp(trade.value, 0, 1.8) * 0.0015 + +recoveryBonus +)))); +city.population += births; +city.storedResources -= births * 0.55; +addBirthsToComposition(city.ethnicityComposition, births, this.rng); +} +if (city.population > capacity && city.supplyStress > 0.45) { +const overLimit = city.population - capacity; +const overCapacityLoss = Math.min(city.population, Math.max(1, Math.ceil(overLimit * (SimConfig.city.overCapacityAttrition ?? 0.020)))); +city.population -= overCapacityLoss; +removeFromComposition(city.ethnicityComposition, overCapacityLoss); +city.strength -= Math.min(0.08, overCapacityLoss / Math.max(1, city.population + overCapacityLoss) * 0.18); +} +if (city.age > 20 && city.activeVisitors < 2 && city.storedResources < city.population * 0.03 && city.population > 0) { +const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010))); +city.population -= attrition; +removeFromComposition(city.ethnicityComposition, attrition); +city.strength -= city.activeVisitors === 0 ? 0.030 : 0.012; +} +if (city.storedResources < 0) { +const deficit = Math.abs(city.storedResources); +const dominant = dominantComposition(city.ethnicityComposition); +const loss = Math.min(city.population, Math.ceil(deficit * 2.2 + city.population * 0.035)); +city.population -= loss; +city.storedResources = 0; +removeFromComposition(city.ethnicityComposition, loss); +city.strength -= Math.min(0.4, 0.03 + deficit * 0.01); +if (loss > 0) { +this.spawnUrbanRefugees(city, Math.min(24, Math.max(2, Math.ceil(loss / 8))), dominant); +} +} +city.population = Math.max(0, Math.floor(city.population)); +city.storedResources = clamp(city.storedResources, 0, Math.max(30, city.population * 1.4)); +} +} +cityTradeProfile(city) { +if (!city?.tradeLinks?.size || !this.tradeLinks.length) return { value: 0, reach: 0 }; +let value = 0; +let reach = 0; +for (const link of this.tradeLinks) { +if (link.from !== city.id && link.to !== city.id) continue; +const other = this.getCityById(link.from === city.id ? link.to : link.from); +if (!other) continue; +const distance = this.distanceBetweenCities(city, other); +const distanceFactor = clamp((distance - 14) / 30, 0, 1); +if (distanceFactor <= 0) continue; +const partnerScale = clamp(Math.sqrt(Math.max(1, other.population || 1)) / 24, 0.35, 2.4); +const pathScale = clamp((link.path?.length || distance) / 30, 0.5, 1.6); +value += (link.strength || 0.12) * distanceFactor * partnerScale * pathScale; +reach = Math.max(reach, distance); +} +return { +value: clamp(value, 0, 3.2), +reach +}; +} +innovateCityKnowledge(city, harvested) { +city.knowledge ??= { farming: 0, metallurgy: 0 }; +const scale = SimConfig.technology.cityInnovation; +const density = clamp(Math.sqrt(city.population) / 20, 0, 1.6); +const foodSurplus = clamp(city.storedResources / Math.max(1, city.population) - 0.08, 0, 0.5); +city.knowledge.farming = clamp( +city.knowledge.farming + scale * density * (0.4 + foodSurplus * 4) + harvested * 0.000015, +0, +1 +); +city.knowledge.metallurgy = clamp( +city.knowledge.metallurgy + scale * density * clamp(city.pheromoneOutput, 0.1, 1.8) * 0.35, +0, +1 +); +if (city.supplyStress > 0.8) { +city.knowledge.farming *= 0.998; +city.knowledge.metallurgy *= 0.997; +} +} +spawnUrbanRefugees(city, count, ethnicity = null) { +const tile = this.world.idx(city.x, city.y); +const dominant = ethnicity || dominantComposition(city.ethnicityComposition) || this.world.dominantEthnicity[tile]; +if (dominant != null && dominant >= 0) this.seedEthnicPopulationPatch(tile, dominant, this.ethnicities.get(dominant)?.averageTraits || this.randomTraits(), count, 2); +} +findCityNear(x, y, radius) { +let best = null; +let bestDistance = Infinity; +for (const c of this.getCitiesNear(x, y, radius)) { +const d = Math.abs(c.x - x) + Math.abs(c.y - y); +if (d <= radius && d < bestDistance) { +best = c; +bestDistance = d; +} +} +return best; +} +applyTerritoryExitPopulationShock(tile, rate = SimConfig.population?.territoryExitPopulationLossRate ?? 0.12) { +const w = this.world; +if (!w?.population || !w?.terrain || tile == null || tile < 0 || tile >= w.count || w.terrain[tile] === Terrain.WATER) return 0; +const population = w.population[tile] || 0; +if (population <= 0 || rate <= 0) return 0; +return this.removeEthnicPopulationProportionally(tile, population * clamp(rate, 0, 0.95)); +} +finishTrackedTerritoryExits() { +const w = this.world; +let removed = 0; +for (const tile of this.territoryExitCandidates) { +if (w.territoryOwner[tile] < 0) removed += this.applyTerritoryExitPopulationShock(tile); +} +this.territoryExitCandidates.length = 0; +return removed; +} +cityInfluence(city) { +if (!city || city.population <= 0 || city.strength <= 0) return 0; +const sedentaryFactor = clamp(0.45 + (city.sedentaryCulture ?? 0.5) * 0.9, 0.45, 1.35); +return (Math.sqrt(city.population) * 1.4 + Math.sqrt(Math.max(0, city.storedResources))) * sedentaryFactor; +} +setTileOwner(tile, polityId, options = {}) { +const w = this.world; +if (tile == null || tile < 0 || tile >= w.count || (w.terrain[tile] === Terrain.WATER && (polityId ?? -1) >= 0)) return false; +const previousOwner = w.territoryOwner[tile]; +const owner = polityId ?? -1; +if (owner >= 0 && !this.getPolityById(owner)) return false; +w.territoryOwner[tile] = owner; +w.polity[tile] = owner; +if (previousOwner >= 0 && owner < 0 && !this.suppressTerritoryExitShock && options.populationShock !== false) { +this.applyTerritoryExitPopulationShock(tile); +} +if (owner < 0) { +w.control[tile] = 0; +w.claim[tile] = 0; +w.contested[tile] = 0; +const stored = this.territorialClaims.get(tile); +if (stored) this.territorialClaims.delete(tile); +return true; +} +if (options.control != null) w.control[tile] = Math.max(w.control[tile] || 0, options.control); +if (options.claim != null) w.claim[tile] = Math.max(w.claim[tile] || 0, options.claim); +if (options.contested != null) w.contested[tile] = options.contested ? 1 : 0; +return true; +} +syncVisibleTerritoryOwners() { +const w = this.world; +if (w.polity !== w.territoryOwner) w.polity.set(w.territoryOwner); +} +validPolityIds() { +return new Set(this.polities.map(polity => polity.id)); +} +// Territory follows a weighted nearest-city model. Distance is primary, while +// city/state power, loyalty, trade, terrain and culture shift the boundary. +clearTerritories() { +const w = this.world; +this.syncVisibleTerritoryOwners(); +w.control.fill(0); +w.claim.fill(0); +w.contested.fill(0); +} +recomputeTerritories(options = {}) { +const w = this.world; +const allowOwnershipChanges = !!options.allowOwnershipChanges; +const trackTerritoryExits = allowOwnershipChanges && !this.suppressTerritoryExitShock; +if (trackTerritoryExits) { +this.territoryExitCandidates.length = 0; +for (const tile of this.activePopulationTiles) { +if (w.territoryOwner[tile] >= 0) this.territoryExitCandidates.push(tile); +} +this.suppressTerritoryExitShock = true; +} +this.clearTerritories(); +if (!this.polities.length || !this.cities.length) { +this.enforceTerritoryConnectivity(); +if (trackTerritoryExits) { +this.suppressTerritoryExitShock = false; +this.finishTrackedTerritoryExits(); +} +return; +} +const best = this.scratchFloatA; +const second = this.scratchFloatB; +const incumbent = this.scratchFloatC; +const owner = this.scratchIntB; +const bestSupport = this.territoryBestSupport; +const incumbentSupport = this.territoryIncumbentSupport; +best.fill(0); +second.fill(0); +incumbent.fill(0); +owner.fill(-1); +bestSupport.fill(0); +incumbentSupport.fill(0); +for (const polity of this.polities) { +const polityCities = this.getPolityCities(polity); +if (!polityCities.length) continue; +const center = this.getCityById(polity.centerCityId) || polityCities[0]; +const maxRange = clamp(Math.ceil(this.polityInfluenceRange(polity, false)), 8, 36); +for (const city of polityCities) { +const targetPopulationSupport = clamp( +Math.sqrt(Math.max(0, city.population || 0) / (SimConfig.territory?.fullMaintenanceCityPopulation ?? 2400)), +SimConfig.territory?.minimumPopulationSupport ?? 0.28, +1 +); +if (city.territorySupport == null) city.territorySupport = targetPopulationSupport; +else if (city.territorySupportUpdatedAt !== this.year) { +city.territorySupport = lerp(city.territorySupport, targetPopulationSupport, SimConfig.territory?.supportSmoothing ?? 0.18); +} +city.territorySupportUpdatedAt = this.year; +const populationSupport = city.territorySupport; +const cityTile = w.idx(city.x, city.y); +const cityEthnicity = this.dominantCityEthnicity(city); +const capitalDistance = center ? this.effectiveDistance(center, city) : 0; +const capitalFactor = city.id === polity.centerCityId ? 1.28 : clamp(1 - capitalDistance / 96, 0.58, 1); +const loyaltyFactor = 0.62 + clamp(city.loyalty ?? 0.5, 0, 1) * 0.58; +const tradeFactor = 1 + clamp((city.tradeReach || 0) / 60, 0, 0.42) + clamp((city.tradeValue || 0) * 0.10, 0, 0.22); +const baseInfluence = Math.max(1, this.cityInfluence(city)) * capitalFactor * loyaltyFactor * tradeFactor; +const normalizedInfluence = baseInfluence / Math.sqrt(SimConfig.city.populationScale || 1); +const cityPowerReach = clamp(Math.log1p(normalizedInfluence) * (SimConfig.territory?.cityPowerWeight ?? 1.8), 2, 10); +const capitalReach = city.id === polity.centerCityId ? (SimConfig.territory?.capitalReachBonus ?? 1.5) : 0; +const radiusScale = (SimConfig.territory?.radiusScale ?? 0.5) * populationSupport; +const radius = clamp( +Math.ceil(maxRange * (0.62 + clamp(Math.sqrt(city.population || 1) / 30, 0, 0.45)) * radiusScale), +3, +Math.max(3, Math.ceil(maxRange * radiusScale)) +); +for (const { dx, dy } of this.getRadiusOffsets(Math.ceil(radius * Math.SQRT2))) { +const distance = Math.hypot(dx, dy); +if (distance > radius) continue; +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +const isWater = w.terrain[tile] === Terrain.WATER; +if (isWater && tile !== cityTile && !w.tradeRoute[tile] && !this.territoryWaterAnchor(tile)) continue; +if ((w.population[tile] || 0) <= 0 && tile !== cityTile) continue; +const routeBoost = w.tradeRoute[tile] ? 1.28 : 1; +const terrainDrag = isWater ? 2.35 : w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.18 : 1); +const effectiveDistance = (distance + terrainDrag * 1.4) / routeBoost; +const localEthnicity = w.dominantEthnicity[tile]; +const ethnicityShift = cityEthnicity !== null && localEthnicity >= 0 && cityEthnicity !== localEthnicity ? -0.65 : 0.35; +const populationAnchor = clamp(Math.sqrt(Math.max(0, w.population[tile])) / 40, 0, 0.7); +const maintainedReach = (SimConfig.territory?.baseCityReach ?? 9) * (0.45 + populationSupport * 0.55); +const score = maintainedReach - effectiveDistance + cityPowerReach * populationSupport + capitalReach + ethnicityShift + populationAnchor; +if (score <= 0.8) continue; +if (w.territoryOwner[tile] === polity.id && score > incumbent[tile]) { +incumbent[tile] = score; +incumbentSupport[tile] = populationSupport; +} +if (score > best[tile]) { +if (owner[tile] !== polity.id) second[tile] = best[tile]; +best[tile] = score; +owner[tile] = polity.id; +bestSupport[tile] = populationSupport; +} else if (owner[tile] !== polity.id && score > second[tile]) { +second[tile] = score; +} +} +} +} +const threshold = SimConfig.territory?.minimumInfluence ?? 2.4; +const rebalanceOwnership = !!options.rebalanceOwnership; +const switchMargin = SimConfig.territory?.ownershipSwitchMargin ?? 1.5; +const activeWarPairs = new Set((this.wars || []) +.filter(war => war.ended === null) +.map(war => `${Math.min(war.aPolityId, war.bPolityId)}:${Math.max(war.aPolityId, war.bPolityId)}`)); +for (let i = 0; i < w.count; i++) { +const currentOwner = w.territoryOwner[i]; +const proposedOwner = owner[i]; +const cityHere = w.city[i] >= 0 ? this.getCityById(w.city[i]) : null; +const protectedCityCore = cityHere?.polityId != null && cityHere.polityId !== proposedOwner; +const currentCityCore = cityHere?.polityId === currentOwner; +const atWarAcrossBorder = currentOwner >= 0 && proposedOwner >= 0 && currentOwner !== proposedOwner && +activeWarPairs.has(`${Math.min(currentOwner, proposedOwner)}:${Math.max(currentOwner, proposedOwner)}`); +const currentSupport = incumbentSupport[i] || 0; +const proposedSupport = bestSupport[i] || 0; +const maintenanceThreshold = threshold + (1 - currentSupport) * (SimConfig.territory?.maintenancePressure ?? 4); +const expansionThreshold = threshold + (1 - proposedSupport) * (SimConfig.territory?.maintenancePressure ?? 4); +if ( +allowOwnershipChanges && +currentOwner >= 0 && +currentSupport < 1 && +!currentCityCore && +incumbent[i] < maintenanceThreshold +) { +this.setTileOwner(i, -1); +this.territorialClaims.delete(i); +continue; +} +const shouldSwitchOwner = +rebalanceOwnership && +currentOwner >= 0 && +proposedOwner >= 0 && +proposedOwner !== currentOwner && +!protectedCityCore && +!atWarAcrossBorder && +best[i] >= expansionThreshold && +best[i] >= incumbent[i] + switchMargin + (1 - Math.min(currentSupport, proposedSupport)) * (SimConfig.territory?.smallStateSwitchPenalty ?? 2.5); +if (allowOwnershipChanges && (currentOwner < 0 || shouldSwitchOwner) && proposedOwner >= 0 && best[i] >= expansionThreshold) { +this.setTileOwner(i, proposedOwner, { +control: clamp((best[i] - threshold) / 18, 0.12, 1), +claim: clamp(best[i] / 22, 0, 1) +}); +if (this.territorialClaims.get(i)?.polityId !== proposedOwner) this.territorialClaims.delete(i); +continue; +} +if (currentOwner < 0) { +if (owner[i] >= 0 && best[i] >= expansionThreshold) { +w.claim[i] = clamp(best[i] / 28, 0, 0.45); +if (second[i] > best[i] * 0.72) w.contested[i] = 1; +} +continue; +} +if (owner[i] === currentOwner && best[i] >= threshold) { +w.claim[i] = clamp(best[i] / 22, 0, 1); +w.control[i] = clamp((best[i] - threshold) / 18, 0.12, 1); +if (second[i] > best[i] * 0.72) w.contested[i] = 1; +} else { +w.claim[i] = Math.max(w.claim[i], 0.18); +w.control[i] = Math.max(w.control[i], 0.08); +if (owner[i] >= 0 && owner[i] !== currentOwner && best[i] >= threshold * 0.8) w.contested[i] = 1; +} +} +this.applyStoredTerritorialClaims(); +this.enforceCityTerritoryAnchors(options); +if (allowOwnershipChanges) this.smoothTerritoryBorders(1); +this.pruneUnpopulatedTerritory(); +this.enforceTerritoryConnectivity(options); +if (trackTerritoryExits) { +this.suppressTerritoryExitShock = false; +this.finishTrackedTerritoryExits(); +} +} +pruneUnpopulatedTerritory() { +const w = this.world; +for (let tile = 0; tile < w.count; tile++) { +if (w.territoryOwner[tile] < 0 || (w.population[tile] || 0) > 0) continue; +const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +if (city && city.population > 0) continue; +this.setTileOwner(tile, -1); +this.territorialClaims.delete(tile); +} +} +smoothTerritoryBorders(passes = 2) { +const w = this.world; +const source = this.scratchIntA; +const protectedTiles = this.scratchUintA; +protectedTiles.fill(0); +for (const city of this.cities) { +const tile = w.idx(city.x, city.y); +protectedTiles[tile] = 1; +for (let n = w.landNeighborStarts[tile]; n < w.landNeighborStarts[tile + 1]; n++) { +protectedTiles[w.landNeighbors[n]] = 1; +} +} +for (let pass = 0; pass < passes; pass++) { +source.set(w.territoryOwner); +let changed = 0; +for (let tile = 0; tile < w.count; tile++) { +if (w.terrain[tile] === Terrain.WATER || protectedTiles[tile] || w.tradeRoute[tile]) continue; +const start = w.landNeighborStarts[tile]; +const end = w.landNeighborStarts[tile + 1]; +if (end - start < 6) continue; // Preserve coasts and the edge of the world. +const owner = source[tile]; +let candidate = -1; +let same = 0; +let neutral = 0; +let foreign = false; +for (let n = start; n < end; n++) { +const neighborOwner = source[w.landNeighbors[n]]; +if (neighborOwner < 0) neutral++; +else if (owner >= 0) { +if (neighborOwner === owner) same++; +else foreign = true; +} else if (candidate < 0) { candidate = neighborOwner; same++; } +else if (neighborOwner === candidate) same++; +else foreign = true; +} +const x = tile % w.size; +let cardinal = 0; +const target = owner >= 0 ? owner : candidate; +if (x > 0 && source[tile - 1] === target) cardinal++; +if (x < w.size - 1 && source[tile + 1] === target) cardinal++; +if (tile >= w.size && source[tile - w.size] === target) cardinal++; +if (tile < w.count - w.size && source[tile + w.size] === target) cardinal++; +if (foreign) { +// At a shared border, move only small leaves toward a clear local majority. +// Tiles with two connections to their own country can be bridges: keep them. +if (owner < 0 || cardinal > 1) continue; +let majority = -1; +let votes = 0; +for (let n = start; n < end; n++) { +const id = source[w.landNeighbors[n]]; +if (!votes) { majority = id; votes = 1; } +else votes += id === majority ? 1 : -1; +} +if (majority < 0 || majority === owner) continue; +let count = 0; +let touching = false; +for (let n = start; n < end; n++) { +const neighbor = w.landNeighbors[n]; +if (source[neighbor] !== majority) continue; +count++; +if (neighbor === tile - w.size || neighbor === tile + w.size || +(Math.floor(neighbor / w.size) === Math.floor(tile / w.size) && Math.abs(neighbor - tile) === 1)) touching = true; +} +if (count < 5 || !touching) continue; +this.setTileOwner(tile, majority, { control: 0.22, claim: 0.35, contested: false }); +this.territorialClaims.set(tile, { polityId: majority, control: 0.22, claim: 0.35, reason: "borderSmoothing", year: this.year }); +changed++; +continue; +} +if (owner < 0 && candidate >= 0 && same >= 5 && cardinal >= 2) { +this.setTileOwner(tile, candidate, { control: 0.22, claim: 0.35 }); +changed++; +} else if (owner >= 0 && neutral >= 5 && cardinal <= 1) { +// Trim only leaves; keep narrow bridges and corridors connected. +this.setTileOwner(tile, -1); +this.territorialClaims.delete(tile); +changed++; +} +} +if (!changed) break; +} +this.syncVisibleTerritoryOwners(); +} +applyStoredTerritorialClaims() { +const w = this.world; +const validPolities = this.validPolityIds(); +for (const [tile, claim] of [...this.territorialClaims]) { +if (!validPolities.has(claim.polityId) || w.terrain[tile] === Terrain.WATER) { +this.territorialClaims.delete(tile); +continue; +} +const polity = this.getPolityById(claim.polityId); +const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +if (!polity || (city && city.polityId !== null && city.polityId !== claim.polityId)) { +this.territorialClaims.delete(tile); +continue; +} +if (city && city.polityId === null) { +w.claim[tile] = Math.max(w.claim[tile], claim.claim || 0.35); +w.control[tile] = Math.max(w.control[tile], Math.min(0.34, claim.control || 0.25)); +w.contested[tile] = 1; +continue; +} +w.claim[tile] = Math.max(w.claim[tile], claim.claim || 0.35); +w.control[tile] = Math.max(w.control[tile], claim.control || 0.25); +if (claim.contested) w.contested[tile] = 1; +} +} +enforceCityTerritoryAnchors(options = {}) { +const w = this.world; +const allowOwnershipChanges = !!options.allowOwnershipChanges; +for (const city of this.cities) { +if (city.population <= 0) continue; +const tile = w.idx(city.x, city.y); +if (city.polityId === null) { +this.setTileOwner(tile, -1); +this.territorialClaims.delete(tile); +continue; +} +const polity = this.getPolityById(city.polityId); +if (!polity) continue; +for (let dy = -1; dy <= 1; dy++) { +for (let dx = -1; dx <= 1; dx++) { +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const t = w.idx(x, y); +if (w.terrain[t] === Terrain.WATER) continue; +const otherCity = w.city[t] >= 0 ? this.getCityById(w.city[t]) : null; +if (otherCity && otherCity.id !== city.id && otherCity.polityId !== city.polityId) continue; +const core = dx === 0 && dy === 0; +if (core || (allowOwnershipChanges && w.territoryOwner[t] < 0)) this.setTileOwner(t, polity.id); +if (core && this.territorialClaims.get(t)?.polityId !== polity.id) this.territorialClaims.delete(t); +const ownedByCityPolity = w.territoryOwner[t] === polity.id; +if (!ownedByCityPolity) { +if (w.territoryOwner[t] >= 0) w.contested[t] = 1; +continue; +} +w.control[t] = Math.max(w.control[t], core ? 0.55 : 0.35); +w.claim[t] = Math.max(w.claim[t], core ? 0.62 : 0.40); +if (core && (ownedByCityPolity || allowOwnershipChanges)) w.contested[t] = 0; +} +} +} +} +enforceTerritoryConnectivity(options = {}) { +const w = this.world; +this.enforceCityTerritoryAnchors(); +let removed = 0; +const reachable = this.visitStamp; +const marker = this.nextVisitMarker(); +const queue = this.scratchIntA; +let tail = 0; +const validPolities = this.validPolityIds(); +for (const city of this.cities) { +if (city.population <= 0 || city.polityId == null || !validPolities.has(city.polityId)) continue; +const seed = w.idx(city.x, city.y); +if (w.terrain[seed] === Terrain.WATER || w.territoryOwner[seed] !== city.polityId || reachable[seed] === marker) continue; +reachable[seed] = marker; +queue[tail++] = seed; +} +// All countries share one traversal: each tile is visited at most once. +for (let q = 0; q < tail; q++) { +const tile = queue[q]; +const owner = w.territoryOwner[tile]; +this.forCardinalNeighbors(tile, n => { +if (reachable[n] === marker || w.territoryOwner[n] !== owner || w.terrain[n] === Terrain.WATER) return; +reachable[n] = marker; +queue[tail++] = n; +}); +} +for (let i = 0; i < w.count; i++) { +if (w.territoryOwner[i] < 0 || reachable[i] === marker) continue; +this.setTileOwner(i, -1); +this.territorialClaims.delete(i); +removed++; +} +this.territoryDebug.disconnectedTerritoryRemoved += removed; +this.syncVisibleTerritoryOwners(); +return removed; +} +reconcileCityTerritoryOwnership() { +const w = this.world; +for (const city of this.cities) { +if (city.population <= 0) continue; +const tile = w.idx(city.x, city.y); +const ownerId = w.territoryOwner[tile]; +if (city.polityId !== null) { +if (ownerId !== city.polityId) { +w.contested[tile] = this.activeEnemyCampaignNearCity(city, 4) ? 1 : w.contested[tile]; +this.territoryDebug.cityTerritoryMismatchesFixed++; +} +continue; +} +const territoryOwner = ownerId >= 0 ? this.getPolityById(ownerId) : null; +if (territoryOwner) w.contested[tile] = 1; +} +this.enforceCityTerritoryAnchors({ allowOwnershipChanges: false }); +} +activeEnemyCampaignNearCity(city, radius) { +const tile = this.world.idx(city.x, city.y); +const x = city.x; +const y = city.y; +for (const campaign of this.campaigns || []) { +if (campaign.status !== "active" || campaign.targetTile == null) continue; +if (campaign.attackerPolityId === city.polityId) continue; +const cx = campaign.targetTile % this.world.size; +const cy = Math.floor(campaign.targetTile / this.world.size); +if (Math.abs(cx - x) + Math.abs(cy - y) <= radius || campaign.targetTile === tile) return true; +} +return false; +} +territoryWaterAnchor(tile) { +const w = this.world; +let anchored = false; +this.forCardinalNeighbors(tile, n => { +if (w.city[n] < 0 && w.tradeRoute[n] <= 0) return; +anchored = true; +return false; +}); +return anchored; +} +isBorderTile(tile) { +const w = this.world; +const polityId = w.polity[tile]; +if (polityId < 0) return false; +let border = false; +this.forCardinalNeighbors(tile, n => { +if (w.polity[n] === polityId) return; +border = true; +return false; +}); +return border; +} +campaignPressureOnTerritories() { +const w = this.world; +const cfg = SimConfig.campaign || {}; +const radius = cfg.claimRadius ?? 8; +for (const campaign of this.campaigns || []) { +if (campaign.status !== "active" || campaign.targetTile == null) continue; +const attacker = this.getPolityById(campaign.attackerPolityId); +if (!attacker) continue; +const cx = campaign.targetTile % w.size; +const cy = Math.floor(campaign.targetTile / w.size); +const pressure = clamp(0.10 + campaign.progress * 0.28, 0.08, 0.42); +for (const { dx, dy, distance } of this.getRadiusOffsets(radius)) { +const x = cx + dx; +const y = cy + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +const local = pressure * (1 - distance / (radius + 1)); +const connected = w.territoryOwner[tile] === attacker.id || this.expansionConnectionDistance(attacker, tile, 5) <= 5; +const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +const ownedCityCore = city?.polityId === attacker.id; +if ((w.territoryOwner[tile] < 0 && connected) || w.territoryOwner[tile] === attacker.id || ownedCityCore) { +w.claim[tile] = Math.max(w.claim[tile], local); +w.control[tile] = Math.max(w.control[tile], local * 0.65); +} else { +w.claim[tile] = Math.max(w.claim[tile], local); +w.contested[tile] = 1; +} +} +} +} +getCityById(id) { +const cached = this.cityById?.get(id); +if (cached) return cached; +const city = this.cities.find(c => c.id === id) || null; +if (city) { +this.cityById ??= new Map(); +this.cityById.set(id, city); +} +return city; +} +getPolityById(id) { +const cached = this.polityById?.get(id); +if (cached) return cached; +const polity = this.polities.find(p => p.id === id) || null; +if (polity) { +this.polityById ??= new Map(); +this.polityById.set(id, polity); +} +return polity; +} +getPolityCities(polity) { +if (!polity) return []; +this.polityCitiesCache ??= new Map(); +const cached = this.polityCitiesCache.get(polity.id); +if (cached) return cached; +const living = []; +for (const id of [...polity.cityIds]) { +const city = this.getCityById(id); +if (!city || city.population <= 0) polity.cityIds.delete(id); +else living.push(city); +} +this.polityCitiesCache.set(polity.id, living); +return living; +} +dominantCityEthnicity(city) { +if (!city || !city.ethnicityComposition || !city.ethnicityComposition.size) return null; +return dominantComposition(city.ethnicityComposition) || null; +} +sameDominantEthnicity(cityA, cityB) { +const a = this.dominantCityEthnicity(cityA); +const b = this.dominantCityEthnicity(cityB); +return a !== null && b !== null && a === b; +} +distanceBetweenCities(cityA, cityB) { +return Math.abs(cityA.x - cityB.x) + Math.abs(cityA.y - cityB.y); +} +hasDirectTradeConnection(cityA, cityB) { +if (!cityA || !cityB) return false; +if (cityA.tradeLinks?.has(cityB.id) || cityB.tradeLinks?.has(cityA.id)) return true; +return (this.tradeLinks || []).some(link => { +const endpoints = this.tradeLinkEndpointIds(link); +return endpoints && endpoints.includes(cityA.id) && endpoints.includes(cityB.id); +}); +} +tradeLinkEndpointId(value) { +return typeof value === "object" ? value?.id ?? null : value ?? null; +} +tradeLinkEndpointIds(link) { +const from = this.tradeLinkEndpointId(link.from ?? link.fromCityId ?? link.cityAId ?? link.aId ?? link.sourceId ?? link.source ?? link.a ?? link.cityA); +const to = this.tradeLinkEndpointId(link.to ?? link.toCityId ?? link.cityBId ?? link.bId ?? link.targetId ?? link.destinationId ?? link.target ?? link.destination ?? link.b ?? link.cityB); +return from != null && to != null ? [from, to] : null; +} +polityTradeNeighbors(city, polityId) { +if (!city || polityId == null) return []; +const neighbors = []; +for (const link of this.tradeLinks || []) { +const endpoints = this.tradeLinkEndpointIds(link); +if (!endpoints) continue; +const [from, to] = endpoints; +const otherId = from === city.id ? to : to === city.id ? from : null; +if (otherId == null) continue; +const other = this.getCityById(otherId); +if (other?.polityId === polityId) neighbors.push(other); +} +return neighbors; +} +tradeAccessToCenter(city, center, polityId) { +if (!city || !center || polityId == null) return { reachable: false, hops: Infinity, transitCities: [] }; +if (city.id === center.id) return { reachable: true, hops: 0, transitCities: [] }; +if (this.hasDirectTradeConnection(city, center)) return { reachable: true, hops: 1, transitCities: [] }; +const maxDepth = SimConfig.polityAccess?.maxSearchDepth ?? 8; +const visited = new Set([city.id]); +const queue = [{ city, hops: 0, transitCities: [] }]; +while (queue.length) { +const current = queue.shift(); +if (current.hops >= maxDepth) continue; +for (const neighbor of this.polityTradeNeighbors(current.city, polityId)) { +if (visited.has(neighbor.id)) continue; +const hops = current.hops + 1; +if (neighbor.id === center.id) return { reachable: true, hops, transitCities: current.transitCities }; +visited.add(neighbor.id); +queue.push({ city: neighbor, hops, transitCities: [...current.transitCities, neighbor] }); +} +} +return { reachable: false, hops: Infinity, transitCities: [] }; +} +tradeAccessLoyaltyPenalty(city, center, polity) { +const cfg = SimConfig.polityAccess; +if (!cfg?.enabled || !city || !center || !polity || city.id === center.id || this.hasDirectTradeConnection(city, center)) return 0; +const access = this.tradeAccessToCenter(city, center, polity.id); +if (!access.reachable) return clamp(cfg.noAccessPenalty, 0, 0.07); +let penalty = cfg.indirectPenalty + Math.max(0, access.hops - 1) * cfg.perHopPenalty; +const originEthnicity = this.dominantCityEthnicity(city); +for (const transitCity of access.transitCities) { +if ((transitCity.loyalty ?? 0.5) < 0.35) penalty += cfg.lowLoyaltyTransitPenalty; +const transitEthnicity = this.dominantCityEthnicity(transitCity); +if (originEthnicity !== null && transitEthnicity !== null && transitEthnicity !== originEthnicity) { +penalty += cfg.foreignTransitPenalty; +} +} +return clamp(penalty, 0, 0.07); +} +effectiveDistance(cityA, cityB) { +let distance = this.distanceBetweenCities(cityA, cityB); +if (this.hasDirectTradeConnection(cityA, cityB)) distance *= 0.55; +return distance; +} +createPolity(centerCity) { +const id = this.nextPolity++; +const polity = { +id, +centerCityId: centerCity.id, +cityIds: new Set(), +treasury: Math.max(0, centerCity.storedResources * 0.12), +color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62), +founded: this.year, +nextWarAllowedWeek: this.year + years(SimConfig.polity.foundingProtectionYears ?? 12), +legitimacy: this.rng.range(0.68, 0.94), +cohesion: this.rng.range(0.58, 0.9), +charisma: this.rng.range(SimConfig.polity.charismaMin ?? 0.5, SimConfig.polity.charismaMax ?? 1.5), +leaderStarted: this.year, +leaderTenureYears: this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 24, SimConfig.polity.leaderTenureMaxYears ?? 68), +crisis: 0, +lastCrisisYear: this.year, +institutionalAging: 0, +lastInstitutionalReformYear: this.year +}; +this.polities.push(polity); +this.polityById.set(polity.id, polity); +this.transferCityToPolity(centerCity, polity, { +loyalty: 1, +control: 0.72, +claim: 0.85, +reason: "founding" +}); +this.enforcePolityTreasuryCap(polity); +this.claimPolityCoreTerritory(polity, centerCity, 4, "founding"); +this.ensurePolityHistory(polity); +this.samplePolityHistory(polity); +return polity; +} +ensurePolityHistory(polity) { +if (!polity) return null; +if (!this.polityHistory.has(polity.id)) { +this.polityHistory.set(polity.id, { +id: polity.id, +color: polity.color || hslToRgb((polity.id * 0.38196601125) % 1, 0.58, 0.62), +founded: polity.founded ?? this.year, +ended: null, +active: true, +centerCityId: polity.centerCityId ?? null, +fate: null, +samples: [], +events: [], +peakPower: 0, +peakYear: null, +peakEventYear: null +}); +} +const history = this.polityHistory.get(polity.id); +history.events ??= []; +history.peakPower ??= 0; +history.peakYear ??= null; +history.peakEventYear ??= null; +return history; +} +averagePolityLoyalty(polity) { +const cities = this.getPolityCities(polity); +const subordinates = cities.filter(city => city.id !== polity.centerCityId); +if (!subordinates.length) return 1; +return subordinates.reduce((sum, city) => sum + city.loyalty, 0) / subordinates.length; +} +samplePolityHistory(polity) { +const history = this.ensurePolityHistory(polity); +if (!history) return; +const cities = this.getPolityCities(polity); +const population = cities.reduce((sum, city) => sum + city.population, 0); +const treasury = polity.treasury || 0; +const avgLoyalty = this.averagePolityLoyalty(polity); +const fallbackPower = Math.sqrt(population) * 1.35 + +Math.sqrt(Math.max(0, treasury)) * 1.15 + +avgLoyalty * 16; +history.centerCityId = polity.centerCityId ?? history.centerCityId; +const sample = { +year: this.year, +cities: cities.length, +population, +treasury, +avgLoyalty, +institutionalAging: clamp(polity.institutionalAging ?? 0, 0, 1), +loyaltyCap: this.polityLoyaltyCap ? this.polityLoyaltyCap(polity) : 1, +power: typeof this.polityPower === "function" ? this.polityPower(polity) : fallbackPower +}; +history.samples.push(sample); +if (sample.power > Math.max(20, (history.peakPower || 0) * 1.18)) { +history.peakPower = sample.power; +history.peakYear = this.year; +} +const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears; +while (history.samples.length > maxSamples) history.samples.shift(); +} +samplePolityHistories() { +for (const polity of this.polities) this.samplePolityHistory(polity); +} +sampleWorldHistory() { +let regionalPopulation = 0; +for (const tile of this.activePopulationTiles) regionalPopulation += Math.max(0, this.world.population[tile] || 0); +const urbanPopulation = this.cities.reduce((sum, city) => sum + Math.max(0, city.population || 0), 0); +const sample = { +year: this.year, +regionalPopulation, +urbanPopulation, +cities: this.cities.length, +polities: this.polities.length, +ethnicities: [...this.ethnicities.values()].filter(ethnicity => (ethnicity.population || 0) > 0).length +}; +const last = this.worldHistory[this.worldHistory.length - 1]; +if (last?.year === this.year) this.worldHistory[this.worldHistory.length - 1] = sample; +else this.worldHistory.push(sample); +const maxSamples = (SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears) * 4; +while (this.worldHistory.length > maxSamples) this.worldHistory.shift(); +} +addPolityEvent(polityId, type, year = this.year, data = {}, importance = 1) { +let history = this.polityHistory.get(polityId) || this.deadPolityHistories.find(h => h.id === polityId) || null; +if (!history) { +const polity = this.getPolityById(polityId); +if (polity) history = this.ensurePolityHistory(polity); +} +if (!history) return; +history.events ??= []; +const sameYearDuplicate = history.events.some(event => +event.type === type && +event.year === year && +JSON.stringify(event.data || {}) === JSON.stringify(data || {}) +); +if (sameYearDuplicate) return; +history.events.push({ year, type, importance, data }); +while (history.events.length > 120) history.events.shift(); +} +addGraphEvent(type, year = this.year, data = {}, importance = 2) { +this.graphEvents ??= []; +const duplicate = this.graphEvents.some(event => +event.type === type && +event.year === year && +JSON.stringify(event.data || {}) === JSON.stringify(data || {}) +); +if (duplicate) return; +this.graphEvents.push({ year, type, importance, data }); +while (this.graphEvents.length > 160) this.graphEvents.shift(); +} +selectNewLeader(polity, forced = false) { +if (!polity) return; +const minCharisma = SimConfig.polity.charismaMin ?? 0.5; +const maxCharisma = SimConfig.polity.charismaMax ?? 1.5; +const avgCharisma = SimConfig.polity.charismaAverage ?? 1; +const previousCharisma = clamp(polity.charisma ?? avgCharisma, minCharisma, maxCharisma); +const center = this.getCityById(polity.centerCityId); +const centerStability = center ? clamp(center.loyalty ?? 0.5, 0, 1) : 0.5; +const institutionalBias = ((polity.legitimacy ?? 0.7) + (polity.cohesion ?? 0.6) + centerStability) / 3; +const randomLeader = this.rng.range(minCharisma, maxCharisma); +const continuity = forced ? 0.18 : 0.34; +const institutionalPull = 0.82 + institutionalBias * 0.36; +const nextCharisma = clamp( +previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull, +minCharisma, +maxCharisma +); +polity.charisma = nextCharisma; +polity.leaderStarted = this.year; +polity.leaderTenureYears = this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 22, SimConfig.polity.leaderTenureMaxYears ?? 72); +const change = nextCharisma - previousCharisma; +polity.legitimacy = clamp((polity.legitimacy ?? 0.7) + change * 0.08 - (forced ? 0.035 : 0), 0, 1); +polity.cohesion = clamp((polity.cohesion ?? 0.6) + change * 0.045 - (forced ? 0.020 : 0), 0, 1); +if (forced) polity.crisis = clamp((polity.crisis || 0) + 0.04, 0, 1.5); +} +updatePolityLeaders() { +for (const polity of this.polities) { +polity.charisma = clamp(polity.charisma ?? (SimConfig.polity.charismaAverage ?? 1), SimConfig.polity.charismaMin ?? 0.5, SimConfig.polity.charismaMax ?? 1.5); +polity.leaderStarted ??= polity.founded ?? this.year; +polity.leaderTenureYears ??= this.rng.range(SimConfig.polity.leaderTenureMinYears ?? 24, SimConfig.polity.leaderTenureMaxYears ?? 68); +const tenureYears = (this.year - polity.leaderStarted) / WEEKS_PER_YEAR; +const oldLeader = Math.max(0, tenureYears - polity.leaderTenureYears); +const crisisPressure = clamp((polity.crisis || 0) * 0.045, 0, 0.09); +const successionChance = clamp(oldLeader * 0.018 + crisisPressure, 0, 0.36); +if (successionChance > 0 && this.rng.next() < successionChance) { +this.selectNewLeader(polity, (polity.crisis || 0) > 0.85); +} +} +} +maybeSpawnDisaster() { +const config = SimConfig.disaster; +const interval = years(config?.checkIntervalYears ?? 8); +if (!interval || this.year % interval !== 0) return; +if (this.rng.next() > (config?.baseChance ?? 0.38)) return; +this.spawnDisaster(); +} +spawnDisaster() { +const config = SimConfig.disaster; +const large = this.rng.next() < (config?.rareLargeChance ?? 0.10); +const radius = large +? this.rng.range(config?.largeRadiusMin ?? 28, config?.largeRadiusMax ?? 54) +: this.rng.range(config?.minRadius ?? 6, config?.maxRadius ?? 34); +const intensity = this.rng.range(config?.minIntensity ?? 0.25, config?.maxIntensity ?? 0.95); +this.applyDisaster(this.rng.int(this.world.size), this.rng.int(this.world.size), radius, intensity); +} +applyDisaster(x, y, radius, intensity) { +const config = SimConfig.disaster; +const w = this.world; +const scaledIntensity = intensity * (config?.damageScale ?? 1); +const majorLossRate = config?.majorLossRate ?? 0.05; +const affectedPolityMap = new Map(); +const affectedPolityIds = new Set(); +for (const city of this.cities) { +if (city.polityId === null) continue; +if (Math.hypot(city.x - x, city.y - y) <= radius) affectedPolityIds.add(city.polityId); +} +const polityPopulationBefore = new Map(); +for (const id of affectedPolityIds) { +const polity = this.getPolityById(id); +if (polity) polityPopulationBefore.set(id, this.totalPolityPopulation(polity)); +} +let affectedCities = 0; +let totalPopulationLoss = 0; +let regionalPopulationLoss = 0; +for (const city of this.cities) { +const d = Math.hypot(city.x - x, city.y - y); +if (d > radius) continue; +const falloff = 1 - d / radius; +const damage = clamp(scaledIntensity * falloff * falloff, 0, 1); +if (damage <= 0) continue; +affectedCities++; +const popBefore = city.population || 0; +const lossRate = clamp(damage * 0.25, 0, 0.45); +const loss = Math.floor(popBefore * lossRate); +city.population = Math.max(0, popBefore - loss); +if (typeof removeFromComposition === "function") removeFromComposition(city.ethnicityComposition, loss); +city.storedResources = Math.max(0, city.storedResources * (1 - damage * 0.55)); +city.strength = Math.max(0, city.strength - damage * 0.38); +this.deaths += Math.floor(loss * 0.35); +totalPopulationLoss += loss; +if (city.polityId !== null) { +let entry = affectedPolityMap.get(city.polityId); +if (!entry) { +entry = { +polityId: city.polityId, +populationBefore: polityPopulationBefore.get(city.polityId) || 0, +populationLoss: 0, +cityIds: new Set() +}; +affectedPolityMap.set(city.polityId, entry); +} +entry.populationLoss += loss; +entry.cityIds.add(city.id); +const polity = this.getPolityById(city.polityId); +if (polity) { +polity.crisis = clamp((polity.crisis || 0) + damage * (city.id === polity.centerCityId ? 0.25 : 0.12), 0, 1.5); +polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - damage * 0.08, 0, 1); +polity.cohesion = clamp((polity.cohesion ?? 0.6) - damage * 0.05, 0, 1); +} +} +} +const minX = Math.max(0, Math.floor(x - radius)); +const maxX = Math.min(w.size - 1, Math.ceil(x + radius)); +const minY = Math.max(0, Math.floor(y - radius)); +const maxY = Math.min(w.size - 1, Math.ceil(y + radius)); +for (let ty = minY; ty <= maxY; ty++) { +for (let tx = minX; tx <= maxX; tx++) { +const d = Math.hypot(tx - x, ty - y); +if (d > radius) continue; +const falloff = 1 - d / radius; +const damage = clamp(scaledIntensity * falloff * falloff, 0, 1); +const tile = w.idx(tx, ty); +if (w.terrain[tile] !== Terrain.WATER && (w.population[tile] || 0) > 0) { +const regionalLossRate = clamp( + damage * (config?.regionalPopulationLossRate ?? 0.42), + 0, + config?.regionalPopulationLossMax ?? 0.65 +); +const loss = this.removeEthnicPopulationProportionally(tile, w.population[tile] * regionalLossRate); +regionalPopulationLoss += loss; +totalPopulationLoss += loss; +this.deaths += Math.floor(loss * 0.40); +} +w.resource[tile] *= (1 - damage * 0.45); +w.farmland[tile] *= (1 - damage * 0.35); +w.pheromone[tile] *= (1 - damage * 0.25); +} +} +const affectedPolities = [...affectedPolityMap.values()].map(entry => { +const populationBefore = Math.max(1, entry.populationBefore || 0); +const lossRate = entry.populationLoss / populationBefore; +return { +polityId: entry.polityId, +populationBefore: entry.populationBefore, +populationLoss: entry.populationLoss, +lossRate, +cityIds: [...entry.cityIds] +}; +}); +const id = this.nextDisaster++; +const record = { +id, +year: this.year, +x, +y, +radius, +intensity, +expires: this.year + years(config?.visualDurationYears ?? 18), +affectedCities, +totalPopulationLoss, +regionalPopulationLoss, +affectedPolities +}; +this.disasters.push(record); +this.disasterHistory.push(record); +for (const entry of affectedPolities) { +if (entry.lossRate >= majorLossRate) { +this.addPolityEvent(entry.polityId, "disaster", this.year, { +disasterId: id, +populationLoss: entry.populationLoss, +lossRate: entry.lossRate, +affectedCities: entry.cityIds.length +}, entry.lossRate >= majorLossRate * 2 ? 3 : 2); +} +} +this.disasters = this.disasters +.filter(disaster => this.year <= disaster.expires) +.slice(-(config?.maxActiveVisuals ?? 12)); +while (this.disasterHistory.length > (config?.maxHistory ?? 160)) this.disasterHistory.shift(); +} +triggerManualDisasterAt(x, y) { +const w = this.world; +const tx = clamp(Math.floor(x), 0, w.size - 1); +const ty = clamp(Math.floor(y), 0, w.size - 1); +const cfg = SimConfig.disaster || {}; +const radius = this.rng.range(Math.max(5, cfg.minRadius ?? 6), Math.min(24, cfg.maxRadius ?? 34)); +const intensity = this.rng.range(Math.max(0.48, cfg.minIntensity ?? 0.25), Math.min(0.82, cfg.maxIntensity ?? 0.95)); +const beforeId = this.nextDisaster; +this.applyDisaster(tx, ty, radius, intensity); +const record = this.disasterHistory.find(item => item.id === beforeId) || this.disasterHistory[this.disasterHistory.length - 1]; +return { ok: true, record, x: tx, y: ty, radius, intensity }; +} +manualCityEthnicitySeed(tile) { +const w = this.world; +const local = this.tileEthnicMix.get(tile); +if (local?.size) return new Map(local); +const dominant = w.dominantEthnicity[tile]; +if (dominant >= 0) return new Map([[dominant, 1]]); +const tx = tile % w.size; +const ty = Math.floor(tile / w.size); +let nearest = null; +let nearestDistance = Infinity; +for (const [cultureTile, mix] of this.tileEthnicMix) { +if (!mix?.size) continue; +const distance = Math.abs(cultureTile % w.size - tx) + Math.abs(Math.floor(cultureTile / w.size) - ty); +if (distance < nearestDistance) { +nearest = mix; +nearestDistance = distance; +if (distance <= 2) break; +} +} +if (nearest?.size) return new Map(nearest); +let nearestCity = null; +let cityDistance = Infinity; +for (const city of this.cities) { +if (!city.ethnicityComposition?.size) continue; +const distance = Math.abs(city.x - tx) + Math.abs(city.y - ty); +if (distance < cityDistance) { +nearestCity = city; +cityDistance = distance; +} +} +if (nearestCity) return new Map(nearestCity.ethnicityComposition); +const ethnicityId = this.createEthnicity(0, { +temperature: w.temperature[tile], +humidity: w.humidity[tile] +}); +const ethnicity = this.ethnicities.get(ethnicityId); +if (ethnicity) ethnicity.averageTraits = this.randomTraits(); +return new Map([[ethnicityId, 1]]); +} +applyCityEthnicitySeed(city, seed, population) { +city.ethnicityComposition ??= new Map(); +city.ethnicityComposition.clear(); +let total = 0; +for (const count of seed?.values?.() || []) total += Math.max(0, count || 0); +if (total <= 0) return; +let assigned = 0; +const entries = [...seed.entries()]; +for (let index = 0; index < entries.length; index++) { +const [id, count] = entries[index]; +const amount = index === entries.length - 1 +? Math.max(0, population - assigned) +: Math.max(0, Math.round(population * Math.max(0, count || 0) / total)); +if (amount <= 0) continue; +city.ethnicityComposition.set(id, amount); +assigned += amount; +} +} +buildCityAt(x, y) { +const w = this.world; +const tx = clamp(Math.floor(x), 0, w.size - 1); +const ty = clamp(Math.floor(y), 0, w.size - 1); +const tile = w.idx(tx, ty); +// Manual intervention bypasses natural spacing and the automatic-city cap, +// but cities still require land. +if (w.terrain[tile] === Terrain.WATER) return { ok: false, reason: "water", x: tx, y: ty }; +const ethnicitySeed = this.manualCityEthnicitySeed(tile); +const city = this.createCity(tx, ty); +const basePopulation = city.population; +const targetPopulation = Math.max(1, SimConfig.city.initialPopulation ?? 400); +this.applyCityEthnicitySeed(city, ethnicitySeed, targetPopulation); +city.population = targetPopulation; +city.peakPopulation = targetPopulation; +city.storedResources = Math.max(city.storedResources, targetPopulation * 0.20); +city.strength = Math.max(city.strength, 6); +city.activeVisitors = Math.max(city.activeVisitors, 12); +if (targetPopulation > basePopulation) { +city.population = targetPopulation - basePopulation; +this.projectCityPopulationToTiles(city); +city.population = targetPopulation; +} +this.cities.push(city); +this.rebuildIndexes(); +w.city[tile] = city.id; +this.assignNewCityToTerritoryOwner(city, tile, { explicit: true }); +if (city.polityId !== null) { +const polity = this.getPolityById(city.polityId); +if (polity) this.claimPolityCoreTerritory(polity, city, 3, "manualCity"); +} +this.updatePopulationCapacity(); +this.syncTilePopulationCulture(); +return { ok: true, city }; +} +markPolityEnded(polity, reason = "dissolved") { +if (!polity) return; +const history = this.ensurePolityHistory(polity); +if (!history) return; +this.samplePolityHistory(polity); +history.ended = this.year; +history.active = false; +history.fate = reason; +history.centerCityId = polity.centerCityId ?? history.centerCityId; +this.polityHistory.delete(polity.id); +this.deadPolityHistories.push(history); +while (this.deadPolityHistories.length > 20) this.deadPolityHistories.shift(); +} +getAllPolityHistories() { +return [...this.deadPolityHistories, ...this.polityHistory.values()] +.sort((a, b) => (a.founded - b.founded) || (a.id - b.id)); +} +addCityToPolity(city, polity, initialLoyalty = 0.5) { +if (!city || !polity) return; +if (city.polityId !== null && city.polityId !== polity.id) this.removeCityFromPolity(city); +city.polityId = polity.id; +city.loyalty = clamp(initialLoyalty, 0, 1); +city.receivedAid = false; +polity.cityIds.add(city.id); +this.polityCitiesCache?.delete(polity.id); +} +removeCityFromPolity(city) { +if (!city || city.polityId === null) return; +const oldPolity = this.getPolityById(city.polityId); +if (oldPolity) { +oldPolity.cityIds.delete(city.id); +this.polityCitiesCache?.delete(oldPolity.id); +} +city.polityId = null; +city.loyalty = 0.45; +city.receivedAid = false; +} +isPolityAtWar(polityId) { +return this.wars.some(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)); +} +warCountForPolity(polityId) { +return this.wars.filter(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId)).length; +} +canBeWarTarget(polityId) { +return this.warCountForPolity(polityId) < 3; +} +getWarBetween(aId, bId) { +return this.wars.find(war => +war.ended === null && +((war.aPolityId === aId && war.bPolityId === bId) || (war.aPolityId === bId && war.bPolityId === aId)) +) || null; +} +startWar(a, b) { +if (!a || !b || a.id === b.id) return null; +if (this.year < (a.nextWarAllowedWeek ?? 0) || this.year < (b.nextWarAllowedWeek ?? 0)) return null; +if (this.wars.filter(war => war.ended === null).length >= SimConfig.war.maxActiveWars) return null; +if (this.getWarBetween(a.id, b.id)) return null; +if (this.isPolityAtWar(a.id) || !this.canBeWarTarget(b.id)) return null; +const id = this.nextWar++; +const goal = this.chooseWarGoal(a, b); +const war = { +id, +aPolityId: a.id, +bPolityId: b.id, +goal, +started: this.year, +lastActionYear: this.year, +intensity: this.rng.range(0.9, 1.4), +exhaustionA: 0, +exhaustionB: 0, +scoreA: 0, +scoreB: 0, +capturedByA: 0, +capturedByB: 0, +cityPressureById: new Map(), +lastFrontAdvanceYear: new Map(), +ended: null +}; +this.wars.push(war); +this.addPolityEvent(a.id, "war", this.year, { outcome: "started", targetPolityId: b.id }, 2); +this.addPolityEvent(b.id, "war", this.year, { outcome: "started", targetPolityId: a.id }, 2); +a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5); +b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5); +return war; +} +chooseWarGoal(a, b) { +const aPower = Math.max(1, this.polityPower(a)); +const bPower = Math.max(1, this.polityPower(b)); +const ratio = aPower / bPower; +const defenderInstability = this.polityInstability ? this.polityInstability(b) : 0; +const defenderOverextension = this.polityOverextension ? this.polityOverextension(b) : 0; +const defenderLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(b) : 0.5; +const distance = this.polityDistance(a, b); +const hasReachableCity = this.bestWarPressureTarget(a, b, { goal: "city_conquest", intensity: 0.8, cityPressureById: new Map() }, aPower, bPower, true); +if (ratio > 2.2 && (defenderInstability > 0.6 || defenderOverextension > 0.8 || defenderLoyalty < 0.38)) return "collapse_exploitation"; +if (ratio > 1.75 && defenderInstability > 0.45 && hasReachableCity && this.rng.next() < 0.22) return "capital_pressure"; +if (ratio > 1.25 && hasReachableCity && this.rng.next() < 0.48) return "city_conquest"; +if (distance < 24 && this.rng.next() < 0.62) return "border_claim"; +return "punitive_raid"; +} +endWar(war) { +if (!war || war.ended !== null) return; +this.applyWarSettlement(war); +war.ended = this.year; +for (const [id, other] of [[war.aPolityId, war.bPolityId], [war.bPolityId, war.aPolityId]]) { +const polity = this.getPolityById(id); +if (polity) polity.nextWarAllowedWeek = Math.max(polity.nextWarAllowedWeek || 0, this.year + SimConfig.war.cooldownWeeks); +this.addPolityEvent(id, "war", this.year, { outcome: "ended", targetPolityId: other }, 2); +} +for (const campaign of this.campaigns) { +if (campaign.status === "active" && campaign.type === "border_war" && +((campaign.attackerPolityId === war.aPolityId && campaign.defenderPolityId === war.bPolityId) || +(campaign.attackerPolityId === war.bPolityId && campaign.defenderPolityId === war.aPolityId))) campaign.status = "ceased"; +} +} +totalPolityPopulation(polity) { +return this.getPolityCities(polity) +.reduce((sum, city) => sum + Math.max(0, city.population || 0), 0); +} +averagePolityTechnology(polity) { +const cities = this.getPolityCities(polity); +if (!cities.length) return 0; +let total = 0; +let count = 0; +for (const city of cities) { +if (!city?.knowledge) continue; +total += ((city.knowledge.farming || 0) + (city.knowledge.metallurgy || 0)) * 0.5; +count++; +} +return count ? total / count : 0; +} +polityPower(polity) { +const cities = this.getPolityCities(polity); +if (!cities.length) return 0; +const population = cities.reduce((sum, c) => sum + Math.max(0, c.population || 0), 0); +const treasury = Math.max(0, polity.treasury || 0); +const avgLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(polity) : 0.5; +const tech = this.averagePolityTechnology(polity); +const instability = this.polityInstability ? this.polityInstability(polity) : 0; +const agePressure = this.polityAgePressure ? this.polityAgePressure(polity) : 0; +const basePower = Math.max(0, +Math.sqrt(population) * 1.35 + +Math.sqrt(treasury) * 1.15 + +avgLoyalty * 16 + +tech * 24 - +instability * 12 - +agePressure * 6 +); +return basePower * clamp(polity.charisma ?? 1, 0.5, 1.5); +} +polityInfluenceRange(polity, wartime = false) { +const power = this.polityPower(polity); +const base = 18 + Math.sqrt(power) * 2.2; +const range = wartime ? base * 1.15 : base * 0.75; +return clamp(range, wartime ? 24 : 18, wartime ? 60 : 42); +} +nearestPolityCity(polity, city) { +if (!polity || !city) return null; +let best = null; +let bestDistance = Infinity; +for (const source of this.getPolityCities(polity)) { +const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city); +if (distance < bestDistance) { +best = source; +bestDistance = distance; +} +} +return best; +} +polityConnectionToCity(polity, city) { +if (!polity || !city) return { source: null, distance: Infinity, connected: false }; +let source = null; +let distance = Infinity; +for (const c of this.getPolityCities(polity)) { +if (c.id === city.id) continue; +const d = this.effectiveDistance(c, city); +if (d < distance) { +source = c; +distance = d; +} +} +const limit = SimConfig.polity.logisticsDistance; +return { source, distance, connected: !!source && distance <= limit }; +} +polityDistance(a, b) { +const aCities = this.getPolityCities(a); +const bCities = this.getPolityCities(b); +if (!aCities.length || !bCities.length) return Infinity; +let best = Infinity; +for (const aCity of aCities) { +for (const bCity of bCities) { +const distance = this.effectiveDistance ? this.effectiveDistance(aCity, bCity) : this.distanceBetweenCities(aCity, bCity); +if (distance < best) best = distance; +} +} +return best; +} +polityTreasuryCap(polity) { +if (!polity) return 0; +const perPopulation = Math.max(0, SimConfig.polity?.treasuryCapPerPopulation ?? 0.30); +return Math.max(0, this.totalPolityPopulation(polity) * perPopulation); +} +enforcePolityTreasuryCap(polity) { +if (!polity) return 0; +const cap = this.polityTreasuryCap(polity); +polity.treasury = clamp(polity.treasury || 0, 0, cap); +return cap; +} +enforcePolityTreasuryCaps() { +for (const polity of this.polities) this.enforcePolityTreasuryCap(polity); +} +treasuryCityInvestmentProfile(cost) { +const cfg = SimConfig.polity || {}; +const safeCost = Math.max(0, cost || 0); +const basePopulation = Math.max(1, SimConfig.city.initialPopulation ?? 400); +const populationPerCost = Math.max(0, cfg.treasuryCityPopulationPerCost ?? 0.18); +const reservePerCost = Math.max(0, cfg.treasuryCityReservePerCost ?? 0.45); +const population = Math.max(basePopulation, Math.round(basePopulation + safeCost * populationPerCost)); +const resources = +34 * SimConfig.city.populationScale + +safeCost * reservePerCost + +Math.max(0, population - basePopulation) * 0.22; +return { population, resources }; +} +findTreasuryCityFoundingSite(polity) { +if (!polity) return -1; +const w = this.world; +const cities = this.getPolityCities(polity); +if (!cities.length) return -1; +const spacing = Math.max(1, SimConfig.city.naturalFoundingMinDistance ?? 6); +const radius = Math.max(spacing + 1, SimConfig.polity?.treasuryCityFoundingSearchRadius ?? 20); +const candidates = []; +const visited = new Set(); +for (const source of cities) { +for (const { dx, dy, distance } of this.getRadiusOffsets(radius, false)) { +if (distance < spacing) continue; +const x = source.x + dx; +const y = source.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (visited.has(tile)) continue; +visited.add(tile); +if (w.terrain[tile] === Terrain.WATER || w.city[tile] >= 0) continue; +if (w.territoryOwner[tile] !== polity.id) continue; +if (this.getCitiesNear(x, y, spacing - 1).length) continue; +const route = w.tradeRoute[tile] ? 1.25 : clamp((w.pheromone[tile] || 0) / 12, 0, 0.8); +const score = +(w.fertility[tile] || 0) * 3.2 + +clamp((w.resource[tile] || 0) / 20, 0, 2.5) + +(w.populationPressure[tile] || 0) * 1.6 + +(w.farmland[tile] || 0) * 1.4 + +(w.control[tile] || 0) * 1.2 + +route - +(w.move[tile] || 1) * 0.75; +candidates.push({ tile, score }); +} +} +if (!candidates.length) return -1; +let bestScore = -Infinity; +for (const candidate of candidates) if (candidate.score > bestScore) bestScore = candidate.score; +const scoreWindow = Math.max(0.2, SimConfig.polity?.treasuryCitySiteScoreWindow ?? 6.0); +const floor = bestScore - scoreWindow; +let totalWeight = 0; +for (const candidate of candidates) { +if (candidate.score < floor) continue; +candidate.weight = Math.pow(Math.max(0.20, candidate.score - floor + 0.20), 1.10); +totalWeight += candidate.weight; +} +let roll = this.rng.next() * totalWeight; +let fallback = candidates[0].tile; +for (const candidate of candidates) { +if (candidate.score < floor) continue; +fallback = candidate.tile; +roll -= candidate.weight; +if (roll <= 0) return candidate.tile; +} +return fallback; +} +maybeBuildTreasuryCities() { +const cfg = SimConfig.polity || {}; +const chance = clamp(cfg.treasuryCityFoundingChance ?? 0.14, 0, 1); +const costRatio = clamp(cfg.treasuryCityFoundingCostRatio ?? 0.35, 0, 1); +if (chance <= 0 || costRatio <= 0 || this.cities.length >= SimConfig.city.maxCities) return 0; +let founded = 0; +for (const polity of [...this.polities]) { +if (this.cities.length >= SimConfig.city.maxCities) break; +const cap = this.polityTreasuryCap(polity); +if (cap <= 0 || (polity.treasury || 0) + 1e-6 < cap) continue; +if (this.rng.next() >= chance) continue; +const tile = this.findTreasuryCityFoundingSite(polity); +if (tile < 0) continue; +const cost = Math.min(polity.treasury || 0, cap * costRatio); +if (cost <= 0) continue; +const x = tile % this.world.size; +const y = Math.floor(tile / this.world.size); +const ethnicitySeed = this.manualCityEthnicitySeed(tile); +const investment = this.treasuryCityInvestmentProfile(cost); +const city = this.createCity(x, y, null, { +initialPopulation: investment.population, +initialResources: investment.resources +}); +this.applyCityEthnicitySeed(city, ethnicitySeed, city.population); +city.strength = Math.max(city.strength, 4.5); +city.activeVisitors = Math.max(city.activeVisitors, 6); +this.cities.push(city); +this.cityById.set(city.id, city); +const bucketKey = this.cityBucketKey(city.x, city.y); +if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []); +this.cityBuckets.get(bucketKey).push(city); +this.world.city[tile] = city.id; +this.addCityToPolity(city, polity, 0.72); +this.claimPolityCoreTerritory(polity, city, 3, "treasuryCity"); +polity.treasury = Math.max(0, (polity.treasury || 0) - cost); +founded++; +} +if (founded) { +this.rebuildIndexes(); +this.updatePopulationCapacity(); +this.syncTilePopulationCulture(); +} +return founded; +} +polityAge(polity) { +return (this.year - (polity?.founded ?? this.year)) / WEEKS_PER_YEAR; +} +polityAgePressure(polity) { +const age = this.polityAge(polity); +if (age < 80) return 0; +const x = (age - 80) / 420; +return clamp(1 - Math.exp(-x), 0, 1.35); +} +polityInstitutionalAging(polity) { +return clamp(polity?.institutionalAging ?? 0, 0, 1); +} +polityLoyaltyCap(polity) { +return clamp(1 - this.polityInstitutionalAging(polity), 0, 1); +} +updateInstitutionalAging() { +const cfg = SimConfig.polity || {}; +const annualIncrease = Math.max(0, cfg.institutionalAgingAnnualIncrease ?? 0.0015); +for (const polity of this.polities) { +polity.institutionalAging = this.polityInstitutionalAging(polity); +if (this.year <= (polity.founded ?? this.year)) continue; +polity.institutionalAging = clamp(polity.institutionalAging + annualIncrease, 0, 1); +} +} +maybeTriggerInstitutionalReforms() { +const cfg = SimConfig.polity || {}; +const threshold = clamp(cfg.institutionalReformThreshold ?? 0.30, 0, 1); +const cooldown = years(Math.max(0, cfg.institutionalReformCooldownYears ?? 50)); +for (const polity of this.polities) { +const before = this.polityInstitutionalAging(polity); +if (before <= threshold) continue; +if (this.year - (polity.lastInstitutionalReformYear ?? polity.founded ?? 0) < cooldown) continue; +const chance = clamp( +(cfg.institutionalReformBaseChance ?? 0.008) + before * (cfg.institutionalReformChanceScale ?? 0.10), +0, +cfg.institutionalReformMaxChance ?? 0.12 +); +if (this.rng.next() >= chance) continue; +const changeRate = this.rng.range( +cfg.institutionalReformMinChange ?? -0.50, +cfg.institutionalReformMaxChange ?? 0.30 +); +const after = clamp(before * (1 + changeRate), 0, 1); +polity.institutionalAging = after; +polity.lastInstitutionalReformYear = this.year; +this.addPolityEvent(polity.id, "institutionalReform", this.year, { +before, +after, +changeRate, +loyaltyCapBefore: clamp(1 - before, 0, 1), +loyaltyCapAfter: clamp(1 - after, 0, 1) +}, 3); +} +} +polityOverextension(polity) { +const cities = this.getPolityCities(polity); +const center = this.getCityById(polity.centerCityId); +if (!center || cities.length <= 1) return 0; +const cityCountPressure = Math.max(0, cities.length - 5) * 0.08; +let distanceSum = 0; +let count = 0; +for (const city of cities) { +if (city.id === center.id) continue; +distanceSum += this.effectiveDistance(center, city); +count++; +} +const avgDistance = count ? distanceSum / count : 0; +const distancePressure = Math.max(0, avgDistance - 28) * 0.008; +return clamp(cityCountPressure + distancePressure, 0, 2); +} +polityResourceStress(polity) { +const cities = this.getPolityCities(polity); +if (!cities.length) return 1; +let poor = 0; +let supplyStress = 0; +for (const city of cities) { +const perCapita = city.storedResources / Math.max(1, city.population); +if (perCapita < 0.06) poor++; +supplyStress += city.supplyStress || 0; +} +const povertyRate = poor / cities.length; +const avgSupplyStress = supplyStress / cities.length; +const treasuryPerCity = (polity.treasury || 0) / Math.max(1, cities.length); +const treasuryStress = treasuryPerCity < 2 ? (2 - treasuryPerCity) / 2 : 0; +return clamp(povertyRate * 0.55 + avgSupplyStress * 0.35 + treasuryStress * 0.28, 0, 1.5); +} +polityLogisticsStress(polity) { +const cities = this.getPolityCities(polity); +const center = this.getCityById(polity.centerCityId); +if (!center || cities.length <= 1) return 0; +let stress = 0; +let count = 0; +for (const city of cities) { +if (city.id === center.id) continue; +const distance = this.effectiveDistance(center, city); +const food = city.storedResources / Math.max(1, city.population); +const distanceStress = Math.max(0, distance - SimConfig.polity.logisticsDistance) * 0.012; +const foodStress = Math.max(0, SimConfig.polity.minimumPerCapitaFood - food) * 4.5; +const routeRelief = this.hasDirectTradeConnection(center, city) ? 0.72 : 1; +stress += (distanceStress + foodStress + (city.supplyStress || 0) * 0.35) * routeRelief; +count++; +} +return clamp(stress / Math.max(1, count), 0, 1.8); +} +polityEthnicFragmentation(polity) { +const cities = this.getPolityCities(polity); +if (cities.length <= 1) return 0; +const counts = new Map(); +for (const city of cities) { +const e = this.dominantCityEthnicity(city); +if (e !== null) counts.set(e, (counts.get(e) || 0) + 1); +} +if (!counts.size) return 0; +let max = 0; +for (const v of counts.values()) max = Math.max(max, v); +const dominantShare = max / cities.length; +return clamp(1 - dominantShare, 0, 1); +} +polityInstability(polity) { +const agePressure = this.polityAgePressure(polity); +const overextension = this.polityOverextension(polity); +const resourceStress = this.polityResourceStress(polity); +const logisticsStress = this.polityLogisticsStress(polity); +const fragmentation = this.polityEthnicFragmentation(polity); +const legitimacyBuffer = (polity.legitimacy ?? 0.7) * 0.85; +const cohesionBuffer = (polity.cohesion ?? 0.6) * 0.62; +return clamp( +agePressure * 0.35 + +overextension * 0.24 + +resourceStress * 0.38 + +logisticsStress * 0.30 + +fragmentation * 0.22 + +(polity.crisis || 0) * 0.42 - +legitimacyBuffer - +cohesionBuffer, +0, +2.5 +); +} +foundPolities() { +let formed = 0; +for (const city of this.cities) { +if (city.polityId !== null || city.population < 55 || city.storedResources < 18) continue; +if (this.world.territoryOwner[this.world.idx(city.x, city.y)] >= 0) continue; +if (formed >= 2) break; +const localPeers = this.getCitiesNear(city.x, city.y, 18).filter(other => other !== city && other.polityId === null); +const localLeader = localPeers.every(other => this.cityInfluence(city) >= this.cityInfluence(other) * 0.92); +if (!localLeader || this.rng.next() > 0.22) continue; +this.createPolity(city); +formed++; +} +} +// Phase 1B: campaigns are discrete targeted events. Influence/control can show +// continuous territory, but new holdings come from these active campaign objects. +maybeStartCampaigns() { +const cfg = SimConfig.campaign || {}; +const interval = years(cfg.checkIntervalYears ?? 3); +if (!interval || this.year % interval !== 0) return; +const active = (this.campaigns || []).filter(c => c.status === "active").length; +if (active >= (cfg.maxActiveCampaigns ?? 12)) return; +for (const polity of this.polities) { +if ((this.campaigns || []).filter(c => c.status === "active").length >= (cfg.maxActiveCampaigns ?? 12)) break; +if (!this.getPolityCities(polity).length) continue; +const cooldown = this.year - (polity.lastCampaignYear ?? -Infinity); +if (cooldown < years(cfg.minCooldownYears ?? 12)) continue; +if (polity.nextCampaignAllowedYear != null && this.year < polity.nextCampaignAllowedYear) continue; +const chance = this.campaignStartChance(polity); +if (this.rng.next() > chance) continue; +const target = this.chooseCampaignTarget(polity); +if (target) this.startCampaign(polity, target); +} +} +campaignStartChance(polity) { +const cfg = SimConfig.campaign || {}; +const power = this.polityPower(polity); +const powerFactor = clamp(power / 80, 0.5, 2.0); +const cohesion = clamp(polity.cohesion ?? 0.6, 0.25, 1.2); +const legitimacy = clamp(polity.legitimacy ?? 0.7, 0.25, 1.2); +const charisma = clamp(polity.charisma ?? 1, 0.5, 1.8); +const crisis = clamp(polity.crisis || 0, 0, 1.5); +const overextension = this.polityOverextension ? this.polityOverextension(polity) : 0; +const overextensionFactor = clamp(1.2 - overextension, 0.1, 1.2); +const crisisFactor = clamp(1.1 - crisis, 0.05, 1.1); +const treasuryFactor = clamp((polity.treasury || 0) / Math.max(8, this.getPolityCities(polity).length * 7), 0.45, 1.35); +const noise = this.rng.range(0.72, 1.24); +return clamp((cfg.baseChance ?? 0.18) * powerFactor * cohesion * legitimacy * charisma * overextensionFactor * crisisFactor * treasuryFactor * noise, 0, 0.68); +} +weightedPick(entries) { +const total = entries.reduce((sum, entry) => sum + Math.max(0, entry.weight || 0), 0); +if (total <= 0) return null; +let roll = this.rng.next() * total; +for (const entry of entries) { +roll -= Math.max(0, entry.weight || 0); +if (roll <= 0) return entry; +} +return entries[entries.length - 1] || null; +} +chooseCampaignTarget(polity) { +const cfg = SimConfig.campaign || {}; +const groups = [ +{ kind: "frontier_colonization", weight: cfg.frontierTargetChance ?? 0.35, items: this.frontierCampaignTargets(polity) }, +{ kind: "nonstate_subjugation", weight: cfg.nonStateTargetChance ?? 0.30, items: this.nonStateCampaignTargets(polity) }, +{ kind: "independent_city_annexation", weight: cfg.independentCityTargetChance ?? 0.20, items: this.independentCityCampaignTargets(polity) }, +{ kind: "border_war", weight: cfg.polityTargetChance ?? 0.15, items: this.borderWarCampaignTargets(polity) } +].filter(group => group.items.length && group.weight > 0); +const group = this.weightedPick(groups); +if (!group) return null; +const target = this.weightedPick(group.items); +return target ? { ...target, type: group.kind } : null; +} +noisyTargetWeight(score) { +return Math.max(0, score * this.rng.range(0.45, 1.65) + this.rng.range(-0.08, 0.12)); +} +frontierCampaignTargets(polity) { +return this.sampleTileCampaignTargets(polity, (tile, connection) => { +const w = this.world; +if (w.polity[tile] >= 0 || w.city[tile] >= 0 || w.terrain[tile] === Terrain.WATER) return null; +const expansion = this.isValidExpansionTarget(polity, tile, { maxNeutralPath: 6, requireUnowned: true }); +if (!expansion) return null; +const nearbyTrade = (w.tradeRoute[tile] ? 0.5 : 0) + clamp(w.pheromone[tile] / 12, 0, 0.45); +const resistance = Math.sqrt(Math.max(0, w.population[tile] || 0)) * 0.10; +const frontierDistance = this.expansionConnectionDistance(polity, tile, 6); +const value = w.fertility[tile] + clamp(w.resource[tile] / 45, 0, 1) * 0.8 + nearbyTrade + w.mineral[tile] * 0.25 - resistance - frontierDistance * 0.10 - connection.distance * 0.010 - w.move[tile] * 0.22; +if (value < 0.38) return null; +return { targetTile: tile, weight: this.noisyTargetWeight(value) }; +}); +} +nonStateCampaignTargets(polity) { +return this.sampleTileCampaignTargets(polity, (tile, connection) => { +const w = this.world; +if (w.polity[tile] >= 0 || w.city[tile] >= 0 || w.terrain[tile] === Terrain.WATER) return null; +const expansion = this.isValidExpansionTarget(polity, tile, { maxNeutralPath: 7, requireUnowned: true }); +if (!expansion) return null; +const population = w.population?.[tile] || 0; +const ethnicity = w.dominantEthnicity[tile]; +if (population < 2 && ethnicity < 0) return null; +const diversity = w.cultureDiversity[tile] || 0; +const sourceEthnicity = this.dominantCityEthnicity(connection.source); +const cultureMismatch = sourceEthnicity !== null && ethnicity >= 0 && sourceEthnicity !== ethnicity ? 0.45 : 0; +const strategic = (w.tradeRoute[tile] ? 0.45 : 0) + w.mineral[tile] * 0.38 + w.fertility[tile] * 0.25; +const frontierDistance = this.expansionConnectionDistance(polity, tile, 7); +const score = Math.sqrt(Math.max(1, population)) * 0.38 + strategic + clamp(w.resource[tile] / 45, 0, 1) * 0.35 - frontierDistance * 0.09 - connection.distance * 0.008 - w.move[tile] * 0.22 - cultureMismatch + diversity * 0.12; +return { +targetTile: tile, +targetEthnicity: ethnicity >= 0 ? ethnicity : null, +weight: this.noisyTargetWeight(score) +}; +}); +} +independentCityCampaignTargets(polity) { +const cfg = SimConfig.campaign || {}; +const targets = []; +for (const city of this.cities) { +if (city.polityId !== null || city.population <= 0) continue; +const connection = this.polityConnectionToCity(polity, city); +if (!connection.connected || connection.distance > (cfg.maxTargetDistance ?? 48)) continue; +const tile = this.world.idx(city.x, city.y); +if (!this.isValidExpansionTarget(polity, tile, { maxNeutralPath: 8, allowIndependentCity: true })) continue; +const cityInfluence = Math.max(1, this.cityInfluence(city)); +const proximity = 1 / (1 + connection.distance * 0.06); +const cityStrength = clamp(city.strength || 1, 0.2, 8) * 0.12; +const score = Math.sqrt(city.population || 1) * 0.12 + (city.tradeValue || 0) * 0.55 + proximity * 1.4 - connection.distance * 0.012 - cityStrength + this.polityPower(polity) / Math.max(1, cityInfluence) * 0.18; +targets.push({ +targetTile: tile, +targetCityId: city.id, +connection, +weight: this.noisyTargetWeight(score) +}); +} +return targets; +} +borderWarCampaignTargets(polity) { +const cfg = SimConfig.campaign || {}; +const targets = []; +for (const other of this.polities) { +if (other.id === polity.id || !this.canBeWarTarget(other.id)) continue; +const distance = this.polityDistance(polity, other); +if (!Number.isFinite(distance) || distance > (cfg.maxTargetDistance ?? 48)) continue; +const border = this.nearestBorderCampaignTile(polity, other); +const city = this.borderWarTargetCity(polity, other); +if (city && !this.isBorderWarCityTarget(polity, other, city, border.tile)) continue; +if (!city && (border.tile == null || !this.isAdjacentToPolity(border.tile, polity.id))) continue; +const defenderPower = Math.max(1, this.polityPower(other)); +const targetCityValue = city ? Math.sqrt(city.population || 1) * 0.18 + (city.tradeValue || 0) * 0.5 : 0.25; +const contested = border.tile != null && this.world.contested[border.tile] ? 0.75 : 0; +const borderValue = this.borderFriction(polity, other, distance) + contested + targetCityValue; +const riskAppetite = this.rng.next() < 0.10 ? this.rng.range(0.4, 1.1) : 0; +const score = borderValue - defenderPower / 90 - distance * 0.018 + this.polityPower(polity) / 120 + riskAppetite; +targets.push({ +targetTile: city ? this.world.idx(city.x, city.y) : border.tile, +targetCityId: city?.id ?? null, +defenderPolityId: other.id, +weight: this.noisyTargetWeight(score) +}); +} +return targets; +} +sampleTileCampaignTargets(polity, scorer) { +const w = this.world; +const cfg = SimConfig.campaign || {}; +const targets = []; +const seen = new Set(); +const cities = this.getPolityCities(polity); +const maxDistance = cfg.maxTargetDistance ?? 48; +const tries = clamp(80 + cities.length * 22, 90, 260); +for (let n = 0; n < tries; n++) { +const source = cities[this.rng.int(cities.length)]; +if (!source) break; +const radius = Math.ceil(maxDistance); +const x = clamp(source.x + this.rng.int(radius * 2 + 1) - radius, 0, w.size - 1); +const y = clamp(source.y + this.rng.int(radius * 2 + 1) - radius, 0, w.size - 1); +const tile = w.idx(x, y); +if (seen.has(tile)) continue; +seen.add(tile); +const connection = this.nearestPolityTileConnection(polity, tile); +if (!connection.source || connection.distance > maxDistance) continue; +const base = scorer(tile, connection); +if (!base) continue; +targets.push({ +...base, +connection, +weight: Math.max(0, base.weight || 0) / (1 + connection.distance * 0.035) +}); +} +return targets; +} +isValidExpansionTarget(polity, tile, options = {}) { +const w = this.world; +if (!polity || tile == null || w.terrain[tile] === Terrain.WATER) return false; +const owner = w.polity[tile]; +if (options.requireUnowned && owner >= 0) return false; +if (owner >= 0 && owner !== polity.id && !options.allowEnemy) return false; +const city = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +if (city && city.polityId !== null && city.polityId !== polity.id && !options.allowEnemy) return false; +if (city && city.polityId === null && !options.allowIndependentCity && !options.requireUnowned) return false; +if (owner === polity.id && options.allowOwned) return true; +const maxPath = options.maxNeutralPath ?? 6; +if (this.expansionConnectionDistance(polity, tile, maxPath) <= maxPath) return true; +if (w.tradeRoute[tile]) { +const connection = this.nearestPolityTileConnection(polity, tile); +return !!connection.source && connection.distance <= (SimConfig.campaign?.maxTargetDistance ?? 48) * 0.6; +} +this.territoryDebug.invalidCampaignTargetsRejected++; +return false; +} +expansionConnectionDistance(polity, tile, maxPath = 6) { +const w = this.world; +if (w.polity[tile] === polity.id) return 0; +const visited = this.visitStamp; +const marker = this.nextVisitMarker(); +const queue = [{ tile, distance: 0 }]; +visited[tile] = marker; +for (let q = 0; q < queue.length; q++) { +const current = queue[q]; +if (current.distance >= maxPath) continue; +let found = Infinity; +this.forCardinalNeighbors(current.tile, n => { +if (visited[n] === marker || w.terrain[n] === Terrain.WATER) return; +if (w.polity[n] === polity.id) { +found = current.distance + 1; +return false; +} +if (w.polity[n] >= 0 && w.polity[n] !== polity.id) return; +visited[n] = marker; +queue.push({ tile: n, distance: current.distance + 1 }); +}); +if (found < Infinity) return found; +} +return Infinity; +} +isAdjacentToPolity(tile, polityId) { +const w = this.world; +let adjacent = false; +this.forCardinalNeighbors(tile, n => { +if (w.polity[n] !== polityId) return; +adjacent = true; +return false; +}); +return adjacent; +} +isBorderWarCityTarget(attacker, defender, city, fallbackBorderTile = null) { +const tile = this.world.idx(city.x, city.y); +if (this.isAdjacentToPolity(tile, attacker.id)) return true; +if (fallbackBorderTile != null) { +const distance = Math.abs(city.x - (fallbackBorderTile % this.world.size)) + Math.abs(city.y - Math.floor(fallbackBorderTile / this.world.size)); +if (distance <= 10) return true; +} +return this.expansionConnectionDistance(attacker, tile, 8) <= 8 && this.isValidExpansionTarget(attacker, tile, { allowEnemy: true, maxNeutralPath: 8 }); +} +nearestPolityTileConnection(polity, tile) { +const w = this.world; +const x = tile % w.size; +const y = Math.floor(tile / w.size); +let source = null; +let distance = Infinity; +for (const city of this.getPolityCities(polity)) { +let d = Math.abs(city.x - x) + Math.abs(city.y - y); +if (w.tradeRoute[tile]) d *= 0.72; +if (d < distance) { +source = city; +distance = d; +} +} +return { source, distance }; +} +nearestBorderCampaignTile(attacker, defender) { +const w = this.world; +let best = { tile: this.world.idx(this.getPolityCities(defender)[0]?.x || 0, this.getPolityCities(defender)[0]?.y || 0), distance: Infinity }; +for (let i = 0; i < w.count; i++) { +if (w.polity[i] !== defender.id || !this.isBorderTile(i)) continue; +const connection = this.nearestPolityTileConnection(attacker, i); +if (connection.distance < best.distance) best = { tile: i, distance: connection.distance }; +} +return best; +} +borderWarTargetCity(attacker, defender) { +const maxDistance = SimConfig.campaign?.maxTargetDistance ?? 48; +let best = null; +let bestDistance = Infinity; +for (const city of this.getPolityCities(defender)) { +const connection = this.polityConnectionToCity(attacker, city); +if (connection.connected && connection.distance < bestDistance && connection.distance <= maxDistance) { +best = city; +bestDistance = connection.distance; +} +} +return best; +} +startCampaign(polity, target) { +const cfg = SimConfig.campaign || {}; +if (target.type === "border_war") { +const defender = this.getPolityById(target.defenderPolityId); +if (!defender || (!this.getWarBetween(polity.id, defender.id) && !this.startWar(polity, defender))) return null; +} +const duration = years(Math.min(cfg.maxDurationYears ?? 25, (cfg.baseDurationYears ?? 8) + this.rng.int(Math.max(1, (cfg.maxDurationYears ?? 25) - (cfg.baseDurationYears ?? 8) + 1)))); +const targetTile = target.targetTile ?? (target.targetCityId ? this.cityTile(this.getCityById(target.targetCityId)) : null); +if (targetTile == null) return null; +const campaign = { +id: this.nextCampaign++, +type: target.type, +annexationMode: target.type === "independent_city_annexation" ? this.independentAnnexationMode() : null, +attackerPolityId: polity.id, +defenderPolityId: target.defenderPolityId ?? null, +targetCityId: target.targetCityId ?? null, +targetTile, +targetEthnicity: target.targetEthnicity ?? null, +started: this.year, +expires: target.type === "border_war" ? Infinity : this.year + duration, +progress: 0, +strength: 0, +resistance: 0, +status: "active" +}; +this.campaigns.push(campaign); +polity.lastCampaignYear = this.year; +polity.nextCampaignAllowedYear = this.year + years(this.rng.range(cfg.minCooldownYears ?? 12, cfg.maxCooldownYears ?? 45)); +if (campaign.type === "border_war") { +const defender = this.getPolityById(campaign.defenderPolityId); +if (defender && !this.getWarBetween(polity.id, defender.id)) this.startWar(polity, defender); +this.addPolityEvent(polity.id, "borderWar", this.year, this.campaignEventData(campaign, "started"), 2); +if (defender) this.addPolityEvent(defender.id, "borderWar", this.year, this.campaignEventData(campaign, "defending"), 2); +} else if (campaign.type === "frontier_colonization") { +this.addPolityEvent(polity.id, "expansion", this.year, this.campaignEventData(campaign, "started"), 1); +} +this.addPolityEvent(polity.id, "campaign", this.year, this.campaignEventData(campaign, "started"), 1); +return campaign; +} +independentAnnexationMode() { +const peacefulShare = SimConfig.campaign?.independentCityPeacefulShare ?? 0.4; +return this.rng.next() < peacefulShare ? "peaceful" : "military"; +} +cityTile(city) { +return city ? this.world.idx(city.x, city.y) : null; +} +updateCampaigns() { +for (const campaign of this.campaigns) { +if (campaign.status !== "active") continue; +const borderWar = campaign.type === "border_war"; +if (this.year % (borderWar ? SimConfig.war.tickWeeks : years(1)) !== 0) continue; +const attacker = this.getPolityById(campaign.attackerPolityId); +if (!attacker) { +campaign.status = "failed"; +continue; +} +const force = this.campaignAttackerForce(campaign, attacker); +const resistance = this.campaignResistance(campaign, attacker); +campaign.strength = force; +campaign.resistance = resistance; +const momentum = (force - resistance) / Math.max(1, force + resistance); +campaign.progress = clamp(campaign.progress + (borderWar ? 0.25 + momentum * 0.4 : 0.09 + momentum * 0.18) + this.rng.range(-0.08, 0.08), -0.35, 1.25); +attacker.treasury = Math.max(0, (attacker.treasury || 0) - Math.max(0.05, force * 0.018)); +if (campaign.progress >= 1) this.resolveCampaignSuccess(campaign); +else if (campaign.progress <= -0.28) this.resolveCampaignFailure(campaign); +else if (this.year >= campaign.expires) { +if (campaign.progress > 0.72 && this.rng.next() < 0.45) this.resolveCampaignSuccess(campaign); +else this.resolveCampaignFailure(campaign, "expired"); +} +} +this.campaigns = this.campaigns.filter(campaign => campaign.status === "active" || this.year - campaign.started < years(8)); +} +campaignAttackerForce(campaign, attacker) { +const connection = this.nearestPolityTileConnection(attacker, campaign.targetTile); +const source = connection.source; +const nearbyStrength = source ? this.cityInfluence(source) * 0.32 : 1; +const routeAccess = this.world.tradeRoute[campaign.targetTile] || (source && this.hasDirectTradeConnection(source, this.getCityById(campaign.targetCityId))) ? 1.18 : 1; +return Math.max(0.1, +this.polityPower(attacker) * 0.42 * +clamp(attacker.cohesion ?? 0.6, 0.2, 1.2) * +clamp(attacker.charisma ?? 1, 0.5, 1.5) * +clamp((attacker.treasury || 0) / Math.max(8, this.getPolityCities(attacker).length * 5), 0.25, 1.6) * +routeAccess + +nearbyStrength +); +} +campaignResistance(campaign, attacker) { +const w = this.world; +const tile = campaign.targetTile; +const targetCity = this.getCityById(campaign.targetCityId); +const defender = campaign.defenderPolityId != null ? this.getPolityById(campaign.defenderPolityId) : null; +const connection = this.nearestPolityTileConnection(attacker, tile); +const terrain = w.move[tile] * (w.terrain[tile] === Terrain.MOUNTAIN ? 1.45 : 1); +const population = targetCity?.population || w.population[tile] || 0; +const defenderPower = defender ? this.polityPower(defender) * 0.55 : 0; +const attackerEthnicity = this.dominantCityEthnicity(connection.source); +const localEthnicity = targetCity ? this.dominantCityEthnicity(targetCity) : w.dominantEthnicity[tile]; +const mismatch = attackerEthnicity !== null && localEthnicity !== null && localEthnicity >= 0 && attackerEthnicity !== localEthnicity ? 1.22 : 0.92; +const foreignControl = w.polity[tile] >= 0 && w.polity[tile] !== attacker.id ? 1.35 : 1; +return Math.max(0.2, +(Math.sqrt(Math.max(1, population)) * 3.2 + terrain * 8 + connection.distance * 0.32 + defenderPower) * +mismatch * +foreignControl * +(1 + (w.cultureDiversity[tile] || 0) * 0.35) +); +} +resolveCampaignSuccess(campaign) { +campaign.status = "succeeded"; +const attacker = this.getPolityById(campaign.attackerPolityId); +if (!attacker) return; +if (campaign.type === "independent_city_annexation") { +const city = this.getCityById(campaign.targetCityId); +const cityTile = city ? this.world.idx(city.x, city.y) : null; +const cityTileOwner = cityTile != null ? this.world.territoryOwner[cityTile] : -1; +if (city && city.polityId === null && (cityTileOwner < 0 || cityTileOwner === attacker.id)) { +const similarity = this.campaignEthnicSimilarity(attacker, campaign); +const peaceful = campaign.annexationMode === "peaceful"; +const resistanceRatio = campaign.resistance / Math.max(1, campaign.strength + campaign.resistance); +if (!peaceful) { +this.applyCityWarDamage(city, clamp(0.025 + resistanceRatio * 0.06, 0.025, 0.10), "independentAnnexation"); +} +this.transferCityToPolity(city, attacker, { +loyalty: peaceful +? clamp(0.58 + similarity * 0.22, 0.58, 0.82) +: clamp(0.20 + similarity * 0.22 - resistanceRatio * 0.12, 0.12, 0.48), +control: peaceful ? 0.58 : 0.46, +claim: peaceful ? 0.68 : 0.62, +reason: peaceful ? "peacefulAnnexation" : "militaryAnnexation" +}); +campaign.affectedTiles = this.applyCampaignTerritory(attacker, cityTile, 5, peaceful ? 0.48 : 0.42, { +allowForeignTakeover: false, +reason: peaceful ? "peacefulAnnexation" : "militaryAnnexation" +}) || []; +} +this.addPolityEvent(attacker.id, "annexation", this.year, this.campaignEventData(campaign, campaign.annexationMode || "military"), 2); +} else if (campaign.type === "border_war") { +const defender = this.getPolityById(campaign.defenderPolityId); +const city = this.getCityById(campaign.targetCityId); +if (city && defender && city.polityId === defender.id) { +this.applyCityWarDamage(city, clamp(0.04 + campaign.progress * 0.025, 0.03, 0.14), "campaign"); +this.transferCityToPolity(city, attacker, { +loyalty: 0.22, +control: 0.58, +claim: 0.72, +allowForeignTakeover: true, +reason: "borderWar" +}); +campaign.affectedTiles = this.transferWarTerritory(attacker, defender, this.world.idx(city.x, city.y), Math.max(9, SimConfig.campaign?.claimRadius ?? 8), { +strength: 0.62, +reason: "borderWar", +maxTiles: 95 +}) || []; +} else { +campaign.affectedTiles = defender +? this.transferFrontierBelt(attacker, defender, campaign.targetTile, 4, 5, { maxTiles: 70, reason: "borderWar" }) || [] +: this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.48, { allowForeignTakeover: false, reason: "campaign" }) || []; +this.applyWarZoneTileDamage(campaign.targetTile, (SimConfig.war.warZoneTileLossRate ?? 0.006) * 1.5); +} +if (defender) { +defender.cohesion = clamp((defender.cohesion ?? 0.6) - 0.04, 0, 1); +defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.025, 0, 1); +defender.treasury = Math.max(0, (defender.treasury || 0) - campaign.strength * 0.04); +const war = this.getWarBetween(attacker.id, defender.id); +if (war) this.addWarScore(war, attacker.id, city ? 1.5 : 0.75); +} +this.addPolityEvent(attacker.id, "borderWar", this.year, this.campaignEventData(campaign, "succeeded"), 2); +} else if (campaign.type === "nonstate_subjugation") { +this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.56, { +allowForeignTakeover: false, +reason: "subjugation" +}); +this.applySubjugationEffects(attacker, campaign); +this.addPolityEvent(attacker.id, "subjugation", this.year, this.campaignEventData(campaign, "succeeded"), 1); +} else { +this.applyCampaignTerritory(attacker, campaign.targetTile, SimConfig.campaign?.claimRadius ?? 8, 0.46, { +allowForeignTakeover: false, +reason: "frontier" +}); +this.seedFrontierPopulation(attacker, campaign.targetTile); +this.maybeCreateFrontierSettlement(attacker, campaign.targetTile); +this.addPolityEvent(attacker.id, "expansion", this.year, this.campaignEventData(campaign, "succeeded"), 1); +this.addPolityEvent(attacker.id, "colonization", this.year, this.campaignEventData(campaign, "succeeded"), 1); +} +this.recordCampaign(attacker, campaign, campaign.status); +this.syncTilePopulationCulture(); +this.recomputeTerritories({ allowOwnershipChanges: false, reason: "campaign" }); +this.reconcileCityTerritoryOwnership(); +this.campaignPressureOnTerritories(); +} +resolveCampaignFailure(campaign, reason = "failed") { +campaign.status = reason; +const attacker = this.getPolityById(campaign.attackerPolityId); +if (!attacker) return; +attacker.treasury = Math.max(0, (attacker.treasury || 0) - Math.max(1, campaign.strength * 0.04)); +attacker.cohesion = clamp((attacker.cohesion ?? 0.6) - 0.025, 0, 1); +attacker.legitimacy = clamp((attacker.legitimacy ?? 0.7) - 0.018, 0, 1); +if (campaign.type === "nonstate_subjugation") { +const w = this.world; +w.cultureDiversity[campaign.targetTile] = clamp((w.cultureDiversity[campaign.targetTile] || 0) + 0.10, 0, 1); +} +const war = campaign.defenderPolityId != null ? this.getWarBetween(attacker.id, campaign.defenderPolityId) : null; +if (war) { +if (war.aPolityId === attacker.id) war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1); +else war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1); +const defender = campaign.defenderPolityId != null ? this.getPolityById(campaign.defenderPolityId) : null; +if (defender) this.addWarScore(war, defender.id, 0.45 + clamp(campaign.resistance / Math.max(1, campaign.strength), 0, 1.2)); +} +this.addPolityEvent(attacker.id, "failedCampaign", this.year, this.campaignEventData(campaign, reason), 1); +const peacefulAnnexation = campaign.type === "independent_city_annexation" && campaign.annexationMode === "peaceful"; +if (!peacefulAnnexation) { +this.applyPolityWarExhaustionPopulationLoss(attacker, 0.45, clamp(campaign.resistance / Math.max(1, campaign.strength), 0.4, 1.4)); +this.applyWarZoneTileDamage(campaign.targetTile, SimConfig.war.warZoneTileLossRate ?? 0.006); +} +this.recordCampaign(attacker, campaign, campaign.status); +} +campaignEthnicSimilarity(polity, campaign) { +const source = this.nearestPolityTileConnection(polity, campaign.targetTile).source; +const targetCity = this.getCityById(campaign.targetCityId); +const a = this.dominantCityEthnicity(source); +const b = targetCity ? this.dominantCityEthnicity(targetCity) : this.world.dominantEthnicity[campaign.targetTile]; +if (a === null || b === null || b < 0) return 0.45; +return a === b ? 1 : 0.25; +} +transferTerritoryTiles(fromPolityId, toPolityId, tiles, options = {}) { +const w = this.world; +const changed = []; +const toPolity = this.getPolityById(toPolityId); +const clearing = toPolityId == null || toPolityId < 0; +if (!toPolity && !clearing) return changed; +for (const tile of tiles) { +if (tile == null || tile < 0 || tile >= w.count || w.terrain[tile] === Terrain.WATER) continue; +const owner = w.territoryOwner[tile]; +if ( +fromPolityId != null && +owner !== fromPolityId && +owner !== toPolityId && +!(options.allowNeutralWithFrom && owner < 0) +) continue; +if (!clearing && owner >= 0 && owner !== toPolityId && !options.allowForeignTakeover) continue; +if (owner === toPolityId || (clearing && owner < 0)) continue; +this.setTileOwner(tile, toPolityId, { +control: options.control ?? 0.32, +claim: options.claim ?? 0.45, +contested: options.contested ?? false +}); +if (clearing) { +this.territorialClaims.delete(tile); +} else { +this.territorialClaims.set(tile, { +polityId: toPolityId, +control: options.control ?? 0.32, +claim: options.claim ?? 0.45, +contested: !!options.contested, +reason: options.reason ?? "transfer", +year: this.year +}); +} +changed.push(tile); +} +if (changed.length) { +this.markContestedFront(toPolity, fromPolityId != null ? this.getPolityById(fromPolityId) : null, changed, options.intensity ?? 0.45); +if (fromPolityId != null && fromPolityId !== toPolityId) { +const defender = this.getPolityById(fromPolityId); +if (defender) { +defender.cohesion = clamp((defender.cohesion ?? 0.6) - Math.min(0.08, changed.length * 0.0008), 0, 1); +defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - Math.min(0.06, changed.length * 0.0006), 0, 1); +} +} +this.recomputeTerritories({ allowOwnershipChanges: false, reason: options.reason ?? "transfer" }); +} +return changed; +} +claimPolityCoreTerritory(polity, city, radius = 4, reason = "founding") { +if (!polity || !city) return []; +const w = this.world; +const tiles = []; +for (const { dx, dy } of this.getRadiusOffsets(Math.ceil(radius * Math.SQRT2))) { +const distance = Math.hypot(dx, dy); +if (distance > radius) continue; +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER) continue; +if (w.territoryOwner[tile] >= 0 && w.territoryOwner[tile] !== polity.id) continue; +const otherCity = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +if (otherCity && otherCity.polityId !== null && otherCity.polityId !== polity.id) continue; +const falloff = 1 - distance / (radius + 1); +if (falloff <= 0) continue; +tiles.push(tile); +} +return this.transferTerritoryTiles(null, polity.id, tiles, { +control: 0.42, +claim: 0.58, +reason +}); +} +isConquestTransferReason(reason) { +return reason === "warCapture" || reason === "borderWar" || reason === "militaryAnnexation" || reason === "settlement" || reason === "nomadConquest"; +} +protectCityFromRebellion(city, graceYears = SimConfig.rebellion?.conquestGraceYears ?? 20) { +if (!city) return; +city.rebellionProtectedUntil = Math.max(city.rebellionProtectedUntil || 0, this.year + years(Math.max(0, graceYears))); +} +isCityRebellionProtected(city) { +return !!city && (city.rebellionProtectedUntil || 0) > this.year; +} +transferCityToPolity(city, toPolity, options = {}) { +if (!city || !toPolity) return false; +const fromPolityId = city.polityId; +if (fromPolityId != null && fromPolityId !== toPolity.id) this.removeCityFromPolity(city); +if (city.polityId !== toPolity.id) this.addCityToPolity(city, toPolity, options.loyalty ?? 0.28); +const tile = this.world.idx(city.x, city.y); +this.transferTerritoryTiles(options.fromPolityId ?? fromPolityId, toPolity.id, [tile], { +allowForeignTakeover: !!options.allowForeignTakeover, +control: options.control ?? 0.58, +claim: options.claim ?? 0.70, +reason: options.reason ?? "cityTransfer" +}); +if (this.isConquestTransferReason(options.reason)) { +this.protectCityFromRebellion(city, options.rebellionProtectionYears); +// City occupation also disrupts the surrounding rural population. Keep this +// separate from ordinary battle-zone damage so every conquest-style transfer +// receives the same occupation shock exactly once. +if (fromPolityId !== toPolity.id) this.applyOccupationPopulationShock(tile); +} +return true; +} +releaseCityTerritory(city, fromPolityId, radius = 4, reason = "fragmentation") { +if (!city || fromPolityId == null) return []; +const w = this.world; +const tiles = []; +for (const { dx, dy, distance } of this.getRadiusOffsets(radius)) { +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const tile = w.idx(x, y); +if (w.terrain[tile] === Terrain.WATER || w.territoryOwner[tile] !== fromPolityId) continue; +const cityHere = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +if (cityHere && cityHere.id !== city.id && cityHere.polityId === fromPolityId) continue; +const falloff = 1 - distance / (radius + 1); +if (falloff > 0) tiles.push(tile); +} +return this.transferTerritoryTiles(fromPolityId, -1, tiles, { reason, intensity: 0.55 }); +} +releasePolityTerritory(polityId, reason = "collapse") { +if (polityId == null) return []; +const w = this.world; +const tiles = []; +for (let i = 0; i < w.count; i++) { +if (w.territoryOwner[i] === polityId) tiles.push(i); +} +return this.transferTerritoryTiles(polityId, -1, tiles, { reason, intensity: 0.65 }); +} +markContestedFront(attacker, defender, tiles, intensity = 0.45) { +const w = this.world; +const attackerId = typeof attacker === "object" ? attacker?.id : attacker; +const defenderId = typeof defender === "object" ? defender?.id : defender; +for (const tile of tiles) { +this.forCardinalNeighbors(tile, n => { +if (w.terrain[n] === Terrain.WATER) return; +if (defenderId != null && w.territoryOwner[n] === defenderId) { +w.contested[n] = 1; +w.claim[n] = Math.max(w.claim[n], clamp(intensity, 0.18, 0.85)); +} +if (attackerId != null && w.territoryOwner[n] === attackerId) { +w.control[n] = Math.max(w.control[n], clamp(0.24 + intensity * 0.35, 0.24, 0.75)); +} +}); +} +} +transferWarTerritory(attacker, defender, centerTile, radius, options = {}) { +if (!attacker || !defender || centerTile == null) return []; +return this.applyCampaignTerritory(attacker, centerTile, radius, options.strength ?? 0.55, { +allowForeignTakeover: true, +fromPolityId: defender.id, +reason: options.reason ?? "war", +preferFront: true, +maxTiles: options.maxTiles ?? null +}); +} +transferFrontierBelt(attacker, defender, frontTile, width = 3, depth = 3, options = {}) { +const w = this.world; +if (!attacker || !defender || frontTile == null) return []; +const queue = [{ tile: frontTile, distance: 0 }]; +const visited = this.visitStamp; +const marker = this.nextVisitMarker(); +visited[frontTile] = marker; +const tiles = []; +const maxTiles = options.maxTiles ?? clamp(width * depth * 7, 10, 140); +for (let q = 0; q < queue.length && tiles.length < maxTiles; q++) { +const { tile, distance } = queue[q]; +if (distance > width + depth) continue; +if (w.terrain[tile] !== Terrain.WATER && w.territoryOwner[tile] === defender.id) tiles.push(tile); +this.forCardinalNeighbors(tile, n => { +if (visited[n] === marker || w.terrain[n] === Terrain.WATER) return; +const owner = w.territoryOwner[n]; +if (owner !== defender.id && owner !== attacker.id) return; +visited[n] = marker; +queue.push({ tile: n, distance: distance + 1 }); +}); +} +return this.transferTerritoryTiles(defender.id, attacker.id, tiles, { +allowForeignTakeover: true, +control: options.control ?? 0.30, +claim: options.claim ?? 0.48, +contested: true, +reason: options.reason ?? "frontAdvance", +intensity: options.intensity ?? 0.55 +}); +} +applyCampaignTerritory(polity, centerTile, radius, strength, options = {}) { +const w = this.world; +const cx = centerTile % w.size; +const cy = Math.floor(centerTile / w.size); +const centerCity = w.city[centerTile] >= 0 ? this.getCityById(w.city[centerTile]) : null; +const capturedCitySeed = centerCity?.polityId === polity.id; +const allowForeignTakeover = !!options.allowForeignTakeover; +const fromPolityId = options.fromPolityId ?? null; +if (!capturedCitySeed && !allowForeignTakeover && !this.isValidExpansionTarget(polity, centerTile, { allowOwned: true, maxNeutralPath: 8 })) { +this.territoryDebug.invalidCampaignTargetsRejected++; +return false; +} +const seeds = capturedCitySeed || allowForeignTakeover ? [centerTile] : this.connectedExpansionSeeds(polity, centerTile, 8); +if (!seeds.length) { +this.territoryDebug.invalidCampaignTargetsRejected++; +return false; +} +const visited = this.visitStamp; +const marker = this.nextVisitMarker(); +const queue = seeds.map(tile => ({ tile, distance: Math.hypot((tile % w.size) - cx, Math.floor(tile / w.size) - cy) })); +for (const seed of seeds) visited[seed] = marker; +const candidates = []; +const maxTiles = options.maxTiles ?? Infinity; +for (let q = 0; q < queue.length; q++) { +const { tile, distance } = queue[q]; +if (distance > radius) continue; +if (w.terrain[tile] === Terrain.WATER) continue; +const tileCity = w.city[tile] >= 0 ? this.getCityById(w.city[tile]) : null; +if (tileCity && tileCity.polityId !== null && tileCity.polityId !== polity.id && !allowForeignTakeover) continue; +const owner = w.territoryOwner[tile]; +const canTake = +owner === polity.id || +(allowForeignTakeover +? (fromPolityId == null ? owner < 0 || owner !== polity.id : owner < 0 || owner === fromPolityId) +: owner < 0); +if (!canTake) { +if (owner >= 0 && owner !== polity.id) w.contested[tile] = 1; +continue; +} +const local = strength * (1 - distance / (radius + 1)); +candidates.push({ tile, distance, local }); +if (candidates.length >= maxTiles) break; +this.forCardinalNeighbors(tile, (n, tx, ty) => { +if (visited[n] === marker) return; +const nd = Math.hypot(tx - cx, ty - cy); +if (nd > radius || w.terrain[n] === Terrain.WATER) return; +const neighborOwner = w.territoryOwner[n]; +if (neighborOwner >= 0 && neighborOwner !== polity.id && !allowForeignTakeover) { +w.contested[n] = 1; +return; +} +if (allowForeignTakeover && fromPolityId != null && neighborOwner >= 0 && neighborOwner !== polity.id && neighborOwner !== fromPolityId) return; +visited[n] = marker; +queue.push({ tile: n, distance: nd }); +}); +} +const ordered = candidates +.sort((a, b) => a.distance - b.distance) +.slice(0, Number.isFinite(maxTiles) ? maxTiles : candidates.length); +return this.transferTerritoryTiles( +fromPolityId, +polity.id, +ordered.map(entry => entry.tile), +{ +allowForeignTakeover, +allowNeutralWithFrom: allowForeignTakeover && fromPolityId != null, +control: Math.max(0.18, strength * 0.65), +claim: Math.max(0.22, strength), +contested: false, +reason: options.reason ?? "campaign", +intensity: strength +} +); +} +connectedExpansionSeeds(polity, tile, maxPath = 8) { +const w = this.world; +if (w.polity[tile] === polity.id) return [tile]; +const visited = this.visitStamp; +const marker = this.nextVisitMarker(); +const queue = [{ tile, distance: 0 }]; +const seeds = []; +visited[tile] = marker; +for (let q = 0; q < queue.length; q++) { +const current = queue[q]; +if (current.distance >= maxPath) continue; +this.forCardinalNeighbors(current.tile, n => { +if (visited[n] === marker || w.terrain[n] === Terrain.WATER) return; +if (w.polity[n] === polity.id) { +seeds.push(n); +return; +} +if (w.polity[n] >= 0 && w.polity[n] !== polity.id) return; +visited[n] = marker; +queue.push({ tile: n, distance: current.distance + 1 }); +}); +} +return seeds; +} +applySubjugationEffects(polity, campaign) { +const localEthnicity = campaign.targetEthnicity ?? this.world.dominantEthnicity[campaign.targetTile]; +if (localEthnicity == null || localEthnicity < 0) return; +const sourceEthnicity = this.dominantCityEthnicity(this.nearestPolityTileConnection(polity, campaign.targetTile).source); +if (sourceEthnicity === localEthnicity) return; +const x = campaign.targetTile % this.world.size; +const y = Math.floor(campaign.targetTile / this.world.size); +for (const city of this.getCitiesNear(x, y, 12)) { +if (city.polityId === polity.id) city.loyalty = clamp((city.loyalty ?? 0.5) - 0.05, 0, 1); +} +} +seedFrontierPopulation(polity, tile) { +const source = this.nearestPolityTileConnection(polity, tile).source; +const ethnicity = this.dominantCityEthnicity(source) || this.world.dominantEthnicity[tile]; +if (!source || ethnicity == null || ethnicity < 0) return; +const migrants = Math.max(1, Math.floor(Math.min(source.population * 0.018, 14))); +source.population = Math.max(1, source.population - migrants); +removeFromComposition(source.ethnicityComposition, migrants); +this.world.population[tile] += migrants; +this.markPopulationTile(tile); +let culture = this.tileEthnicMix.get(tile); +if (!culture) { +culture = new Map(); +this.tileEthnicMix.set(tile, culture); +} +culture.set(ethnicity, (culture.get(ethnicity) || 0) + migrants); +this.updateCultureTile(tile); +} +maybeCreateFrontierSettlement(polity, tile) { +const w = this.world; +if (this.cities.length >= SimConfig.city.maxCities || w.city[tile] >= 0) return null; +if ((w.population[tile] || 0) < 8 || w.fertility[tile] + w.resource[tile] * 0.025 < 0.8) return null; +const target = { +type: "frontier_colonization", +tile, +x: tile % w.size, +y: Math.floor(tile / w.size), +population: w.population[tile] || 0, +connection: this.nearestPolityTileConnection(polity, tile) +}; +return this.foundCampaignOutpost(polity, target); +} +campaignEventData(campaign, outcome) { +return { +campaignId: campaign.id, +campaignType: campaign.type, +annexationMode: campaign.annexationMode ?? null, +outcome, +targetCityId: campaign.targetCityId ?? null, +targetPolityId: campaign.defenderPolityId ?? null, +targetTile: campaign.targetTile, +progress: Number((campaign.progress || 0).toFixed(2)) +}; +} +recordCampaign(polity, campaign, outcome) { +const data = this.campaignEventData(campaign, outcome); +this.campaignHistory.push({ year: this.year, polityId: polity.id, ...data }); +while (this.campaignHistory.length > 160) this.campaignHistory.shift(); +} +foundCampaignOutpost(polity, target) { +const w = this.world; +const targetTile = target?.targetTile ?? target?.tile; +if (this.cities.length >= SimConfig.city.maxCities || !target || targetTile == null) return null; +const source = target.connection?.source || this.getCityById(polity.centerCityId); +if (!source) return null; +const isFrontier = target.type === "frontier_colonization" || target.type === "frontier"; +const cost = isFrontier ? 5.5 : 8.5; +if ((polity.treasury || 0) < cost && source.storedResources < cost * 1.4) return null; +const treasuryPaid = Math.min(polity.treasury || 0, cost); +polity.treasury = Math.max(0, (polity.treasury || 0) - treasuryPaid); +source.storedResources = Math.max(0, source.storedResources - Math.max(0, cost - treasuryPaid) * 0.5); +const culture = this.tileCultures.get(targetTile); +const composition = new Map(); +let regionalTotal = 0; +if (culture?.size) { +for (const [id, count] of culture) regionalTotal += count; +const basePopulation = clamp(Math.round((target.population || 0) * 0.55), 6, 58); +for (const [id, count] of culture) { +const share = regionalTotal > 0 ? count / regionalTotal : 0; +if (share > 0) composition.set(id, Math.max(1, Math.round(basePopulation * share))); +} +} +if (!composition.size) { +const regionalEthnicity = this.world.dominantEthnicity[targetTile]; +const sourceEthnicity = this.dominantCityEthnicity(source) || (regionalEthnicity >= 0 ? regionalEthnicity : 1); +composition.set(sourceEthnicity, isFrontier ? 12 : 18); +} +const populationScale = SimConfig.city.populationScale; +const seedPopulation = Math.max(12, Math.round(compositionTotal(composition) * (isFrontier ? 1.2 : 1.6))) * populationScale; +for (const [id, count] of composition) composition.set(id, count * populationScale); +const city = { +id: this.nextCity++, +x: target.x ?? targetTile % w.size, +y: target.y ?? Math.floor(targetTile / w.size), +population: seedPopulation, +storedResources: (18 + (w.resource[targetTile] || 0) * 0.25) * populationScale, +ethnicityComposition: composition, +pheromoneOutput: 0, +agriculturalRadius: 2, +tradeLinks: new Set(), +activeVisitors: 0, +age: 0, +peakPopulation: seedPopulation, +strength: isFrontier ? 1.8 : 2.4, +sedentaryCulture: isFrontier ? 0.42 : 0.55, +knowledge: { ...source.knowledge }, +supplyStress: 0, +polityId: null, +loyalty: isFrontier ? 0.52 : 0.32, +receivedAid: false, +tradeValue: 0, +tradeReach: 0, +rebellionProtectedUntil: 0 +}; +this.cities.push(city); +this.cityById.set(city.id, city); +const bucketKey = this.cityBucketKey(city.x, city.y); +if (!this.cityBuckets.has(bucketKey)) this.cityBuckets.set(bucketKey, []); +this.cityBuckets.get(bucketKey).push(city); +this.transferCityToPolity(city, polity, { +loyalty: city.loyalty, +control: 0.52, +claim: 0.66, +reason: "frontierSettlement" +}); +this.claimPolityCoreTerritory(polity, city, 3, "frontierSettlement"); +w.city[targetTile] = city.id; +this.removeEthnicPopulationProportionally(targetTile, Math.min(seedPopulation, (w.population[targetTile] || 0) * 0.65)); +this.projectCityPopulationToTiles(city); +this.syncTilePopulationCulture(); +return city; +} +maybeStartWars() { +if (this.year % SimConfig.war.tickWeeks !== 0) return; +let actions = 0; +let warSlots = SimConfig.war.maxActiveWars - this.wars.filter(war => war.ended === null).length; +const maxActions = SimConfig.war.maxStartsPerTick; +for (const a of this.polities) { +if (actions >= maxActions) break; +if (this.year < (a.nextWarAllowedWeek ?? 0)) continue; +if (this.isPolityAtWar(a.id)) continue; +const aPower = this.polityPower(a); +if (aPower <= 0) continue; +let started = false; +for (const b of this.polities) { +if (started) break; +if (a.id === b.id) continue; +if (this.year < (b.nextWarAllowedWeek ?? 0)) continue; +if (!this.canBeWarTarget(b.id)) continue; +if (this.getWarBetween(a.id, b.id)) continue; +const distance = this.polityDistance(a, b); +if (distance > 46) continue; +const bPower = this.polityPower(b); +if (bPower <= 0) continue; +const larger = Math.max(aPower, bPower); +const smaller = Math.min(aPower, bPower); +const advantage = larger / Math.max(1, smaller); +const proximity = clamp((46 - distance) / 46, 0, 1); +const aInstability = this.polityInstability ? this.polityInstability(a) : 0; +const bInstability = this.polityInstability ? this.polityInstability(b) : 0; +const aAge = this.polityAgePressure ? this.polityAgePressure(a) : 0; +const bAge = this.polityAgePressure ? this.polityAgePressure(b) : 0; +const aOverextension = this.polityOverextension ? this.polityOverextension(a) : 0; +const bOverextension = this.polityOverextension ? this.polityOverextension(b) : 0; +const aLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(a) : 0.5; +const bLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(b) : 0.5; +const asymmetryPressure = clamp((advantage - 1.15) / 2.5, 0, 1); +const weakSideInstability = aPower > bPower ? bInstability : aInstability; +const weakSideAge = aPower > bPower ? bAge : aAge; +const weakSideOverextension = aPower > bPower ? bOverextension : aOverextension; +const weakSideLoyalty = aPower > bPower ? bLoyalty : aLoyalty; +const lowLoyaltyPressure = clamp(1 - Math.min(aLoyalty, bLoyalty), 0, 1); +const weakSideLowLoyalty = clamp(1 - weakSideLoyalty, 0, 1); +const generalInstability = Math.max(aInstability, bInstability) * 0.035; +const targetWarCount = this.warCountForPolity(b.id); +const targetLowLoyalty = clamp(1 - bLoyalty, 0, 1); +const attackerAdvantage = clamp((aPower / Math.max(1, bPower) - 1) / 2.25, 0, 1); +const opportunisticPressure = targetWarCount > 0 +? clamp(targetWarCount / 3, 0, 1) * ( +0.010 + +bInstability * 0.018 + +targetLowLoyalty * 0.018 + +attackerAdvantage * 0.020 +) +: 0; +const borderFriction = this.borderFriction(a, b, distance); +const chance = +0.001 + +proximity * 0.007 + +asymmetryPressure * 0.008 + +weakSideInstability * 0.018 + +weakSideAge * 0.010 + +weakSideOverextension * 0.010 + +borderFriction * 0.008 + +generalInstability + +lowLoyaltyPressure * 0.018 + +weakSideLowLoyalty * 0.028 + +opportunisticPressure; +if (this.rng.next() < clamp(chance * SimConfig.war.startChanceMultiplier, 0, 0.35)) { +if (warSlots > 0) { +started = !!this.startWar(a, b); +if (started) warSlots--; +} +if (started) actions++; +} +} +} +} +borderFriction(a, b, distance) { +if (!Number.isFinite(distance)) return 0; +const proximity = clamp((34 - distance) / 34, 0, 1); +const aCities = this.getPolityCities(a).length; +const bCities = this.getPolityCities(b).length; +const sizePressure = clamp((aCities + bCities - 3) / 8, 0, 1); +return proximity * (0.35 + sizePressure * 0.65); +} +updateWars() { +if (this.year % SimConfig.war.tickWeeks !== 0) return; +this.warLogisticsCache = new Map(); +for (const war of this.wars) { +if (war.ended !== null) continue; +const a = this.getPolityById(war.aPolityId); +const b = this.getPolityById(war.bPolityId); +if (!a || !b) { +this.endWar(war); +continue; +} +this.applyWarPressure(war, a, b); +this.applyWarPressure(war, b, a); +this.applyWarExhaustion(war, a, b); +this.maybeEndWar(war); +} +this.wars = this.wars.filter(w => w.ended === null); +} +applyWarPressure(war, attacker, defender) { +const attackerPower = this.polityPower(attacker); +const defenderPower = this.polityPower(defender); +const selected = this.bestWarPressureTarget(attacker, defender, war, attackerPower, defenderPower); +if (!selected) return; +const pressure = this.warAbsorptionPressure( +attacker, +defender, +selected.city, +selected.connection.distance, +attackerPower, +defenderPower, +selected.connection.logisticsFactor +); +const key = `${attacker.id}:${selected.city.id}`; +const memory = (war.cityPressureById?.get(key) || 0) * (SimConfig.war.pressureMemoryDecay ?? 0.88) + pressure * (war.intensity || 0.75) * 1.6; +war.cityPressureById?.set(key, memory); +const captureThreshold = war.goal === "collapse_exploitation" ? 1.55 : war.goal === "city_conquest" ? 1.75 : war.goal === "capital_pressure" ? 1.85 : 2.15; +const baseCaptureChance = clamp((memory - captureThreshold * 0.65 + pressure * 0.35) * 0.45, 0.05, 0.85); +const chance = clamp(baseCaptureChance * (SimConfig.war.captureChanceMultiplier ?? 1), 0, 0.85); +const damageRate = clamp( +(SimConfig.war.cityDamageMinRate ?? 0.003) + pressure * 0.030 + memory * 0.018, +SimConfig.war.cityDamageMinRate ?? 0.003, +SimConfig.war.cityDamageMaxRate ?? 0.08 +); +if (memory > captureThreshold * 0.65 && pressure > 0.55 && this.rng.next() < chance) { +this.captureCityInWar(selected.city, attacker, defender, war, pressure); +} else { +this.applyCityWarDamage(selected.city, damageRate, "warPressure"); +this.applyWarZoneTileDamage(this.world.idx(selected.city.x, selected.city.y), SimConfig.war.warZoneTileLossRate ?? 0.006); +selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.018 - memory * 0.004, 0, 1); +this.addWarScore(war, attacker.id, pressure * 0.08 + damageRate * 8); +const lastAdvance = war.lastFrontAdvanceYear instanceof Map ? (war.lastFrontAdvanceYear.get(attacker.id) ?? -Infinity) : -Infinity; +const canAdvanceFront = +memory > 1.05 && +pressure > 0.58 && +this.year - lastAdvance >= SimConfig.war.tickWeeks; +if (canAdvanceFront && this.rng.next() < clamp((memory - 0.9) * 0.10 + (pressure - 0.55) * 0.22, 0.02, 0.34)) { +const border = this.nearestBorderCampaignTile(attacker, defender); +if (border?.tile != null && Number.isFinite(border.distance)) { +const width = pressure > 1.05 ? 5 : 3; +const depth = memory > 1.8 ? 6 : 4; +const maxTiles = pressure > 1.25 || memory > 2.1 ? 120 : pressure > 0.9 ? 62 : 28; +const moved = this.transferFrontierBelt(attacker, defender, border.tile, width, depth, { +maxTiles, +reason: "frontAdvance", +intensity: clamp(0.38 + pressure * 0.18, 0.42, 0.72) +}); +if (moved.length) { +war.lastActionYear = this.year; +war.lastFrontAdvanceYear ??= new Map(); +war.lastFrontAdvanceYear.set(attacker.id, this.year); +this.addWarScore(war, attacker.id, moved.length * 0.018); +} +} +} +} +} +bestWarPressureTarget(attacker, defender, war, attackerPower = this.polityPower(attacker), defenderPower = this.polityPower(defender), probe = false) { +const attackerCities = this.getPolityCities(attacker); +const defenderCities = this.getPolityCities(defender); +if (!attackerCities.length || !defenderCities.length) return null; +const range = this.warTargetRange(attacker, defender, war, attackerPower, defenderPower); +const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0; +const defenderCohesion = clamp(defender.cohesion ?? 0.6, 0, 1); +const defenderLegitimacy = clamp(defender.legitimacy ?? 0.7, 0, 1); +const pressureMap = war.cityPressureById || new Map(); +let best = null; +let bestScore = -Infinity; +for (const city of defenderCities) { +if (city.population <= 0) continue; +const capital = city.id === defender.centerCityId; +if (capital && defenderCities.length > 2 && war.goal !== "capital_pressure" && war.goal !== "collapse_exploitation") continue; +const connection = this.warConnectionToCity(attacker, city); +if (!connection.source || connection.distance > range) continue; +const memory = pressureMap.get(`${attacker.id}:${city.id}`) || 0; +const lowLoyalty = 1 - clamp(city.loyalty ?? 0.5, 0, 1); +const routeAccess = this.hasDirectTradeConnection(connection.source, city) || this.world.tradeRoute[this.world.idx(city.x, city.y)] ? 0.55 : 0; +const cityValue = Math.sqrt(city.population || 1) * 0.10 + (city.tradeValue || 0) * 0.55 + (capital ? 0.9 : 0); +const powerAdvantage = clamp(attackerPower / Math.max(1, defenderPower) - 1, -0.4, 2.4); +const deepPenalty = Math.max(0, connection.distance - SimConfig.polity.logisticsDistance) * (war.goal === "capital_pressure" ? 0.010 : 0.018); +const logisticsPenalty = (1 - connection.logisticsFactor) * 2.2; +const weakState = defenderInstability * 0.55 + (1 - defenderCohesion) * 0.35 + (1 - defenderLegitimacy) * 0.28; +const goalBonus = +war.goal === "punitive_raid" ? (city.storedResources || 0) * 0.004 : +war.goal === "border_claim" ? Math.max(0, 18 - connection.distance) * 0.035 : +war.goal === "collapse_exploitation" ? weakState * 0.9 + lowLoyalty * 0.45 : +war.goal === "capital_pressure" && capital ? 1.15 : +0.35; +const score = cityValue + lowLoyalty * 0.85 + routeAccess + memory * 0.45 + powerAdvantage * 0.45 + weakState + goalBonus - connection.distance * 0.018 - deepPenalty - logisticsPenalty + (probe ? 0 : this.rng.range(-0.08, 0.10)); +if (score > bestScore) { +bestScore = score; +best = { city, connection, score }; +} +} +return best; +} +warTargetRange(attacker, defender, war, attackerPower, defenderPower) { +const base = this.polityInfluenceRange(attacker, true); +const ratio = attackerPower / Math.max(1, defenderPower); +const routeReach = this.averagePolityTechnology(attacker) * 12; +const cohesion = clamp(attacker.cohesion ?? 0.6, 0, 1) * 8; +const weakDefender = (this.polityInstability ? this.polityInstability(defender) : 0) * 8; +const goalBoost = war.goal === "capital_pressure" ? 16 : war.goal === "collapse_exploitation" ? 14 : war.goal === "city_conquest" ? 8 : 0; +return clamp(base + clamp((ratio - 1) * 12, 0, 18) + routeReach + cohesion + weakDefender + goalBoost, 24, 82); +} +warConnectionToCity(polity, city) { +let source = null; +let distance = Infinity; +let logisticsFactor = 0; +let campaignCost = Infinity; +for (const c of this.getPolityCities(polity)) { +if (c.id === city.id) continue; +let d = this.effectiveDistance(c, city); +if (this.hasDirectTradeConnection(c, city)) d *= 0.72; +const logistics = this.warLogisticsFactor(polity, c, city); +const cost = d / Math.max(0.28, logistics); +if (cost < campaignCost) { +source = c; +distance = d; +logisticsFactor = logistics; +campaignCost = cost; +} +} +return { source, distance, connected: !!source, logisticsFactor, campaignCost }; +} +warLogisticsFactor(polity, sourceCity, targetCity) { +if (!polity || !sourceCity || !targetCity) return 0.25; +const capital = this.getCityById(polity.centerCityId) || sourceCity; +const capitalDistance = this.effectiveDistance(capital, targetCity); +const distanceStart = SimConfig.polity.warCapitalDistanceStart ?? 18; +const distanceScale = SimConfig.polity.warCapitalDistanceScale ?? 42; +let factor = 1 / (1 + Math.max(0, capitalDistance - distanceStart) / Math.max(1, distanceScale)); +const connected = sourceCity.id === capital.id || this.territoryRouteConnected(polity.id, capital, sourceCity); +if (!connected) factor *= SimConfig.polity.warDisconnectedTerritoryFactor ?? 0.42; +if (sourceCity.id !== capital.id && this.hasDirectTradeConnection(capital, sourceCity)) factor *= 1.08; +return clamp(factor, 0.18, 1); +} +territoryRouteConnected(polityId, fromCity, toCity) { +if (!fromCity || !toCity) return false; +if (fromCity.id === toCity.id) return true; +const key = `${polityId}:${fromCity.id}:${toCity.id}`; +if (this.warLogisticsCache?.has(key)) return this.warLogisticsCache.get(key); +const w = this.world; +const start = w.idx(fromCity.x, fromCity.y); +const goal = w.idx(toCity.x, toCity.y); +if (w.territoryOwner[start] !== polityId || w.territoryOwner[goal] !== polityId) { +this.warLogisticsCache?.set(key, false); +return false; +} +const visited = this.visitStamp; +const marker = this.nextVisitMarker(); +const queue = this.scratchIntA; +let head = 0; +let tail = 0; +queue[tail++] = start; +visited[start] = marker; +let connected = false; +while (head < tail) { +const tile = queue[head++]; +if (tile === goal) { +connected = true; +break; +} +this.forCardinalNeighbors(tile, neighbor => { +if (visited[neighbor] === marker || w.territoryOwner[neighbor] !== polityId || w.terrain[neighbor] === Terrain.WATER) return; +visited[neighbor] = marker; +queue[tail++] = neighbor; +}); +} +this.warLogisticsCache?.set(key, connected); +return connected; +} +warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower, logisticsFactor = 1) { +const powerRatio = attackerPower / Math.max(1, defenderPower); +const proximity = 1 / (1 + distance * 0.055); +const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0; +const defenderAge = this.polityAgePressure ? this.polityAgePressure(defender) : 0; +const defenderOverextension = this.polityOverextension ? this.polityOverextension(defender) : 0; +const loyaltyWeakness = 1 - clamp(city.loyalty ?? 0.5, 0, 1); +const techAdvantage = 1 + clamp( +this.averagePolityTechnology(attacker) - this.averagePolityTechnology(defender), +-0.25, +0.45 +); +const nearest = this.nearestPolityCity(attacker, city); +const ethnicityFactor = nearest && this.sameDominantEthnicity(city, nearest) ? 1.10 : 0.92; +const tradeFactor = nearest && this.hasDirectTradeConnection && this.hasDirectTradeConnection(nearest, city) ? 1.15 : 1.0; +return ( +powerRatio * +proximity * +techAdvantage * +ethnicityFactor * +tradeFactor * +clamp(logisticsFactor, 0.18, 1) * +(1 + defenderInstability * 0.32) * +(1 + defenderAge * 0.18) * +(1 + defenderOverextension * 0.14) * +(0.65 + loyaltyWeakness * 0.72) +); +} +captureCityInWar(city, attacker, defender, war, pressure) { +const lossRate = clamp((SimConfig.war.captureLossBase ?? 0.08) + pressure * 0.055 + (war.intensity || 0.75) * 0.035, 0.06, SimConfig.war.captureLossMax ?? 0.32); +const loss = this.applyCityWarDamage(city, lossRate, "capture"); +this.applyWarZoneTileDamage(this.world.idx(city.x, city.y), (SimConfig.war.warZoneTileLossRate ?? 0.006) * 3); +this.transferCityToPolity(city, attacker, { +loyalty: 0.28, +control: 0.62, +claim: 0.78, +allowForeignTakeover: true, +reason: "warCapture" +}); +city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36); +war.lastActionYear = this.year; +if (war.aPolityId === attacker.id) war.capturedByA = (war.capturedByA || 0) + 1; +else war.capturedByB = (war.capturedByB || 0) + 1; +this.addWarScore(war, attacker.id, 1.8 + pressure * 0.7); +if (war.aPolityId === attacker.id) { +war.exhaustionA = clamp((war.exhaustionA || 0) + 0.04, 0, 1); +war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1); +} else { +war.exhaustionB = clamp((war.exhaustionB || 0) + 0.04, 0, 1); +war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1); +} +attacker.treasury = Math.max(0, (attacker.treasury || 0) - loss * 0.015); +attacker.crisis = clamp((attacker.crisis || 0) + 0.035, 0, 1.5); +defender.crisis = clamp((defender.crisis || 0) + 0.12, 0, 1.5); +defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.05, 0, 1); +defender.cohesion = clamp((defender.cohesion ?? 0.6) - 0.045, 0, 1); +const advantage = this.polityPower(attacker) / Math.max(1, this.polityPower(defender)); +const captureRadius = clamp(Math.round(7 + pressure * 5 + clamp(advantage - 1, 0, 1.8) * 4 + (war.intensity || 0.75) * 2), 6, 20); +const maxTiles = clamp(Math.round(28 + captureRadius * captureRadius * 0.38 + pressure * 26), 24, 150); +const transferred = this.transferWarTerritory(attacker, defender, this.world.idx(city.x, city.y), captureRadius, { +strength: clamp(0.52 + pressure * 0.10, 0.52, 0.78), +reason: "warCapture", +maxTiles +}); +if (transferred.length) this.addWarScore(war, attacker.id, transferred.length * 0.012); +this.addPolityEvent(attacker.id, "war", this.year, { outcome: "capturedCity", cityId: city.id, targetPolityId: defender.id }, 2); +this.addPolityEvent(defender.id, "war", this.year, { outcome: "lostCity", cityId: city.id, targetPolityId: attacker.id }, 2); +} +applyWarExhaustion(war, a, b) { +const intensity = war.intensity || 0.75; +const costA = 0.25 * intensity + this.getPolityCities(a).length * 0.035; +const costB = 0.25 * intensity + this.getPolityCities(b).length * 0.035; +a.treasury = Math.max(0, (a.treasury || 0) - costA); +b.treasury = Math.max(0, (b.treasury || 0) - costB); +a.crisis = clamp((a.crisis || 0) + 0.004 * intensity, 0, 1.5); +b.crisis = clamp((b.crisis || 0) + 0.004 * intensity, 0, 1.5); +war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08 * intensity, 0, 1); +war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08 * intensity, 0, 1); +this.applyPolityWarExhaustionPopulationLoss(a, war.exhaustionA || 0, intensity); +this.applyPolityWarExhaustionPopulationLoss(b, war.exhaustionB || 0, intensity); +} +maybeEndWar(war) { +const a = this.getPolityById(war.aPolityId); +const b = this.getPolityById(war.bPolityId); +if (!a || !b) { +this.endWar(war); +return; +} +const age = this.year - war.started; +const noActionFor = this.year - war.lastActionYear; +const exhaustion = Math.max(war.exhaustionA || 0, war.exhaustionB || 0); +if (!this.getPolityCities(a).length || !this.getPolityCities(b).length) { +this.endWar(war); +return; +} +if (age >= SimConfig.war.minDurationWeeks && noActionFor >= SimConfig.war.idleEndWeeks) { +this.endWar(war); +return; +} +if (age >= SimConfig.war.minDurationWeeks && (exhaustion >= 0.8 || Math.abs((war.scoreA || 0) - (war.scoreB || 0)) >= SimConfig.war.decisiveScore)) { +this.endWar(war); +} +} +addWarScore(war, polityId, amount) { +if (!war || amount <= 0) return; +if (war.aPolityId === polityId) war.scoreA = (war.scoreA || 0) + amount; +else if (war.bPolityId === polityId) war.scoreB = (war.scoreB || 0) + amount; +} +applyCityWarDamage(city, rate, reason = "war") { +if (!city || city.population <= 1 || rate <= 0) return 0; +const damageMultiplier = SimConfig.war.damageMultiplier ?? 1; +const maxRate = reason === "capture" ? SimConfig.war.captureLossMax : SimConfig.war.cityDamageMaxRate; +const appliedRate = clamp(rate, 0, maxRate) * damageMultiplier; +const loss = Math.min(city.population - 1, Math.max(1, Math.floor(city.population * appliedRate))); +if (loss <= 0) return 0; +city.population = Math.max(1, city.population - loss); +if (city.ethnicityComposition) removeFromComposition(city.ethnicityComposition, loss); +city.storedResources = Math.max(0, (city.storedResources || 0) - loss * (reason === "capture" ? 0.38 : 0.16)); +city.loyalty = clamp((city.loyalty ?? 0.5) - appliedRate * (reason === "capture" ? 1.8 : 0.9), 0, 1); +city.lastWarDamageYear = this.year; +this.recordWarDeaths(loss, reason === "capture" ? 0.45 : 0.65); +return loss; +} + +applyPolityWarExhaustionPopulationLoss(polity, exhaustion, intensity) { +if (!polity || exhaustion <= 0.04) return 0; +const cities = this.getPolityCities(polity); +if (!cities.length) return 0; +const shortage = (polity.treasury || 0) <= 0 ? 0.35 : 0; +const crisis = clamp(polity.crisis || 0, 0, 1.5) * 0.15; +const activeWars = this.warCountForPolity(polity.id); +const cohesionStress = clamp(1 - (polity.cohesion ?? 0.6), 0, 1) * 0.22; +const rate = clamp( +(SimConfig.war.exhaustionPopulationLossBase ?? 0.0007) * (1 + exhaustion * 4.8 + intensity + activeWars * 0.35 + shortage + crisis + cohesionStress), +0, +SimConfig.war.exhaustionPopulationLossMax ?? 0.012 +); +let total = 0; +for (const city of cities) { +const localRate = rate * (city.supplyStress > 0.8 ? 1.35 : 1); +if (localRate <= 0) continue; +total += this.applyCityWarDamage(city, localRate, "exhaustion"); +city.loyalty = clamp((city.loyalty ?? 0.5) - exhaustion * 0.004, 0, 1); +} +if (exhaustion > 0.55) { +polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - rate * 1.4, 0, 1); +polity.cohesion = clamp((polity.cohesion ?? 0.6) - rate * 1.1, 0, 1); +} +return total; +} +applyOccupationPopulationShock(tile, radius = SimConfig.war.occupationPopulationLossRadius ?? 4, rate = SimConfig.war.occupationPopulationLossRate ?? 0.08) { +const w = this.world; +if (tile == null || tile < 0 || tile >= w.count || radius <= 0 || rate <= 0) return 0; +const cx = tile % w.size; +const cy = Math.floor(tile / w.size); +const damageMultiplier = SimConfig.war.damageMultiplier ?? 1; +let removed = 0; +for (let dy = -radius; dy <= radius; dy++) { +for (let dx = -radius; dx <= radius; dx++) { +const distance = Math.abs(dx) + Math.abs(dy); +if (distance === 0 || distance > radius) continue; +const x = cx + dx; +const y = cy + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const t = w.idx(x, y); +if (w.terrain[t] === Terrain.WATER || w.population[t] <= 0) continue; +const falloff = 1 - distance / (radius + 1); +const amount = w.population[t] * rate * damageMultiplier * falloff; +if (amount <= 0.03) continue; +removed += this.removeEthnicPopulationProportionally(t, amount) || 0; +w.resource[t] = Math.max(0, w.resource[t] - amount * 0.015); +} +} +this.recordWarDeaths(removed, 0.72); +return removed; +} + +applyWarZoneTileDamage(tile, rate) { +const w = this.world; +if (tile == null || tile < 0 || tile >= w.count || rate <= 0) return 0; +const damageMultiplier = SimConfig.war.damageMultiplier ?? 1; +let removed = 0; +for (const { dx, dy, distance } of this.getRadiusOffsets(2)) { +const x = tile % w.size + dx; +const y = Math.floor(tile / w.size) + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +const t = w.idx(x, y); +if (w.terrain[t] === Terrain.WATER || w.population[t] <= 0) continue; +const localRate = rate * damageMultiplier * (1 - distance / 4); +const amount = w.population[t] * localRate; +if (amount <= 0.03) continue; +removed += this.removeEthnicPopulationProportionally(t, amount) || 0; +w.resource[t] = Math.max(0, w.resource[t] - amount * 0.02); +} +this.recordWarDeaths(removed, 0.8); +return removed; +} + +recordWarDeaths(amount, civilianShare = 0.5) { +if (amount <= 0) return; +this.deaths += Math.max(0, Math.floor(amount * clamp(civilianShare, 0, 1))); +} +applyWarSettlement(war) { +const a = this.getPolityById(war.aPolityId); +const b = this.getPolityById(war.bPolityId); +if (!a || !b) return; +const margin = (war.scoreA || 0) - (war.scoreB || 0) + ((war.exhaustionB || 0) - (war.exhaustionA || 0)) * 1.2; +const threshold = SimConfig.war.majorSettlementThreshold ?? 1.25; +if (Math.abs(margin) < threshold) return; +const winner = margin > 0 ? a : b; +const loser = margin > 0 ? b : a; +const transferred = this.applyWarSettlementTransfer(winner, loser, war, Math.abs(margin)); +loser.legitimacy = clamp((loser.legitimacy ?? 0.7) - 0.035 - Math.abs(margin) * 0.006, 0, 1); +loser.cohesion = clamp((loser.cohesion ?? 0.6) - 0.03 - (transferred ? 0.04 : 0), 0, 1); +loser.crisis = clamp((loser.crisis || 0) + 0.08 + Math.abs(margin) * 0.015, 0, 1.5); +for (const city of this.getPolityCities(loser)) city.loyalty = clamp((city.loyalty ?? 0.5) - 0.025, 0, 1); +this.addPolityEvent(winner.id, "war", this.year, { outcome: transferred ? "settlementGain" : "settlementPressure", targetPolityId: loser.id }, 2); +this.addPolityEvent(loser.id, "war", this.year, { outcome: transferred ? "settlementLoss" : "settlementPressure", targetPolityId: winner.id }, 2); +} +applyWarSettlementTransfer(winner, loser, war, margin) { +const loserCities = this.getPolityCities(loser) +.filter(city => city.population > 0 && (city.id !== loser.centerCityId || margin > 2.8 || this.getPolityCities(loser).length <= 2)) +.map(city => ({ city, connection: this.warConnectionToCity(winner, city), memory: war.cityPressureById?.get(`${winner.id}:${city.id}`) || 0 })) +.filter(entry => entry.connection.source && entry.connection.distance <= this.warTargetRange(winner, loser, war, this.polityPower(winner), this.polityPower(loser)) * 0.92) +.sort((a, b) => (b.memory + (1 - (b.city.loyalty ?? 0.5)) - b.connection.distance * 0.018) - (a.memory + (1 - (a.city.loyalty ?? 0.5)) - a.connection.distance * 0.018)); +const entry = loserCities[0]; +if (!entry || margin < 1.8) return false; +this.applyCityWarDamage(entry.city, clamp(0.025 + margin * 0.008, 0.02, 0.10), "settlement"); +this.transferCityToPolity(entry.city, winner, { +loyalty: clamp(0.24 + (1 - (entry.city.loyalty ?? 0.5)) * 0.25, 0.18, 0.48), +control: 0.52, +claim: 0.66, +allowForeignTakeover: true, +reason: "settlement" +}); +this.transferWarTerritory(winner, loser, this.world.idx(entry.city.x, entry.city.y), Math.max(7, SimConfig.campaign?.claimRadius ?? 8), { +strength: 0.50, +reason: "settlement", +maxTiles: 70 +}); +return true; +} +collectAndRedistributeResources() { +for (const polity of this.polities) { +const cities = this.getPolityCities(polity); +const centerId = polity.centerCityId; +for (const city of cities) city.receivedAid = false; +for (const city of cities) { +if (city.id === centerId) continue; +const tax = city.storedResources * 0.035; +city.storedResources -= tax; +polity.treasury += tax; +} +const agePressure = this.polityAgePressure(polity); +const overextension = this.polityOverextension(polity); +const adminCost = +cities.length * 0.24 + +overextension * 1.1 + +agePressure * cities.length * 0.18; +polity.treasury -= adminCost; +if (polity.treasury < 0) { +const deficit = Math.abs(polity.treasury); +polity.treasury = 0; +polity.crisis = clamp((polity.crisis || 0) + deficit * 0.012, 0, 1.5); +} +const maintenance = Math.pow(cities.length, 1.12) * 0.10; +if (maintenance > 0) { +const center = this.getCityById(centerId); +const treasuryPayment = Math.min(polity.treasury, maintenance); +polity.treasury -= treasuryPayment; +const unpaid = maintenance - treasuryPayment; +if (unpaid > 0 && center) { +const paidByCenter = this.drainCityResources(center, unpaid); +if (paidByCenter < unpaid) { +for (const city of cities) { +if (city.id !== centerId) city.loyalty = clamp(city.loyalty - 0.012, 0, 1); +} +} +} +} +const poorCount = Math.ceil(cities.length * 0.1); +const poorest = [...cities] +.sort((a, b) => (a.storedResources / Math.max(1, a.population)) - (b.storedResources / Math.max(1, b.population))) +.slice(0, poorCount); +for (const city of poorest) { +const target = city.population * 0.08; +const need = target - city.storedResources; +if (need > 0 && polity.treasury > 0) { +const aid = Math.min(need, polity.treasury); +city.storedResources += aid; +polity.treasury -= aid; +city.receivedAid = true; +city.loyalty = clamp(city.loyalty + 0.05, 0, 1); +} +} +this.enforcePolityTreasuryCap(polity); +} +} +erodePolityLegitimacy() { +for (const polity of this.polities) { +const agePressure = this.polityAgePressure(polity); +const resourceStress = this.polityResourceStress(polity); +const overextension = this.polityOverextension(polity); +const erosion = +0.0015 + +agePressure * 0.003 + +resourceStress * 0.003 + +overextension * 0.0015; +polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - erosion, 0, 1); +polity.cohesion = clamp((polity.cohesion ?? 0.6) - erosion * 0.45, 0, 1); +const cities = this.getPolityCities(polity); +if (this.polityAge(polity) < 80 && polity.treasury > cities.length * 10) { +polity.legitimacy = clamp(polity.legitimacy + 0.008, 0, 1); +polity.cohesion = clamp(polity.cohesion + 0.005, 0, 1); +} +polity.crisis = clamp((polity.crisis || 0) * 0.88, 0, 1.5); +} +} +triggerPolityCrises() { +for (const polity of this.polities) { +const age = this.polityAge(polity); +if (age < 140) continue; +if ((this.year - (polity.lastCrisisYear ?? 0)) / WEEKS_PER_YEAR < 160) continue; +const instability = this.polityInstability(polity); +const agePressure = this.polityAgePressure(polity); +const chance = 0.006 + agePressure * 0.018 + instability * 0.014; +if (this.rng.next() >= chance) continue; +polity.lastCrisisYear = this.year; +const severity = clamp( +0.08 + +agePressure * this.rng.range(0.08, 0.20) + +instability * this.rng.range(0.05, 0.16), +0.05, +0.36 +); +polity.crisis = clamp((polity.crisis || 0) + severity, 0, 1.5); +polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - severity * 0.30, 0, 1); +polity.cohesion = clamp((polity.cohesion ?? 0.6) - severity * 0.18, 0, 1); +const center = this.getCityById(polity.centerCityId); +for (const city of this.getPolityCities(polity)) { +if (city.id === polity.centerCityId) continue; +const distance = center ? this.effectiveDistance(center, city) : 40; +const distanceFactor = clamp(distance / 40, 0.25, 1); +city.loyalty = clamp(city.loyalty - severity * distanceFactor, 0, 1); +} +} +} +applyOldStateStress() { +for (const polity of this.polities) { +const age = this.polityAge(polity); +if (age < 1000) continue; +const cities = this.getPolityCities(polity); +if (cities.length <= 1) continue; +const oldAge = clamp((age - 1000) / 1000, 0, 1); +polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - oldAge * 0.018, 0, 1); +polity.cohesion = clamp((polity.cohesion ?? 0.6) - oldAge * 0.012, 0, 1); +polity.crisis = clamp((polity.crisis || 0) + oldAge * 0.035, 0, 1.5); +const center = this.getCityById(polity.centerCityId); +for (const city of cities) { +if (city.id === polity.centerCityId) continue; +const distance = center ? this.effectiveDistance(center, city) : 40; +const distanceFactor = clamp(distance / 35, 0.2, 1); +city.loyalty = clamp(city.loyalty - oldAge * distanceFactor * 0.045, 0, 1); +} +} +} +updateCityLoyalty() { +for (const polity of this.polities) { +const center = this.getCityById(polity.centerCityId); +if (!center) continue; +const loyaltyCap = this.polityLoyaltyCap(polity); +center.loyalty = loyaltyCap; +const instability = this.polityInstability(polity); +const agePressure = this.polityAgePressure(polity); +const overextension = this.polityOverextension(polity); +const logisticsStress = this.polityLogisticsStress(polity); +for (const city of this.getPolityCities(polity)) { +if (city.id === center.id) continue; +const perCapita = city.storedResources / Math.max(1, city.population); +const distance = this.effectiveDistance(city, center); +let delta = 0; +delta += clamp((perCapita - 0.10) * 0.07, -0.025, 0.045); +delta += this.sameDominantEthnicity(city, center) ? 0.03 : -0.012; +delta += clamp(0.045 - distance * 0.0011, -0.025, 0.045); +const directCenterTrade = this.hasDirectTradeConnection(city, center); +if (directCenterTrade) delta += 0.025; +else delta -= this.tradeAccessLoyaltyPenalty(city, center, polity); +if (city.receivedAid) delta += 0.04; +delta -= 0.004; +if (city.storedResources < city.population * 0.05) delta -= 0.035; +delta -= (city.supplyStress || 0) * 0.018; +delta -= instability * 0.020; +delta -= agePressure * 0.010; +delta -= overextension * clamp(distance / 48, 0, 1) * 0.014; +delta -= logisticsStress * clamp(distance / 42, 0.25, 1) * 0.018; +if ((polity.legitimacy ?? 0.7) < 0.25) delta -= 0.020; +if ((polity.crisis || 0) > 0.6) delta -= 0.014; +city.loyalty = clamp(city.loyalty + delta, 0, loyaltyCap); +} +} +} +splitUnloyalCities() { +const cfg = SimConfig.rebellion || {}; +const participationLoyalty = cfg.participationLoyalty ?? 0.50; +const automaticLoyalty = cfg.automaticLoyalty ?? 0.30; +for (const polity of [...this.polities]) { +const originalCities = this.getPolityCities(polity); +const preRebellionPopulation = originalCities.reduce((sum, city) => sum + Math.max(0, city.population || 0), 0); +const members = originalCities +.filter(city => city.id !== polity.centerCityId && city.polityId === polity.id) +.filter(city => !this.isCityRebellionProtected(city)) +.filter(city => (city.loyalty ?? 0.5) < participationLoyalty); +if (!members.length) continue; +const lowestLoyalty = Math.min(...members.map(city => city.loyalty ?? 0.5)); +const automatic = lowestLoyalty < automaticLoyalty; +if (!automatic) { +const instability = this.polityInstability(polity); +const agePressure = this.polityAgePressure(polity); +const averageLoyalty = members.reduce((sum, city) => sum + (city.loyalty ?? 0.5), 0) / members.length; +const chance = clamp( +(participationLoyalty - averageLoyalty) * (cfg.chanceScale ?? 0.20) + +instability * 0.018 + +agePressure * 0.012, +0, +cfg.maxAnnualChance ?? 0.16 +); +if (this.rng.next() >= chance) continue; +} +// Once a rebellion begins, every non-capital city below 50% loyalty joins +// simultaneously, regardless of distance from the first rebel city. +const trigger = members.reduce((lowest, city) => +(city.loyalty ?? 0.5) < (lowest.loyalty ?? 0.5) ? city : lowest, members[0]); +const rebellionData = members.map(city => ({ cityId: city.id, population: city.population || 0, loyalty: city.loyalty ?? 0 })); +const oldPolityId = polity.id; +this.removeCityFromPolity(trigger); +this.releaseCityTerritory(trigger, oldPolityId, 5, "rebellion"); +const rebelPolity = this.createPolity(trigger); +trigger.loyalty = 1; +for (const city of members) { +if (city.id === trigger.id) continue; +const loyalty = city.loyalty ?? 0.3; +this.removeCityFromPolity(city); +this.releaseCityTerritory(city, oldPolityId, 5, "rebellion"); +this.transferCityToPolity(city, rebelPolity, { +loyalty: clamp(0.52 + (participationLoyalty - loyalty) * 0.38, 0.46, 0.72), +control: 0.48, +claim: 0.62, +reason: "rebellion" +}); +this.claimPolityCoreTerritory(rebelPolity, city, 3, "rebellion"); +} +const population = rebellionData.reduce((sum, rebellion) => sum + rebellion.population, 0); +const avgLoyalty = rebellionData.reduce((sum, rebellion) => sum + rebellion.loyalty, 0) / rebellionData.length; +const rebelPopulationShare = preRebellionPopulation > 0 ? clamp(population / preRebellionPopulation, 0, 1) : 0; +const transferRate = clamp(cfg.treasuryTransferRate ?? 0.65, 0, 1); +const treasuryTransferred = Math.min( +Math.max(0, polity.treasury || 0), +Math.max(0, polity.treasury || 0) * rebelPopulationShare * transferRate +); +polity.treasury = Math.max(0, (polity.treasury || 0) - treasuryTransferred); +rebelPolity.treasury = Math.max(0, (rebelPolity.treasury || 0) + treasuryTransferred); +this.enforcePolityTreasuryCap(polity); +this.enforcePolityTreasuryCap(rebelPolity); +const eventData = { +cityIds: rebellionData.map(rebellion => rebellion.cityId), +cityCount: rebellionData.length, +population, +loyalty: avgLoyalty, +automatic, +treasuryTransferred, +newPolityId: rebelPolity.id +}; +this.addPolityEvent(polity.id, "rebellion", this.year, eventData, 3); +this.addPolityEvent(rebelPolity.id, "rebellion", this.year, { +cityIds: eventData.cityIds, +cityCount: eventData.cityCount, +population, +automatic, +treasuryTransferred, +formerPolityId: polity.id +}, 3); +} +} + +enforcePolityContinuity(polity) { +const center = this.getCityById(polity.centerCityId); +const cities = this.getPolityCities(polity); +if (!center || cities.length <= 1) return cities; +const limit = SimConfig.polity.logisticsDistance; +const reachable = new Set([center.id]); +const frontier = [center]; +while (frontier.length) { +const source = frontier.pop(); +for (const city of cities) { +if (reachable.has(city.id)) continue; +if (this.effectiveDistance(source, city) > limit) continue; +reachable.add(city.id); +frontier.push(city); +} +} +const isolated = cities.filter(city => !reachable.has(city.id)); +if (!isolated.length) return cities; +for (const city of isolated) { +const oldPolityId = city.polityId; +this.removeCityFromPolity(city); +this.releaseCityTerritory(city, oldPolityId, 4, "fragmentation"); +city.loyalty = clamp(city.loyalty - 0.28, 0, 1); +} +polity.cohesion = clamp((polity.cohesion ?? 0.6) - isolated.length * 0.035, 0, 1); +polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - isolated.length * 0.02, 0, 1); +polity.crisis = clamp((polity.crisis || 0) + isolated.length * 0.025, 0, 1.5); +return this.getPolityCities(polity); +} +detectPolityFamines() { +const minimumFood = SimConfig.polity?.minimumPerCapitaFood ?? 0.06; +for (const polity of this.polities) { +const cities = this.getPolityCities(polity); +if (!cities.length) continue; +let poorCities = 0; +let totalFood = 0; +let totalPopulation = 0; +for (const city of cities) { +const population = Math.max(1, city.population || 0); +const perCapita = (city.storedResources || 0) / population; +if (perCapita < minimumFood) poorCities++; +totalFood += city.storedResources || 0; +totalPopulation += population; +} +const poorRate = poorCities / cities.length; +const avgPerCapitaFood = totalFood / Math.max(1, totalPopulation); +const famine = (cities.length >= 2 && poorRate >= 0.45) || avgPerCapitaFood < 0.035; +if (!famine) continue; +if (polity.lastFamineYear && this.year - polity.lastFamineYear < years(60)) continue; +this.addPolityEvent(polity.id, "famine", this.year, { +poorRate, +avgPerCapitaFood +}, poorRate > 0.65 ? 3 : 2); +polity.lastFamineYear = this.year; +} +} +cleanupPolities() { +const survivors = []; +for (const polity of this.polities) { +const cities = this.getPolityCities(polity); +if (!cities.length) { +this.markPolityEnded(polity, "collapsed"); +this.releasePolityTerritory(polity.id, "collapse"); +continue; +} +let center = this.getCityById(polity.centerCityId); +if (!center || center.population <= 0) { +const oldCenterCityId = polity.centerCityId; +center = cities.reduce((best, city) => this.cityInfluence(city) > this.cityInfluence(best) ? city : best, cities[0]); +polity.centerCityId = center.id; +center.loyalty = this.polityLoyaltyCap ? this.polityLoyaltyCap(polity) : 1; +if (oldCenterCityId !== center.id) { +this.addPolityEvent(polity.id, "capitalShift", this.year, { +oldCenterCityId, +newCenterCityId: center.id +}, 2); +} +polity.treasury *= 0.5; +polity.legitimacy = clamp((polity.legitimacy ?? 0.7) - 0.22, 0, 1); +polity.cohesion = clamp((polity.cohesion ?? 0.6) - 0.16, 0, 1); +polity.crisis = clamp((polity.crisis || 0) + 0.35, 0, 1.5); +polity.lastCrisisYear = this.year; +for (const city of cities) { +if (city.id !== center.id) city.loyalty = clamp(city.loyalty - 0.18, 0, 1); +} +const history = this.ensurePolityHistory(polity); +if (history) history.centerCityId = center.id; +} +const connectedCities = this.enforcePolityContinuity(polity); +if (connectedCities.length !== cities.length) { +cities.length = 0; +cities.push(...connectedCities); +} +if (cities.length === 1) { + const city = cities[0]; + const ageYears = (this.year - (polity.founded ?? this.year)) / WEEKS_PER_YEAR; + const weakSingleCityState = + ageYears > 80 && + city.population < 45 && + (polity.legitimacy ?? 0.7) < 0.25 && + (polity.cohesion ?? 0.6) < 0.25 && + (polity.crisis || 0) > 0.9; + + if (weakSingleCityState) { + this.markPolityEnded(polity, "collapsed"); + this.releasePolityTerritory(polity.id, "collapse"); + city.polityId = null; + city.loyalty = 0.45; + city.receivedAid = false; + continue; + } +} +survivors.push(polity); +} +this.polities = survivors; +this.rebuildIndexes(); +const validPolities = new Set(this.polities.map(p => p.id)); +for (const city of this.cities) { +if (city.polityId !== null && !validPolities.has(city.polityId)) { +const oldPolityId = city.polityId; +city.polityId = null; +city.loyalty = 0.45; +city.receivedAid = false; +this.releaseCityTerritory(city, oldPolityId, 5, "collapse"); +} +} +} +updatePolities(force = false) { +if (!force && this.year % years(1) !== 0) return; +this.cleanupPolities(); +this.foundPolities(); +this.reinforcePolityTradeRoutes(); +this.collectAndRedistributeResources(); +this.maybeBuildTreasuryCities(); +this.detectPolityFamines(); +this.erodePolityLegitimacy(); +this.updateInstitutionalAging(); +this.maybeTriggerInstitutionalReforms(); +this.triggerPolityCrises(); +this.applyOldStateStress(); +this.updatePolityLeaders(); +this.updateCityLoyalty(); +this.maybeStartCampaigns(); +this.splitUnloyalCities(); +this.enforcePolityTreasuryCaps(); +this.cleanupPolities(); +this.samplePolityHistories(); +this.recomputeTerritories({ allowOwnershipChanges: true, rebalanceOwnership: true, reason: "normal" }); +this.reconcileCityTerritoryOwnership(); +this.campaignPressureOnTerritories(); +} +reinforcePolityTradeRoutes() { +for (const polity of this.polities) { +if (this.rng.next() > 0.48) continue; +const center = this.getCityById(polity.centerCityId); +if (!center) continue; +const candidates = this.getPolityCities(polity) +.filter(city => city.id !== center.id && !this.hasDirectTradeConnection(center, city)) +.filter(city => this.distanceBetweenCities(center, city) <= 40) +.sort((a, b) => this.effectiveDistance(center, a) - this.effectiveDistance(center, b)); +if (!candidates.length) continue; +const target = candidates[Math.min(candidates.length - 1, this.rng.int(Math.min(3, candidates.length)))]; +const limit = this.effectiveRouteLengthLimit(center, target, polity); +const path = this.findTerrainRoute(center.x, center.y, target.x, target.y, limit, center, target, polity); +if (!this.isValidRoutePath(path, target.x, target.y, limit)) continue; +if (this.hasReservedRouteSegment(path, this.activeTradeRouteTiles)) continue; +const cities = this.getPolityCities(polity); +const roadCost = 5 + cities.length * 1.2 + this.polityOverextension(polity) * 2.4; +if ((polity.treasury || 0) < roadCost) { +polity.crisis = clamp((polity.crisis || 0) + 0.025, 0, 1.5); +continue; +} +polity.treasury -= roadCost; +if (!this.payRouteConstructionCost(center, target, path, polity)) continue; +this.registerTradeLink(center, target, 0.22, path, null, 34, 2.4, polity.id); +} +} +routeCityLinkLimit(city) { +if (!city) return 1; +const capitalBonus = city.polityId !== null && this.getPolityById(city.polityId)?.centerCityId === city.id ? 1 : 0; +return clamp((city.population > 180 ? 4 : city.population > 90 ? 3 : 2) + capitalBonus, 2, 5); +} +desertCorridorShare(cityA, cityB, samples = 12) { +if (!cityA || !cityB) return 0; +const w = this.world; +let desert = 0; +let land = 0; +for (let step = 0; step <= samples; step++) { +const t = step / samples; +const x = clamp(Math.round(lerp(cityA.x, cityB.x, t)), 0, w.size - 1); +const y = clamp(Math.round(lerp(cityA.y, cityB.y, t)), 0, w.size - 1); +const terrain = w.terrain[w.idx(x, y)]; +if (terrain === Terrain.WATER) continue; +land++; +if (terrain === Terrain.DESERT) desert++; +} +return land > 0 ? desert / land : 0; +} +routeDesertShare(path) { +if (!path?.length) return 0; +let desert = 0; +for (const tile of path) if (this.world.terrain[tile] === Terrain.DESERT) desert++; +return desert / path.length; +} +effectiveRouteLengthLimit(cityA = null, cityB = null, ownerPolity = null) { +const cfg = SimConfig.route; +const base = cfg.maxRouteLength ?? 42; +if (!cityA || !cityB) return base; +const avgPopulation = ((cityA.population || 1) + (cityB.population || 1)) * 0.5; +const avgKnowledge = ( +(cityA.knowledge?.farming || 0) + (cityA.knowledge?.metallurgy || 0) + +(cityB.knowledge?.farming || 0) + (cityB.knowledge?.metallurgy || 0) +) * 0.25; +const samePolity = cityA.polityId !== null && cityA.polityId === cityB.polityId; +const capital = ownerPolity && (ownerPolity.centerCityId === cityA.id || ownerPolity.centerCityId === cityB.id) ? 1 : 0; +const org = ownerPolity ? clamp((ownerPolity.cohesion ?? 0.6) * 0.7 + (ownerPolity.legitimacy ?? 0.7) * 0.3, 0, 1.2) : 0; +const treasury = ownerPolity ? clamp(Math.sqrt(Math.max(0, ownerPolity.treasury || 0)) / 18, 0, 1.1) : 0; +const desertShare = this.desertCorridorShare(cityA, cityB); +const desertBonus = desertShare * (cfg.desertRouteLengthBonus ?? 30); +const normalMax = cfg.adaptiveMaxRouteLength ?? 72; +const desertMax = cfg.desertAdaptiveMaxRouteLength ?? normalMax; +const upperLimit = lerp(normalMax, desertMax, desertShare); +return Math.floor(clamp( +base + +clamp(Math.sqrt(avgPopulation) * 0.55, 0, 10) + +avgKnowledge * 12 + +(samePolity ? 7 : 0) + +capital * 6 + +org * 5 + +treasury * 4 + +desertBonus, +base, +upperLimit +)); +} +cityHasRouteTilesNear(city, radius = SimConfig.route.isolatedRouteRadius ?? 4) { +if (!city) return false; +const w = this.world; +for (const { dx, dy } of this.getRadiusOffsets(radius)) { +const x = city.x + dx; +const y = city.y + dy; +if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue; +if (w.tradeRoute[w.idx(x, y)] > 0) return true; +} +return false; +} +bootstrapIsolatedCityRoutes(supportedRoutes) { +const cfg = SimConfig.route; +if (!cfg.isolatedCityBootstrap || this.cities.length < 2) return; +let built = 0; +const maxBootstraps = clamp(Math.ceil(this.cities.length * 0.12), 4, cfg.isolatedRouteSearchLimit ?? 8); +const isolated = this.cities +.filter(city => city.population > 0 && (city.tradeLinks.size === 0 || !this.cityHasRouteTilesNear(city))) +.sort((a, b) => (a.tradeLinks.size - b.tradeLinks.size) || ((a.tradeReach || 0) - (b.tradeReach || 0))); +for (const city of isolated) { +if (built >= maxBootstraps) break; +if (city.tradeLinks.size > 0 && this.cityHasRouteTilesNear(city)) continue; +if (this.tryBootstrapCityRoute(city, supportedRoutes)) built++; +} +} +tryBootstrapCityRoute(city, supportedRoutes) { +const partners = this.bootstrapRoutePartners(city); +for (const partner of partners) { +if (!partner || partner.id === city.id || city.tradeLinks.has(partner.id)) continue; +const ownerPolityId = city.polityId !== null && city.polityId === partner.polityId ? city.polityId : null; +const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; +const limit = this.effectiveRouteLengthLimit(city, partner, ownerPolity); +if (Math.abs(city.x - partner.x) + Math.abs(city.y - partner.y) > limit) continue; +const path = this.findTerrainRoute(city.x, city.y, partner.x, partner.y, limit, city, partner, ownerPolity); +if (!this.isValidRoutePath(path, partner.x, partner.y, limit)) continue; +const strength = Math.max(SimConfig.route.isolatedRouteMinStrength ?? 0.12, this.routeStrengthForPath(path) * 0.75); +if (!this.canPayRouteConstructionCost(city, partner, path, ownerPolity, { bootstrap: true })) continue; +if (!this.payRouteConstructionCost(city, partner, path, ownerPolity, { bootstrap: true })) continue; +this.registerTradeLink( +city, +partner, +strength, +path, +supportedRoutes, +SimConfig.route.newRouteGraceBoost ?? 18, +SimConfig.route.newRoutePheromoneSeed ?? 2.5, +ownerPolityId +); +this.exchangeCityResources(city, partner, strength, path); +this.applyRouteConnectionBenefits(city, partner, strength, SimConfig.route.routePheromoneMaintain ?? 0.12, true); +return true; +} +return false; +} +bootstrapRoutePartners(city) { +const result = []; +const seen = new Set([city.id]); +const add = candidate => { +if (candidate && candidate.id !== city.id && !seen.has(candidate.id)) { +seen.add(candidate.id); +result.push(candidate); +} +}; +if (city.polityId !== null) { +const polity = this.getPolityById(city.polityId); +const capital = this.getCityById(polity?.centerCityId); +add(capital); +const samePolity = this.getPolityCities(polity || { cityIds: new Set() }) +.filter(other => other.id !== city.id) +.sort((a, b) => this.distanceBetweenCities(city, a) - this.distanceBetweenCities(city, b)); +for (const candidate of samePolity.slice(0, 4)) add(candidate); +} +const others = this.cities +.filter(other => other.id !== city.id) +.sort((a, b) => { +const polityBiasA = city.polityId !== null && a.polityId === city.polityId ? -12 : 0; +const polityBiasB = city.polityId !== null && b.polityId === city.polityId ? -12 : 0; +return this.distanceBetweenCities(city, a) + polityBiasA - (this.distanceBetweenCities(city, b) + polityBiasB); +}); +for (const candidate of others.slice(0, 8)) add(candidate); +return result; +} +createManualTradeRoute(cityAId, cityBId) { +const cityA = this.getCityById(cityAId); +const cityB = this.getCityById(cityBId); +if (!cityA || !cityB) return { ok: false, reason: "missingCity" }; +if (cityA.id === cityB.id) return { ok: false, reason: "sameCity" }; +const cfg = SimConfig.route || {}; +const direct = this.distanceBetweenCities(cityA, cityB); +const maxLength = cfg.manualRouteMaxLength ?? 120; +if (direct > maxLength) return { ok: false, reason: "tooFar", maxLength }; +const ownerPolityId = cityA.polityId !== null && cityA.polityId === cityB.polityId ? cityA.polityId : null; +const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; +const naturalLimit = this.effectiveRouteLengthLimit(cityA, cityB, ownerPolity); +const limit = Math.min(maxLength, Math.max(naturalLimit, Math.ceil(direct + 24))); +const path = this.findTerrainRoute(cityA.x, cityA.y, cityB.x, cityB.y, limit, cityA, cityB, ownerPolity); +if (!this.isValidRoutePath(path, cityB.x, cityB.y, limit)) return { ok: false, reason: "noPath" }; +const protectionYears = cfg.manualProtectionYears ?? 25; +const protectedUntil = this.year + years(protectionYears); +const strength = Math.max(0.35, this.routeStrengthForPath(path)); +this.manualTradeRoutes ??= []; +this.manualTradeRoutes = this.manualTradeRoutes.filter(route => +!((route.from === cityA.id && route.to === cityB.id) || (route.from === cityB.id && route.to === cityA.id))); +const record = { from: cityA.id, to: cityB.id, path: [...path], strength, created: this.year, protectedUntil }; +this.manualTradeRoutes.push(record); +this.manualRouteProtection ??= new Map(); +for (const tile of path) { +if (this.world.terrain[tile] === Terrain.WATER) continue; +this.manualRouteProtection.set(tile, Math.max(this.manualRouteProtection.get(tile) || 0, protectedUntil)); +this.world.tradeRoute[tile] = Math.max(this.world.tradeRoute[tile], cfg.manualProtectedStrength ?? 32); +this.activeTradeRouteTiles.add(tile); +this.addPheromone(tile, cfg.newRoutePheromoneSeed ?? 2.5); +} +const existingLink = this.tradeLinks.some(link => { +const endpoints = this.tradeLinkEndpointIds(link); +return endpoints && endpoints.includes(cityA.id) && endpoints.includes(cityB.id); +}); +cityA.tradeLinks.add(cityB.id); +cityB.tradeLinks.add(cityA.id); +if (!existingLink) { +this.tradeLinks.push({ from: cityA.id, to: cityB.id, strength, path: [...path], ownerPolityId, manual: true, protectedUntil }); +} +return { ok: true, path, protectionYears, protectedUntil }; +} +cleanupManualTradeRouteProtection() { +this.manualTradeRoutes ??= []; +this.manualRouteProtection ??= new Map(); +const active = []; +this.manualRouteProtection.clear(); +for (const route of this.manualTradeRoutes) { +if (!route || route.protectedUntil <= this.year) continue; +const a = this.getCityById(route.from); +const b = this.getCityById(route.to); +if (!a || !b || !route.path?.length) continue; +active.push(route); +for (const tile of route.path) { +if (tile < 0 || tile >= this.world.count || this.world.terrain[tile] === Terrain.WATER) continue; +this.manualRouteProtection.set(tile, Math.max(this.manualRouteProtection.get(tile) || 0, route.protectedUntil)); +} +} +this.manualTradeRoutes = active; +} +isManualRouteTileProtected(tile) { +return (this.manualRouteProtection?.get(tile) || 0) > this.year; +} +restoreProtectedManualRoutes(supportedRoutes) { +const cfg = SimConfig.route || {}; +for (const route of this.manualTradeRoutes || []) { +if (route.protectedUntil <= this.year) continue; +const cityA = this.getCityById(route.from); +const cityB = this.getCityById(route.to); +if (!cityA || !cityB) continue; +cityA.tradeLinks.add(cityB.id); +cityB.tradeLinks.add(cityA.id); +const ownerPolityId = cityA.polityId !== null && cityA.polityId === cityB.polityId ? cityA.polityId : null; +const exists = this.tradeLinks.some(link => +(link.from === cityA.id && link.to === cityB.id) || (link.from === cityB.id && link.to === cityA.id)); +if (!exists) this.tradeLinks.push({ ...route, ownerPolityId, manual: true }); +for (const tile of route.path) { +if (tile < 0 || tile >= this.world.count || this.world.terrain[tile] === Terrain.WATER) continue; +if (supportedRoutes) supportedRoutes.add(tile); +this.world.tradeRoute[tile] = Math.max(this.world.tradeRoute[tile], cfg.manualProtectedStrength ?? 32); +this.activeTradeRouteTiles.add(tile); +} +} +} +updateTradeRoutes(fieldBased = false) { +const w = this.world; +this.cleanupManualTradeRouteProtection(); +for (let i = 0; i < w.count; i++) { +const invalidTerrain = w.terrain[i] === Terrain.WATER; +if (invalidTerrain) w.tradeRoute[i] = 0; +else if (w.tradeRoute[i] > 0 && this.year % years(4) === 0 && !this.isManualRouteTileProtected(i)) { +const pheromoneSupport = clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); +const decay = pheromoneSupport >= SimConfig.route.routePheromoneMaintain ? 1 : SimConfig.route.routeUnsupportedDecay; +w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - decay); +if (w.tradeRoute[i] < SimConfig.route.routeWeakThreshold) w.tradeRoute[i] = 0; +} +} +this.tradeLinks = []; +for (const city of this.cities) city.tradeLinks.clear(); +const supportedRoutes = this.supportedRouteScratch; +supportedRoutes.clear(); +this.restoreProtectedManualRoutes(supportedRoutes); +const maxPairsToRoute = clamp(this.cities.length * 7, 80, 1400); +const pairHeap = this.pairCandidateScratch; +pairHeap.length = 0; +const sqrtPop = this.cityScoreScratch; +sqrtPop.length = 0; +for (const city of this.cities) sqrtPop[city.id] = Math.sqrt(city.population || 1); +for (let a = 0; a < this.cities.length; a++) { +for (let b = a + 1; b < this.cities.length; b++) { +const c1 = this.cities[a]; +const c2 = this.cities[b]; +const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y); +const ownerPolityId = c1.polityId !== null && c1.polityId === c2.polityId ? c1.polityId : null; +const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; +const limit = this.effectiveRouteLengthLimit(c1, c2, ownerPolity); +if (d > limit) continue; +const distanceFactor = clamp((d - 14) / 30, 0, 1); +const marketScale = clamp(((sqrtPop[c1.id] || 1) + (sqrtPop[c2.id] || 1)) / 34, 0.45, 2.1); +const routeBias = this.hasDirectTradeConnection(c1, c2) ? 0.25 : 0; +const isolatedBias = (!this.cityHasRouteTilesNear(c1) ? 0.55 : 0) + (!this.cityHasRouteTilesNear(c2) ? 0.55 : 0); +const preliminaryScore = marketScale * (1 + distanceFactor * 0.9) + routeBias + isolatedBias + (ownerPolityId !== null ? 0.18 : 0); +this.topHeapPush(pairHeap, { c1, c2, d, limit, distanceFactor, marketScale, preliminaryScore, ownerPolityId }, maxPairsToRoute, "preliminaryScore"); +} +} +const pairCandidates = this.heapToDescending(pairHeap, "preliminaryScore"); +const candidates = this.routeCandidateScratch; +candidates.length = 0; +const maxLinks = Math.max(1, Math.floor(this.cities.length / 2)); +const routeCandidateLimit = clamp(maxLinks * 12, 80, maxPairsToRoute); +const maxPathAttempts = clamp(maxLinks * 16, 120, 520); +let pathAttempts = 0; +for (const pair of pairCandidates) { +if (pathAttempts++ >= maxPathAttempts) break; +const { c1, c2, distanceFactor, marketScale, ownerPolityId } = pair; +const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; +const limit = pair.limit || this.effectiveRouteLengthLimit(c1, c2, ownerPolity); +const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y, limit, c1, c2, ownerPolity); +if (!this.isValidRoutePath(path, c2.x, c2.y, limit)) continue; +const strength = this.routeStrengthForPath(path); +const pheromoneSupport = this.routePheromoneSupport(path); +if (strength < 0.08) continue; +const desertShare = this.routeDesertShare(path); +const desertThresholdFactor = SimConfig.route.desertPheromoneThresholdFactor ?? 0.45; +const buildThreshold = SimConfig.route.routePheromoneBuild * (1 - desertShare * (1 - desertThresholdFactor)); +if (!fieldBased && ownerPolityId === null && pheromoneSupport < buildThreshold) continue; +if (!this.canPayRouteConstructionCost(c1, c2, path, ownerPolity)) continue; +const tradeScore = strength * (1 + distanceFactor * 0.7 + pheromoneSupport * 0.75 + (ownerPolityId !== null ? 0.28 : 0)) * marketScale; +this.topHeapPush(candidates, { c1, c2, strength, path, tradeScore, pheromoneSupport, ownerPolityId }, routeCandidateLimit, "tradeScore"); +} +this.heapToDescending(candidates, "tradeScore"); +for (const candidate of candidates) { +if (this.tradeLinks.length >= maxLinks) break; +const { c1, c2, strength, path, pheromoneSupport, ownerPolityId } = candidate; +const c1Limit = this.routeCityLinkLimit(c1); +const c2Limit = this.routeCityLinkLimit(c2); +if (c1.tradeLinks.size >= c1Limit || c2.tradeLinks.size >= c2Limit) continue; +if (this.hasReservedRouteSegment(path, supportedRoutes)) continue; +const ownerPolity = ownerPolityId !== null ? this.getPolityById(ownerPolityId) : null; +if (!this.payRouteConstructionCost(c1, c2, path, ownerPolity)) continue; +const upkeepPaid = this.payRouteUpkeep(c1, c2, path, ownerPolity, pheromoneSupport); +if (!fieldBased && !upkeepPaid && pheromoneSupport < SimConfig.route.routePheromoneMaintain) { +this.weakenRoutePath(path, SimConfig.route.routeUnsupportedDecay); +continue; +} +const routeBoost = upkeepPaid ? SimConfig.route.routeMaintainedBoost : Math.floor(SimConfig.route.routeMaintainedBoost * 0.45); +this.registerTradeLink(c1, c2, strength, path, supportedRoutes, routeBoost, fieldBased ? 0.8 : 0, ownerPolityId); +this.exchangeCityResources(c1, c2, strength, path); +this.applyRouteConnectionBenefits(c1, c2, strength, pheromoneSupport, upkeepPaid); +} +this.bootstrapIsolatedCityRoutes(supportedRoutes); +for (let i = 0; i < w.count; i++) { +if (w.tradeRoute[i] && !supportedRoutes.has(i)) { +w.tradeRoute[i] = 0; +} +} +this.activeTradeRouteTiles.clear(); +for (let i = 0; i < w.count; i++) { +if (w.tradeRoute[i]) this.activeTradeRouteTiles.add(i); +} +this.diffuseCityKnowledgeThroughTrade(); +} +pruneOrphanTradeRoutes() { +const w = this.world; +this.cleanupManualTradeRouteProtection(); +const validLinks = []; +const validTiles = new Set(); +for (const link of this.tradeLinks || []) { +if (link.manual && link.protectedUntil != null && link.protectedUntil <= this.year) continue; +const a = this.getCityById(link.from); +const b = this.getCityById(link.to); +const path = link.path || []; +if (!a || !b || !path.length) continue; +const first = path[0]; +const last = path[path.length - 1]; +const endpointDistance = (tile, city) => Math.abs(tile % w.size - city.x) + Math.abs(Math.floor(tile / w.size) - city.y); +const aligned = +(endpointDistance(first, a) <= 1 && endpointDistance(last, b) <= 1) || +(endpointDistance(first, b) <= 1 && endpointDistance(last, a) <= 1); +if (!aligned) continue; +validLinks.push(link); +for (const tile of path) validTiles.add(tile); +} +// Protected user-created routes remain valid even if a normal route refresh has +// not run since they were created. Once protection expires they are omitted and +// can disappear under the ordinary route rules. +for (const route of this.manualTradeRoutes || []) { +if (route.protectedUntil <= this.year) continue; +const a = this.getCityById(route.from); +const b = this.getCityById(route.to); +if (!a || !b || !route.path?.length) continue; +const already = validLinks.some(link => { +const endpoints = this.tradeLinkEndpointIds(link); +return endpoints && endpoints.includes(a.id) && endpoints.includes(b.id); +}); +if (!already) validLinks.push({ ...route, manual: true }); +for (const tile of route.path) if (w.terrain[tile] !== Terrain.WATER) validTiles.add(tile); +} +this.tradeLinks = validLinks; +for (const city of this.cities) { +city.tradeLinks ??= new Set(); +city.tradeLinks.clear(); +} +for (const link of validLinks) { +const a = this.getCityById(link.from); +const b = this.getCityById(link.to); +if (!a || !b) continue; +a.tradeLinks.add(b.id); +b.tradeLinks.add(a.id); +} +for (let tile = 0; tile < w.count; tile++) if (w.tradeRoute[tile] && !validTiles.has(tile)) w.tradeRoute[tile] = 0; +this.activeTradeRouteTiles.clear(); +for (const tile of validTiles) if (w.tradeRoute[tile]) this.activeTradeRouteTiles.add(tile); +} + +registerTradeLink(cityA, cityB, strength, path, supportedRoutes = null, routeBoost = 18, pheromoneBoost = 0, ownerPolityId = null) { +cityA.tradeLinks.add(cityB.id); +cityB.tradeLinks.add(cityA.id); +this.tradeLinks.push({ from: cityA.id, to: cityB.id, strength, path, ownerPolityId }); +for (const tile of path) { +if (supportedRoutes) supportedRoutes.add(tile); +this.world.tradeRoute[tile] = Math.min(255, this.world.tradeRoute[tile] + routeBoost); +this.activeTradeRouteTiles.add(tile); +this.addPheromone(tile, pheromoneBoost); +} +} +diffuseCityKnowledgeThroughTrade() { +for (const link of this.tradeLinks) { +const a = this.getCityById(link.from); +const b = this.getCityById(link.to); +if (!a || !b) continue; +a.knowledge ??= { farming: 0, metallurgy: 0 }; +b.knowledge ??= { farming: 0, metallurgy: 0 }; +const rate = clamp(SimConfig.technology.tradeDiffusion * (0.5 + link.strength), 0, 0.012); +const farmingDelta = (a.knowledge.farming - b.knowledge.farming) * rate; +const metallurgyDelta = (a.knowledge.metallurgy - b.knowledge.metallurgy) * rate; +a.knowledge.farming = clamp(a.knowledge.farming - farmingDelta, 0, 1); +b.knowledge.farming = clamp(b.knowledge.farming + farmingDelta, 0, 1); +a.knowledge.metallurgy = clamp(a.knowledge.metallurgy - metallurgyDelta, 0, 1); +b.knowledge.metallurgy = clamp(b.knowledge.metallurgy + metallurgyDelta, 0, 1); +} +} +findTerrainRoute(x1, y1, x2, y2, lengthLimit = SimConfig.route.maxRouteLength, cityA = null, cityB = null, ownerPolity = null) { +const w = this.world; +const start = w.idx(x1, y1); +const goal = w.idx(x2, y2); +if (w.terrain[start] === Terrain.WATER || w.terrain[goal] === Terrain.WATER) return []; +const limit = Math.max(1, Math.floor(lengthLimit || this.effectiveRouteLengthLimit(cityA, cityB, ownerPolity))); +const direct = Math.abs(x1 - x2) + Math.abs(y1 - y2); +if (direct > limit) return []; +const padding = clamp(Math.ceil(limit * 0.35), 8, 22); +const minX = Math.max(0, Math.min(x1, x2) - padding); +const maxX = Math.min(w.size - 1, Math.max(x1, x2) + padding); +const minY = Math.max(0, Math.min(y1, y2) - padding); +const maxY = Math.min(w.size - 1, Math.max(y1, y2) + padding); +const costs = this.routeCostScratch; +const prev = this.routePrevScratch; +const seen = this.visitStamp; +const marker = this.nextVisitMarker(); +const heap = this.routeHeapScratch; +heap.length = 0; +costs[start] = 0; +prev[start] = -1; +seen[start] = marker; +this.routeHeapPush(heap, { tile: start, score: direct }); +let reached = false; +let expansions = 0; +const maxExpansions = clamp(limit * limit * 3, 420, 5200); +while (heap.length && expansions++ < maxExpansions) { +const current = this.routeHeapPop(heap); +const tile = current.tile; +if (tile === goal) { +reached = true; +break; +} +const cx = tile % w.size; +const cy = Math.floor(tile / w.size); +this.forCardinalNeighbors(tile, (n, nx, ny) => { +if (nx < minX || nx > maxX || ny < minY || ny > maxY) return; +if (w.terrain[n] === Terrain.WATER) return; +const stepsFromStart = Math.abs(nx - x1) + Math.abs(ny - y1); +const remaining = Math.abs(nx - x2) + Math.abs(ny - y2); +if (stepsFromStart + remaining > limit + 8) return; +const terrainCost = this.routeTileCost(n); +const nextCost = costs[tile] + terrainCost; +if (seen[n] === marker && nextCost >= costs[n]) return; +seen[n] = marker; +costs[n] = nextCost; +prev[n] = tile; +const heuristic = remaining * 1.08; +this.routeHeapPush(heap, { tile: n, score: nextCost + heuristic }); +}); +} +if (!reached) return []; +const path = []; +let current = goal; +while (current >= 0 && path.length <= limit + 8) { +path.push(current); +if (current === start) break; +current = prev[current]; +} +if (path[path.length - 1] !== start) return []; +path.reverse(); +return path; +} +routeHeapPush(heap, node) { +heap.push(node); +let index = heap.length - 1; +while (index > 0) { +const parent = (index - 1) >> 1; +if (heap[parent].score <= node.score) break; +heap[index] = heap[parent]; +index = parent; +} +heap[index] = node; +} +routeHeapPop(heap) { +const root = heap[0]; +const last = heap.pop(); +if (heap.length && last) { +let index = 0; +while (true) { +let child = index * 2 + 1; +if (child >= heap.length) break; +if (child + 1 < heap.length && heap[child + 1].score < heap[child].score) child++; +if (heap[child].score >= last.score) break; +heap[index] = heap[child]; +index = child; +} +heap[index] = last; +} +return root; +} +routeTileCost(tile) { +const w = this.world; +const routeRelief = w.tradeRoute[tile] ? 0.58 : 1; +const pheromoneRelief = 1 - clamp(w.pheromone[tile] / SimConfig.route.maxPheromone, 0, 0.32); +const mountain = w.terrain[tile] === Terrain.MOUNTAIN ? 1.2 : 0; +const desertFactor = w.terrain[tile] === Terrain.DESERT ? (SimConfig.route.desertTraversalCostFactor ?? 0.68) : 1; +return Math.max(0.45, (w.move[tile] * desertFactor + mountain) * routeRelief * pheromoneRelief); +} +routeStrengthForPath(path) { +const w = this.world; +if (!path.length) return 0; +let route = 0; +let pheromone = 0; +let terrainEase = 0; +for (const i of path) { +route += w.tradeRoute[i] > 0 ? 1 : 0; +pheromone += clamp(w.pheromone[i] / 9, 0, 1); +terrainEase += 1 / Math.max(0.75, this.routeTileCost(i)); +} +return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2; +} +hasReservedRouteSegment(path, reservedTiles) { +let previousReserved = false; +for (let p = 1; p < path.length - 1; p++) { +const reserved = reservedTiles.has(path[p]); +if (reserved && previousReserved) return true; +previousReserved = reserved; +} +return false; +} +routePheromoneSupport(path) { +const w = this.world; +if (!path.length) return 0; +let support = 0; +for (const i of path) support += clamp(w.pheromone[i] / SimConfig.route.maxPheromone, 0, 1); +return support / path.length; +} +isValidRoutePath(path, targetX, targetY, lengthLimit = SimConfig.route.maxRouteLength) { +const w = this.world; +if (!path.length) return false; +const last = path[path.length - 1]; +if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false; +const limit = lengthLimit; +if (path.length - 1 > limit) return false; +const seen = new Set(); +for (const tile of path) { +if (seen.has(tile)) return false; +seen.add(tile); +if (w.terrain[tile] === Terrain.WATER) return false; +} +return true; +} +exchangeCityResources(a, b, strength, path) { +const delta = (a.storedResources - b.storedResources) * 0.018 * strength; +a.storedResources -= delta; +b.storedResources += delta; +const traffic = Math.min(0.8, strength * 0.26); +this.depositRoutePheromone(path, traffic); +} +routeUpkeepCost(path, pheromoneSupport) { +const pheromoneRelief = clamp(pheromoneSupport / Math.max(0.001, SimConfig.route.routePheromoneMaintain), 0, 1) * 0.55; +const difficulty = this.routePathDifficulty(path); +return path.length * SimConfig.route.routeUpkeepPerTile * (0.65 + difficulty * 0.35) * (1 - pheromoneRelief); +} +payRouteUpkeep(cityA, cityB, path, polity = null, pheromoneSupport = 0) { +const cost = this.routeUpkeepCost(path, pheromoneSupport); +if (cost <= 0.001) return true; +let remaining = cost; +if (polity) { +const paid = Math.min(polity.treasury || 0, remaining * 0.72); +polity.treasury = Math.max(0, (polity.treasury || 0) - paid); +remaining -= paid; +} +const cityShare = remaining * 0.5; +remaining -= this.drainCityResources(cityA, cityShare); +remaining -= this.drainCityResources(cityB, cityShare); +if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining); +if (remaining > 0 && polity) { +const paid = Math.min(polity.treasury || 0, remaining); +polity.treasury = Math.max(0, (polity.treasury || 0) - paid); +remaining -= paid; +} +return remaining <= cost * 0.25; +} +weakenRoutePath(path, amount) { +for (const tile of path) { +this.world.tradeRoute[tile] = Math.max(0, this.world.tradeRoute[tile] - amount); +if (this.world.tradeRoute[tile] < SimConfig.route.routeWeakThreshold) this.world.tradeRoute[tile] = 0; +} +} +applyRouteConnectionBenefits(cityA, cityB, strength, pheromoneSupport, upkeepPaid) { +const reliability = clamp(strength * 0.65 + pheromoneSupport * 0.35, 0, 1) * (upkeepPaid ? 1 : 0.55); +if (reliability <= 0) return; +const tradeValue = reliability * 0.18; +cityA.tradeValue = (cityA.tradeValue || 0) + tradeValue; +cityB.tradeValue = (cityB.tradeValue || 0) + tradeValue; +cityA.tradeReach = Math.max(cityA.tradeReach || 0, reliability); +cityB.tradeReach = Math.max(cityB.tradeReach || 0, reliability); +const resourceBonus = reliability * 0.12; +cityA.storedResources += resourceBonus; +cityB.storedResources += resourceBonus; +if (cityA.polityId !== null && cityA.polityId === cityB.polityId) { +cityA.loyalty = clamp((cityA.loyalty ?? 0.5) + reliability * 0.002, 0, 1); +cityB.loyalty = clamp((cityB.loyalty ?? 0.5) + reliability * 0.002, 0, 1); +} +} +routePathDifficulty(path) { +if (!path.length) return 1; +let difficulty = 0; +for (const tile of path) { +let move = this.world.move[tile] || 1; +if (this.world.terrain[tile] === Terrain.DESERT) move *= SimConfig.route.desertTraversalCostFactor ?? 0.68; +const hard = move > 1.8 ? 0.35 : 0; +const existing = this.world.tradeRoute[tile] ? -0.25 : 0; +difficulty += Math.max(0.45, move + hard + existing); +} +return difficulty / path.length; +} +routeConstructionCost(path, polityBacked = false, options = {}) { +let weakTiles = 0; +for (const tile of path) if (this.world.tradeRoute[tile] < 24) weakTiles++; +const difficulty = this.routePathDifficulty(path); +const desertShare = this.routeDesertShare(path); +const desertFactor = SimConfig.route.desertConstructionCostFactor ?? 0.72; +const terrainConstructionFactor = 1 - desertShare * (1 - desertFactor); +const base = weakTiles * (polityBacked ? 0.16 : 0.12) * clamp(difficulty / 1.35, 0.75, 2.1) * terrainConstructionFactor; +return options.bootstrap ? base * 0.62 : base; +} +canPayRouteConstructionCost(cityA, cityB, path, polity = null, options = {}) { +const cost = this.routeConstructionCost(path, !!polity, options); +if (cost <= 0) return true; +const reserve = options.bootstrap ? Math.max(2.5, Math.sqrt((cityA.population || 1) + (cityB.population || 1)) * 0.25) : 0; +return cityA.storedResources + cityB.storedResources + (polity?.treasury || 0) + reserve >= cost; +} +payRouteConstructionCost(cityA, cityB, path, polity = null, options = {}) { +const cost = this.routeConstructionCost(path, !!polity, options); +if (cost <= 0) return true; +if (!this.canPayRouteConstructionCost(cityA, cityB, path, polity, options)) return false; +let remaining = cost; +if (polity) { +const treasuryPayment = Math.min(polity.treasury, cost * 0.65); +polity.treasury -= treasuryPayment; +remaining -= treasuryPayment; +} +const half = remaining * 0.5; +const paidA = this.drainCityResources(cityA, half); +const paidB = this.drainCityResources(cityB, half); +remaining -= paidA + paidB; +if (remaining > 0) remaining -= this.drainCityResources(cityA.storedResources >= cityB.storedResources ? cityA : cityB, remaining); +if (remaining > 0 && polity) { +const treasuryPayment = Math.min(polity.treasury, remaining); +polity.treasury -= treasuryPayment; +remaining -= treasuryPayment; +} +if (options.bootstrap && remaining <= Math.max(2.5, Math.sqrt((cityA.population || 1) + (cityB.population || 1)) * 0.25)) return true; +return remaining <= 0.001; +} +drainCityResources(city, amount) { +const paid = Math.min(city.storedResources, amount); +city.storedResources -= paid; +return paid; +} +addPheromone(tile, amount) { +if (tile < 0 || tile >= this.world.count || amount <= 0) return; +this.world.pheromone[tile] = Math.min(SimConfig.route.maxPheromone, this.world.pheromone[tile] + amount); +if (this.world.pheromone[tile] > 0) this.activePheromoneTiles?.add(tile); +} +depositRoutePheromone(path, amount) { +for (const i of path) { +this.addPheromone(i, amount); +} +} +updateEthnicStats() { +for (const e of this.ethnicities.values()) { +e.population = 0; +e.diversity = 0; +e.centroidX = 0; +e.centroidY = 0; +} +for (const band of this.nomadBands || []) { +const e = this.ethnicities.get(band.ethnicityId); +if (!e) continue; +e.population += band.population; +e.centroidX += band.x * band.population; +e.centroidY += band.y * band.population; +e.diversity += (1 - band.cohesion) * band.population; +} +for (const [tile, mix] of this.tileEthnicMix) { +const x = tile % this.world.size; +const y = Math.floor(tile / this.world.size); +const diversity = this.world.cultureDiversity[tile] || 0; +for (const [id, count] of mix) { +const e = this.ethnicities.get(id); +if (!e || count <= 0) continue; +e.population += count; +e.centroidX += x * count; +e.centroidY += y * count; +e.diversity += diversity * count; +} +} +for (const city of this.cities) { +for (const [id, count] of city.ethnicityComposition) { +const e = this.ethnicities.get(id); +if (!e || count <= 0) continue; +e.population += count * 0.25; +e.centroidX += city.x * count * 0.25; +e.centroidY += city.y * count * 0.25; +} +} +for (const e of this.ethnicities.values()) { +if (!e.population) continue; +e.centroidX /= e.population; +e.centroidY /= e.population; +e.diversity /= e.population; +e.averageTraits ??= this.randomTraits(); +} +} +absorptionTargetForEthnicity(ethnicityId) { +const ethnicity = this.ethnicities.get(ethnicityId); +if (!ethnicity) return null; +const scores = new Map(); +const addScore = (id, amount) => { +if (id == null || id === ethnicityId || amount <= 0 || !this.ethnicities.has(id)) return; +scores.set(id, (scores.get(id) || 0) + amount); +}; +for (const [tile, mix] of this.tileEthnicMix) { +const local = mix.get(ethnicityId) || 0; +if (local <= 0) continue; +for (const [id, count] of mix) addScore(id, count * 1.5); +const begin = this.world.landNeighborStarts?.[tile] ?? 0; +const end = this.world.landNeighborStarts?.[tile + 1] ?? begin; +for (let index = begin; index < end; index++) { +const neighborMix = this.tileEthnicMix.get(this.world.landNeighbors[index]); +if (!neighborMix) continue; +for (const [id, count] of neighborMix) addScore(id, count); +} +} +for (const city of this.cities) { +const local = city.ethnicityComposition?.get(ethnicityId) || 0; +if (local <= 0) continue; +for (const [id, count] of city.ethnicityComposition) addScore(id, count * 1.25); +} +let bestId = null; +let bestScore = -Infinity; +for (const [id, score] of scores) { +if (score > bestScore) { bestId = id; bestScore = score; } +} +if (bestId !== null) return bestId; +const parent = ethnicity.parent; +if (parent != null && parent !== ethnicityId && this.ethnicities.has(parent)) return parent; +let nearest = null; +let nearestDistance = Infinity; +for (const candidate of this.ethnicities.values()) { +if (candidate.id === ethnicityId || (candidate.population || 0) <= 0) continue; +const distance = Math.hypot((candidate.centroidX || 0) - (ethnicity.centroidX || 0), (candidate.centroidY || 0) - (ethnicity.centroidY || 0)); +if (distance < nearestDistance) { nearest = candidate.id; nearestDistance = distance; } +} +return nearest; +} +absorbEthnicityInto(ethnicityId, targetId) { +if (ethnicityId === targetId || !this.ethnicities.has(ethnicityId) || !this.ethnicities.has(targetId)) return false; +for (const [tile, mix] of this.tileEthnicMix) { +const amount = mix.get(ethnicityId) || 0; +if (amount <= 0) continue; +mix.delete(ethnicityId); +mix.set(targetId, (mix.get(targetId) || 0) + amount); +this.updateCultureTile(tile); +} +for (const city of this.cities) { +const amount = city.ethnicityComposition?.get(ethnicityId) || 0; +if (amount <= 0) continue; +city.ethnicityComposition.delete(ethnicityId); +city.ethnicityComposition.set(targetId, (city.ethnicityComposition.get(targetId) || 0) + amount); +} +for (const band of this.nomadBands || []) if (band.ethnicityId === ethnicityId) band.ethnicityId = targetId; +for (const ethnicity of this.ethnicities.values()) if (ethnicity.parent === ethnicityId) ethnicity.parent = targetId; +this.ethnicities.delete(ethnicityId); +return true; +} +absorbSmallEthnicities() { +const threshold = Math.max(0, SimConfig.culture?.absorptionPopulationThreshold ?? 30); +if (threshold <= 0 || this.ethnicities.size <= 1) return 0; +let absorbed = 0; +const candidates = [...this.ethnicities.values()] +.filter(ethnicity => (ethnicity.population || 0) > 0 && (ethnicity.population || 0) < threshold) +.sort((a, b) => (a.population || 0) - (b.population || 0)); +for (const ethnicity of candidates) { +if (!this.ethnicities.has(ethnicity.id)) continue; +const target = this.absorptionTargetForEthnicity(ethnicity.id); +if (target == null) continue; +if (this.absorbEthnicityInto(ethnicity.id, target)) absorbed++; +} +return absorbed; +} +splitDivergentEthnicities() { +this.splitDivergentFieldEthnicities(); +} +splitDivergentFieldEthnicities() { +let formed = 0; +const divergenceSpeed = clamp(SimConfig.culture?.divergenceRateMultiplier ?? 0.25, 0.05, 1); +const divergenceChance = 0.32; +for (const e of [...this.ethnicities.values()]) { +if (formed >= 1) break; +if (this.year < (e.nextDivergenceCheck ?? 0)) continue; +e.nextDivergenceCheck = this.year + years(this.rng.range(24, 90) / divergenceSpeed); +if (e.population < 120 || e.diversity < 0.24) continue; +const candidates = []; +let population = 0; +let tempSum = 0; +let humidSum = 0; +for (const [tile, mix] of this.tileEthnicMix) { +const count = mix.get(e.id) || 0; +if (count < 4) continue; +const x = tile % this.world.size; +const y = Math.floor(tile / this.world.size); +const spatial = Math.hypot(x - e.centroidX, y - e.centroidY) > this.world.size * 0.11; +const climate = this.climateMismatch(e.id, tile) > 0.20; +if ((spatial || climate || this.world.cultureDiversity[tile] > 0.38) && this.rng.next() < divergenceChance) { +candidates.push({ tile, count }); +population += count; +tempSum += this.world.temperature[tile] * count; +humidSum += this.world.humidity[tile] * count; +} +} +if (population < 35 || candidates.length < 3) continue; +const newId = this.createEthnicity(e.id, { +temperature: tempSum / population, +humidity: humidSum / population +}); +const parentTraits = e.averageTraits || this.randomTraits(); +const newTraits = mutateTraits(parentTraits, this.rng, 0.12); +const lineage = this.ethnicities.get(newId); +if (lineage) lineage.averageTraits = newTraits; +for (const { tile, count } of candidates) { +const mix = this.tileEthnicMix.get(tile); +if (!mix) continue; +const split = count * this.rng.range(0.35, 0.68); +mix.set(e.id, Math.max(0, count - split)); +mix.set(newId, (mix.get(newId) || 0) + split); +this.updateCultureTile(tile); +} +formed++; +} +} +} diff --git a/index.html b/index.html index 269d45d..7cd7de7 100644 --- a/index.html +++ b/index.html @@ -1,127 +1,165 @@ - +
-