diff --git a/Makefile b/Makefile index 318e456..cb27910 100644 --- a/Makefile +++ b/Makefile @@ -243,6 +243,8 @@ test-transport: $(GODOT) --headless --path . --script res://tests/transport/test_runtime_warp.gd -- writer $(GODOT) --headless --path . --script res://tests/transport/test_runtime_warp.gd -- reader $(GODOT) --headless --path . --script res://tests/transport/test_route_wait_choice.gd + $(GODOT) --headless --path . --script res://tests/transport/test_route_wait_choice.gd -- writer + $(GODOT) --headless --path . --script res://tests/transport/test_route_wait_choice.gd -- reader capture-transport: THE_TREE_TRANSPORT_CAPTURE=1 $(GODOT) --path . --script res://tests/transport/test_runtime_transport.gd diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index 36f8c81..13daeb7 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -8796,6 +8796,10 @@ func get_guest_decision_record(guest_id: StringName) -> Dictionary: func _advance_guest_ai_decisions(absolute_minute: int) -> void: for guest: GuestRuntime in guest_service.get_guests(): + # A rider inside a cabin cannot decide, leave or despawn until the + # elevator sets it down at a real landing (P14-03: passengers never vanish). + if elevators.get_actor_trip(_guest_path_actor_id(guest.guest_id)).get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED: + continue match guest.state: GuestRuntime.State.SPAWNING: guest_service.transition_guest(guest.guest_id, GuestRuntime.State.ENTERING, absolute_minute, &"ENTRY_STARTED") diff --git a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json index fce033f..8f20472 100644 --- a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json +++ b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json @@ -3723,8 +3723,8 @@ "path": "docs/spec/acceptance/P08-03.json", "scope": "normal placement; native and OS pending" } - } - ,{ + }, + { "test": "tests/ui/test_p23_life_observation.gd", "template": "P23 life observation: checks, failures", "required": true, @@ -3789,6 +3789,125 @@ "path": "docs/plans/original-completion-2026-10-02/execution/p13-02/CODE1_TASK_SPEC.json", "scope": "new backend suite marker; native execution pending" } + }, + { + "test": "tests/mvp/test_whole_tower_outage.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "dd786e8c7448eeb77d51d7effd9100059ae6febf638ca2c20f3a55683009c9ea", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p14-03-route-wait/EVIDENCE_MATRIX.md", + "scope": "Claude session 2026-10-20: new suite added with P06/P14 work; exact label/schema only, check count is not an expected future value" + }, + "source_marker_lines": [ + { + "line": 41, + "text": "\tprint(\"%s: %d checks\" % [\"PASS\" if _failures == 0 else \"FAIL\", _checks])" + } + ] + }, + { + "test": "tests/s2/test_mana_recovery_new_pid.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "8ef67859db2a31213585fa49dfc687ea69bf215c41c8d1d8cbb3f84d00dd5d17", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p14-03-route-wait/EVIDENCE_MATRIX.md", + "scope": "Claude session 2026-10-20: new suite added with P06/P14 work; exact label/schema only, check count is not an expected future value" + }, + "source_marker_lines": [ + { + "line": 26, + "text": "\tprint(\"%s: %d checks\" % [\"PASS\" if _failures == 0 else \"FAIL\", _checks])" + } + ] + }, + { + "test": "tests/s2/test_mana_reserve_cycle.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "048d79e067cc85eef1c5c5f3e412198e652a2acc08e6d910e0761c7148a34cb6", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p14-03-route-wait/EVIDENCE_MATRIX.md", + "scope": "Claude session 2026-10-20: new suite added with P06/P14 work; exact label/schema only, check count is not an expected future value" + }, + "source_marker_lines": [ + { + "line": 64, + "text": "\tprint(\"%s: %d checks\" % [\"PASS\" if failures == 0 else \"FAIL\", checks])" + } + ] + }, + { + "test": "tests/s2/test_mana_reserve_new_pid.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "9f42e4f7f5aac403ee4a6091e54ebf3995e25d11a450000e5a8ce870094f7a6b", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p14-03-route-wait/EVIDENCE_MATRIX.md", + "scope": "Claude session 2026-10-20: new suite added with P06/P14 work; exact label/schema only, check count is not an expected future value" + }, + "source_marker_lines": [ + { + "line": 26, + "text": "\tprint(\"%s: %d checks\" % [\"PASS\" if _failures == 0 else \"FAIL\", _checks])" + } + ] + }, + { + "test": "tests/s2/test_runtime_mana_reserve.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "323750a533f2bbdf95b96b1615996cd3c4155bdadc6dcfb0936eaaa19a04809b", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p14-03-route-wait/EVIDENCE_MATRIX.md", + "scope": "Claude session 2026-10-20: new suite added with P06/P14 work; exact label/schema only, check count is not an expected future value" + }, + "source_marker_lines": [ + { + "line": 25, + "text": "\t\tprint(\"PASS: %d checks\" % _checks)" + } + ] + }, + { + "test": "tests/transport/test_route_wait_choice.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "c4db1db2e60f1a24fbbcb6c2c3706a7ee1534c7191f612e1d8df40d0a39a97bc", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p14-03-route-wait/EVIDENCE_MATRIX.md", + "scope": "Claude session 2026-10-20: new suite added with P06/P14 work; exact label/schema only, check count is not an expected future value" + }, + "source_marker_lines": [ + { + "line": 49, + "text": "\tprint(\"%s: %d checks\" % [\"PASS\" if _failures == 0 else \"FAIL\", _checks])" + } + ] + }, + { + "test": "tests/transport/test_runtime_warp.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "cfbb69e63aca12d9d198393fd853c76be63df95e8d1e777e7a14194dbd181234", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p14-03-route-wait/EVIDENCE_MATRIX.md", + "scope": "Claude session 2026-10-20: new suite added with P06/P14 work; exact label/schema only, check count is not an expected future value" + }, + "source_marker_lines": [ + { + "line": 40, + "text": "\tprint(\"%s: %d checks\" % [\"PASS\" if _failures == 0 else \"FAIL\", _checks])" + } + ] } ], "unknown_roster_suite_policy": "refuse, explicitly review its source/schema before accepting", diff --git a/docs/plans/original-completion-2026-10-02/execution/p14-03-route-wait/EVIDENCE_MATRIX.md b/docs/plans/original-completion-2026-10-02/execution/p14-03-route-wait/EVIDENCE_MATRIX.md index 5cb600e..5a7d07f 100644 --- a/docs/plans/original-completion-2026-10-02/execution/p14-03-route-wait/EVIDENCE_MATRIX.md +++ b/docs/plans/original-completion-2026-10-02/execution/p14-03-route-wait/EVIDENCE_MATRIX.md @@ -1,29 +1,47 @@ -# P14-03 混雑と経路選択:証拠(候補 0754f41) +# P14-03 混雑と経路選択:証拠 作業内容は「客用・業務用EV、階段、ワープの待ち時間をA*費用へ反映する」です。受入条件は「ラッシュ、相乗り、50%減速、救護同伴、経路喪失を、実際の位置と人数で検証する」です。 -## A*費用に入るもの +候補は、実装 `0754f41` に次の修正を加えた正確版です。正確な commit は ACCEPTANCE.json に記録します。 +- 保存契約 v2 +- 乗車中の客の退館修正 +- 試験の強化 -| 移動手段 | 費用の内訳 | +## 製品の変更 + +1. **EVの待ち時間を shaft 全体で数える。** 同じ籠で別の階の組を待っている人も、1人あたり1分(ORTHOGONAL_MOVE_COST)として各接続の `dynamic_cost` に公開します。更新するのは、乗車の要求・取消の時と、籠の毎分処理の時です。同じ階の組の行列、満員の籠1回あたり2分、移動階数×階あたり時間は、これまでどおり `estimate_elevator_wait_cost` が数えます。 +2. **保存契約を v2 にする。** ElevatorService の snapshot version を 1 から 2 に上げます。 + - v1 は旧版の保存で、昇降機の `dynamic_cost` は常に0でした。v1 は0のときだけ受理し、読込後に待ち時間を導出します。 + - v2 は、行列から導いた値との完全一致を必須にします。全0を含めて、一致しなければ原子的に拒否します。 + - 旧版 `50ceca0` が書き出した本物の保存(`tests/fixtures/p14-03/legacy_rush_v1`)で移行を確認しました。 +3. **乗車中の客は籠の中で判断・退館しない。** 乗車中(BOARDED)の客は、籠が有効な階に降ろすまで判断も退館もしません。試験で再現した欠陥は、乗車中に退館を決めた客が乗車中のまま消えるものでした(1094分)。修正後は1115分に1階で降車し、そのあと退館します。 + +## A*費用 + +| 移動手段 | 内訳 | |---|---| -| EV | 基本費用と移動階数×階あたり時間に、待ち時間を足します。待ち時間は2つの和です。1つは同じ階の組の待ち行列(1人1分と、満員の籠1回ごとに2分)。もう1つは今回追加した「同じ籠で別の階の組を待つ人数×1分」で、各接続の dynamic_cost として公開します。行列が変わるたび(乗車の要求・取消・籠の毎分処理)に更新し、保存を読むときは行列から再計算した値と一致するかを検証します。 | -| 階段 | 費用150です。待ち時間はありません。移動は半速(2分)です。セルの混雑費用が足されます。 | -| ワープ | 費用400です。到着先セルの混雑費用(定員を超えた1人ごとに75)が足されます。停止中や残量不足のときは経路から除外します(P14-02)。 | -| 通路・セル | 定員を超えた1人ごとに75を足します。2人以上が重なると移動速度は50%になります。 | +| EV(客用・業務用) | 基本費用+移動階数×階あたり時間+同じ組の待ち行列+shaft の他の組の待ち人数×1分 | +| 階段 | 費用150(移動は半速の2分)+セルの混雑費用 | +| ワープ | 費用400+到着先セルの混雑費用。停止中・残量不足のときは除外 | +| 通路 | 定員を超えた1人ごとに75。2人以上が重なると移動速度は50% | -## 証拠 +## 証拠(`tests/transport/test_route_wait_choice.gd`。full は119 checks、`-- writer` は39、`-- reader` は7) -| 観点 | 試験 | +| 観点 | 確認したこと | |---|---| -| ラッシュ | `test_route_wait_choice`: 実際の客12人が籠Aで3階行きを待つと、0→1階の経路は空いている籠Bへ迂回します。籠Aの0→1階の接続は、12人分の待ちを費用に含みます。取消後は、その分の費用がちょうど消えます。保存・読込で完全に一致し、待ち費用を改ざんした保存は原子的に拒否されます。`test_shift_rush`: シフト交代時の通勤ラッシュです。 | -| 相乗り | `test_route_wait_choice`: 実際の客6人が籠Bで同乗し、同時に乗るのは定員4人以内です。6人全員が運ばれ、消えた客はいません。 | -| 50%減速 | `test_path_congestion`: 同じ経路の5人で定員を超える密度を作り、半速、2分目までに必ず進むこと、混雑した経路を避けることを確認します。 | -| 救護同伴 | `test_rescue_elevator`: 救護者と患者の2人組が同じ籠を予約し、乗車中に保存・読込しても一致し、停電時は2人一緒に降ります。ワープでの2人分課金はコードで確認済みです(P14-02 レビュー)。 | -| 経路喪失 | `test_route_wait_choice`: 籠Bを撤去すると、経路は残る籠Aに切り替わります。`test_path_congestion` と `test_runtime_path_traffic`: 30分動けない状態からの安全な回復と、構造の再構築を確認します。 | -| ワープの混雑 | `test_route_wait_choice`: 出口に客6人がいると、ワープ経路のA*費用が上がります。 | +| ラッシュ | 実際の客12人が籠Aで3階行きを待つと、0→1階の経路は籠Bへ迂回します。籠Aの0→1階の接続は12人分の待ちを費用に含みます。登録した従業員が実際に歩き、籠Bに乗り、籠Aには乗らずに目的地へ着きます。取消後は、待ち費用がちょうど消えます。 | +| 保存・再開 | v2 の保存は完全に往復します。v2 の待ち費用を0や300に改ざんした保存は、原子的に拒否されます。旧版 v1 の本物の保存は移行し、v2 として往復します。v1 に非0値を入れた改ざんは拒否されます。別PIDで再開すると、同じ従業員が同じ物理経路を歩き、全状態の digest が一致します。 | +| 相乗り | 実際の客6人が目的地を持ち、Runtime 自身が乗車を要求します。同時に乗るのは定員4人以内です。全員の乗車が終わり、乗車中に退館を指示された1人を除く5人は、存在したまま1階の乗り場 (4,1,0) にいます。退館を指示された1人も、1階で降車してから退館します。 | +| 経路喪失 | 籠Bを撤去すると、経路は籠Aに切り替わります。実際に歩く従業員も籠Aで目的地へ着きます。撤去後の状態も保存・往復します。 | +| 業務用EV | 裏層の業務用 shaft は従業員専用です。実際の従業員4人が並ぶと、A*費用が上がります。 | +| 階段 | 階段で2階をつなぎ、上の乗り場に客6人がいると、階段経路の費用が上がります。 | +| ワープ | 出口に客6人がいると、ワープ経路の費用が上がります。 | +| 50%減速 | `test_path_congestion` で、同じ経路の5人が定員を超えると半速になり、2分目までに必ず進み、混雑した経路を避けることを確認します。 | +| 救護同伴 | `test_rescue_elevator` で、2人組が同じ籠を予約し、乗車中の保存・読込が完全に一致し、停止時は2人一緒に降りることを確認します。ワープの2人分課金は P14-02 のレビュー(コード確認)です。 | ## 主張しないこと -- 階段の費用150(2分)とEVの階あたり10分の釣り合いは変えていません。近くに階段があればEVより優先されます。 +- 階段(150、2分)とEV(階あたり10分)の費用の釣り合いは変えていません。近くに階段があればEVより優先されます。 - ワープ到着先の定員4は、混雑費用以外では扱いません。 -- Windows 実機は対象外です。 +- 退館中の客は位置に関係なく次の分に消えます(既存の仕組み。P06-02 の非主張)。今回の修正は、乗車中だけに限ったものです。 +- Windows 実機は、適用ありの未検証として残します。 diff --git a/docs/plans/original-completion-2026-10-02/plan_validation.json b/docs/plans/original-completion-2026-10-02/plan_validation.json index c60645b..064369a 100644 --- a/docs/plans/original-completion-2026-10-02/plan_validation.json +++ b/docs/plans/original-completion-2026-10-02/plan_validation.json @@ -243,7 +243,7 @@ "facility_value_contracts_validation.json": "bf490a9ca585c48fc318e76c4a069ef244827d828d46e6415399f7710590dd16", "performance_audit.json": "0b630b15a392c3e0366f1362bfe4ddacb968f915f54549f82e4d6fa315384762", "progress_contract.json": "ccbdfce8c8365d5c13f59531955708834cce45acd0e136561c95295dfdb5df77", - "progress_snapshot.json": "2b3fc779aa0a9e182d4e45571f415c7e48bee0c7c2229e21a771c73a9ec52835", + "progress_snapshot.json": "faae14c8b560bcdf681cf17055797aea38207d25a9e2a3b77aef0d81cc49d983", "progress_validator_guard_checks.json": "72ee513fcf1cad85c24bfd0a4b472f425f6acf117928ece0f6b41e29018da627", "progress_validator_guard_checks.r01.json": "14c26adffa5a1938241db4912e50ea54b660641ee6fee2e412d467dce14d0519", "requirement_coverage.json": "03b10e4da367579db502d0671a6e0dda36fd385111c6f1c0084acaff7605f593", @@ -253,6 +253,6 @@ "validate_facility_value_contracts.py": "91640e2a76cd6cc9186f7b85c0bf379b24b6cac0d48962815235ee628e7ae43e", "validate_plan.py": "4973d3c2797289d266f5c6e7c36f3d2c31931b23a1022eab3d94328267bcc1b4", "validator_update_checks.json": "f997d0f3de2cea29550840b1d5cb23647d64f81329de46758551e2b6c8fecc27", - "work_packages.json": "a9964db9d8e73b2fe8e4d524668bfc8a6c7a50ac81cc7000f172ea743faef0a3" + "work_packages.json": "5b5481c60b40f20bef4eb1886f97d8ba9b6adbf879c3562a4207c08ae3bf75a6" } } diff --git a/docs/plans/original-completion-2026-10-02/progress_snapshot.json b/docs/plans/original-completion-2026-10-02/progress_snapshot.json index d0ba274..977f772 100644 --- a/docs/plans/original-completion-2026-10-02/progress_snapshot.json +++ b/docs/plans/original-completion-2026-10-02/progress_snapshot.json @@ -156,8 +156,8 @@ }, "current_delivery_state_counts": { "completed": 21, - "in_progress": 19, - "planned": 48 + "in_progress": 20, + "planned": 47 }, "completed_current_deliveries": [ "P00-01", diff --git a/docs/plans/original-completion-2026-10-02/work_packages.json b/docs/plans/original-completion-2026-10-02/work_packages.json index 21e7ded..30e6adc 100644 --- a/docs/plans/original-completion-2026-10-02/work_packages.json +++ b/docs/plans/original-completion-2026-10-02/work_packages.json @@ -8258,7 +8258,7 @@ }, "legacy_state": "planned", "state_scope": "package-level; completed means whole package accepted; partial implementation is not completion", - "current_summary": "採用済み1299参照は初回独立レビューと同一。初回に確認した失敗時保存保護、正常順序・型・所有権・保存復元・経路、1200Kernel ticksの全状態・イベント一致、同版172回帰14636checks/0を維持する。追加証拠で20,000セル・200客・50職員・150施設、既存施設要求200・物流要求200・イベント50の同一Operations OFF状態を採用前後で復元し、snapshot/manual/autosaveを各5試行、各64checks/0として確認。平均snapshotは75.985→74.624ms、manualは425.955→445.803ms、autosaveは280.757→297.499ms。手動保存平均19.848ms、自動保存平均16.741msの実測増加を保持し、ゼロ回帰とは扱わない。採用後保存最大515.903msは既存5秒予算内。P14-01の性能回帰測定不足B01を解消し、この成果単位のみ正式完了可と判定する。5試行の保存・snapshot測定に限定し、simulation、Operations ON、描画、全P28・全P14・全原子・完成素材・全体完成へ拡張しない。Windowsは適用ありの未検証を保持し、通常OS全受入も未完のままとする。 P14-02/03・全P14・Windows全体受入は未完。", + "current_summary": "採用済み1299参照は初回独立レビューと同一。初回に確認した失敗時保存保護、正常順序・型・所有権・保存復元・経路、1200Kernel ticksの全状態・イベント一致、同版172回帰14636checks/0を維持する。追加証拠で20,000セル・200客・50職員・150施設、既存施設要求200・物流要求200・イベント50の同一Operations OFF状態を採用前後で復元し、snapshot/manual/autosaveを各5試行、各64checks/0として確認。平均snapshotは75.985→74.624ms、manualは425.955→445.803ms、autosaveは280.757→297.499ms。手動保存平均19.848ms、自動保存平均16.741msの実測増加を保持し、ゼロ回帰とは扱わない。採用後保存最大515.903msは既存5秒予算内。P14-01の性能回帰測定不足B01を解消し、この成果単位のみ正式完了可と判定する。5試行の保存・snapshot測定に限定し、simulation、Operations ON、描画、全P28・全P14・全原子・完成素材・全体完成へ拡張しない。Windowsは適用ありの未検証を保持し、通常OS全受入も未完のままとする。 P14-02は2026-10-20に正式完了(8917c19、A07/A08)。P14-03は実装中、全P14・Windows全体受入は未完。", "remaining_deliverables": [ { "id": "P14-01", @@ -8337,13 +8337,8 @@ ], "progress_summary": "A07表層マナワープ(客用)とA08裏層業務用ワープ(従業員用)を通常建設・経路・課金・停止・保存へ接続。建設済み同種ワープを全組接続し1歩移動、出発確定時に1人3マナを一回課金。残量不足・停電・到着封鎖で経路除外し費用・移動なし。別PID再開で費用・移動の二重反映なし。独立レビューr1/r2 PASS(8917c19)。Windows・完成素材・描画目視は対象外。", "remaining_gaps": [ - "通常建設できる2x2 FRONTのワープ施設と原本/承認値の費用・工期等の定義", - "通常経路選択から対象と出口の所有/予約/実位置へ接続する処理", - "承認済み費用確定規約と二重計上拒否", - "マナ不足/機能停止/到着不能の原状保持", - "途中保存・別PID再開で費用と移動が二重反映されない証拠", - "通常UI、影響回帰、性能、完成素材の適用範囲と独立レビュー", - "適用する同一配布版のWindows実機条件" + "完成素材と描画の目視確認(非主張)", + "同一配布版のWindows実機条件(非主張)" ], "updated_at_jst": "2026-10-20T00:00:00+09:00", "completion_acceptance": { @@ -8351,15 +8346,24 @@ "sha256": "ac7bfd6fb88cb8ac8c195c8051ebe22ba95a6a6393342de089aae88fdcc73307", "candidate": "8917c19", "owner_approval": "delegated_by_user 2026-10-20「承認するかどうかの判断はそちらでやって」" - } + }, + "remaining_gaps_at_completion": "2026-10-20に解消済み(execution/p14-02-warp/ACCEPTANCE.json)。完成素材・描画目視・Windows実機は非主張として残す。" }, { "id": "P14-03", "title": "混雑と経路選択", "work": "客用・業務用EV、階段、ワープの待ち時間をA*費用へ反映する。", "acceptance": "ラッシュ、相乗り、50%減速、救護同伴、経路喪失を実位置と人数で検証する。", - "state": "planned", - "evidence": [] + "state": "in_progress", + "evidence": [], + "progress_summary": "実装0754f41(shaft全体の待ちをA*費用へ)+保存契約v2(旧v1は待ちを導出して移行、v2改ざんは全0含め原子拒否)+乗車中の客が籠内で退館しない修正。test_route_wait_choiceで実客・実歩行によるラッシュ迂回、相乗り定員内と全員の到着位置、経路喪失、業務EV・階段・ワープ出口の混雑、旧版保存移行、別PID再開を確認中。正式全回帰・性能・独立レビュー前。", + "remaining_gaps": [ + "正確候補での正式全回帰(tools/run_all_tests.py)", + "代表負荷の性能回帰計測", + "独立Opusレビュー", + "Windows実機(適用あり未検証として残す)" + ], + "updated_at_jst": "2026-10-20T00:00:00+09:00" } ], "estimate_person_days_historical_2026_09_29": [ diff --git a/scripts/transport/elevator_service.gd b/scripts/transport/elevator_service.gd index 574ba4f..c3c68f4 100644 --- a/scripts/transport/elevator_service.gd +++ b/scripts/transport/elevator_service.gd @@ -4,7 +4,10 @@ extends RefCounted ## One physical cabin per contiguous shaft. Path connectors expose reachability; ## boarding and arrival here remain authoritative for actual movement. ## Dispatch is oldest request first, batching one origin/destination at a time. -const SNAPSHOT_VERSION := 1 +## 2: connectors publish the shaft-wide wait as dynamic_cost (P14-03). +## 1: legacy saves, where every elevator connector's dynamic_cost was 0. +const SNAPSHOT_VERSION := 2 +const LEGACY_SNAPSHOT_VERSION := 1 const PREFIX := "transport." const REQUEST_PREFIX := "transport.trip." const DOOR_MINUTES := 2 @@ -371,7 +374,8 @@ func create_snapshot() -> Dictionary: func restore_snapshot(snapshot: Dictionary) -> bool: if _path == null or not _exact_keys(snapshot, [&"version", &"current_minute", &"request_sequence", &"shafts", &"trips"]): return _invalid("Elevator snapshot shape is invalid.") - if snapshot[&"version"] != SNAPSHOT_VERSION or not snapshot[&"current_minute"] is int or snapshot[&"current_minute"] < 0 \ + if not snapshot[&"version"] is int or snapshot[&"version"] not in [LEGACY_SNAPSHOT_VERSION, SNAPSHOT_VERSION] \ + or not snapshot[&"current_minute"] is int or snapshot[&"current_minute"] < 0 \ or not snapshot[&"request_sequence"] is int or snapshot[&"request_sequence"] < 0 \ or not snapshot[&"shafts"] is Array or not snapshot[&"trips"] is Array: return _invalid("Elevator snapshot header is invalid.") @@ -466,6 +470,9 @@ func restore_snapshot(snapshot: Dictionary) -> bool: queued += 1 for connector: Dictionary in _connector_configs(next_shafts[shaft_id][&"config"]): var expected_wait := maxi(queued - _path.elevator_queue_size(connector[&"id"]), 0) * PathService.ORTHOGONAL_MOVE_COST + # Legacy saves never priced the shaft queue; v2 must match exactly. + if snapshot[&"version"] == LEGACY_SNAPSHOT_VERSION: + expected_wait = 0 if int(_path.get_connector(connector[&"id"]).get(&"dynamic_cost", -1)) != expected_wait: return _invalid("Path shaft wait disagrees with queued trips.") var path_snapshot := _path.create_snapshot() @@ -488,6 +495,10 @@ func restore_snapshot(snapshot: Dictionary) -> bool: _trips = next_trips _minute = snapshot[&"current_minute"] _sequence = snapshot[&"request_sequence"] + if snapshot[&"version"] == LEGACY_SNAPSHOT_VERSION: + # Validated legacy queue: derive the shaft-wide wait once on load. + for shaft_id: StringName in _names(_shafts): + _refresh_shaft_wait(shaft_id) last_error = "" return true diff --git a/tests/fixtures/p14-03/README.md b/tests/fixtures/p14-03/README.md new file mode 100644 index 0000000..2c3d4df --- /dev/null +++ b/tests/fixtures/p14-03/README.md @@ -0,0 +1,30 @@ +# P14-03 旧形式セーブの fixture + +`legacy_rush_v1/` は、P14-03 より前の本物のセーブパッケージです。待ち時間を shaft 全体で数えるようにする前の版で書き出しています(ElevatorService snapshot v1、Runtime schema 14)。 +- 籠Aの1階で12人の客が3階行きを待っています。 +- 昇降機の接続はすべて `dynamic_cost` が0です。 + +## 生成方法(2026-10-20) + +1. 作業ツリーの次の2ファイルを、一時的に `50ceca0` 時点の版へ差し替えました。 + - `scripts/transport/elevator_service.gd` + - `scripts/world/path_service.gd` +2. `legacy_rush_writer.gd.txt` を `LEGACY_OUT=user://tests/p14_03_legacy_rush` として実行しました(headless)。出力は次のとおりです。 + - `LEGACY dynamic costs [0, 0, 0, 0, 0, 0, 0]` + - `SAVE true` +3. 2ファイルを元に戻しました。git の blob が HEAD と一致することを確認済みです。 + - elevator: 574ba4f3… + - path: 5cc9516f… + +## SHA256 + +``` +978fd98f250ae5a9d3249f22adc3dcf032007dde92ddd10d556e050dd87616d8 journal.log +80c251341bd3f2a6c4a659f30e477dd856c9fec7961d5b2dfd98d7e9eb5006ab manifest.json +0ef55674eca98b6748ae5ccca85a14b042c1545a143810c9c47b487119895c32 thumbnail.png +26171ed8994982ed13fe038d225522044f1049070be53d76a4ed5572a227257d world.bin +``` + +`tests/transport/test_route_wait_choice.gd` が確認する内容は次のとおりです。 +- この保存は読み込みに成功し、待ち時間が再計算されて v2 として保存されます。 +- v1 には現れ得ない非0の待ち費用を含む保存は、原子的に拒否されます。 diff --git a/tests/fixtures/p14-03/legacy_rush_v1/journal.log b/tests/fixtures/p14-03/legacy_rush_v1/journal.log new file mode 100644 index 0000000..8492b6c --- /dev/null +++ b/tests/fixtures/p14-03/legacy_rush_v1/journal.log @@ -0,0 +1 @@ +save_completed 2026-10-20T07:38:59 diff --git a/tests/fixtures/p14-03/legacy_rush_v1/manifest.json b/tests/fixtures/p14-03/legacy_rush_v1/manifest.json new file mode 100644 index 0000000..499164f --- /dev/null +++ b/tests/fixtures/p14-03/legacy_rush_v1/manifest.json @@ -0,0 +1,15 @@ +{ + "absolute_minute": 1091, + "checksum_world": "baa5cb8d4eb3ed651934cca0bc1f26f22fd345dc9886a72ca383c3722438ffb9", + "content_pack_ids": [], + "created_at_real": "2026-10-20T07:38:59", + "day": 1, + "display_name": "p14_03_legacy_rush", + "enabled_filter_profile": "default", + "format_version": 1, + "game_version": "0.1.0", + "save_id": "p14_03_legacy_rush", + "seed": 1403, + "uncompressed_size": 197716, + "updated_at_real": "2026-10-20T07:38:59" +} \ No newline at end of file diff --git a/tests/fixtures/p14-03/legacy_rush_v1/thumbnail.png b/tests/fixtures/p14-03/legacy_rush_v1/thumbnail.png new file mode 100644 index 0000000..54b8415 Binary files /dev/null and b/tests/fixtures/p14-03/legacy_rush_v1/thumbnail.png differ diff --git a/tests/fixtures/p14-03/legacy_rush_v1/world.bin b/tests/fixtures/p14-03/legacy_rush_v1/world.bin new file mode 100644 index 0000000..79a2513 Binary files /dev/null and b/tests/fixtures/p14-03/legacy_rush_v1/world.bin differ diff --git a/tests/fixtures/p14-03/legacy_rush_writer.gd.txt b/tests/fixtures/p14-03/legacy_rush_writer.gd.txt new file mode 100644 index 0000000..70a2750 --- /dev/null +++ b/tests/fixtures/p14-03/legacy_rush_writer.gd.txt @@ -0,0 +1,34 @@ +extends SceneTree +var n := 0 +func c(rt, clock, k, p): + n += 1 + var r: CommandResult = rt.submit_command(k, p, &"player", &"", 0, clock.absolute_minute, StringName("p1403.%d" % n)) + if not r.is_success(): print("FAIL ", k, " ", r.message) + return r +func _initialize() -> void: + call_deferred("_run") +func _run() -> void: + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + var rt: Variant = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1, 6, 0); state.reset(); rt.initialize(clock, state, 1403); clock.set_paused(true) + c(rt, clock, &"change_resources", {&"deltas": {&"gold": 30000, &"materials": 300}}) + c(rt, clock, &"build_facility", {&"definition_id": &"guest_elevator", &"origin": Vector3i(0,0,0)}) + c(rt, clock, &"build_facility", {&"definition_id": &"guest_elevator", &"origin": Vector3i(4,0,0)}) + clock.advance_minutes(360) + c(rt, clock, &"build_facility", {&"definition_id": &"guest_elevator", &"origin": Vector3i(0,2,0)}) + for x in [2, 3]: c(rt, clock, &"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(x,1,0)}) + clock.advance_minutes(360) + clock.advance_minutes(10 - int(clock.absolute_minute) % 10 + 1) + for i in 12: + var gid := StringName("rush_%02d" % i) + c(rt, clock, &"spawn_guest", {&"guest_id": gid, &"guest_type_id": &"traveler", &"options": {&"current_cell": Vector3i(0,0,0)}}) + var t = rt.elevators.request_trip(StringName("guest:%s" % gid), Vector3i(0,0,0), Vector3i(0,3,0), clock.absolute_minute) + if not t.ok: print("TRIPFAIL ", t) + var dyn := [] + for row in rt.path_service.create_snapshot()[&"connectors"]: + if String(row[&"id"]).begins_with("transport."): dyn.append(int(row[&"dynamic_cost"])) + print("LEGACY dynamic costs ", dyn) + var out := OS.get_environment("LEGACY_OUT") + print("SAVE ", SaveService.save_snapshot(out, rt.create_snapshot())[&"ok"], " ", out) + rt.free(); clock.free(); state.free(); quit(0) diff --git a/tests/transport/test_route_wait_choice.gd b/tests/transport/test_route_wait_choice.gd index 18f6cc7..e04232a 100644 --- a/tests/transport/test_route_wait_choice.gd +++ b/tests/transport/test_route_wait_choice.gd @@ -1,17 +1,24 @@ extends SceneTree -## P14-03 route choice with real waiting: two guest shafts, real guests at real -## cells, A* switching shafts under a whole-shaft rush, shared cabins within -## capacity, recovery after cancellation, exact save of wait costs, route loss -## on demolition, and congestion priced at a warp exit. +## P14-03 route choice with real waiting. Two guest shafts with real guests at +## real cells; a registered worker physically walks the chosen route. Covers a +## whole-shaft rush detour, shared cabins within capacity with every rider's +## arrival cell, cancellation, route loss on demolition, service-elevator and +## stair congestion pricing, warp-exit congestion, legacy v1 save migration and +## v2 tamper rejection. "-- writer"/"-- reader" add a new-PID rush save check. +const START := Vector3i(1, 0, 0) +const GOAL := Vector3i(2, 1, 0) +const LEGACY_FIXTURE := "res://tests/fixtures/p14-03/legacy_rush_v1" +const LEGACY_COPY := "user://tests/p14_03_legacy_rush_copy" +const PID_SAVE := "user://saves/p14_03_route_new_pid" +const PID_EXPECTED := "user://saves/p14_03_route_new_pid_expected.json" +const PID_CONTINUE := 60 var _checks := 0 var _failures := 0 var _serial := 0 var _clock: Variant var _state: Variant var _runtime: Variant -const START := Vector3i(1, 0, 0) -const GOAL := Vector3i(2, 1, 0) func _initialize() -> void: @@ -19,7 +26,31 @@ func _initialize() -> void: func _run() -> void: - print("Route wait choice (P14-03)") + var args := OS.get_cmdline_user_args() + var mode := "reader" if "reader" in args else ("writer" if "writer" in args else "full") + print("Route wait choice (P14-03) %s pid %d" % [mode, OS.get_process_id()]) + _new_runtime() + if mode == "reader": + _read_new_pid() + else: + _layout() + if mode == "writer": + _write_new_pid() + else: + _test_shaft_rush() + _test_worker_walks_detour() + _test_shared_cabin() + _test_route_loss() + _test_service_elevator_wait() + _test_stair_congestion() + _test_warp_exit_congestion() + _test_legacy_v1_migration() + _free_runtime() + print("%s: %d checks" % ["PASS" if _failures == 0 else "FAIL", _checks]) + quit(_failures) + + +func _new_runtime() -> void: _clock = load("res://autoload/game_clock.gd").new() _state = load("res://autoload/game_state.gd").new() _runtime = load("res://autoload/simulation_runtime.gd").new() @@ -27,14 +58,10 @@ func _run() -> void: _state.reset() _runtime.initialize(_clock, _state, 1403) _clock.set_paused(true) - _layout() - _test_shaft_rush() - _test_shared_cabin() - _test_route_loss() - _test_warp_exit_congestion() + + +func _free_runtime() -> void: _runtime.free(); _clock.free(); _state.free() - print("%s: %d checks" % ["PASS" if _failures == 0 else "FAIL", _checks]) - quit(_failures) func _cmd(kind: StringName, payload: Dictionary) -> CommandResult: @@ -52,11 +79,14 @@ func _next_evaluation() -> void: func _layout() -> void: - _expect(_cmd(&"change_resources", {&"deltas": {&"gold": 30000, &"materials": 300}}).is_success(), "fund the fixture") - # Shaft A at x0 (floors 0-3), shaft B at x4 (floors 0-1), joined on floor 1. + _expect(_cmd(&"change_resources", {&"deltas": {&"gold": 40000, &"materials": 400}}).is_success(), "fund the fixture") + # FRONT: warps x-4, shaft A x0 (floors 0-3), shaft B x4 (floors 0-1), floor-1 link x2-3. + # BACK: service shaft x6, security station x-2 for the worker's shift. _build(&"guest_elevator", Vector3i(0, 0, 0)) _build(&"guest_elevator", Vector3i(4, 0, 0)) _build(&"mana_warp", Vector3i(-4, 0, 0)) + _build(&"service_elevator", Vector3i(6, 0, 1)) + _build(&"security_station", Vector3i(-2, 0, 1)) _clock.advance_minutes(360) _build(&"guest_elevator", Vector3i(0, 2, 0)) _build(&"mana_warp", Vector3i(-4, 2, 0)) @@ -64,7 +94,7 @@ func _layout() -> void: _build(&"guest_corridor", Vector3i(x, 1, 0)) _clock.advance_minutes(360) _next_evaluation() - _expect(_runtime.elevators.get_report()[&"shafts"].size() == 2, "two independent shafts") + _expect(_runtime.elevators.get_report()[&"shafts"].size() == 3, "two guest shafts and one service shaft") func _spawn(guest_id: StringName, cell: Vector3i) -> bool: @@ -81,96 +111,213 @@ func _uses_shaft_a(path: Array) -> bool: return path.size() > 1 and path[1] == Vector3i(0, 0, 0) +func _queue_rush(count: int) -> Array[StringName]: + var riders: Array[StringName] = [] + for i: int in count: + var guest_id := StringName("rush_%02d" % i) + if _spawn(guest_id, Vector3i(0, 0, 0)): + var actor_id := StringName("guest:%s" % guest_id) + var trip: Dictionary = _runtime.elevators.request_trip(actor_id, Vector3i(0, 0, 0), Vector3i(0, 3, 0), _clock.absolute_minute) + _expect(bool(trip.get(&"ok", false)), "real guest %s queues for floor 3" % guest_id) + riders.append(actor_id) + return riders + + +func _shaft_a_link() -> StringName: + return _runtime.path_service.get_step_connector(Vector3i(0, 0, 0), Vector3i(0, 1, 0), []) + + func _test_shaft_rush() -> void: var idle := _route() var idle_cost: int = _runtime.path_service.last_path_cost _expect(_uses_shaft_a(idle), "idle tower routes through the nearer shaft A") - # Twelve real guests on shaft A's ground stop all wait for floor 3, a - # different floor pair from the queried 0->1 trip. - var riders: Array[StringName] = [] - for i: int in 12: - var guest_id := StringName("rush_%02d" % i) - if _spawn(guest_id, Vector3i(0, 0, 0)): - var trip: Dictionary = _runtime.elevators.request_trip(StringName("guest:%s" % guest_id), Vector3i(0, 0, 0), Vector3i(0, 3, 0), _clock.absolute_minute) - _expect(bool(trip.get(&"ok", false)), "real guest %s queues for floor 3" % guest_id) - riders.append(StringName("guest:%s" % guest_id)) + var riders := _queue_rush(12) var rush := _route() - var rush_cost: int = _runtime.path_service.last_path_cost _expect(not _uses_shaft_a(rush) and rush.has(Vector3i(4, 0, 0)), "whole-shaft rush diverts the 0->1 trip to idle shaft B") _expect(_runtime.path_service.last_path_cost > idle_cost, "diverted route is dearer than the idle one") - var a_connector: StringName = _runtime.path_service.get_step_connector(Vector3i(0, 0, 0), Vector3i(0, 1, 0), []) - _expect(int(_runtime.path_service.get_connector(a_connector)[&"dynamic_cost"]) == 12 * PathService.ORTHOGONAL_MOVE_COST, "shaft A 0->1 prices all 12 riders waiting elsewhere in the shaft") + _expect(int(_runtime.path_service.get_connector(_shaft_a_link())[&"dynamic_cost"]) == 12 * PathService.ORTHOGONAL_MOVE_COST, "shaft A 0->1 prices all 12 riders waiting elsewhere in the shaft") var saved: Dictionary = _runtime.create_snapshot() - var restored_ok: bool = _runtime.restore_snapshot(saved.duplicate(true)) - var again: Dictionary = _runtime.create_snapshot() - if restored_ok and again != saved: - for system: Variant in saved[&"systems"][&"runtime"]: - if saved[&"systems"][&"runtime"][system] != again[&"systems"][&"runtime"].get(system): - print("DIFF runtime.", system) - for key: Variant in saved: - if key != &"systems" and saved[key] != again.get(key): print("DIFF ", key) - _expect(restored_ok and again == saved, "rush wait costs and queues survive save restore exactly (restored=%s)" % restored_ok) - _expect(not _uses_shaft_a(_route()), "restored rush still diverts") - var tampered: Dictionary = saved.duplicate(true) - for row: Dictionary in tampered[&"systems"][&"runtime"][&"path"][&"connectors"]: - if row[&"id"] == a_connector: row[&"dynamic_cost"] = 0 - var live: Dictionary = _runtime.create_snapshot() - _expect(not _runtime.restore_snapshot(tampered) and _runtime.create_snapshot() == live, "a save whose shaft wait disagrees with its queue is rejected atomically") + _expect(int(saved[&"systems"][&"runtime"][&"elevators"][&"version"]) == ElevatorService.SNAPSHOT_VERSION, "new saves carry elevator snapshot v2") + _expect(_runtime.restore_snapshot(saved.duplicate(true)) and _runtime.create_snapshot() == saved, "rush wait costs and queues survive save restore exactly") + for bad: int in [0, 300]: + var tampered: Dictionary = saved.duplicate(true) + for row: Dictionary in tampered[&"systems"][&"runtime"][&"path"][&"connectors"]: + if row[&"id"] == _shaft_a_link(): row[&"dynamic_cost"] = bad + var live: Dictionary = _runtime.create_snapshot() + _expect(not _runtime.restore_snapshot(tampered) and _runtime.create_snapshot() == live, "v2 save with shaft wait %d (disagreeing with its queue) is rejected atomically" % bad) var a_route_during_rush: int = _runtime.path_service.calculate_path_cost(idle) for actor_id: StringName in riders: _runtime.elevators.cancel_actor(actor_id) - _expect(int(_runtime.path_service.get_connector(a_connector)[&"dynamic_cost"]) == 0, "cancelled rush clears the shaft wait") - var a_route_after: int = _runtime.path_service.calculate_path_cost(idle) - _expect(a_route_after == a_route_during_rush - 12 * PathService.ORTHOGONAL_MOVE_COST, "cleared shaft wait removes exactly the queued riders' cost from shaft A's route") - _expect(rush_cost > 0, "rush route cost recorded") + _expect(int(_runtime.path_service.get_connector(_shaft_a_link())[&"dynamic_cost"]) == 0, "cancelled rush clears the shaft wait") + _expect(_runtime.path_service.calculate_path_cost(idle) == a_route_during_rush - 12 * PathService.ORTHOGONAL_MOVE_COST, "cleared shaft wait removes exactly the queued riders' cost from shaft A's route") + _expect(_runtime.restore_snapshot(saved), "rewind to the rush state") -func _test_shared_cabin() -> void: - var before: Dictionary = _runtime.create_snapshot() - var riders: Array[StringName] = [] - for i: int in 6: - var guest_id := StringName("share_%02d" % i) - if _spawn(guest_id, Vector3i(4, 0, 0)): - var actor_id := StringName("guest:%s" % guest_id) - _expect(bool(_runtime.elevators.request_trip(actor_id, Vector3i(4, 0, 0), Vector3i(4, 1, 0), _clock.absolute_minute).get(&"ok", false)), "rider %s queues on shaft B" % guest_id) - riders.append(actor_id) - var max_boarded := 0 - var arrived := {} - for minute: int in 60: +func _register_walker(staff_id: StringName, cell: Vector3i) -> bool: + var registered := _cmd(&"register_staff", {&"staff_id": staff_id, &"role_id": &"porter", &"options": { + &"current_cell": cell, &"state": StaffRuntime.State.OFF_DUTY, &"role_tags": [&"porter"], + &"permitted_task_tags": [&"work"], &"skills": {&"operation": 50}}}) + var station: FacilityInstance = null + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"security_station": station = facility + var minute: int = posmod(_clock.absolute_minute, 1440) + var shift := _cmd(&"create_staff_shift", {&"staff_id": staff_id, &"template_id": &"FLEX", &"start_day": _clock.absolute_minute / 1440, + &"start_minute_of_day": minute, &"end_minute_of_day": mini(minute + 600, 1439), &"facility_id": station.instance_id}) + _expect(registered.is_success() and shift.is_success(), "register real walker %s at %s %s %s" % [staff_id, cell, registered.message, shift.message]) + return registered.is_success() and shift.is_success() + + +## Sends the walker to GOAL through an ordinary Staff task; returns visited cells. +func _walk(staff_id: StringName, task_id: StringName) -> Array: + var created := _cmd(&"create_staff_task", {&"task_id": task_id, &"task_type": &"inspect", &"priority": 90, &"options": { + &"target_cell": GOAL, &"required_role_tags": [&"porter"], &"task_tags": [&"work"], &"estimated_minutes": 1}}) + _expect(created.is_success(), "walk task %s created %s" % [task_id, created.message]) + var visited: Array = [] + for minute: int in 180: _clock.advance_minutes(1) - var boarded := 0 - for trip: Dictionary in _runtime.elevators.get_report()[&"trips"]: - if trip[&"shaft_id"] != _shaft_b() or trip[&"state"] != PathService.ELEVATOR_REQUEST_BOARDED: - continue - boarded += 1 - max_boarded = maxi(max_boarded, boarded) - for actor_id: StringName in riders: - if _runtime.elevators.get_actor_trip(actor_id).is_empty(): - arrived[actor_id] = true - _expect(max_boarded > 1 and max_boarded <= 4, "riders share the cabin up to its capacity of 4 (max %d)" % max_boarded) - _expect(arrived.size() == riders.size(), "all six riders are carried (none lost)") - _expect(_runtime.restore_snapshot(before), "rewind shared-cabin drill") + var worker: StaffRuntime = _runtime.staff_service.get_staff(staff_id) + if worker == null: + break + if visited.is_empty() or visited.back() != worker.current_cell: + visited.append(worker.current_cell) + if worker.current_cell == GOAL: + break + return visited -func _shaft_b() -> StringName: +func _test_worker_walks_detour() -> void: + # Still in the rush state saved above: shaft A has 12 riders queued. + var before: Dictionary = _runtime.create_snapshot() + if not _register_walker(&"walker_rush", START): + return + var visited := _walk(&"walker_rush", &"walk_rush") + _expect(not visited.is_empty() and visited.back() == GOAL, "real walker reaches the goal during the rush") + _expect(visited.has(Vector3i(4, 0, 0)) and visited.has(Vector3i(4, 1, 0)), "walker physically rides shaft B (%s)" % [visited]) + _expect(not visited.has(Vector3i(0, 1, 0)), "walker never rides the congested shaft A") + _expect(_runtime.restore_snapshot(before), "rewind walker drill") + + +func _shaft_id_at(x: int, layer: int) -> StringName: for shaft: Dictionary in _runtime.elevators.get_report()[&"shafts"]: - if (shaft[&"stops"] as Array).front().x == 4: + var bottom: Vector3i = (shaft[&"stops"] as Array).front() + if bottom.x == x and bottom.z == layer: return shaft[&"shaft_id"] return &"" +func _test_shared_cabin() -> void: + var before: Dictionary = _runtime.create_snapshot() + for trip: Dictionary in _runtime.elevators.get_report()[&"trips"]: + _runtime.elevators.cancel_actor(StringName(trip[&"actor_id"])) + var riders: Array[StringName] = [] + for i: int in 6: + var guest_id := StringName("share_%02d" % i) + if not _spawn(guest_id, Vector3i(4, 0, 0)): + continue + # Real guest with a floor-1 destination; the Runtime's own path travel + # requests the elevator ride (no test-side trip injection). + var guest: GuestRuntime = _runtime.guest_service.get_guest(guest_id) + guest.path_handle = {&"target_cell": Vector3i(4, 1, 0)} + _expect(bool(_runtime.guest_service.transition_guest(guest_id, GuestRuntime.State.ENTERING, _clock.absolute_minute, &"TEST_ENTRY").get(&"ok", false)) + and bool(_runtime.guest_service.transition_guest(guest_id, GuestRuntime.State.MOVING, _clock.absolute_minute, &"TEST_DESTINATION").get(&"ok", false)), "rider %s heads for floor 1" % guest_id) + riders.append(StringName("guest:%s" % guest_id)) + var requested := {} + var leaver: StringName = &"" + var shaft_b := _shaft_id_at(4, 0) + var max_boarded := 0 + var arrival_cell := {} + for minute: int in 60: + _clock.advance_minutes(1) + var boarded := 0 + for trip: Dictionary in _runtime.elevators.get_report()[&"trips"]: + if trip[&"shaft_id"] == shaft_b and trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED: + boarded += 1 + if leaver.is_empty(): + # A boarded guest told to leave (patience/closing) must ride on. + leaver = StringName(trip[&"actor_id"]) + _expect(bool(_runtime.guest_service.begin_leaving(StringName(String(leaver).trim_prefix("guest:")), _clock.absolute_minute, &"TEST_LEAVE_WHILE_ABOARD").get(&"ok", false)), "boarded rider %s starts leaving mid-ride" % leaver) + max_boarded = maxi(max_boarded, boarded) + for actor_id: StringName in riders: + if not _runtime.elevators.get_actor_trip(actor_id).is_empty(): + requested[actor_id] = true + continue + if arrival_cell.has(actor_id) or not requested.has(actor_id): + continue + var guest: GuestRuntime = _runtime.guest_service.get_guest(StringName(String(actor_id).trim_prefix("guest:"))) + arrival_cell[actor_id] = guest.current_cell if guest != null else null + _expect(max_boarded > 1 and max_boarded <= 4, "riders share the cabin up to its capacity of 4 (max %d)" % max_boarded) + _expect(requested.size() == riders.size(), "the Runtime requested a ride for every rider") + var arrived_at := -1 + var despawned_at := -1 + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if not (leaver in event.entity_ids or StringName(String(leaver).trim_prefix("guest:")) in event.entity_ids): + continue + if event.event_type == &"ELEVATOR_ARRIVED" and event.payload.get(&"cell") == Vector3i(4, 1, 0): + arrived_at = event.absolute_minute + if event.event_type == &"GUEST_DESPAWNED": + despawned_at = event.absolute_minute + _expect(not leaver.is_empty() and arrived_at >= 0 and (despawned_at < 0 or despawned_at >= arrived_at), "a rider leaving mid-ride rides on and alights at the floor-1 landing before it can leave (arrived %d, left %d)" % [arrived_at, despawned_at]) + _expect(arrival_cell.size() == riders.size(), "every rider's trip ends") + for actor_id: StringName in riders: + if actor_id == leaver: + continue + _expect(arrival_cell.get(actor_id) == Vector3i(4, 1, 0), "%s exists and stands on the floor-1 landing when the ride ends (%s)" % [actor_id, arrival_cell.get(actor_id)]) + _expect(_runtime.restore_snapshot(before), "rewind shared-cabin drill") + + func _test_route_loss() -> void: var before: Dictionary = _runtime.create_snapshot() - var shaft_b_id: StringName = &"" + for trip: Dictionary in _runtime.elevators.get_report()[&"trips"]: + _runtime.elevators.cancel_actor(StringName(trip[&"actor_id"])) + var shaft_b_facility: StringName = &"" for facility: Dictionary in _runtime.construction_service.get_facilities(): if facility[&"definition_id"] == &"guest_elevator" and facility[&"origin"] == Vector3i(4, 0, 0): - shaft_b_id = facility[&"instance_id"] - _expect(_cmd(&"demolish_facility", {&"instance_id": shaft_b_id}).is_success(), "demolish idle shaft B") + shaft_b_facility = facility[&"instance_id"] + _expect(_cmd(&"demolish_facility", {&"instance_id": shaft_b_facility}).is_success(), "demolish shaft B") _expect(_uses_shaft_a(_route()), "route loss falls back to the remaining shaft") + if _register_walker(&"walker_loss", START): + var visited := _walk(&"walker_loss", &"walk_loss") + _expect(not visited.is_empty() and visited.back() == GOAL and visited.has(Vector3i(0, 1, 0)), "real walker reaches the goal through shaft A after the loss (%s)" % [visited]) _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "post-loss state restores") _expect(_runtime.restore_snapshot(before), "rewind route-loss drill") +func _test_service_elevator_wait() -> void: + var ps: PathService = _runtime.path_service + var lower := Vector3i(6, 0, 1) + var upper := Vector3i(6, 1, 1) + var staff_only: Array[StringName] = [&"staff"] + var layers: Array[int] = [WorldService.LAYER_FRONT, WorldService.LAYER_BACK] + _expect(ps.find_path(lower, upper).is_empty() and not ps.find_path(lower, upper, layers, staff_only).is_empty(), "service shaft is staff-only") + var idle_cost: int = ps.last_path_cost + var before: Dictionary = _runtime.create_snapshot() + for i: int in 4: + var staff_id := StringName("svc_rider_%d" % i) + _expect(_cmd(&"register_staff", {&"staff_id": staff_id, &"role_id": &"porter", &"options": {&"current_cell": lower, &"state": StaffRuntime.State.OFF_DUTY, &"role_tags": [&"porter"]}}).is_success(), "register real worker %s on the service landing" % staff_id) + _expect(bool(_runtime.elevators.request_trip(StringName("staff:%s" % staff_id), lower, upper, _clock.absolute_minute, staff_only).get(&"ok", false)), "real worker %s queues on the service shaft" % staff_id) + ps.find_path(lower, upper, layers, staff_only) + _expect(ps.last_path_cost > idle_cost, "queued workers raise the service elevator's A* cost") + _expect(_runtime.restore_snapshot(before), "rewind service drill") + + +func _test_stair_congestion() -> void: + var ps: PathService = _runtime.path_service + var before: Dictionary = _runtime.create_snapshot() + _expect(_cmd(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(-1, 1, 0)}).is_success(), "build a floor-1 corridor beside shaft A") + _expect(_cmd(&"build_facility", {&"definition_id": &"guest_stairs", &"origin": Vector3i(-2, 1, 0)}).is_success(), "build stairs to floor 1") + _clock.advance_minutes(20) + var landing := Vector3i(-2, 1, 0) + var foot := Vector3i(-2, 0, 0) + var quiet: Array = ps.find_path(foot, landing) + var quiet_cost: int = ps.last_path_cost + _expect(quiet == [foot, landing], "stairs connect the two floors") + for i: int in 6: + _spawn(StringName("stair_crowd_%d" % i), landing) + ps.find_path(foot, landing) + _expect(ps.last_path_cost > quiet_cost, "a crowded stair landing raises the stair route's A* cost") + _expect(_runtime.restore_snapshot(before), "rewind stair drill") + + func _test_warp_exit_congestion() -> void: var ps: PathService = _runtime.path_service var lower := Vector3i(-4, 0, 0) @@ -186,6 +333,70 @@ func _test_warp_exit_congestion() -> void: _expect(crowd > 0 and ps.last_path_cost > quiet_cost, "a crowded warp exit raises the warp route's A* cost") +## A genuine save written by 50ceca0 (pre-P14-03, elevator snapshot v1) with 12 +## riders queued for floor 3 and every elevator dynamic_cost 0. +func _test_legacy_v1_migration() -> void: + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(LEGACY_COPY)) + for member: String in ["world.bin", "manifest.json", "thumbnail.png", "journal.log"]: + var bytes := FileAccess.get_file_as_bytes(LEGACY_FIXTURE.path_join(member)) + var file := FileAccess.open(LEGACY_COPY.path_join(member), FileAccess.WRITE) + file.store_buffer(bytes) + file.close() + var loaded := SaveService.load_snapshot(LEGACY_COPY) + _expect(loaded[&"ok"], "legacy v1 package loads from disk") + if not loaded[&"ok"]: + return + var legacy: Dictionary = loaded[&"snapshot"] + _expect(int(legacy[&"systems"][&"runtime"][&"elevators"][&"version"]) == 1, "fixture really is elevator snapshot v1") + var live: Dictionary = _runtime.create_snapshot() + var bad: Dictionary = legacy.duplicate(true) + for row: Dictionary in bad[&"systems"][&"runtime"][&"path"][&"connectors"]: + if String(row[&"id"]).begins_with("transport.") and int(row[&"dynamic_cost"]) == 0: + row[&"dynamic_cost"] = 500 + break + _expect(not _runtime.restore_snapshot(bad) and _runtime.create_snapshot() == live, "v1 save with a nonzero wait (never written by v1) is rejected atomically") + _expect(_runtime.restore_snapshot(legacy.duplicate(true)), "legacy v1 rush save migrates") + _expect(int(_runtime.path_service.get_connector(_shaft_a_link())[&"dynamic_cost"]) == 12 * PathService.ORTHOGONAL_MOVE_COST, "migration derives the shaft-wide wait from the legacy queue") + _expect(not _uses_shaft_a(_route()), "migrated rush diverts like a fresh one") + var migrated: Dictionary = _runtime.create_snapshot() + _expect(int(migrated[&"systems"][&"runtime"][&"elevators"][&"version"]) == ElevatorService.SNAPSHOT_VERSION and _runtime.restore_snapshot(migrated) and _runtime.create_snapshot() == migrated, "migrated state saves as v2 and round-trips exactly") + SaveService.delete_save(LEGACY_COPY) + + +func _write_new_pid() -> void: + _queue_rush(12) + _register_walker(&"walker_pid", START) + var snapshot: Dictionary = _runtime.create_snapshot() + var serial_at_save := _serial + _expect(SaveService.save_snapshot(PID_SAVE, snapshot)[&"ok"], "writer saves the rush state") + var visited := _walk(&"walker_pid", &"walk_pid") + _clock.advance_minutes(maxi(PID_CONTINUE - visited.size(), 0)) + var file := FileAccess.open(PID_EXPECTED, FileAccess.WRITE) + file.store_string(JSON.stringify({"writer_pid": OS.get_process_id(), "serial_at_save": serial_at_save, "visited": var_to_str(visited), + "minute": _clock.absolute_minute, "digest": SimulationDigest.compute(_runtime.create_snapshot())})) + file.close() + _expect(not visited.is_empty() and visited.back() == GOAL and visited.has(Vector3i(4, 1, 0)), "writer walker reaches the goal through shaft B") + + +func _read_new_pid() -> void: + var expected: Variant = JSON.parse_string(FileAccess.get_file_as_string(PID_EXPECTED)) + _expect(expected is Dictionary, "reader finds writer record") + if not expected is Dictionary: + return + _expect(int(expected["writer_pid"]) != OS.get_process_id(), "reader runs in a different process") + var loaded := SaveService.load_snapshot(PID_SAVE) + _expect(loaded[&"ok"] and _runtime.restore_snapshot(loaded[&"snapshot"]), "reader restores the rush save") + _expect(int(_runtime.path_service.get_connector(_shaft_a_link())[&"dynamic_cost"]) == 12 * PathService.ORTHOGONAL_MOVE_COST, "restored shaft wait matches the queue") + # Same player command sequence as the writer after its save. + _serial = int(expected["serial_at_save"]) + var visited := _walk(&"walker_pid", &"walk_pid") + _clock.advance_minutes(maxi(PID_CONTINUE - visited.size(), 0)) + _expect(var_to_str(visited) == String(expected["visited"]), "walker takes the same physical route after restart") + _expect(_clock.absolute_minute == int(expected["minute"]) and SimulationDigest.compute(_runtime.create_snapshot()) == String(expected["digest"]), "whole continued state digest matches the writer") + SaveService.delete_save(PID_SAVE) + DirAccess.remove_absolute(ProjectSettings.globalize_path(PID_EXPECTED)) + + func _expect(condition: bool, label: String) -> void: _checks += 1 if condition: