Compare commits

..

No commits in common. "8a9fd9b3042b68ea43a1407fe62a177df716fbb1" and "bddec03d229c4d16d41d9451d2fb12589ab31b92" have entirely different histories.

12 changed files with 4707 additions and 9321 deletions

View file

@ -1,446 +0,0 @@
# 文明シミュレータ 改修記録
## 目的
ユーザー指定の操作系・反乱・交易路・戦争損害・地形計算の変更を、既存シミュレーションの構造を維持したまま追加する。
## 実装した変更
- 「1週進める」ボタンを削除。
- 初期人口スライダーを削除。初期人口は標準値 50,000 に固定。
- 「再生成」を押すとダイアログを開き、その時点で世界サイズ128 / 160 / 220 / 280を選択する方式に変更。
- 反乱ロジックを変更。
- 忠誠度 30% 未満の都市が1つでもあれば反乱を自動発生。
- 反乱が発生した場合、首都を除く忠誠度 50% 未満の全都市が同時に同一反乱勢力へ参加。
- 30〜50%の範囲では従来のランダム性を残しつつ、忠誠度・不安定度・国家年齢から年次発生確率を算出。
- 「災害発生」「都市建設」「交通接続」の3ボタンを追加。
- 災害発生: 地図上の指定地点を中心に即時発生。
- 都市建設: 地図上のクリック地点へ都市を即時建設。ユーザー建設都市は都市間隔・自動都市数上限を無視するが、水域には建設できない。
- 交通接続: 都市を2つ順にクリックして交易路を作成。
- ユーザー作成交易路に内部保護期間を追加し、期間終了後は通常管理へ戻るよう変更。
- 戦争による都市人口損失と戦場タイル人口損失を従来の約 65% に軽減。
- 戦争発生頻度・勝敗・占領判定そのものは変更していない。
- 水域の肥沃度を計算対象外にし、常に 0 とするよう変更。
- 既存コードの堅牢性修正。
- `getCityById` / `getPolityById` がキャッシュ未初期化のテスト・部分実行でも動作するよう修正。
- 完全に囲まれた小さな中立穴を領土平滑化で埋められるよう修正。
## 追加修正(連続介入・ヘルプ)
- 災害発生・都市建設・交通接続が、1回成功するたびに解除されていた挙動を修正。選択した介入は同じボタンを再度押すまで継続する。
- 交通接続は1本作成後、同じモードのまま次の1つ目の都市選択へ戻る。
- ヘルプを現行仕様に合わせて更新し、説明量を圧縮。
- 交通接続完了メッセージから保護期間に関する説明を削除。
## 追加修正(砂漠交易路・手動都市民族・征服後反乱)
- 砂漠横断交易路の成立条件を改善。
- 交易路経路探索では砂漠の移動コストを通常移動より軽く評価し、隊商路として横断しやすくした。
- 都市間の直線上に砂漠が多い場合、交易路の有効距離上限を段階的に拡張する。
- 砂漠を多く通る経路では、新規交易路に必要なフェロモン閾値と建設コストを緩和する。
- 水域は従来どおり交易路不可。山地の高コストも維持。
- ユーザー建設都市の民族構成を修正。
- 建設地点の民族構成を優先して継承。
- 地点に民族が存在しない場合は最寄りの民族構成を継承。
- 世界内に参照可能な民族がない場合も、新しい現地民族を生成して民族構成が空にならないようにした。
- 征服された都市に20年間の反乱猶予期間を追加。
- 戦争占領、国境戦争、軍事併合、講和による都市移譲、遊牧民征服を対象とする。
- 猶予期間中は忠誠度が30%未満でも反乱を自動発生せず、他都市の反乱にも参加しない。
- 猶予終了後は通常の反乱判定へ戻る。
## 追加修正(国家ツールチップ・制度老朽化・制度改革)
- 国家領土または国家所属都市にカーソルを合わせた際、ツールチップに「国家データ」を表示。
- 国力、人口、都市数、国庫、正統性 / 結束、制度老朽化、忠誠度上限を表示する。
- 国家に `institutionalAging`制度老朽化度、01を追加。
- 国家成立後、年次更新ごとに徐々に増加する。
- 忠誠度上限を `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段階に固定。
- 200999: 最小
- 10003999: 中
- 40005999: 大
- 6000以上: 最大
- 民族の新規分化速度を従来の1/4へ低下。分化判定間隔を4倍にし、分化条件そのものは維持する。
- 総人口30未満の極小民族は年次処理で周辺民族へ吸収する。
- 同一地点・隣接地点・同一都市で接触している民族を優先する。
- 周辺に候補がない場合は親系統、さらに最寄りの存続民族へ統合する。
- タイル人口、都市民族構成、遊牧集団の所属を移し替えるため、吸収によって総人口は減らない。
- 旧仕様の「都市人口を populationScale に合わせて10倍化する」ことだけを確認していた回帰試験を削除し、現行仕様を直接検査する試験へ置換。
## v11: 都市表示・制度改革クールダウン・国庫都市建設
- 都市アイコンの人口帯を変更。
- 200999: 最小
- 10004999: 中
- 50009999: 大
- 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の静的参照確認で、定義のみで未参照のメソッドが残っていないことを確認。

View file

@ -1,11 +0,0 @@
# 文明シミュレータ
`index.html` をブラウザで開いて実行する文明シミュレータです。
今回の改修内容、設定値、検査結果は `MODIFICATION_NOTES.md` を参照してください。
テスト:
```bash
node --test tests/simulation.test.cjs tests/modifications.test.cjs
```

View file

@ -1,56 +0,0 @@
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 => `<span><i style="background: rgb(${t.color.join(",")})"></i>${t.name}</span>`).join(""),
ethnicity: () => "<span>民族の色・濃さは人口と優勢度</span>",
pressure: () => "<span>赤いほど人口圧力が高い</span>",
polities: () => "",
nomads: () => "<span>遊牧集団</span>",
technology: () => "<span>緑:農業 黄土色:冶金</span>",
pheromone: () => "<span>明るい黄色ほど交通量が多い</span>",
resources: () => "<span>明るい緑ほど資源が豊富</span>"
};

228
boot.js
View file

@ -1,228 +0,0 @@
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();

254
config.js
View file

@ -1,254 +0,0 @@
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;
}

6732
engine.js

File diff suppressed because it is too large Load diff

View file

@ -1,165 +1,127 @@
<!doctype html>
<html lang="ja">
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>文明シミュレータ</title>
<title>Emergent Civilization Simulation</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="app">
<aside class="sidebar left-sidebar" aria-label="シミュレーションの操作">
<details class="panel map-guide">
<summary>ヘルプ</summary>
<h3>地図</h3>
<dl class="guide-list">
<div><dt>四角</dt><dd>都市。人口200999 / 10003999 / 40005999 / 6000以上の4段階で大きくなります。</dd></div>
<div><dt>領土色</dt><dd>同色が同じ国家。茶色寄りは係争地です。</dd></div>
<div><dt class="guide-gold">黄色い道</dt><dd>都市間の交易路です。</dd></div>
<div><dt class="guide-red">赤い円・広がり</dt><dd>戦争や災害の影響です。</dd></div>
</dl>
<h3>操作</h3>
<p>再生成では世界サイズを選択します。介入ボタンは選択中、地図を続けてクリックして連続実行できます。もう一度同じボタンを押すと解除します。</p>
<p>災害発生は地点、都市建設は地点、交通接続は都市を2つ順に選択します。ユーザー建設都市は都市間隔・自動都市数上限を無視できますが、水域には建設できません。表示レイヤーで地形・資源・民族・人口圧力・国家・技術・交通量を切り替えます。</p>
<h3>主なパラメータ</h3>
<dl class="guide-list">
<div><dt>人口 / 人口圧力</dt><dd>都市は人口400で成立し、200未満で消滅します。人口圧力は地域人口と収容力の比です。</dd></div>
<div><dt>資源 / 肥沃度</dt><dd>資源は生活・都市経済に使われ、肥沃度は陸地の人口維持と農業に影響します。</dd></div>
<div><dt>農業 / 冶金</dt><dd>地域・都市の生産力や発展を支える技術です。</dd></div>
<div><dt>民族</dt><dd>環境・距離によって分化します。極小民族は周辺民族へ吸収されます。</dd></div>
<div><dt>交通量</dt><dd>人や交易の往来の強さ。交易路形成にも影響します。</dd></div>
<div><dt>忠誠度</dt><dd>都市の国家への帰属安定度。低下すると反乱につながります。</dd></div>
<div><dt>国庫</dt><dd>国家の共通資金。国庫で戦争や都市建設を行います。反乱時はいくらかが奪取されます。</dd></div>
<div><dt>正統性 / 結束</dt><dd>国家の統治安定性。低いほど危機や分裂の影響を受けやすくなります。</dd></div>
<div><dt>制度老朽化</dt><dd>時間とともに増え、忠誠度上限を下げます。老朽化が進むと制度改革が起こり、制度が新陳代謝されることもありますが、もしかしたら失敗するかもしれません。</dd></div>
<div><dt>国力</dt><dd>人口・国庫・忠誠度・技術などをまとめた国家の総合的な力です。</dd></div>
</dl>
<h3>歴史</h3>
<p>右側で世界の推移と国家の存続期間・出来事を確認できます。記号の意味は下の凡例に表示されます。</p>
<div id="eventLegend" class="event-legend"></div>
</details>
<aside class="sidebar left-sidebar" aria-label="Simulation controls">
<div class="brand">
<h1>文明シミュレータ</h1>
<p>人口の移動、文化の分化、国家の興亡を観察します。</p>
<a class="return-link" href="https://2012r2.nishi.boats/~333/">333の部屋に戻る</a>
<h1>Civil Emergence Lab</h1>
<p>Local rules, genealogical cultures, settlement pressure, migration, and collapse.</p>
</div>
<section class="panel controls">
<div class="row row-two">
<button id="toggleRun" class="primary" type="button">一時停止</button>
<button id="resetWorld" type="button">再生成</button>
<div class="row">
<button id="toggleRun" class="primary" type="button">Pause</button>
<button id="stepOnce" type="button">Step</button>
<button id="resetWorld" type="button">Reset</button>
</div>
<label>
進行速度
Speed
<input id="speed" type="range" min="1" max="12" value="5">
</label>
<div class="intervention-tools" aria-label="世界への介入">
<span class="control-heading">世界への介入</span>
<div class="tool-row">
<button id="triggerDisaster" type="button" aria-pressed="false">災害発生</button>
<button id="buildCity" type="button" aria-pressed="false">都市建設</button>
<button id="connectCities" type="button" aria-pressed="false">交通接続</button>
</div>
<p id="interventionStatus" class="intervention-status">ボタンを選び、地図上をクリックして実行します。</p>
</div>
<label>
Initial groups
<input id="agentCount" type="range" min="500" max="20000" step="500" value="5000">
</label>
<label>
表示レイヤー
World scale
<select id="worldSize">
<option value="128">128 x 128</option>
<option value="160" selected>160 x 160</option>
<option value="220">220 x 220</option>
<option value="280">280 x 280</option>
</select>
</label>
<label>
View
<select id="viewMode">
<option value="terrain">地形</option>
<option value="resources">資源</option>
<option value="ethnicity">民族</option>
<option value="pressure">人口圧力</option>
<option value="polities" selected>国家</option>
<option value="technology">技術</option>
<option value="pheromone">交通量</option>
<option value="terrain">Terrain</option>
<option value="resources">Resources</option>
<option value="ethnicity">Ethnicity</option>
<option value="pressure">Population pressure</option>
<option value="polities">Polities</option>
<option value="technology">Technology</option>
<option value="pheromone">Pheromone</option>
</select>
</label>
</section>
<section class="panel stats" aria-live="polite">
<dl>
<div><dt>Date</dt><dd id="year">0y 1m</dd></div>
<div><dt>Active groups</dt><dd id="activeGroups">0</dd></div>
<div><dt>Urban population</dt><dd id="urbanPopulation">0</dd></div>
<div><dt>Ethnicities</dt><dd id="ethnicities">0</dd></div>
<div><dt>Cities</dt><dd id="cities">0</dd></div>
<div><dt>States</dt><dd id="polities">0</dd></div>
<div><dt>Wars</dt><dd id="wars">0</dd></div>
<div><dt>Trade routes</dt><dd id="routes">0</dd></div>
<div><dt>Farming knowledge</dt><dd id="farmingKnowledge">0.000</dd></div>
<div><dt>Metallurgy knowledge</dt><dd id="metallurgyKnowledge">0.000</dd></div>
<div><dt>Collapse deaths</dt><dd id="deaths">0</dd></div>
<div><dt>Frame cost</dt><dd id="frameCost">0ms</dd></div>
</dl>
</section>
<section class="panel ledger">
<h2>主な民族系統</h2>
<h2>Dominant lineages</h2>
<ol id="ethnicityList"></ol>
<div id="fpsCounter" class="fps-counter" aria-live="off">FPS --</div>
</section>
</aside>
<section class="sim">
<canvas id="world" width="960" height="960" aria-label="文明シミュレーションの地図"></canvas>
<canvas id="world" width="960" height="960" aria-label="Civilization simulation canvas"></canvas>
<div class="legend" id="legend"></div>
<div class="tooltip" id="tooltip" hidden></div>
</section>
<aside class="history-sidebar" aria-label="国家の歴史">
<div class="current-date" aria-live="polite"><span>現在</span><strong id="currentDate">0年 1月 第1週</strong></div>
<aside class="history-sidebar" aria-label="State history">
<section class="panel history-panel">
<div class="history-header">
<h2>国家の興亡</h2>
<h2>State lifespans</h2>
<span id="historyRange">-</span>
</div>
<div class="world-trend-wrap">
<h3>世界の推移</h3>
<canvas id="worldTrendGraph" width="300" height="176" aria-label="地域人口、都市人口、都市数、国家数、民族数の推移"></canvas>
</div>
<div class="history-controls">
<select id="historyFilter" aria-label="国家の絞り込み">
<option value="all">すべて</option>
<option value="active">存続中</option>
<option value="ended">滅亡済み</option>
<select id="historyFilter" aria-label="State graph filter">
<option value="all">All</option>
<option value="active">Living</option>
<option value="ended">Past</option>
</select>
<select id="historySort" aria-label="国家の並び順">
<option value="oldest">建国順</option>
<option value="strongest">国力順</option>
<option value="largest">人口順</option>
<select id="historySort" aria-label="State graph sort">
<option value="important">Important</option>
<option value="active">Active</option>
<option value="oldest">Oldest</option>
<option value="strongest">Strongest</option>
<option value="largest">Largest</option>
</select>
<select id="historyMetric" aria-label="歴史の表示指標">
<option value="power">国力</option>
<option value="population">人口</option>
<option value="cities">都市数</option>
<option value="loyalty">忠誠度</option>
<select id="historyMetric" aria-label="State graph metric">
<option value="power">Power</option>
<option value="population">Population</option>
<option value="cities">Cities</option>
<option value="loyalty">Loyalty</option>
</select>
</div>
<div class="history-group-controls" aria-label="歴史グラフの表示">
<button id="toggleActiveStates" type="button" aria-pressed="true">存続中</button>
<button id="togglePastStates" type="button" aria-pressed="true">滅亡済み</button>
<button id="toggleEventIcons" type="button" aria-pressed="true">出来事アイコン</button>
<div class="history-group-controls" aria-label="State graph groups">
<button id="toggleActiveStates" type="button" aria-pressed="true">Living</button>
<button id="togglePastStates" type="button" aria-pressed="true">Past</button>
</div>
<div class="state-graph-scroll">
<canvas id="stateGraph" width="300" height="720" aria-label="国家の存続期間のグラフ"></canvas>
<canvas id="stateGraph" width="300" height="720" aria-label="State lifespan graph"></canvas>
<div id="stateGraphTooltip" class="state-graph-tooltip" hidden></div>
</div>
<div id="stateGraphInfo" class="state-graph-info"></div>
<div id="stateGraphInfo" class="state-graph-info">Hover a row for details</div>
</section>
</aside>
</main>
<dialog id="regenerateDialog" class="regenerate-dialog">
<form class="regenerate-form" method="dialog">
<h2>世界を再生成</h2>
<label>
世界の大きさ
<select id="worldSize">
<option value="128">128 × 128</option>
<option value="160" selected>160 × 160</option>
<option value="220">220 × 220</option>
<option value="280">280 × 280</option>
</select>
</label>
<p>再生成すると現在の世界は破棄されます。</p>
<div class="dialog-actions">
<button id="cancelRegenerate" type="button">キャンセル</button>
<button id="confirmRegenerate" class="primary" type="button">再生成</button>
</div>
</form>
</dialog>
<script src="config.js"></script>
<script src="utils.js"></script>
<script src="app-state.js"></script>
<script src="engine.js"></script>
<script src="render.js"></script>
<script src="boot.js"></script>
<script src="script.js"></script>
</body>
</html>

1173
render.js

File diff suppressed because it is too large Load diff

4630
script.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,7 @@ body {
body {
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Yu Gothic", "Meiryo", sans-serif;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button,
@ -64,28 +64,6 @@ select {
background: #16191b;
}
.current-date {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 9px 11px;
border: 1px solid var(--line);
border-radius: 7px;
background: #101315;
}
.current-date span {
color: var(--muted);
font-size: 11px;
}
.current-date strong {
color: var(--text);
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.history-panel {
display: flex;
flex-direction: column;
@ -135,7 +113,7 @@ select {
}
.history-group-controls {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.history-group-controls button[aria-pressed="false"] {
@ -152,29 +130,6 @@ select {
image-rendering: auto;
}
.world-trend-wrap {
flex: none;
margin-bottom: 8px;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
background: #101315;
}
.world-trend-wrap h3 {
margin: 0;
padding: 6px 8px 2px;
color: var(--muted);
font-size: 11px;
font-weight: 600;
}
#worldTrendGraph {
display: block;
width: 100%;
height: 176px;
}
.state-graph-scroll {
position: relative;
flex: 1;
@ -237,23 +192,6 @@ select {
line-height: 1.35;
}
.return-link {
display: inline-block;
margin-top: 8px;
padding: 5px 9px;
border: 1px solid #3d464b;
border-radius: 6px;
background: var(--panel-2);
color: var(--text);
font-size: 11px;
text-decoration: none;
}
.return-link:hover {
border-color: #59656b;
color: var(--accent);
}
.panel {
padding: 12px;
border: 1px solid var(--line);
@ -268,49 +206,6 @@ select {
margin-bottom: 12px;
}
.row-two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.intervention-tools {
margin-top: 12px;
padding-top: 11px;
border-top: 1px solid var(--line);
}
.control-heading {
display: block;
margin-bottom: 7px;
color: var(--muted);
font-size: 12px;
}
.tool-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.tool-row button {
min-width: 0;
padding: 0 6px;
font-size: 11px;
}
.tool-row button[aria-pressed="true"] {
border-color: #8b7840;
background: #4b4023;
color: #fff0b1;
}
.intervention-status {
min-height: 2.6em;
margin: 7px 0 0;
color: var(--muted);
font-size: 10px;
line-height: 1.3;
}
button,
select {
min-height: 34px;
@ -411,14 +306,6 @@ select {
vertical-align: -1px;
}
.fps-counter {
margin-top: 8px;
color: var(--muted);
font-size: 10px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.sim {
position: relative;
display: grid;
@ -458,10 +345,6 @@ canvas#world {
font-size: 11px;
}
.legend[hidden] {
display: none;
}
.legend span {
display: inline-flex;
align-items: center;
@ -488,42 +371,10 @@ canvas#world {
color: var(--text);
font-size: 11px;
line-height: 1.35;
pointer-events: none;
pointer-events: auto;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
}
.map-guide summary {
cursor: pointer;
font-size: 14px;
font-weight: 700;
}
.map-guide h3 {
margin: 14px 0 6px;
font-size: 12px;
}
.map-guide p,
.guide-list {
margin: 6px 0;
color: var(--muted);
font-size: 11px;
line-height: 1.65;
}
.guide-list div { margin-bottom: 8px; }
.guide-list dt { color: var(--text); font-weight: 700; }
.guide-list dd { margin: 2px 0 0; }
.guide-list .guide-gold { color: #ffd75a; }
.guide-list .guide-red { color: #ec7e6f; }
.event-legend {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px 8px;
font-size: 11px;
color: var(--muted);
}
.event-legend span { display: flex; align-items: baseline; gap: 5px; }
.event-legend canvas { width: 20px; height: 20px; flex: 0 0 20px; }
.event-legend .event-legend-empty { grid-column: 1 / -1; }
.tooltip strong {
display: block;
margin-bottom: 6px;
@ -561,15 +412,6 @@ canvas#world {
text-align: right;
}
.tooltip .status,
.tooltip .warning {
font-weight: 700;
}
.tooltip .warning em {
color: var(--danger);
}
.ethnicity-pie-wrap {
display: grid;
grid-template-columns: 44px 1fr;
@ -607,51 +449,6 @@ canvas#world {
border-radius: 2px;
}
.regenerate-dialog {
width: min(380px, calc(100vw - 32px));
padding: 0;
border: 1px solid #465158;
border-radius: 9px;
background: var(--panel);
color: var(--text);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.48);
}
.regenerate-dialog::backdrop {
background: rgba(0, 0, 0, 0.62);
}
.regenerate-form {
display: grid;
gap: 13px;
padding: 18px;
}
.regenerate-form h2 {
margin: 0;
font-size: 17px;
}
.regenerate-form label {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 12px;
}
.regenerate-form p {
margin: 0;
color: var(--muted);
font-size: 11px;
line-height: 1.5;
}
.dialog-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
@media (max-width: 1100px) {
body {
overflow: auto;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

103
utils.js
View file

@ -1,103 +0,0 @@
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
function bestBy(items, score) {
let best = null;
let bestScore = -Infinity;
for (const item of items) {
const value = score(item);
if (value > bestScore) {
best = item;
bestScore = value;
}
}
return best;
}
function smoothstep(t) {
return t * t * (3 - 2 * t);
}
const traitFields = [
["mobility", traits => traits.mobility, 0.02, 1, 1, 1],
["resourceAttraction", traits => traits.resourceAttraction, 0.05, 1.2, 1, 1],
["assimilation", traits => traits.assimilation, 0, 0.75, 1, 1],
["ethnocentrism", traits => traits.ethnocentrism, 0, 1.2, 1, 1],
["reproductionThreshold", traits => traits.reproductionThreshold, 12, 48, 16, 1 / 48],
["sedentary", traits => traits.sedentary, 0, 1, 1, 1]
];
function mutateTraits(traits, rng, amount) {
return Object.fromEntries(traitFields.map(([key, get, min, max, mutationScale]) => [
key,
clamp(get(traits) + rng.range(-amount * mutationScale, amount * mutationScale), min, max)
]));
}
function compositionTotal(composition) {
let sum = 0;
for (const value of composition.values()) sum += value;
return sum;
}
function addBirthsToComposition(composition, births, rng = null) {
const entries = [...composition.entries()];
const total = entries.reduce((sum, [, value]) => sum + value, 0);
if (!total || births <= 0) return;
for (let n = 0; n < births; n++) {
let r = (rng ? rng.next() : Math.random()) * total;
for (const [id, count] of entries) {
r -= count;
if (r <= 0) {
composition.set(id, (composition.get(id) || 0) + 1);
break;
}
}
}
}
function removeFromComposition(composition, loss) {
let remaining = loss;
const total = compositionTotal(composition);
if (!total) return;
for (const [id, count] of [...composition]) {
const removed = Math.min(count, Math.ceil(loss * (count / total)));
composition.set(id, Math.max(0, count - removed));
remaining -= removed;
if (composition.get(id) <= 0) composition.delete(id);
if (remaining <= 0) break;
}
}
function dominantComposition(composition) {
let bestId = null;
let bestCount = 0;
for (const [id, count] of composition) {
if (count > bestCount) {
bestId = id;
bestCount = count;
}
}
return bestId;
}
function hslToRgb(h, s, l) {
const hue = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [
Math.round(hue(p, q, h + 1 / 3) * 255),
Math.round(hue(p, q, h) * 255),
Math.round(hue(p, q, h - 1 / 3) * 255)
];
}
function mix(a, b, t) {
return [
Math.round(lerp(a[0], b[0], t)),
Math.round(lerp(a[1], b[1], t)),
Math.round(lerp(a[2], b[2], t))
];
}
function lerp(a, b, t) {
return a + (b - a) * t;
}