From c50136017b686994c79bdb3ecf49a9df5a465052 Mon Sep 17 00:00:00 2001 From: Agent Control Runner Date: Sat, 24 Oct 2026 05:04:48 +0900 Subject: [PATCH] =?UTF-8?q?P07-01-WX:=20=E4=BB=95=E4=B8=8A=E3=81=8C?= =?UTF-8?q?=E3=81=A3=E3=81=9F=E5=A4=A9=E5=80=99=E3=81=AE=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E5=8F=97=E5=85=A5=EF=BC=9A=E8=87=AA=E7=84=B6=E5=A4=A9?= =?UTF-8?q?=E5=80=99=E3=83=BB=E9=9B=A8=E3=83=BB=E5=B5=90=E3=83=BB=E4=B8=96?= =?UTF-8?q?=E7=95=8C=E6=A8=B9=E3=81=A8=E6=88=90=E4=BA=BA=E5=AE=A2=E3=81=AE?= =?UTF-8?q?=E3=83=9E=E3=83=8A=E9=9C=A7=E3=83=BB=E4=B8=8A=E9=83=A8=E3=81=AE?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=A8=E4=BA=88=E5=A0=B1=E3=83=BB=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E3=81=A8=E7=A7=BB=E8=A1=8C=EF=BC=88=E3=83=9E=E3=83=8A?= =?UTF-8?q?=E7=82=89=E3=81=AE=E7=94=A3=E5=87=BA=20120=20=E3=82=92=E9=99=A4?= =?UTF-8?q?=E3=81=8F=E3=80=82P07-01=20=E3=81=AE=E8=A3=9C=E5=8A=A9=E3=81=AE?= =?UTF-8?q?=E4=BD=9C=E6=A5=AD=E3=80=82=E5=8E=9F=E5=8D=98=E4=BD=8D=E3=81=B8?= =?UTF-8?q?=E3=81=AE=E5=8A=A0=E7=AE=97=200=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autoload/simulation_runtime.gd | 91 ++- .../completion_marker_contracts.json | 33 + .../execution/p07-01/EXPECTATION_CHANGES.json | 46 ++ .../execution/p07-01/IMPLEMENTATION_NOTES.md | 42 ++ .../p07-01/wx/IMPLEMENTATION_NOTES.md | 53 ++ scenes/main/main.gd | 16 + scripts/environment/natural_weather_policy.gd | 68 ++ .../environment/natural_weather_policy.gd.uid | 1 + scripts/environment/weather_timeline.gd | 168 ++++- scripts/guest/guest_service.gd | 6 +- scripts/mana/mana_reserve_cycle.gd | 33 +- scripts/operations/operations_coordinator.gd | 27 +- .../test_p07_weather_restart_1_write.gd | 86 +++ .../test_p07_weather_restart_1_write.gd.uid | 1 + .../test_p07_weather_restart_2_read.gd | 92 +++ .../test_p07_weather_restart_2_read.gd.uid | 1 + tests/environment/test_p07_weather_runtime.gd | 351 ++++++++++ .../test_p07_weather_runtime.gd.uid | 1 + tests/environment/test_runtime_weather.gd | 9 +- tests/environment/test_weather_recovery.gd | 6 +- tests/environment/test_weather_timeline.gd | 2 +- tests/helpers/p07_weather_restart_fixture.gd | 105 +++ .../p07_weather_restart_fixture.gd.uid | 1 + tools/regression/suite-policy.json | 25 + tools/run_p07_weather_tests.py | 632 ++++++++++++++++++ 25 files changed, 1868 insertions(+), 28 deletions(-) create mode 100644 docs/plans/original-completion-2026-10-02/execution/p07-01/EXPECTATION_CHANGES.json create mode 100644 docs/plans/original-completion-2026-10-02/execution/p07-01/IMPLEMENTATION_NOTES.md create mode 100644 docs/plans/original-completion-2026-10-02/execution/p07-01/wx/IMPLEMENTATION_NOTES.md create mode 100644 scripts/environment/natural_weather_policy.gd create mode 100644 scripts/environment/natural_weather_policy.gd.uid create mode 100644 tests/environment/test_p07_weather_restart_1_write.gd create mode 100644 tests/environment/test_p07_weather_restart_1_write.gd.uid create mode 100644 tests/environment/test_p07_weather_restart_2_read.gd create mode 100644 tests/environment/test_p07_weather_restart_2_read.gd.uid create mode 100644 tests/environment/test_p07_weather_runtime.gd create mode 100644 tests/environment/test_p07_weather_runtime.gd.uid create mode 100644 tests/helpers/p07_weather_restart_fixture.gd create mode 100644 tests/helpers/p07_weather_restart_fixture.gd.uid create mode 100644 tools/run_p07_weather_tests.py diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index 5e42436..7dbdafa 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -37,6 +37,7 @@ const CleaningBridge = preload("res://scripts/environment/facility_cleaning_brid const CleaningPolicy = preload("res://scripts/environment/facility_cleaning_policy.gd") const UPKEEP_RUNTIME_SNAPSHOT_VERSION := 10 const WeatherTimeline = preload("res://scripts/environment/weather_timeline.gd") +const NaturalWeather = preload("res://scripts/environment/natural_weather_policy.gd") const STAIRS_RUNTIME_SNAPSHOT_VERSION := 9 const TRANSPORT_RUNTIME_SNAPSHOT_VERSION := 8 const SUPPLY_RUNTIME_SNAPSHOT_VERSION := 7 @@ -85,6 +86,7 @@ const RNG_STREAM_IDS: Array[StringName] = [ &"defense", &"event", &"loot", + &"weather", ] const DEFENSE_COMMAND_TYPES: Array[StringName] = [ &"create_defense_raid", @@ -463,6 +465,8 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S if not report[&"valid"]: _clock.set_paused(true) definition_validation_failed.emit(report) + # A13: a new game opening at 06:00 draws its first day once streams exist. + _draw_natural_weather_if_due(_clock.absolute_minute) _publish_snapshot(_clock.absolute_minute) return report @@ -1291,6 +1295,15 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo return false elif runtime_data.has(&"weather") or not restored_weather.initialize(&"clear", restored_minute): return false + # A13 rain holds must still describe a present guest's extended plan. + var rain_holds := restored_weather.get_rain_holds() + for held_key: Variant in rain_holds: + var held_guest := restored_guests.get_guest(StringName(held_key)) + if held_guest == null or held_guest.planned_exit_at != int(rain_holds[held_key]["extended_to"]): + return false + var saved_weather: Variant = runtime_data.get(&"weather", null) + var weather_stream_required: bool = saved_weather is Dictionary \ + and int(saved_weather.get("version", 0)) >= WeatherTimeline.VERSION var restored_cleanliness := FacilityCleanliness.new() if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION: if not runtime_data.get(&"facility_cleanliness") is Dictionary or not restored_cleanliness.restore_snapshot(runtime_data[&"facility_cleanliness"]): @@ -1323,6 +1336,12 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo if has_defense_snapshot: return false restored_streams[&"defense"] = DeterministicRng.from_root_seed(restored_world_seed, &"defense") + # A13 migration: saves before natural weather start the weather stream from + # the world seed, as defense did (SPEC section 5.2). + if not restored_streams.has(&"weather"): + if weather_stream_required: + return false + restored_streams[&"weather"] = DeterministicRng.from_root_seed(restored_world_seed, &"weather") if runtime_schema_version >= DEFENSE_RUNTIME_SNAPSHOT_VERSION: for required_stream_id: StringName in RNG_STREAM_IDS: if not restored_streams.has(required_stream_id): @@ -2951,6 +2970,8 @@ func _operations_arrival_boundary( &"ok": not captured.is_empty(), &"token": captured, &"capacity_remaining": maxi(population_cap - guest_service.guest_count(), 0) if int(_state.get_resource(&"mana")) > 0 and weather.permits_new_arrivals() else 0, + # A13: rain halves the planned new arrivals of this boundary. + &"arrival_percent": NaturalWeather.arrival_percent(weather.get_kind()), } if phase == OperationsCoordinator.PHASE_VALIDATE: return { @@ -8784,6 +8805,7 @@ func _advance_guest_vitals(absolute_minute: int) -> void: &"is_night": minute_of_day < 360 or minute_of_day >= 1_080, &"intimacy_day": 3, &"intimacy_night": 7, + &"mana_fog": weather.get_kind() == &"mana_fog", } }) @@ -9035,7 +9057,7 @@ func _advance_guest_ai_decisions(absolute_minute: int) -> void: GuestRuntime.State.ENTERING: guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, absolute_minute, &"ENTRY_COMPLETED") GuestRuntime.State.DECIDING: - if guest.planned_exit_at <= absolute_minute: + if guest.planned_exit_at <= absolute_minute and not _hold_guest_for_rain(guest, absolute_minute): guest_service.begin_leaving(guest.guest_id, absolute_minute, &"PLANNED_EXIT") continue guest.wallet_gold = economy_service.get_balance(_guest_wallet_account(guest.guest_id)) @@ -10993,13 +11015,14 @@ func _reservation_references_are_valid( ## HUD view of the A06 reserve: supply, consumption and storage per interval. func get_mana_reserve_report() -> Dictionary: var demand := _mana_reserve_demand() + var generation := NaturalWeather.mana_yield(ManaReserveCycle.WORLD_TREE_GENERATION, weather.get_kind()) return { &"amount": int(_state.get_resource(&"mana")), &"capacity": ManaReserveCycle.CAPACITY, - &"generation": ManaReserveCycle.WORLD_TREE_GENERATION, + &"generation": generation, &"interval_minutes": ManaReserveCycle.INTERVAL_MINUTES, &"demand": demand, - &"net": ManaReserveCycle.WORLD_TREE_GENERATION - demand, + &"net": generation - demand, &"last_flow": mana_reserve.get_last_flow(), } @@ -11032,8 +11055,10 @@ func _advance_mana(absolute_minute: int) -> void: return # A06: generation and upkeep demand settle before local distribution and the # whole-tower gate, so the network and projection see the committed reserve. + # A13: mana fog raises the world tree yield of this boundary to x1.2 (floor). var reserve_flow := mana_reserve.advance_to( - absolute_minute, int(_state.get_resource(&"mana")), _mana_reserve_demand() + absolute_minute, int(_state.get_resource(&"mana")), _mana_reserve_demand(), + NaturalWeather.mana_yield(ManaReserveCycle.WORLD_TREE_GENERATION, weather.get_kind()) ) if not bool(reserve_flow.get(&"ok", false)): _latch_infrastructure_failure( @@ -13572,10 +13597,66 @@ func get_staff_rest_report(staff_id: StringName) -> Dictionary: func _advance_weather(absolute_minute: int) -> void: weather.advance_to(absolute_minute) + _draw_natural_weather_if_due(absolute_minute) + if not weather.get_rain_holds().is_empty(): + var present := {} + for guest: GuestRuntime in guest_service.get_guests(): + present[guest.guest_id] = true + weather.prune_rain_holds(present) + + +## A13: one draw per day at 06:00 from the saved weather stream. The weather +## boundary runs first, so the same minute's arrivals and closes see the result. +func _draw_natural_weather_if_due(absolute_minute: int) -> void: + if not NaturalWeather.is_draw_minute(absolute_minute) or weather.get_minute() != absolute_minute: + return + var day := NaturalWeather.day_of(absolute_minute) + var stream := get_rng_stream(&"weather") + if day <= weather.get_last_draw_day() or stream == null: + return + var draw := NaturalWeather.draw(stream, day) + var applied := weather.apply_draw(draw) + var no_entities: Array[StringName] = [] + _publish_actor_event(&"WEATHER_FORECAST_DRAWN", &"weather", no_entities, { + &"day": day, + &"kind": String(draw[&"kind"]), + &"roll": int(draw[&"roll"]), + &"slot": int(draw[&"slot"]), + &"start": int(draw[&"start"]), + &"end": int(draw[&"end"]), + &"applied": bool(applied.get(&"ok", false)), + &"code": String(applied.get(&"code", &"")), + }) + + +## A13: a guest due to leave while it rains stays until the rain ends, at most +## 240 minutes after the original plan, once per guest (SPEC section 4.3). +func _hold_guest_for_rain(guest: GuestRuntime, absolute_minute: int) -> bool: + if weather.get_kind() != &"rain" or not weather.get_rain_hold(guest.guest_id).is_empty(): + return false + var original := guest.planned_exit_at + var extended := original + NaturalWeather.RAIN_HOLD_MAX_MINUTES + var rain_end := weather.rain_end_minute() + if rain_end >= 0: + extended = mini(extended, rain_end) + if extended <= absolute_minute or not weather.add_rain_hold(guest.guest_id, original, extended): + return false + guest.planned_exit_at = extended + return true func get_weather_report() -> Dictionary: - return {&"kind": weather.get_kind(), &"minute": weather.get_minute()} + var kind := weather.get_kind() + return { + &"kind": kind, + &"minute": weather.get_minute(), + &"since": weather.get_since(), + &"next_change": weather.next_change(), + &"today_draw": weather.get_today_draw(), + &"effect_text": NaturalWeather.effect_text(kind), + &"arrival_percent": NaturalWeather.arrival_percent(kind), + &"rain_holds": weather.get_rain_holds().size(), + } ## Read-only migration preview; applying dimensions remains a separate transaction. 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 b1e084b..e9f4854 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 @@ -3958,6 +3958,39 @@ "path": "docs/plans/original-completion-2026-10-02/execution/p05-02/SPEC.md", "scope": "new P05-02 F6 restart reader marker; native execution pending" } + }, + { + "test": "tests/environment/test_p07_weather_runtime.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "11caac7e652213a708c270e136853b0fa053bcda502576da741f1f5d67cdd64b", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md", + "scope": "new P07-01 suite marker; native execution pending" + } + }, + { + "test": "tests/environment/test_p07_weather_restart_1_write.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "ad8067f42348b803ce7736bee2f55f4b0789fefaeaaac702ff8a8800d926d8ea", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md", + "scope": "new P07-01 F7 restart writer marker; native execution pending" + } + }, + { + "test": "tests/environment/test_p07_weather_restart_2_read.gd", + "template": "PASS: checks", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "fb0a633c08cca1de66dc549f760f2a8e822c6c14c54f58e609de7f78c22fb469", + "authority": { + "path": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md", + "scope": "new P07-01 F7 restart reader marker; native execution pending" + } } ], "unknown_roster_suite_policy": "refuse, explicitly review its source/schema before accepting", diff --git a/docs/plans/original-completion-2026-10-02/execution/p07-01/EXPECTATION_CHANGES.json b/docs/plans/original-completion-2026-10-02/execution/p07-01/EXPECTATION_CHANGES.json new file mode 100644 index 0000000..10e91d8 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p07-01/EXPECTATION_CHANGES.json @@ -0,0 +1,46 @@ +{ + "task": "P07-01", + "spec": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md", + "rule": "SPEC F8: every changed expectation of an existing suite is listed with the value before and after and the reason. Only the natural-weather contract (A13, SPEC sections 4 and 5) causes these changes.", + "changes": [ + { + "suite": "tests/environment/test_runtime_weather.gd", + "check": "old neutral save migration preserves other state", + "before": "runtime.create_snapshot() == original (the whole snapshot after migrating a version 10 save equals the original new game)", + "after": "the whole snapshot without systems.runtime.weather equals the original without it, and the migrated weather has last_draw_day -1 and no drawn day", + "reason": "A13: a new game opening at 06:00 draws its first day (SPEC 4.1), and a pre-weather save has no draw and must not draw for the past (SPEC 5.2). Only the weather section can differ; every other system stays byte-equal." + }, + { + "suite": "tests/environment/test_runtime_weather.gd", + "check": "future weather clock rejects atomically", + "before": "the refused restore leaves runtime.create_snapshot() == original", + "after": "the refused restore leaves runtime.create_snapshot() == the migrated snapshot taken just before", + "reason": "The state before this refusal is the migrated state of the previous check, which now differs from the original only in the weather section; the check still proves the refusal is atomic." + }, + { + "suite": "tests/environment/test_weather_timeline.gd", + "check": "malformed or contradictory state rejects atomically (unknown version case)", + "before": "{\"version\": 2} is an unknown version", + "after": "{\"version\": 3} is an unknown version", + "reason": "Version 2 is now the current weather snapshot (natural state and rain holds, SPEC 5.1); version 1 is read as the legacy format (SPEC 5.2)." + }, + { + "suite": "tests/environment/test_weather_recovery.gd", + "check": "disk-restored recovery occurs once at scheduled minute", + "before": "after the recovery the pending weather list is empty", + "after": "after the recovery the pending weather list equals only the opening day's natural draw (rain 16:30-22:30 for seed 424242)", + "reason": "A13: the opening day's natural draw schedules its own start and return to clear (SPEC 4.1); the manually scheduled storm and recovery are still consumed exactly once." + } + ], + "unchanged_related_suites_passing_locally": [ + "tests/mvp/test_runtime_operations_integration.gd", + "tests/s2/test_runtime_operations_cycle.gd", + "tests/s2/test_runtime_mana_reserve.gd", + "tests/s2/test_mana_reserve_cycle.gd", + "tests/s3/test_guest_service.gd", + "tests/s6/test_runtime_save_lifecycle.gd", + "tests/s6/test_runtime_snapshot_serialization.gd", + "tests/ui/test_main_dashboard.gd" + ], + "local_run_scope": "diagnostic direct runs on a scratch stage; the formal evidence is the submitted S native collection, the registered validation and the final full roster" +} diff --git a/docs/plans/original-completion-2026-10-02/execution/p07-01/IMPLEMENTATION_NOTES.md b/docs/plans/original-completion-2026-10-02/execution/p07-01/IMPLEMENTATION_NOTES.md new file mode 100644 index 0000000..18310b8 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p07-01/IMPLEMENTATION_NOTES.md @@ -0,0 +1,42 @@ +# P07-01 実装の記録(実 Opus の実装担当、2026-10-24) + +- 仕様: 同じフォルダの `SPEC.md`(固定の commit `4654789`、Runner の `spec_sha256` `2626d70b…7956`)。決定 A13(`PROVENANCE.json`)。 +- この記録は、仕様の §4・§5 を実装した場所と、実装担当として選んだ詳細、開示を書く。人の新しい承認は主張しない。 + +## 1. 実装した場所 + +- `scripts/environment/natural_weather_policy.gd`(新しい): A13 の値(確率の境 60・85・95、続く時間 360・240・120 分、開始の 29 個の 30 分刻み、雨の来客 50%、雨の延長の上限 240 分、霧のマナ ×1.2・性欲 ×1.5)と、1日1回の抽選の手順。天候の仕組みは確率を持たない方針のままにするため分けた。 +- `scripts/environment/weather_timeline.gd`(VERSION 2): 自然天候の状態(最後に抽選した日、その日の抽選)と雨の延長の記録を持つ。予定の規則(同じ分の別の種類・過去の時刻・上限)は変えない。その日の抽選の当てはめ(`apply_draw`)は、その日の 06:00 にだけ、1回だけ受け付ける。予定と重なれば `SCHEDULE_CONFLICT` で拒む。VERSION 1 の保存は、過去の抽選をさかのぼらない形で読む。 +- `autoload/simulation_runtime.gd`: + - 系列 `weather` を足した。 + - 06:00 の抽選と出来事 `WEATHER_FORECAST_DRAWN`。新しいゲームが 06:00 に始まる時は、初期化の最後に開始の日を抽選する。 + - 来客の受付の境界は、雨の割合(`arrival_percent`)を渡す。 + - 帰りの判定は、雨の延長(`_hold_guest_for_rain`)を通す。 + - マナの境界は、霧の産出を渡す。 + - 客の生理の文脈に `mana_fog` を足した。 + - 予報を含む `get_weather_report`。 + - 復元では、延長の記録と客の予定を照合し、旧保存の系列を `world_seed` から作る。 +- `scripts/operations/operations_coordinator.gd`: 来客の計画を2段にした(行ごとの受け入れと持ち越しを決め、雨なら合計を floor(×50/100) まで後ろの行から減らし、減らした分は持ち越さない)。雨でない時の計画と客の ID は今と同じ。 +- `scripts/mana/mana_reserve_cycle.gd`: 境界の世界樹の産出を引数で受ける(既定 20、霧 24)。炉の受け入れの判定と `plan` も霧の量(24・120)を受ける。保存の照合は産出 20 と 24 だけを許す。上限 300(A06)は変えない。 +- `scripts/guest/guest_service.gd`: 文脈の真偽値 `mana_fog` を検査し、成人確認済みの客の正の性欲の増分 n を n + floor(n / 2)(= floor(1.5n))にする。 +- `scenes/main/main.gd`: 上部の天候の名前の表示はそのままにし、隣に予報(`WeatherForecastLabel`、「次: 晴れ 12:00」「次: 予定なし」)を足した。天候の名前の案内(tooltip)に効果の案内を出す。 + +## 2. 実装担当として選んだ詳細(SPEC §2 のとおり人の承認ではない) + +- 抽選の乱数: 0〜99 の整数を1つ、晴れ以外は開始の枠(0〜28)を1つ引く。 +- 日の数え方: 絶対分 ÷ 1440 の切り捨て。06:00 ちょうどは、まだ抽選していない日として数える(新しいゲームの開始の日のため)。旧保存の移行では、06:00 を過ぎていればその日は抽選済みとして数える。 +- 当てはめに失敗した抽選(手で置いた予定と同じ分になる時など)は、系列を進めたまま `applied: false` と理由を出来事に残し、その日の予定は変えない。次の日の 06:00 には抽選する。 +- 雨の延長は、帰りの判定(客が「決める」状態の時)で行う。延長の後の時刻には天候に関わらず今の規則で帰る。帰った客の記録は、天候の境界で片づける。 +- 霧の性欲は、その分の正の増分だけに掛ける(減少と、成人確認の無い客は変えない)。 + +## 3. 開示 + +- マナ炉の施設と産出の経路は今のゲームに無い(P06 の未決定)。霧の炉の ×1.2(120)は、マナの貯蔵の規則(受け入れの判定と `plan`)にだけ入り、通常の営業で観測できるのは世界樹の産出(20→24)だけ。 +- 今の客の種類で性欲が増えるのは、成人確認済みの客(`adventurer` など)に Runtime が渡す昼 3・夜 7 だけなので、霧の効果は昼 4・夜 10 として表れる。 +- 予報はその日の予定だけを出す(翌日は 06:00 に決まる)。UI は今の部品のままで、完成の見た目ではない。Windows の実機は後。 +- 性能の調整はしていない。横断条件の性能の回帰は、最終の S の全 roster の既存の性能の suite の結果で見る。 +- 新しい出来事 `WEATHER_FORECAST_DRAWN` が毎日 1 件増える。出来事の番号は、その分だけ後ろへずれる。 + +## 4. 確かめ(局所。正式の証拠ではない) + +- 既存の天候の 3 本(期待値の変更は `EXPECTATION_CHANGES.json`)、関係する既存の 8 本、新しい suite(88 checks)、再開の書き手(28 checks)と読み手(47 checks、別の process)が、scratch の stage で全て合格した。正式の証拠は、提出の S の native の収集、登録の validation、最終の S の全 roster。 diff --git a/docs/plans/original-completion-2026-10-02/execution/p07-01/wx/IMPLEMENTATION_NOTES.md b/docs/plans/original-completion-2026-10-02/execution/p07-01/wx/IMPLEMENTATION_NOTES.md new file mode 100644 index 0000000..bfdb987 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p07-01/wx/IMPLEMENTATION_NOTES.md @@ -0,0 +1,53 @@ +# P07-01-WX 実装の記録(実 Opus の実装担当、2026-10-24) + +- 仕様: 同じフォルダの `SPEC.md`(main `7ac2224` に固定。Runner の `spec_sha256` は `f1cd4262…fbd783`)。出典は `PROVENANCE.json`。 +- 原本への加算は 0。原 P07-01 の F1〜F8・炉 120・`AR.weather.fog_magic`・原 88 の分母は未達のまま。 +- 管理の記録は `artifacts/original-completion-2026-10-02/p07-01-wx-control-r01/` にある(git の外)。 + +## 1. 持ち越し(SPEC §3) + +- 原 task の提出 S `dfacc83` の差分 24 ファイルを、`CARRY_OVER_DFACC83.patch`(SHA-256 `a880ea89…58f9`)で W に1回だけ当てた。 +- 当てた後、24 ファイル全部が manifest(`CARRY_OVER_DFACC83.json`)の bytes・SHA-256 と、S `dfacc83` の blob の両方に一致した(`CARRY_OVER_APPLY_R01.json`)。 +- 1回目は sandbox が W への書き込みを拒み、何も当たらなかった(W は clean のまま)。その記録を残してから当て直した。 +- 製品と試験は持ち越しのまま。変えたのは下の照合の道具だけ。 + +## 2. 照合の直し(`tools/run_p07_weather_tests.py` の `verify`) + +- 原因: + - S `dfacc83` の native の収集では、14 本の suite が全て PASS した。その後の照合が「exact controlled Guard/proof environment」で止まった。 + - 照合は `ordinary_cleanup_prepared` の suite の環境しか期待していなかった。`old_bounded_teardown` の suite(`tests/environment/test_p07_weather_runtime.gd` と `tests/ui/test_main_dashboard.gd`)には、共通の収集が `THE_TREE_AUDIO_TEARDOWN_PROOF_DIR` を渡しているのに、それを期待していなかった。 +- 直し(`VERIFY_OLD_BOUNDED_FIX_DRAFT_R01.md` のとおり): + 1. 段ごとの分類(ordinary・old_bounded)を、共通の収集(`tools/run_all_tests.py`)と同じ `phase_policy` から決める。 + 2. 期待する環境: + - ordinary の時は今のまま(`<段>-teardown/owned-audio` と `control.json`)。 + - old_bounded の時は `<段>-teardown`(共通の収集と同じ)。 + - どちらでもない時は無し。 + 3. old_bounded の suite は、共通の `teardown_gate` で証明を照合し直し、収集の記録(`old_bounded_proof`)と一致することを求める。 + 4. 照合を build の場所の写しにかけた時のために、`teardown_gate` が読む証明の path を読み替える。方法は ordinary の照合(`owners.read`)と同じ。照合の道具の中で作った module の写しだけを変え、共通の gate は変えていない。 + 5. ordinary の証明・old_bounded の証明・`<段>-teardown` のファイルが、分類された suite にだけあることを求める(import と parse の段を含む)。 +- 消した検査は無い。任意の環境は許さない。 + +## 3. 訂正 + +- 持ち越した `../IMPLEMENTATION_NOTES.md` §3 の「(P06 の未決定)」は誤り。 +- 炉の産出を通常の営業で示すのに要るものは、次の3つ(SPEC §6): + - P13 の炉の施設 + - D04 のユニットの消費 + - A06 の別承認 +- 持ち越しのファイルは bytes のまま残し、訂正はここに書く。 + +## 4. 確かめ(局所。正式の証拠ではない) + +- 直した判定を、旧 S の native の生の出力(読むだけ)に当てた(`VERIFIER_FIX_LOCAL_CHECK_R01.json`)。照合の全体は、受領が旧い道具の bytes を固定しているので、旧い出力にはかけられない。 + - 16 段の環境と分類が記録と一致した。 + - bounded の 2 本の証明が、元の場所と別の場所の写しの両方で記録と一致した。 + - 直す前の期待は、元の失敗と同じ 000・011 の段で外れる。 + - 読み替えの無い写しと、1 byte 変えた写しは拒まれる。 +- 直した道具の `--prepare-only` は exit 0(engine 0)。 +- 製品は S `dfacc83` と同じ bytes なので、engine の局所の再実行はしていない。 +- 正式の証拠は、新しい S の native の収集、登録の validation、全 roster、独立レビュー。旧 S の結果は流用しない。 + +## 5. 開示 + +- 炉 120 と `AR.weather.fog_magic` は受け入れない(SPEC §6)。 +- 他の開示は、持ち越した `../IMPLEMENTATION_NOTES.md` §2・§3(P06 の句を除く)と `../EXPECTATION_CHANGES.json` のとおり。 diff --git a/scenes/main/main.gd b/scenes/main/main.gd index f8224eb..18ce5b4 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -136,6 +136,7 @@ var rank_button: Button var rank_dialog: ConfirmationDialog var event_log_dialog: AcceptDialog var weather_label: Label +var weather_forecast_label: Label var guest_count_label: Button var dimension_migration_dialog: ConfirmationDialog var _dimension_migration_digest := "" @@ -467,6 +468,11 @@ func _create_top_bar() -> Control: weather_label.tooltip_text = "嵐の間は新規入館が停止します。現在のお客様は館内に残ります。" weather_label.mouse_filter = Control.MOUSE_FILTER_STOP row.add_child(weather_label) + # A13 forecast: the next change of today's weather (kind and time). + weather_forecast_label = _hud_value_label("", 72) + weather_forecast_label.name = "WeatherForecastLabel" + weather_forecast_label.mouse_filter = Control.MOUSE_FILTER_STOP + row.add_child(weather_forecast_label) guest_count_label = Button.new() guest_count_label.text = "来客 0人" @@ -1550,6 +1556,16 @@ func _refresh_dashboard() -> void: var weather_report: Dictionary = _runtime.get_weather_report() var weather_names := {&"clear": "晴れ", &"rain": "雨", &"storm": "嵐", &"mana_fog": "マナ霧"} weather_label.text = weather_names.get(weather_report.get(&"kind", &""), "天候 —") + var next_change: Dictionary = weather_report.get(&"next_change", {}) + var forecast := "次: 予定なし" + if not next_change.is_empty(): + var change_minute := posmod(int(next_change.get(&"minute", 0)), 1_440) + forecast = "次: %s %02d:%02d" % [weather_names.get(StringName(next_change.get(&"kind", &"")), "天候 —"), change_minute / 60, change_minute % 60] + var effect := String(weather_report.get(&"effect_text", "")) + weather_label.tooltip_text = "%s\n毎朝6時にその日の天候が決まります。" % effect if not effect.is_empty() else weather_label.tooltip_text + if weather_forecast_label != null: + weather_forecast_label.text = forecast + weather_forecast_label.tooltip_text = "%s\n%s" % [forecast, effect] var guests := _call_array(_service(&"guest_service"), &"get_guests") var queued_guests := 0 var served_guests := 0 diff --git a/scripts/environment/natural_weather_policy.gd b/scripts/environment/natural_weather_policy.gd new file mode 100644 index 0000000..5f387fd --- /dev/null +++ b/scripts/environment/natural_weather_policy.gd @@ -0,0 +1,68 @@ +extends RefCounted + +## Approved initial weather values A13 (P07-01 SPEC sections 2 and 4). The +## timeline stays a pure state model; this file owns the draw and the effects. +const DAY_MINUTES := 1440 +const DRAW_MINUTE_OF_DAY := 360 +const FIRST_START_MINUTE_OF_DAY := 360 +const START_SLOT_MINUTES := 30 +const START_SLOTS := 29 +## Rolls 0..99: clear 0-59, rain 60-84, mana fog 85-94, storm 95-99. +const CLEAR_BELOW := 60 +const RAIN_BELOW := 85 +const FOG_BELOW := 95 +const DURATION_MINUTES := {&"rain": 360, &"mana_fog": 240, &"storm": 120} +const RAIN_ARRIVAL_PERCENT := 50 +const RAIN_HOLD_MAX_MINUTES := 240 +const FOG_MANA_NUMERATOR := 12 +const FOG_MANA_DENOMINATOR := 10 +const FOG_INTIMACY_NUMERATOR := 3 +const FOG_INTIMACY_DENOMINATOR := 2 + +static func is_draw_minute(absolute_minute: int) -> bool: + return absolute_minute >= 0 and posmod(absolute_minute, DAY_MINUTES) == DRAW_MINUTE_OF_DAY + +static func day_of(absolute_minute: int) -> int: + return int(floor(float(absolute_minute) / DAY_MINUTES)) if absolute_minute >= 0 else -1 + +## One draw per day from the saved weather stream. A clear day consumes one +## roll; other days also consume one start-slot roll. +static func draw(rng: DeterministicRng, day: int) -> Dictionary: + var roll := rng.randi_range(0, 99) + var kind: StringName = &"clear" + if roll >= FOG_BELOW: + kind = &"storm" + elif roll >= RAIN_BELOW: + kind = &"mana_fog" + elif roll >= CLEAR_BELOW: + kind = &"rain" + if kind == &"clear": + return {&"day": day, &"kind": kind, &"roll": roll, &"slot": -1, &"start": -1, &"end": -1} + var slot := rng.randi_range(0, START_SLOTS - 1) + var start := day * DAY_MINUTES + FIRST_START_MINUTE_OF_DAY + slot * START_SLOT_MINUTES + return {&"day": day, &"kind": kind, &"roll": roll, &"slot": slot, + &"start": start, &"end": start + int(DURATION_MINUTES[kind])} + +static func arrival_percent(kind: StringName) -> int: + return RAIN_ARRIVAL_PERCENT if kind == &"rain" else 100 + +static func mana_yield(base_amount: int, kind: StringName) -> int: + if kind != &"mana_fog": + return base_amount + return int(floor(float(base_amount * FOG_MANA_NUMERATOR) / FOG_MANA_DENOMINATOR)) + +static func fog_intimacy_increase(increase: int) -> int: + if increase <= 0: + return increase + return int(floor(float(increase * FOG_INTIMACY_NUMERATOR) / FOG_INTIMACY_DENOMINATOR)) + +## Japanese HUD guidance for the current kind (SPEC section 4.6). +static func effect_text(kind: StringName) -> String: + match kind: + &"rain": + return "雨: 新規来客が半分になり、帰る予定の客は雨が止むまで(最大4時間)残ります。" + &"storm": + return "嵐: 新規来客はありません。館内の客は残ります。" + &"mana_fog": + return "マナ霧: 世界樹のマナ産出が1.2倍、成人確認済みの客の性欲の増え方が1.5倍になります。" + return "晴れ: 天候の影響はありません。" diff --git a/scripts/environment/natural_weather_policy.gd.uid b/scripts/environment/natural_weather_policy.gd.uid new file mode 100644 index 0000000..eb625f4 --- /dev/null +++ b/scripts/environment/natural_weather_policy.gd.uid @@ -0,0 +1 @@ +uid://bkr55x5to4cgi diff --git a/scripts/environment/weather_timeline.gd b/scripts/environment/weather_timeline.gd index ea4c0a0..10e3996 100644 --- a/scripts/environment/weather_timeline.gd +++ b/scripts/environment/weather_timeline.gd @@ -2,14 +2,25 @@ extends RefCounted ## Weather state and scheduled transitions. The caller owns generation policy; ## this model invents neither probabilities nor rain/fog balance multipliers. -const VERSION := 1 +## Version 2 also keeps the natural-weather bookkeeping (last daily draw, the +## current day's draw) and the once-per-guest rain holds; version 1 snapshots +## migrate with no draw for the past and no holds (P07-01 SPEC section 5). +const VERSION := 2 +const LEGACY_VERSION := 1 const KINDS := [&"clear", &"rain", &"storm", &"mana_fog"] const MAX_MINUTE := 2147483647 const MAX_PENDING := 4096 +const MAX_RAIN_HOLDS := 100000 +const DAY_MINUTES := 1440 +const DRAW_MINUTE_OF_DAY := 360 +const RAIN_HOLD_MAX_MINUTES := 240 var _minute := -1 var _kind: StringName = &"" var _since := -1 var _pending: Array[Dictionary] = [] +var _last_draw_day := -1 +var _today_draw: Dictionary = {} +var _rain_holds: Dictionary = {} func initialize(kind: Variant, minute: Variant) -> bool: if _minute >= 0 or not _valid_kind(kind) or not _valid_minute(minute): @@ -17,6 +28,7 @@ func initialize(kind: Variant, minute: Variant) -> bool: _minute = minute _since = minute _kind = StringName(kind) + _last_draw_day = _days_before(minute) return true func get_kind() -> StringName: @@ -25,6 +37,9 @@ func get_kind() -> StringName: func get_minute() -> int: return _minute +func get_since() -> int: + return _since + func permits_new_arrivals() -> bool: return _minute >= 0 and _kind != &"storm" @@ -55,13 +70,95 @@ func advance_to(minute: Variant) -> Dictionary: _minute = minute return {&"ok": true, &"replayed": replayed, &"changes": changes} +## Natural weather (A13): the caller draws once per day at 06:00 and applies +## the result here. A start at the current minute switches now; later starts +## and the return to clear use the ordinary schedule rules, applied together. +func get_last_draw_day() -> int: + return _last_draw_day + +func get_today_draw() -> Dictionary: + return _today_draw.duplicate(true) + +func apply_draw(draw: Dictionary) -> Dictionary: + if _minute < 0 or not _valid_draw(draw): + return {&"ok": false, &"code": &"INVALID_DRAW"} + var day: int = draw[&"day"] + if day <= _last_draw_day or _minute != day * DAY_MINUTES + DRAW_MINUTE_OF_DAY: + return {&"ok": false, &"code": &"DRAW_NOT_DUE"} + var kind := StringName(draw[&"kind"]) + var start: int = draw[&"start"] + var end: int = draw[&"end"] + var changes: Array[Dictionary] = [] + if kind != &"clear": + for row: Dictionary in _pending: + if int(row[&"minute"]) == start or int(row[&"minute"]) == end: + return {&"ok": false, &"code": &"SCHEDULE_CONFLICT"} + if _pending.size() + 2 > MAX_PENDING: + return {&"ok": false, &"code": &"SCHEDULE_FULL"} + if start == _minute: + if kind != _kind: + changes.append({&"from": _kind, &"to": kind, &"minute": start}) + _kind = kind + _since = start + else: + _pending.append({&"kind": kind, &"minute": start}) + _pending.append({&"kind": &"clear", &"minute": end}) + _pending.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a[&"minute"] < b[&"minute"]) + _last_draw_day = day + _today_draw = _copy_draw(draw) + return {&"ok": true, &"changes": changes} + +## The next scheduled change away from the current kind, for the forecast. +func next_change() -> Dictionary: + for row: Dictionary in _pending: + if row[&"kind"] != _kind: + return {&"kind": row[&"kind"], &"minute": row[&"minute"]} + return {} + +## When the current rain ends (-1 when it is not raining or no end is known). +func rain_end_minute() -> int: + if _kind != &"rain": + return -1 + for row: Dictionary in _pending: + if row[&"kind"] != &"rain": + return int(row[&"minute"]) + return -1 + +func get_rain_hold(guest_id: StringName) -> Dictionary: + var row: Variant = _rain_holds.get(String(guest_id), null) + return row.duplicate(true) if row is Dictionary else {} + +func get_rain_holds() -> Dictionary: + return _rain_holds.duplicate(true) + +func add_rain_hold(guest_id: StringName, original_exit: int, extended_to: int) -> bool: + var key := String(guest_id) + if key.is_empty() or _rain_holds.has(key) or _rain_holds.size() >= MAX_RAIN_HOLDS \ + or not _valid_hold({"original_exit": original_exit, "extended_to": extended_to}): + return false + _rain_holds[key] = {"original_exit": original_exit, "extended_to": extended_to} + return true + +## Holds of guests that have left are history; the caller passes who remains. +func prune_rain_holds(present_guest_ids: Dictionary) -> void: + for key: String in _rain_holds.keys(): + if not present_guest_ids.has(StringName(key)): + _rain_holds.erase(key) + func create_snapshot() -> Dictionary: return {&"version": VERSION, &"minute": _minute, &"kind": _kind, - &"since": _since, &"pending": _pending.duplicate(true)} + &"since": _since, &"pending": _pending.duplicate(true), + &"natural": {&"last_draw_day": _last_draw_day, &"today": _today_draw.duplicate(true)}, + &"rain_holds": _rain_holds.duplicate(true)} func restore_snapshot(source: Dictionary) -> bool: - if not _keys(source, ["version", "minute", "kind", "since", "pending"]) \ - or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \ + var version: Variant = source.get("version", source.get(&"version", null)) + if typeof(version) != TYPE_INT or version not in [LEGACY_VERSION, VERSION]: + return false + var expected := ["version", "minute", "kind", "since", "pending"] + if version == VERSION: + expected = expected + ["natural", "rain_holds"] + if not _keys(source, expected) \ or not _valid_minute(source["minute"]) or not _valid_minute(source["since"]) \ or source["since"] > source["minute"] or not _valid_kind(source["kind"]) \ or not source["pending"] is Array or source["pending"].size() > MAX_PENDING: @@ -75,12 +172,75 @@ func restore_snapshot(source: Dictionary) -> bool: return false previous = value["minute"] pending.append({&"kind": StringName(value["kind"]), &"minute": value["minute"]}) + var last_draw_day := _days_before(source["minute"]) + var today: Dictionary = {} + var holds: Dictionary = {} + if version == VERSION: + var natural: Variant = source["natural"] + if not natural is Dictionary or not _keys(natural, ["last_draw_day", "today"]) \ + or typeof(natural["last_draw_day"]) != TYPE_INT or natural["last_draw_day"] < -1 \ + or natural["last_draw_day"] > _day_of(source["minute"]) or not natural["today"] is Dictionary: + return false + last_draw_day = natural["last_draw_day"] + if not natural["today"].is_empty(): + if not _valid_draw(natural["today"]) or int(natural["today"]["day"]) != last_draw_day: + return false + today = _copy_draw(natural["today"]) + var raw_holds: Variant = source["rain_holds"] + if not raw_holds is Dictionary or raw_holds.size() > MAX_RAIN_HOLDS: + return false + for key: Variant in raw_holds: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key).is_empty() \ + or not raw_holds[key] is Dictionary or not _keys(raw_holds[key], ["original_exit", "extended_to"]) \ + or not _valid_hold(raw_holds[key]): + return false + holds[String(key)] = {"original_exit": int(raw_holds[key]["original_exit"]), + "extended_to": int(raw_holds[key]["extended_to"])} _minute = source["minute"] _kind = StringName(source["kind"]) _since = source["since"] _pending = pending + _last_draw_day = last_draw_day + _today_draw = today + _rain_holds = holds return true +## A day whose 06:00 is before the minute counts as drawn, so a new or migrated +## timeline never draws for the past (SPEC section 5.2). At exactly 06:00 the +## day is still due, which lets a new game draw its opening day. +static func _days_before(minute: int) -> int: + var day := _day_of(minute) + return day if posmod(minute, DAY_MINUTES) > DRAW_MINUTE_OF_DAY else day - 1 + +static func _day_of(minute: int) -> int: + return int(floor(float(minute) / DAY_MINUTES)) + +static func _valid_draw(draw: Variant) -> bool: + if not draw is Dictionary or not _keys(draw, ["day", "kind", "roll", "slot", "start", "end"]): + return false + for key: String in ["day", "roll", "slot", "start", "end"]: + if typeof(draw[key]) != TYPE_INT: + return false + if not _valid_kind(draw["kind"]) or draw["day"] < 0 or draw["roll"] < 0 or draw["roll"] > 99: + return false + if StringName(draw["kind"]) == &"clear": + return draw["slot"] == -1 and draw["start"] == -1 and draw["end"] == -1 + var day_start: int = draw["day"] * DAY_MINUTES + DRAW_MINUTE_OF_DAY + return draw["slot"] >= 0 and draw["start"] >= day_start and draw["start"] <= day_start + 14 * 60 \ + and draw["end"] > draw["start"] and draw["end"] < day_start + DAY_MINUTES + +static func _copy_draw(draw: Dictionary) -> Dictionary: + var result := {} + for key: String in ["day", "kind", "roll", "slot", "start", "end"]: + result[key] = draw[key] if key != "kind" else String(draw[key]) + return result + +static func _valid_hold(row: Dictionary) -> bool: + var original: Variant = row.get("original_exit", null) + var extended: Variant = row.get("extended_to", null) + return typeof(original) == TYPE_INT and typeof(extended) == TYPE_INT and original >= 0 \ + and extended > original and extended <= original + RAIN_HOLD_MAX_MINUTES + static func _valid_kind(value: Variant) -> bool: return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and StringName(value) in KINDS diff --git a/scripts/guest/guest_service.gd b/scripts/guest/guest_service.gd index 61a63ff..c14dbb8 100644 --- a/scripts/guest/guest_service.gd +++ b/scripts/guest/guest_service.gd @@ -365,6 +365,10 @@ func _update_needs_runtime( delta_medical += int(normalized_effects.get(&"medical", 0)) delta_leisure += int(normalized_effects.get(&"leisure", 0)) delta_intimacy += int(normalized_effects.get(&"intimacy", 0)) + ## A13: mana fog raises an adult-verified guest's positive intimacy increase + ## to floor(x1.5) = n + floor(n / 2); decreases and other guests are unchanged. + if definition.adult_verified and delta_intimacy > 0 and bool(_value(context, &"mana_fog", false)): + delta_intimacy += delta_intimacy >> 1 var after_hunger := clampi(before_hunger + delta_hunger, 0, 100) var after_sleep := clampi(before_sleep + delta_sleep, 0, 100) @@ -1157,7 +1161,7 @@ func _need_context_error(context: Dictionary) -> String: var raw_value: Variant = _value(context, field_id, null) if not raw_value is int or int(raw_value) < 0 or int(raw_value) > 100: return "%s must be an integer from 0 through 100." % field_id - for field_id: StringName in [&"is_night", &"work_like_activity", &"unstimulated"]: + for field_id: StringName in [&"is_night", &"work_like_activity", &"unstimulated", &"mana_fog"]: if _has_value(context, field_id) and not _value(context, field_id, null) is bool: return "%s must be boolean." % field_id if _has_value(context, &"need_effects"): diff --git a/scripts/mana/mana_reserve_cycle.gd b/scripts/mana/mana_reserve_cycle.gd index 586b809..cda513a 100644 --- a/scripts/mana/mana_reserve_cycle.gd +++ b/scripts/mana/mana_reserve_cycle.gd @@ -11,6 +11,10 @@ const CAPACITY := 300 const WORLD_TREE_GENERATION := 20 const INTERVAL_MINUTES := 10 const FURNACE_YIELD := 100 +## A13 mana fog: world tree and furnace yields x1.2 (floor) at fog boundaries. +const FOG_WORLD_TREE_GENERATION := 24 +const FOG_FURNACE_YIELD := 120 +const GENERATIONS: Array[int] = [WORLD_TREE_GENERATION, FOG_WORLD_TREE_GENERATION] const LIMIT := 1000000000 const FLOW_KEYS: Array[String] = [ "from_minute", "to_minute", "before", "generation", "furnace", "demand", @@ -35,12 +39,14 @@ func get_last_flow() -> Dictionary: ## Processes every boundary in (last boundary, minute]. Boundaries already ## processed are no-ops, so per-minute callers and same-minute retries are safe. ## Mana may rise between boundaries (event refunds, commands); anything above -## capacity is discarded and recorded as overflow at the next boundary. -func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary: +## capacity is discarded and recorded as overflow at the next boundary. The +## caller passes the world tree yield of the boundary (A13 fog: 24, else 20). +func advance_to(minute: Variant, before: Variant, demand: Variant, generation: Variant = WORLD_TREE_GENERATION) -> Dictionary: if _minute < 0: return _failure(&"NOT_STARTED") if typeof(minute) != TYPE_INT or not _amount(before) \ - or typeof(demand) != TYPE_INT or demand < 0 or demand > LIMIT or minute < _minute: + or typeof(demand) != TYPE_INT or demand < 0 or demand > LIMIT or minute < _minute \ + or typeof(generation) != TYPE_INT or generation not in GENERATIONS: return _failure(&"INVALID_FLOW") var boundary: int = minute - minute % INTERVAL_MINUTES if boundary <= _minute: @@ -49,7 +55,7 @@ func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary var flow := {} var count := 0 for to_minute: int in range(_minute + INTERVAL_MINUTES, boundary + 1, INTERVAL_MINUTES): - flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute) + flow = plan(amount, demand, 0, to_minute - INTERVAL_MINUTES, to_minute, generation) amount = flow[&"after"] count += 1 _minute = boundary @@ -57,17 +63,18 @@ func advance_to(minute: Variant, before: Variant, demand: Variant) -> Dictionary return {&"ok": true, &"processed": true, &"boundaries": count, &"after": amount, &"flow": flow.duplicate(true)} ## Furnace offers are judged after natural generation and upkeep demand are -## paid: base_after = min(C, max(0, B + G - D)); accept only with 100 free. -static func furnace_offer_accepted(before: int, demand: int) -> bool: - var base_after := mini(CAPACITY, maxi(0, before + WORLD_TREE_GENERATION - demand)) - return CAPACITY - base_after >= FURNACE_YIELD +## paid: base_after = min(C, max(0, B + G - D)); accept only with the yield free +## (100, or 120 at an A13 fog boundary where G is 24). +static func furnace_offer_accepted(before: int, demand: int, generation: int = WORLD_TREE_GENERATION, furnace_yield: int = FURNACE_YIELD) -> bool: + var base_after := mini(CAPACITY, maxi(0, before + generation - demand)) + return CAPACITY - base_after >= furnace_yield -static func plan(before: int, demand: int, furnace: int, start: int, end: int) -> Dictionary: - var available := before + WORLD_TREE_GENERATION + furnace +static func plan(before: int, demand: int, furnace: int, start: int, end: int, generation: int = WORLD_TREE_GENERATION) -> Dictionary: + var available := before + generation + furnace var consumed := mini(available, demand) var remaining := available - consumed return {&"from_minute": start, &"to_minute": end, &"before": before, - &"generation": WORLD_TREE_GENERATION, &"furnace": furnace, &"demand": demand, + &"generation": generation, &"furnace": furnace, &"demand": demand, &"consumed": consumed, &"shortage": demand - consumed, &"overflow": maxi(remaining - CAPACITY, 0), &"after": mini(remaining, CAPACITY)} @@ -105,10 +112,10 @@ func restore_snapshot(source: Variant) -> bool: if not _amount(flow[key]): return false if flow["to_minute"] != source["minute"] \ - or flow["generation"] != WORLD_TREE_GENERATION \ + or flow["generation"] not in GENERATIONS \ or flow["from_minute"] + INTERVAL_MINUTES != flow["to_minute"]: return false - var expected := plan(flow["before"], flow["demand"], flow["furnace"], flow["from_minute"], flow["to_minute"]) + var expected := plan(flow["before"], flow["demand"], flow["furnace"], flow["from_minute"], flow["to_minute"], flow["generation"]) if not _same_flow(expected, flow): return false _minute = source["minute"] diff --git a/scripts/operations/operations_coordinator.gd b/scripts/operations/operations_coordinator.gd index 61339c8..fdcb235 100644 --- a/scripts/operations/operations_coordinator.gd +++ b/scripts/operations/operations_coordinator.gd @@ -1013,6 +1013,15 @@ func _process_arrival_boundary(minute: int) -> CommandResult: &"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_INVALID" ) var external_capacity := int(capacity_value) + # A13: the arrival boundary reports the weather share of planned arrivals + # (rain 50, otherwise 100). Absent means 100 for callers without weather. + var percent_value: Variant = capture.get(&"arrival_percent", 100) + if not _is_integral_value(percent_value) or int(percent_value) < 0 or int(percent_value) > 100: + return _failed_boundary_after_capture( + _arrival_boundary, capture_payload, token, + &"ARRIVAL_PLAN", minute, PHASE_CAPTURE, &"ARRIVAL_CAPTURE_INVALID" + ) + var arrival_percent := int(percent_value) var remaining := mini( mini(local_capacity, external_capacity), int(_config[&"max_arrivals_per_interval"]) @@ -1022,6 +1031,8 @@ func _process_arrival_boundary(minute: int) -> CommandResult: var plans: Array[Dictionary] = [] var total_count := 0 var capped_rows: Array[Dictionary] = [] + var accepted_by_row: Array[int] = [] + var planned_total := 0 for row: Dictionary in intent_rows: var accepted := mini(int(row[&"desired_count"]), remaining) var key: String = row[&"key"] @@ -1034,6 +1045,21 @@ func _process_arrival_boundary(minute: int) -> CommandResult: &"guest_class": String(row[&"guest_class"]), &"discarded_numerator": retained - capped_retained, }) + accepted_by_row.append(accepted) + planned_total += accepted + remaining -= accepted + # Rain keeps floor(planned * percent / 100) arrivals; the dropped arrivals + # are lost to the weather and are not carried to the next boundary. + var dropped := planned_total - planned_total * arrival_percent / 100 + var drop_index := accepted_by_row.size() - 1 + while dropped > 0 and drop_index >= 0: + var cut := mini(dropped, accepted_by_row[drop_index]) + accepted_by_row[drop_index] -= cut + dropped -= cut + drop_index -= 1 + for row_index in intent_rows.size(): + var row: Dictionary = intent_rows[row_index] + var accepted := accepted_by_row[row_index] if accepted <= 0: continue if plan_sequence > MAX_SEQUENCE_VALUE \ @@ -1058,7 +1084,6 @@ func _process_arrival_boundary(minute: int) -> CommandResult: }) plan_sequence += 1 total_count += accepted - remaining -= accepted var payload := capture_payload.duplicate(true) payload[&"plans"] = plans diff --git a/tests/environment/test_p07_weather_restart_1_write.gd b/tests/environment/test_p07_weather_restart_1_write.gd new file mode 100644 index 0000000..a9459c5 --- /dev/null +++ b/tests/environment/test_p07_weather_restart_1_write.gd @@ -0,0 +1,86 @@ +extends SceneTree + +## P07-01 F7, writer phase. Plays the minute script, writes ordinary save files +## mid-rain, just before and after the rain end and just before and after the +## day 1 draw, and hands the exact save packages to the reader suite +## (test_p07_weather_restart_2_read.gd), which runs as a separate engine process. +## The handoff is the declared generated report res://artifacts/p07-01/restart-handoff.json +## (tools/regression/suite-policy.json fixed_generated_report_routes). + +const Fixture := preload("res://tests/helpers/p07_weather_restart_fixture.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P07-01: restart writer") + var fixtures := Fixture.new(_expect) + var straight := fixtures.new_fixture() + var straight_runtime: Variant = straight[&"runtime"] + fixtures.play(straight, Fixture.FIRST_MINUTE, Fixture.LAST_MINUTE, Callable()) + _expect(straight_runtime.weather.get_rain_hold(Fixture.HELD_GUEST) == {"original_exit": 362, "extended_to": 602} or straight_runtime.guest_service.get_guest(Fixture.HELD_GUEST) == null, + "the guest due at 06:02 was held to 10:02 (or has left since)") + _expect(straight[&"draws"][&"count"] == Fixture.DRAW_MINUTES.size() and straight_runtime.weather.get_last_draw_day() == 1, "the uninterrupted script draws day 1 once") + var saving := fixtures.new_fixture() + var saving_runtime: Variant = saving[&"runtime"] + var saved_states := {} + var saved_paths := {} + fixtures.play(saving, Fixture.FIRST_MINUTE, Fixture.LAST_MINUTE, func(minute: int) -> void: + if minute in Fixture.SAVE_MINUTES: + if minute == 500: + var held_present: bool = saving_runtime.guest_service.get_guest(Fixture.HELD_GUEST) != null + _expect(saving_runtime.weather.get_kind() == &"rain" and (not held_present or not saving_runtime.weather.get_rain_hold(Fixture.HELD_GUEST).is_empty()), "minute 500 saves mid-rain with the hold of a present guest") + var path := Fixture.WRITER_SAVE % minute + saved_states[minute] = fixtures.state_bytes(saving_runtime) + _expect(bool(saving_runtime.save_to_path(path).get(&"ok", false)), "save file at minute %d" % minute) + saved_paths[minute] = path) + _expect(fixtures.state_bytes(saving_runtime) == fixtures.state_bytes(straight_runtime), "writing the save files leaves the weather state as without saving") + var saves: Array = [] + for minute: int in Fixture.SAVE_MINUTES: + var directory := ProjectSettings.globalize_path(str(saved_paths.get(minute, Fixture.WRITER_SAVE % minute))) + _expect(DirAccess.dir_exists_absolute(directory) and DirAccess.get_directories_at(directory).is_empty(), "save package at minute %d holds only files" % minute) + var files := {} + for name: String in DirAccess.get_files_at(directory): + var bytes := FileAccess.get_file_as_bytes(directory.path_join(name)) + files[name] = {"sha256": Fixture.sha256(bytes), "bytes": bytes.size(), "base64": Marshalls.raw_to_base64(bytes)} + _expect(files.has("manifest.json") and files.has("world.bin"), "save package at minute %d has its manifest and world" % minute) + var state: PackedByteArray = saved_states.get(minute, PackedByteArray()) + saves.append({"minute": minute, "files": files, "state_sha256": Fixture.sha256(state), "state_base64": Marshalls.raw_to_base64(state)}) + var final_state := fixtures.state_bytes(straight_runtime) + var handoff := { + "schema": Fixture.HANDOFF_SCHEMA, + "writer_pid": OS.get_process_id(), + "writer_source_sha256": FileAccess.get_sha256(Fixture.WRITER_SOURCE), + "seed": Fixture.SEED, + "last_minute": Fixture.LAST_MINUTE, + "straight_final_state_sha256": Fixture.sha256(final_state), + "straight_final_state_base64": Marshalls.raw_to_base64(final_state), + "saves": saves, + } + DirAccess.make_dir_recursive_absolute(Fixture.HANDOFF_PATH.get_base_dir()) + var file := FileAccess.open(Fixture.HANDOFF_PATH, FileAccess.WRITE) + _expect(file != null, "handoff file opens for writing") + if file != null: + file.store_string(JSON.stringify(handoff, "\t") + "\n") + file.close() + print("P07_RESTART_WRITER pid=%d handoff_sha256=%s" % [OS.get_process_id(), FileAccess.get_sha256(Fixture.HANDOFF_PATH)]) + fixtures.free_fixture(straight) + fixtures.free_fixture(saving) + await process_frame + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) diff --git a/tests/environment/test_p07_weather_restart_1_write.gd.uid b/tests/environment/test_p07_weather_restart_1_write.gd.uid new file mode 100644 index 0000000..5648358 --- /dev/null +++ b/tests/environment/test_p07_weather_restart_1_write.gd.uid @@ -0,0 +1 @@ +uid://bz9x67zdqdrc6 diff --git a/tests/environment/test_p07_weather_restart_2_read.gd b/tests/environment/test_p07_weather_restart_2_read.gd new file mode 100644 index 0000000..8b9c6d8 --- /dev/null +++ b/tests/environment/test_p07_weather_restart_2_read.gd @@ -0,0 +1,92 @@ +extends SceneTree + +## P07-01 F7, reader phase (a separate engine process after the writer). Each +## handed-off save package is loaded into a fresh Runtime, must equal the writer's +## state at that minute, and must continue to the writer's uninterrupted final +## state, with every remaining 06:00 draw happening exactly once. + +const Fixture := preload("res://tests/helpers/p07_weather_restart_fixture.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P07-01: restart reader") + var handoff := _read_handoff() + if not handoff.is_empty(): + _check_restarts(handoff) + await process_frame + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _read_handoff() -> Dictionary: + _expect(FileAccess.file_exists(Fixture.HANDOFF_PATH), "the writer phase left its handoff (run test_p07_weather_restart_1_write.gd first)") + if not FileAccess.file_exists(Fixture.HANDOFF_PATH): + return {} + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(Fixture.HANDOFF_PATH)) + _expect(parsed is Dictionary and str((parsed as Dictionary).get("schema", "")) == Fixture.HANDOFF_SCHEMA, "handoff schema") + if not parsed is Dictionary: + return {} + var handoff: Dictionary = parsed + var writer_pid := int(handoff.get("writer_pid", 0)) + _expect(writer_pid > 0 and writer_pid != OS.get_process_id(), "the writer ran in another engine process") + _expect(str(handoff.get("writer_source_sha256", "")) == FileAccess.get_sha256(Fixture.WRITER_SOURCE), "the handoff comes from the current writer suite") + _expect(int(handoff.get("seed", -1)) == Fixture.SEED and int(handoff.get("last_minute", -1)) == Fixture.LAST_MINUTE, "same fixture and minute script") + print("P07_RESTART_READER pid=%d writer_pid=%d handoff_sha256=%s" % [OS.get_process_id(), writer_pid, FileAccess.get_sha256(Fixture.HANDOFF_PATH)]) + return handoff + + +func _check_restarts(handoff: Dictionary) -> void: + var fixtures := Fixture.new(_expect) + var final_state := Marshalls.base64_to_raw(str(handoff.get("straight_final_state_base64", ""))) + _expect(Fixture.sha256(final_state) == str(handoff.get("straight_final_state_sha256", "")), "the writer's final state arrived intact") + var saves: Array = handoff.get("saves", []) + _expect(saves.size() == Fixture.SAVE_MINUTES.size(), "every save package was handed off") + for save: Variant in saves: + var minute := int((save as Dictionary).get("minute", -1)) + var path := Fixture.READER_SAVE % minute + var directory := ProjectSettings.globalize_path(path) + DirAccess.make_dir_recursive_absolute(directory) + var files: Dictionary = (save as Dictionary).get("files", {}) + var intact := not files.is_empty() + for name: String in files: + var bytes := Marshalls.base64_to_raw(str(files[name].get("base64", ""))) + intact = intact and Fixture.sha256(bytes) == str(files[name].get("sha256", "")) and bytes.size() == int(files[name].get("bytes", -1)) + var file := FileAccess.open(directory.path_join(name), FileAccess.WRITE) + if file != null: + file.store_buffer(bytes) + file.close() + _expect(intact, "save package at minute %d arrived byte-exact" % minute) + var expected_state := Marshalls.base64_to_raw(str((save as Dictionary).get("state_base64", ""))) + var fixture := fixtures.new_fixture(false) + var runtime: Variant = fixture[&"runtime"] + _expect(bool(runtime.load_from_path(path).get(&"ok", false)), "the reader loads the minute %d save" % minute) + _expect(fixtures.state_bytes(runtime) == expected_state and fixture[&"clock"].absolute_minute == minute, "the minute %d save resumes the writer's weather, holds, stream and plans" % minute) + # Loading replaces the event stream, so the draw counter subscribes again. + fixture[&"draws"][&"count"] = 0 + fixtures.watch_draws(fixture) + fixtures.play(fixture, minute, Fixture.LAST_MINUTE, Callable()) + var expected_draws := 0 + for draw_minute: int in Fixture.DRAW_MINUTES: + if draw_minute > minute: + expected_draws += 1 + _expect(fixture[&"draws"][&"count"] == expected_draws, "after the minute %d save the remaining 06:00 draws happen once (%d)" % [minute, expected_draws]) + _expect(fixtures.state_bytes(runtime) == final_state, "continuing from minute %d reaches the uninterrupted final state" % minute) + fixtures.free_fixture(fixture) + SaveService.delete_save(path) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) diff --git a/tests/environment/test_p07_weather_restart_2_read.gd.uid b/tests/environment/test_p07_weather_restart_2_read.gd.uid new file mode 100644 index 0000000..794de91 --- /dev/null +++ b/tests/environment/test_p07_weather_restart_2_read.gd.uid @@ -0,0 +1 @@ +uid://bni9w8axk6f97 diff --git a/tests/environment/test_p07_weather_runtime.gd b/tests/environment/test_p07_weather_runtime.gd new file mode 100644 index 0000000..9a02c41 --- /dev/null +++ b/tests/environment/test_p07_weather_runtime.gd @@ -0,0 +1,351 @@ +extends SceneTree + +## P07-01 (rain, mana fog and natural weather) F1-F7 on the real Runtime with the +## approved initial values A13 (SPEC sections 4 and 5). F6 reads the ordinary Main +## HUD. The seeds open day 0 with clear (1000), rain (1106), mana fog (1592) and +## storm (2501) starting at 06:00; the draws are asserted, not assumed. + +const NaturalWeather := preload("res://scripts/environment/natural_weather_policy.gd") +const WeatherTimeline := preload("res://scripts/environment/weather_timeline.gd") +const ManaReserveCycle := preload("res://scripts/mana/mana_reserve_cycle.gd") +const AudioTeardown := preload("res://tests/helpers/ui_audio_teardown.gd") +const SEED_CLEAR := 1000 +const SEED_RAIN := 1106 +const SEED_FOG := 1592 +const SEED_STORM := 2501 + +var _checks := 0 +var _failures := 0 +var _fixtures: Array[Dictionary] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P07-01: natural weather runtime") + _test_policy_draws() + _test_daily_natural_draw() + _test_rain_arrivals() + _test_rain_stay_and_saves() + _test_storm() + _test_mana_fog() + _test_migration_and_rejection() + await _test_hud_forecast() + _release_fixtures() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _new_runtime(seed: int, hour := 6, minute := 0) -> Dictionary: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1, hour, minute) + state.reset() + _expect(runtime.initialize(clock, state, seed)[&"valid"], "initialize Runtime with seed %d" % seed) + clock.set_paused(true) + var fixture := {&"runtime": runtime, &"clock": clock, &"state": state} + _fixtures.append(fixture) + return fixture + + +func _release_fixtures() -> void: + for fixture: Dictionary in _fixtures: + for key: StringName in [&"runtime", &"clock", &"state"]: + if is_instance_valid(fixture[key]): + fixture[key].free() + _fixtures.clear() + + +func _drawn_events(runtime: Variant) -> Array: + var found: Array = [] + for event: SimulationDomainEvent in runtime.domain_events.recent_events(): + if event.event_type == &"WEATHER_FORECAST_DRAWN": + found.append(event.payload.duplicate(true)) + return found + + +func _spawn(runtime: Variant, guest_id: StringName, guest_type: StringName, planned_exit_at: int) -> Variant: + var result: CommandResult = runtime.submit_command(&"spawn_guest", { + &"guest_id": guest_id, + &"guest_type_id": guest_type, + &"gold": 100, + &"options": { + &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + &"needs": {&"hunger": 10, &"sleep": 10, &"hygiene": 10, &"medical": 0, &"leisure": 10, &"intimacy": 0}, + &"planned_exit_at": planned_exit_at, + }, + }, &"world", guest_id, 10, -1, StringName("p07." + String(guest_id))) + _expect(result.is_success(), "spawn %s" % guest_id) + return runtime.guest_service.get_guest(guest_id) + + +## F1: the A13 draw rule, determinism and the once-per-day timeline rule. +func _test_policy_draws() -> void: + var rng := DeterministicRng.from_root_seed(77, &"weather") as DeterministicRng + var counts := {&"clear": 0, &"rain": 0, &"mana_fog": 0, &"storm": 0} + var rule_ok := true + for day in range(2000): + var draw: Dictionary = NaturalWeather.draw(rng, day) + var kind: StringName = draw[&"kind"] + counts[kind] += 1 + var roll: int = draw[&"roll"] + var expected_kind: StringName = &"clear" if roll < 60 else (&"rain" if roll < 85 else (&"mana_fog" if roll < 95 else &"storm")) + rule_ok = rule_ok and kind == expected_kind and roll >= 0 and roll <= 99 + if kind == &"clear": + rule_ok = rule_ok and draw[&"slot"] == -1 and draw[&"start"] == -1 and draw[&"end"] == -1 + else: + var offset: int = int(draw[&"start"]) - day * 1440 - 360 + rule_ok = rule_ok and offset >= 0 and offset <= 14 * 60 and offset % 30 == 0 and offset / 30 == int(draw[&"slot"]) \ + and int(draw[&"end"]) - int(draw[&"start"]) == int(NaturalWeather.DURATION_MINUTES[kind]) + _expect(rule_ok, "every draw maps rolls 0-59/60-84/85-94/95-99 to clear/rain/fog/storm, starts 06:00-20:00 in 30-minute steps and lasts 6/4/2 hours") + _expect(counts[&"clear"] > 1100 and counts[&"clear"] < 1300 and counts[&"rain"] > 400 and counts[&"rain"] < 600 + and counts[&"mana_fog"] > 140 and counts[&"mana_fog"] < 260 and counts[&"storm"] > 60 and counts[&"storm"] < 140, + "2000 draws follow 60/25/10/5 percent (%s)" % str(counts)) + var left := DeterministicRng.from_root_seed(5, &"weather") as DeterministicRng + var right := DeterministicRng.from_root_seed(5, &"weather") as DeterministicRng + var same := true + for day in range(60): + same = same and NaturalWeather.draw(left, day) == NaturalWeather.draw(right, day) + _expect(same, "the same seed draws the same weather list") + var timeline := WeatherTimeline.new() + _expect(timeline.initialize(&"clear", 360) and timeline.get_last_draw_day() == -1, "a timeline opened at 06:00 still owes that day's draw") + var rain := {&"day": 0, &"kind": &"rain", &"roll": 70, &"slot": 2, &"start": 420, &"end": 780} + _expect(timeline.apply_draw(rain)[&"ok"] and timeline.get_last_draw_day() == 0 and timeline.next_change() == {&"kind": &"rain", &"minute": 420}, "a drawn rain is scheduled with its return to clear") + _expect(timeline.apply_draw(rain)[&"code"] == &"DRAW_NOT_DUE", "a second draw for the same day is refused") + var later := WeatherTimeline.new() + later.initialize(&"clear", 361) + _expect(later.apply_draw(rain)[&"code"] == &"DRAW_NOT_DUE", "a draw outside its 06:00 is refused") + + +## F1: one draw at 06:00 on the saved stream, recorded as WEATHER_FORECAST_DRAWN. +func _test_daily_natural_draw() -> void: + var opening := _new_runtime(SEED_RAIN) + var runtime: Variant = opening[&"runtime"] + var today: Dictionary = runtime.weather.get_today_draw() + _expect(String(today.get("kind", "")) == "rain" and int(today.get("start", -1)) == 360 and int(today.get("end", -1)) == 720, "seed 1106 opens day 0 with rain 06:00-12:00 (%s)" % str(today)) + _expect(runtime.weather.get_kind() == &"rain" and runtime.weather.get_last_draw_day() == 0, "a start at the draw minute switches at once") + var events := _drawn_events(runtime) + _expect(events.size() == 1 and int(events[0][&"day"]) == 0 and String(events[0][&"kind"]) == "rain" and bool(events[0][&"applied"]), "the opening draw is recorded once") + runtime.call("_draw_natural_weather_if_due", 360) + _expect(_drawn_events(runtime).size() == 1, "the same minute does not draw again") + var twin: Variant = _new_runtime(SEED_RAIN)[&"runtime"] + _expect(twin.weather.create_snapshot() == runtime.weather.create_snapshot() + and twin.get_rng_stream(&"weather").snapshot() == runtime.get_rng_stream(&"weather").snapshot(), "the same seed gives the same weather and stream") + var early := _new_runtime(SEED_RAIN, 5, 50) + var early_runtime: Variant = early[&"runtime"] + _expect(early_runtime.weather.get_today_draw().is_empty() and _drawn_events(early_runtime).is_empty(), "a game opened before 06:00 waits for the boundary") + early[&"clock"].advance_minutes(10) + _expect(early_runtime.weather.get_today_draw() == today and early_runtime.weather.get_kind() == &"rain", "the 06:00 boundary draws the same day from the same stream") + var daily := _new_runtime(SEED_CLEAR) + var daily_runtime: Variant = daily[&"runtime"] + _expect(String(daily_runtime.weather.get_today_draw().get("kind", "")) == "clear" and daily_runtime.weather.get_kind() == &"clear", "seed 1000 opens day 0 clear") + var seen := {&"count": 0} + daily_runtime.domain_events.subscribe(&"WEATHER_FORECAST_DRAWN", &"p07_test", func(_event: SimulationDomainEvent) -> void: seen[&"count"] += 1) + daily[&"clock"].advance_minutes(1439) + _expect(seen[&"count"] == 0 and daily_runtime.weather.get_last_draw_day() == 0, "no draw before the next 06:00") + daily[&"clock"].advance_minutes(1) + _expect(seen[&"count"] == 1 and daily_runtime.weather.get_last_draw_day() == 1 and int(daily_runtime.weather.get_today_draw().get("day", -1)) == 1, "the next 06:00 draws day 1 once") + + +## F2: rain halves the planned new arrivals of a boundary; the dropped ones are not carried. +func _test_rain_arrivals() -> void: + var clear_side := _new_runtime(SEED_CLEAR) + var rain_side := _new_runtime(SEED_CLEAR) + var clear_runtime: Variant = clear_side[&"runtime"] + var rain_runtime: Variant = rain_side[&"runtime"] + _expect(clear_runtime.enable_production_operations()[&"ok"] and rain_runtime.enable_production_operations()[&"ok"], "enable production arrivals on both sides") + _expect(rain_runtime.weather.schedule(&"rain", 361)[&"ok"] and rain_runtime.weather.schedule(&"clear", 1200)[&"ok"], "rain from 06:01 on one side only") + var compared := false + for _minute in range(300): + clear_side[&"clock"].advance_minutes(1) + rain_side[&"clock"].advance_minutes(1) + var clear_reports: Array = clear_runtime.operations_coordinator.get_arrival_reports() + var rain_reports: Array = rain_runtime.operations_coordinator.get_arrival_reports() + # Only boundaries inside the rain (from 06:01) are compared. + if clear_reports.is_empty() or int(clear_reports[-1][&"minute"]) <= 360 or int(clear_reports[-1][&"total_count"]) < 2: + continue + var minute: int = clear_reports[-1][&"minute"] + var clear_total: int = clear_reports[-1][&"total_count"] + _expect(not rain_reports.is_empty() and int(rain_reports[-1][&"minute"]) == minute + and int(rain_reports[-1][&"total_count"]) == clear_total / 2, "rain plans floor(%d / 2) arrivals at minute %d" % [clear_total, minute]) + var carry_same := true + for profile: Dictionary in clear_runtime.operations_coordinator.get_demand_profiles(): + carry_same = carry_same and clear_runtime.operations_coordinator.get_arrival_carry(profile[&"facility_tag"], profile[&"guest_class"], profile[&"time_band"]) \ + == rain_runtime.operations_coordinator.get_arrival_carry(profile[&"facility_tag"], profile[&"guest_class"], profile[&"time_band"]) + _expect(carry_same, "arrivals lost to rain are not carried to the next boundary") + compared = true + break + _expect(compared, "an arrival boundary with at least two arrivals was compared") + var rain_capture: Dictionary = rain_runtime.call("_operations_arrival_boundary", {}, OperationsCoordinator.PHASE_CAPTURE, null) + var clear_capture: Dictionary = clear_runtime.call("_operations_arrival_boundary", {}, OperationsCoordinator.PHASE_CAPTURE, null) + _expect(int(rain_capture[&"arrival_percent"]) == 50 and int(clear_capture[&"arrival_percent"]) == 100, "the arrival boundary reports the rain share") + + +## F3 and F7 (same process): a guest due to leave in rain stays once, up to the rain end or 240 minutes. +func _test_rain_stay_and_saves() -> void: + var rainy := _new_runtime(SEED_RAIN) + var runtime: Variant = rainy[&"runtime"] + var guest: Variant = _spawn(runtime, &"p07_rain_guest", &"traveler", 362) + # Spawned 06:00, entering 06:01, deciding 06:02, first exit check at 06:03. + rainy[&"clock"].advance_minutes(3) + var hold: Dictionary = runtime.weather.get_rain_hold(&"p07_rain_guest") + _expect(hold == {"original_exit": 362, "extended_to": 602} and guest.planned_exit_at == 602, "a guest due at 06:02 in rain stays to 10:02 (+240, before the rain end 12:00) (%s)" % str(hold)) + _expect(guest.exit_reason != &"PLANNED_EXIT", "the held guest did not leave at its first plan") + _expect(not runtime.call("_hold_guest_for_rain", guest, 400), "a guest is held only once") + var late: Variant = _spawn(runtime, &"p07_late_guest", &"traveler", 650) + _expect(runtime.call("_hold_guest_for_rain", late, 650) and late.planned_exit_at == 720, "a guest due at 10:50 stays to the rain end 12:00") + var saved: Dictionary = runtime.create_snapshot() + _expect(runtime.restore_snapshot(saved) and runtime.create_snapshot() == saved, "rain, holds and the stream restore exactly in the same process") + var path := "user://tests/p07_weather_%d" % OS.get_process_id() + var weather_before: Dictionary = runtime.weather.create_snapshot() + var stream_before: Dictionary = runtime.get_rng_stream(&"weather").snapshot() + _expect(bool(runtime.save_to_path(path).get(&"ok", false)) and bool(runtime.load_from_path(path).get(&"ok", false)) + and runtime.weather.create_snapshot() == weather_before and runtime.get_rng_stream(&"weather").snapshot() == stream_before + and runtime.guest_service.get_guest(&"p07_late_guest").planned_exit_at == 720, "an ordinary save file restores rain, holds and the weather stream") + SaveService.delete_save(path) + # The ordinary save operation records its own lifecycle; later checks compare with the loaded state. + saved = runtime.create_snapshot() + for bad_hold: Dictionary in [{"original_exit": 362, "extended_to": 603}, {"original_exit": 362, "extended_to": 362}]: + var malformed := saved.duplicate(true) + malformed[&"systems"][&"runtime"][&"weather"][&"rain_holds"]["p07_rain_guest"] = bad_hold + _expect(not runtime.restore_snapshot(malformed) and runtime.create_snapshot() == saved, "a hold beyond 240 minutes or not extending is refused atomically (%s)" % str(bad_hold)) + var ghost := saved.duplicate(true) + ghost[&"systems"][&"runtime"][&"weather"][&"rain_holds"]["nobody"] = {"original_exit": 400, "extended_to": 500} + _expect(not runtime.restore_snapshot(ghost) and runtime.create_snapshot() == saved, "a hold of a missing guest is refused atomically") + var unmatched := saved.duplicate(true) + unmatched[&"systems"][&"runtime"][&"weather"][&"rain_holds"]["p07_late_guest"] = {"original_exit": 650, "extended_to": 700} + _expect(not runtime.restore_snapshot(unmatched) and runtime.create_snapshot() == saved, "a hold that differs from the guest plan is refused atomically") + var clear_runtime: Variant = _new_runtime(SEED_CLEAR)[&"runtime"] + var dry: Variant = _spawn(clear_runtime, &"p07_dry_guest", &"traveler", 361) + _expect(not clear_runtime.call("_hold_guest_for_rain", dry, 361) and dry.planned_exit_at == 361, "no hold without rain") + _expect(clear_runtime.weather.schedule(&"storm", 361)[&"ok"], "schedule a storm") + clear_runtime.weather.advance_to(361) + _expect(not clear_runtime.call("_hold_guest_for_rain", dry, 361), "no hold in a storm") + + +## F4: natural storm keeps zero new arrivals. +func _test_storm() -> void: + var stormy := _new_runtime(SEED_STORM) + var runtime: Variant = stormy[&"runtime"] + _expect(runtime.weather.get_kind() == &"storm" and String(runtime.weather.get_today_draw().get("kind", "")) == "storm" and int(runtime.weather.get_today_draw().get("end", -1)) == 480, "seed 2501 opens day 0 with a natural storm 06:00-08:00") + _expect(runtime.enable_production_operations()[&"ok"], "enable production arrivals in the storm") + var capture: Dictionary = runtime.call("_operations_arrival_boundary", {}, OperationsCoordinator.PHASE_CAPTURE, null) + _expect(int(capture[&"capacity_remaining"]) == 0, "a natural storm exposes zero admission capacity") + var spawned := {&"count": 0} + runtime.guest_service.guest_spawned.connect(func(_guest: GuestRuntime) -> void: spawned[&"count"] += 1) + stormy[&"clock"].advance_minutes(119) + _expect(spawned[&"count"] == 0 and runtime.weather.get_kind() == &"storm", "no new guest enters during the natural storm") + stormy[&"clock"].advance_minutes(1) + _expect(runtime.weather.get_kind() == &"clear", "the storm returns to clear at 08:00") + + +## F5: mana fog x1.2 world tree and furnace yield (floor), adult intimacy increase x1.5 (floor). +func _test_mana_fog() -> void: + var foggy := _new_runtime(SEED_FOG) + var fog_runtime: Variant = foggy[&"runtime"] + var clear_side := _new_runtime(SEED_CLEAR) + var clear_runtime: Variant = clear_side[&"runtime"] + _expect(fog_runtime.weather.get_kind() == &"mana_fog" and int(fog_runtime.weather.get_today_draw().get("end", -1)) == 600, "seed 1592 opens day 0 with mana fog 06:00-10:00") + _expect(int(fog_runtime.get_mana_reserve_report()[&"generation"]) == 24 and int(clear_runtime.get_mana_reserve_report()[&"generation"]) == 20, "the reserve report shows yield 24 in fog and 20 in clear") + foggy[&"clock"].advance_minutes(10) + clear_side[&"clock"].advance_minutes(10) + _expect(int(fog_runtime.mana_reserve.get_last_flow().get(&"generation", -1)) == 24 and int(clear_runtime.mana_reserve.get_last_flow().get(&"generation", -1)) == 20, "the 06:10 boundary adds 24 in fog and 20 in clear") + var fog_saved: Dictionary = fog_runtime.create_snapshot() + _expect(fog_runtime.restore_snapshot(fog_saved) and fog_runtime.create_snapshot() == fog_saved, "a fog boundary flow restores") + var reserve := ManaReserveCycle.new() + var bad_reserve: Dictionary = fog_saved[&"systems"][&"runtime"][&"mana_reserve"].duplicate(true) + bad_reserve[&"last_flow"][&"generation"] = 23 + _expect(not reserve.restore_snapshot(bad_reserve), "a yield that is neither 20 nor 24 is refused") + _expect(ManaReserveCycle.furnace_offer_accepted(160, 0) and not ManaReserveCycle.furnace_offer_accepted(160, 0, ManaReserveCycle.FOG_WORLD_TREE_GENERATION, ManaReserveCycle.FOG_FURNACE_YIELD), "a fog boundary judges the furnace with yield 24 and 120") + _expect(int(ManaReserveCycle.plan(100, 0, ManaReserveCycle.FOG_FURNACE_YIELD, 0, 10, ManaReserveCycle.FOG_WORLD_TREE_GENERATION)[&"after"]) == 244 and int(ManaReserveCycle.plan(200, 0, 120, 0, 10, 24)[&"after"]) == 300, "a fog furnace adds 120 and capacity 300 stays") + foggy[&"clock"].advance_minutes(230) + _expect(fog_runtime.weather.get_kind() == &"clear" and int(fog_runtime.mana_reserve.get_last_flow().get(&"generation", -1)) == 20, "after the fog the yield is 20 again") + var adult: Variant = _spawn(fog_runtime, &"p07_adult", &"adventurer", 2000) + var minor: Variant = _spawn(fog_runtime, &"p07_other", &"traveler", 2000) + var fog_report: Dictionary = fog_runtime.guest_service.update_needs(&"p07_adult", {&"intimacy_day": 3, &"mana_fog": true}) + var plain_report: Dictionary = fog_runtime.guest_service.update_needs(&"p07_adult", {&"intimacy_day": 3, &"mana_fog": false}) + var other_report: Dictionary = fog_runtime.guest_service.update_needs(&"p07_other", {&"intimacy_day": 3, &"mana_fog": true}) + var down_report: Dictionary = fog_runtime.guest_service.update_needs(&"p07_adult", {&"need_effects": {&"intimacy": -4}, &"mana_fog": true}) + _expect(int(fog_report[&"deltas"]["intimacy"]) == 4 and int(plain_report[&"deltas"]["intimacy"]) == 3, "fog raises an adult-verified increase of 3 to floor(4.5) = 4") + _expect(int(other_report[&"deltas"]["intimacy"]) == 0 and int(down_report[&"deltas"]["intimacy"]) == -4, "fog leaves guests without adult verification and decreases unchanged") + _expect(not fog_runtime.guest_service.update_needs(&"p07_adult", {&"mana_fog": 1})[&"ok"], "a non-boolean fog context is refused") + var vital_fog := _new_runtime(SEED_FOG) + var vital_clear := _new_runtime(SEED_CLEAR) + var fog_guest: Variant = _spawn(vital_fog[&"runtime"], &"p07_fog_adult", &"adventurer", 2000) + var clear_guest: Variant = _spawn(vital_clear[&"runtime"], &"p07_clear_adult", &"adventurer", 2000) + vital_fog[&"clock"].advance_minutes(1) + vital_clear[&"clock"].advance_minutes(1) + _expect(int(fog_guest.needs[&"intimacy"]) == 4 and int(clear_guest.needs[&"intimacy"]) == 3, "the ordinary minute raises intimacy by 4 in fog and 3 in clear (day)") + _expect(adult != null and minor != null, "fog guests exist") + + +## F1 and F7: a pre-natural save migrates without a past draw; malformed natural state is refused. +func _test_migration_and_rejection() -> void: + var opened := _new_runtime(SEED_RAIN) + var runtime: Variant = opened[&"runtime"] + opened[&"clock"].advance_minutes(5) + var saved: Dictionary = runtime.create_snapshot() + var legacy := saved.duplicate(true) + var legacy_weather: Dictionary = legacy[&"systems"][&"runtime"][&"weather"] + legacy_weather.erase(&"natural") + legacy_weather.erase(&"rain_holds") + legacy_weather[&"version"] = 1 + legacy[&"systems"][&"runtime"][&"rng_streams"].erase("weather") + _expect(runtime.restore_snapshot(legacy), "a version 1 weather save without the weather stream loads") + _expect(runtime.weather.get_kind() == &"rain" and runtime.weather.get_last_draw_day() == 0 and runtime.weather.get_today_draw().is_empty() and runtime.weather.get_rain_holds().is_empty(), "the migration keeps the current weather and does not draw for the past") + _expect(runtime.get_rng_stream(&"weather").snapshot() == DeterministicRng.from_root_seed(SEED_RAIN, &"weather").snapshot(), "the migrated weather stream starts from the world seed") + var migrated: Dictionary = runtime.create_snapshot() + var missing_stream := saved.duplicate(true) + missing_stream[&"systems"][&"runtime"][&"rng_streams"].erase("weather") + _expect(not runtime.restore_snapshot(missing_stream) and runtime.create_snapshot() == migrated, "a natural-weather save without its stream is refused atomically") + var wrong_day := saved.duplicate(true) + wrong_day[&"systems"][&"runtime"][&"weather"][&"natural"][&"last_draw_day"] = 1 + _expect(not runtime.restore_snapshot(wrong_day) and runtime.create_snapshot() == migrated, "a draw day after the saved minute is refused atomically") + var unknown_kind := saved.duplicate(true) + unknown_kind[&"systems"][&"runtime"][&"weather"][&"natural"][&"today"]["kind"] = "snow" + _expect(not runtime.restore_snapshot(unknown_kind) and runtime.create_snapshot() == migrated, "an unknown drawn kind is refused atomically") + var overlap := saved.duplicate(true) + overlap[&"systems"][&"runtime"][&"weather"][&"pending"] = [{"kind": "storm", "minute": 700}, {"kind": "clear", "minute": 700}] + _expect(not runtime.restore_snapshot(overlap) and runtime.create_snapshot() == migrated, "overlapping weather changes are refused atomically") + + +## F6: the ordinary Main HUD shows the weather, the forecast and the effect guidance. +func _test_hud_forecast() -> void: + var clock: Variant = root.get_node("GameClock") + var state: Variant = root.get_node("GameState") + var runtime: Variant = root.get_node("SimulationRuntime") + clock.reset(1, 6, 0) + state.reset() + _expect(runtime.initialize(clock, state, SEED_RAIN)[&"valid"], "initialize the ordinary Runtime with seed 1106") + clock.set_paused(true) + var main := load("res://scenes/main/main.tscn").instantiate() as Control + main.set("manual_save_path", "user://tests/p07_weather_main_%d/manual_ui" % OS.get_process_id()) + root.add_child(main) + await process_frame + await process_frame + main.call("_refresh_dashboard") + var weather_label := main.get("weather_label") as Label + var forecast_label := main.get("weather_forecast_label") as Label + var report: Dictionary = runtime.get_weather_report() + _expect(weather_label.text == "雨" and report[&"kind"] == &"rain", "HUD shows the Runtime rain") + _expect(forecast_label != null and forecast_label.text == "次: 晴れ 12:00" and report[&"next_change"] == {&"kind": &"clear", &"minute": 720}, "HUD forecast shows the next change from the Runtime (%s)" % (forecast_label.text if forecast_label != null else "none")) + _expect(String(report[&"effect_text"]) in weather_label.tooltip_text and "新規来客が半分" in weather_label.tooltip_text, "HUD guidance explains the rain effect") + clock.advance_minutes(360) + main.call("_refresh_dashboard") + _expect(weather_label.text == "晴れ" and forecast_label.text == "次: 予定なし" and "影響はありません" in weather_label.tooltip_text, "HUD follows the end of the rain") + var teardown: Dictionary = await AudioTeardown.free_owner_and_wait(self, main) + _expect(bool(teardown.get(&"passed", false)) and not bool(teardown.get(&"deadline_reached", true)), "owned audio references release before bounded teardown deadline with whole Runtime unchanged") + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if condition: + return + _failures += 1 + push_error(message) diff --git a/tests/environment/test_p07_weather_runtime.gd.uid b/tests/environment/test_p07_weather_runtime.gd.uid new file mode 100644 index 0000000..b3af7c2 --- /dev/null +++ b/tests/environment/test_p07_weather_runtime.gd.uid @@ -0,0 +1 @@ +uid://bsaoksmz5s26w diff --git a/tests/environment/test_runtime_weather.gd b/tests/environment/test_runtime_weather.gd index b0cc358..1396757 100644 --- a/tests/environment/test_runtime_weather.gd +++ b/tests/environment/test_runtime_weather.gd @@ -20,10 +20,15 @@ func run() -> void: legacy[&"systems"][&"runtime"].erase(&"weather") legacy[&"systems"][&"runtime"].erase(&"facility_cleanliness") expect(runtime.restore_snapshot(legacy) and runtime.weather.get_kind() == &"clear" and runtime.weather.get_minute() == 360, "v10 migrates to neutral weather at saved minute") - expect(runtime.create_snapshot() == original, "old neutral save migration preserves other state") + # P07-01 (A13): the original carries the opening day's natural draw, which a + # pre-weather save never had; every other system must stay byte-equal. + var migrated: Dictionary = runtime.create_snapshot() + var migrated_rest: Dictionary = migrated.duplicate(true); migrated_rest[&"systems"][&"runtime"].erase(&"weather") + var original_rest: Dictionary = original.duplicate(true); original_rest[&"systems"][&"runtime"].erase(&"weather") + expect(migrated_rest == original_rest and runtime.weather.get_last_draw_day() == -1 and runtime.weather.get_today_draw().is_empty(), "old neutral save migration preserves other state") var malformed := original.duplicate(true) malformed[&"systems"][&"runtime"][&"weather"][&"minute"] = 361 - expect(not runtime.restore_snapshot(malformed) and runtime.create_snapshot() == original, "future weather clock rejects atomically") + expect(not runtime.restore_snapshot(malformed) and runtime.create_snapshot() == migrated, "future weather clock rejects atomically") malformed = original.duplicate(true) malformed[&"systems"][&"runtime"].erase(&"weather") expect(not runtime.restore_snapshot(malformed), "new schema cannot silently lose weather") diff --git a/tests/environment/test_weather_recovery.gd b/tests/environment/test_weather_recovery.gd index 969f26c..04c558c 100644 --- a/tests/environment/test_weather_recovery.gd +++ b/tests/environment/test_weather_recovery.gd @@ -32,7 +32,11 @@ func run() -> void: var completed: Dictionary = runtime.create_snapshot() expect(runtime.restore_snapshot(completed) and runtime.create_snapshot() == completed, "completed retry restores exactly") clock.advance_minutes(30) - expect(runtime.weather.get_kind() == &"clear" and runtime.weather.create_snapshot()[&"pending"].is_empty(), "disk-restored recovery occurs once at scheduled minute") + # P07-01 (A13): only the opening day's natural draw may remain scheduled. + var today: Dictionary = runtime.weather.get_today_draw() + var natural_pending: Array = [] if String(today.get("kind", "clear")) == "clear" else [ + {&"kind": StringName(today["kind"]), &"minute": int(today["start"])}, {&"kind": &"clear", &"minute": int(today["end"])}] + expect(runtime.weather.get_kind() == &"clear" and runtime.weather.create_snapshot()[&"pending"] == natural_pending, "disk-restored recovery occurs once at scheduled minute") expect(not runtime.get_operations_report()[&"runtime_failure_latched"], "recovery resumes ordinary operations") expect(SaveService.delete_save(path), "delete isolated test save") runtime.free(); clock.free(); state.free() diff --git a/tests/environment/test_weather_timeline.gd b/tests/environment/test_weather_timeline.gd index a63f440..4420f2d 100644 --- a/tests/environment/test_weather_timeline.gd +++ b/tests/environment/test_weather_timeline.gd @@ -32,7 +32,7 @@ func _initialize() -> void: for field: String in ["version", "minute", "kind", "since", "pending"]: var bad := baseline.duplicate(true); bad.erase(field) expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "missing field rejects atomically: " + field) - for change: Dictionary in [{"version": 2}, {"minute": 380.0}, {"kind": "snow"}, {"since": 381}, {"pending": [{"kind":"clear","minute":380}]}, {"pending": [{"kind":"clear","minute":400},{"kind":"rain","minute":390}]}, {"extra":true}]: + for change: Dictionary in [{"version": 3}, {"minute": 380.0}, {"kind": "snow"}, {"since": 381}, {"pending": [{"kind":"clear","minute":380}]}, {"pending": [{"kind":"clear","minute":400},{"kind":"rain","minute":390}]}, {"extra":true}]: var bad := baseline.duplicate(true) bad.merge(change, true) expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "malformed or contradictory state rejects atomically") diff --git a/tests/helpers/p07_weather_restart_fixture.gd b/tests/helpers/p07_weather_restart_fixture.gd new file mode 100644 index 0000000..ac41066 --- /dev/null +++ b/tests/helpers/p07_weather_restart_fixture.gd @@ -0,0 +1,105 @@ +extends RefCounted + +## P07-01 F7: the fixture and minute script shared by the restart writer +## (tests/environment/test_p07_weather_restart_1_write.gd) and reader +## (tests/environment/test_p07_weather_restart_2_read.gd), which run as separate +## engine processes. Seed 1106 opens day 0 with rain 06:00-12:00 (A13), a guest +## due at 06:02 is held to 10:02, production arrivals run, and day 1 draws at 06:00. +const HANDOFF_PATH := "res://artifacts/p07-01/restart-handoff.json" +const HANDOFF_SCHEMA := "p07-01-restart-handoff-v1" +const WRITER_SOURCE := "res://tests/environment/test_p07_weather_restart_1_write.gd" +const WRITER_SAVE := "user://p07_restart_write/minute_%d" +const READER_SAVE := "user://p07_restart_read/minute_%d" +const SEED := 1106 +const FIRST_MINUTE := 360 +# Mid-rain, just before and just after the rain end, just before and just after the day 1 draw. +const SAVE_MINUTES: Array[int] = [500, 719, 720, 1799, 1800] +const LAST_MINUTE := 1830 +const DRAW_MINUTES: Array[int] = [1800] +const HELD_GUEST := &"p07_restart_guest" + +var _expect: Callable + + +func _init(expect: Callable) -> void: + _expect = expect + + +func new_fixture(with_guest := true) -> Dictionary: + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1, 6, 0) + state.reset() + _expect.call(bool(runtime.initialize(clock, state, SEED).get(&"valid", false)), "fixture Runtime initializes") + clock.set_paused(true) + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _expect.call(bool(runtime.enable_production_operations().get(&"ok", false)), "fixture enables production Operations") + var fixture := {&"clock": clock, &"state": state, &"runtime": runtime, &"draws": {&"count": 0}} + if with_guest: + var result: CommandResult = runtime.submit_command(&"spawn_guest", { + &"guest_id": HELD_GUEST, + &"guest_type_id": &"traveler", + &"gold": 100, + &"options": { + &"current_cell": Vector3i(0, 0, WorldService.LAYER_FRONT), + &"needs": {&"hunger": 10, &"sleep": 10, &"hygiene": 10, &"medical": 0, &"leisure": 10, &"intimacy": 0}, + &"planned_exit_at": 362, + }, + }, &"world", HELD_GUEST, 10, -1, &"p07.restart.guest") + _expect.call(result.is_success(), "fixture spawns the guest due at 06:02") + watch_draws(fixture) + return fixture + + +func watch_draws(fixture: Dictionary) -> void: + var draws: Dictionary = fixture[&"draws"] + fixture[&"runtime"].domain_events.subscribe(&"WEATHER_FORECAST_DRAWN", &"p07_restart", + func(_event: SimulationDomainEvent) -> void: draws[&"count"] += 1) + + +func free_fixture(fixture: Dictionary) -> void: + for key: StringName in [&"runtime", &"clock", &"state"]: + if fixture.get(key) != null and is_instance_valid(fixture[key]): + fixture[key].free() + + +## Advances one minute at a time after `after_minute` through `through_minute` +## and calls `on_minute` after each minute (the writer saves there). +func play(fixture: Dictionary, after_minute: int, through_minute: int, on_minute: Callable) -> void: + var clock: Variant = fixture[&"clock"] + _expect.call(clock.absolute_minute == after_minute, "play starts at minute %d" % after_minute) + for minute: int in range(after_minute + 1, through_minute + 1): + clock.advance_minutes(1) + if on_minute.is_valid(): + on_minute.call(minute) + + +## The weather-facing state compared across processes: weather and its stream, +## guest plans, the reserve flow and the arrival totals. +func state_value(runtime: Variant) -> Dictionary: + var plans := {} + for guest: GuestRuntime in runtime.guest_service.get_guests(): + plans[String(guest.guest_id)] = guest.planned_exit_at + var arrivals := 0 + for report: Dictionary in runtime.operations_coordinator.get_arrival_reports(): + arrivals += int(report[&"total_count"]) + return { + "minute": runtime.weather.get_minute(), + "weather": runtime.weather.create_snapshot(), + "stream": runtime.get_rng_stream(&"weather").snapshot(), + "plans": plans, + "reserve_flow": runtime.mana_reserve.get_last_flow(), + "arrivals": arrivals, + } + + +func state_bytes(runtime: Variant) -> PackedByteArray: + return var_to_bytes(state_value(runtime)) + + +static func sha256(bytes: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(bytes) + return context.finish().hex_encode() diff --git a/tests/helpers/p07_weather_restart_fixture.gd.uid b/tests/helpers/p07_weather_restart_fixture.gd.uid new file mode 100644 index 0000000..3af0d77 --- /dev/null +++ b/tests/helpers/p07_weather_restart_fixture.gd.uid @@ -0,0 +1 @@ +uid://bczrqi1qiv5zk diff --git a/tools/regression/suite-policy.json b/tools/regression/suite-policy.json index 7ddba83..bf3e27a 100644 --- a/tools/regression/suite-policy.json +++ b/tools/regression/suite-policy.json @@ -438,6 +438,28 @@ "old_bounded_teardown": false, "new_audio_QA": false } +, + "tests/environment/test_p07_weather_runtime.gd": { + "source_sha256": "11caac7e652213a708c270e136853b0fa053bcda502576da741f1f5d67cdd64b", + "ordinary_cleanup_prepared": false, + "ordinary_control_labels": [], + "old_bounded_teardown": true, + "new_audio_QA": false + }, + "tests/environment/test_p07_weather_restart_1_write.gd": { + "source_sha256": "ad8067f42348b803ce7736bee2f55f4b0789fefaeaaac702ff8a8800d926d8ea", + "ordinary_cleanup_prepared": false, + "ordinary_control_labels": [], + "old_bounded_teardown": false, + "new_audio_QA": false + }, + "tests/environment/test_p07_weather_restart_2_read.gd": { + "source_sha256": "fb0a633c08cca1de66dc549f760f2a8e822c6c14c54f58e609de7f78c22fb469", + "ordinary_cleanup_prepared": false, + "ordinary_control_labels": [], + "old_bounded_teardown": false, + "new_audio_QA": false + } }, "outside_routes": { "tests/supply/test_normal_auto_purchase_chain.gd": { @@ -474,6 +496,9 @@ "tests/economy/test_p05_funds_restart_1_write.gd": [ "artifacts/p05-02/restart-handoff.json" ], + "tests/environment/test_p07_weather_restart_1_write.gd": [ + "artifacts/p07-01/restart-handoff.json" + ], "tests/mvp/test_runtime_upkeep.gd": [ "artifacts/upkeep-2026-09-29/preclose-snapshot.txt", "artifacts/upkeep-2026-09-29/closed-snapshot.txt" diff --git a/tools/run_p07_weather_tests.py b/tools/run_p07_weather_tests.py new file mode 100644 index 0000000..baf56b2 --- /dev/null +++ b/tools/run_p07_weather_tests.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +"""Root-owned narrow P07-01 (natural weather, rain, mana fog, storm, HUD forecast; A13) collection using the +unchanged guarded phase protocol of tools/run_p05_funds_tests.py (itself the protocol of +tools/run_p18_relationship_tests.py). After the roster, the F7 restart writer and reader suites run as two more +phases (separate engine processes); the writer's only declared generated report is the save handoff the reader loads.""" +import argparse +import hashlib +import json +import os +import re +import stat +from pathlib import Path +import shutil +import sys +import types +import uuid + +sys.dont_write_bytecode = True +import run_all_tests as shared + +ROSTER = [ + 'tests/environment/test_p07_weather_runtime.gd', + 'tests/environment/test_runtime_weather.gd', + 'tests/environment/test_weather_timeline.gd', + 'tests/environment/test_weather_recovery.gd', + 'tests/mvp/test_runtime_operations_integration.gd', + 'tests/s2/test_runtime_operations_cycle.gd', + 'tests/s2/test_runtime_mana_reserve.gd', + 'tests/s2/test_mana_reserve_cycle.gd', + 'tests/s3/test_guest_service.gd', + 'tests/s6/test_runtime_save_lifecycle.gd', + 'tests/s6/test_runtime_snapshot_serialization.gd', + 'tests/ui/test_main_dashboard.gd', +] +# P07-01 F7 (SPEC section 8): the writer saves, the reader in the next phase's engine resumes. +RESTART = [ + 'tests/environment/test_p07_weather_restart_1_write.gd', + 'tests/environment/test_p07_weather_restart_2_read.gd', +] +HANDOFF = 'artifacts/p07-01/restart-handoff.json' +SUITES = ROSTER + RESTART +WRITER_LINE = r'(?m)^P07_RESTART_WRITER pid=([0-9]+) handoff_sha256=([0-9a-f]{64})$' +READER_LINE = r'(?m)^P07_RESTART_READER pid=([0-9]+) writer_pid=([0-9]+) handoff_sha256=([0-9a-f]{64})$' + + +def collect(args, root, out, refs, markers, contracts, policy, contract_path, contract_sha, parse_paths): + source_bytes = sum(pin['bytes'] for pin in refs.values()) + free = shutil.disk_usage(out.parent).free + needed = source_bytes + shared.MIN_SPACE + shared.require(free >= needed, 'fresh source and phase disk admission') + outer = shared.module('p07_owned_outer', shared.SUPPORT / 'owned_outer.py') + shared.require(not outer.native.helper['live_engines'](), 'another Godot owns CPU') + engine = shutil.which('godot') + shared.require(engine and Path(engine).is_file(), 'native launcher exists') + engine = str(Path(engine).resolve()) + out.mkdir(parents=True, exist_ok=False) + stage = out / 'stage' + stage.mkdir() + (out / 'starters').mkdir() + (out / 'bootstrap').mkdir() + for name, pin in shared.read(shared.SUPPORT / 'bootstrap-scripts.json').items(): + data = pin['text'].encode('utf-8') + shared.require(hashlib.sha256(data).hexdigest() == pin['sha256'], 'unchanged external bootstrap bytes') + (out / 'bootstrap' / name).write_bytes(data) + protected = shared.default_player(root) + expected = {name: {'bytes': pin['bytes'], 'sha256': pin['sha256']} for name, pin in refs.items()} + selected = {'root_before': refs, 'map': {name: pin['sha256'] for name, pin in refs.items()}, 'timeout': args.timeout} + for name, pin in refs.items(): + target = stage / name + target.parent.mkdir(parents=True, exist_ok=True) + shared.copy_source(Path(pin['path']), target) + shared.require(shared.inventory(stage, stage=True) == expected, 'complete private source copy') + public = (stage / 'project.godot').read_bytes() + shared.write(out / 'parse-paths.json', parse_paths) + run_id = uuid.uuid4().hex + shared.write(out / 'runner-input.json', dict(protocol='guarded-p07-01-v1', run_id=run_id, + owner_runner_pid=os.getpid(), project_root=str(root), stage=str(stage), roster=ROSTER, + restart_phases=RESTART, restart_handoff=HANDOFF, + selected_source_map=selected['map'], marker_contract_sha256=contract_sha, + runner_sha256=shared.sha(Path(__file__)), shared_runner_sha256=shared.sha(shared.HERE / 'run_all_tests.py'), + marker_module_sha256=shared.sha(shared.HERE / 'strict_test_markers.py'), + engine=engine, engine_sha256=shared.sha(engine), default_player_before=protected, + formal_acceptance=False, original_unit_complete=False)) + shared.write(out / 'source-start.json', refs) + diagnostics = shared.module('p07_diagnostics', shared.SUPPORT / 'diagnostic_gate.py') + owners = shared.module('p07_owners', shared.SUPPORT / 'ordinary_proof_gate.py') + audio = shared.module('p07_audio', shared.SUPPORT / 'audio_evidence_gate.py') + phases, rows, previous = [], [], {} + error = None + try: + for phase, test in [('import', None), ('parse', None)] + [(str(i).zfill(3), name) for i, name in enumerate(SUITES)]: + shared.require(shared.sha(contract_path) == contract_sha, 'marker contract unchanged') + row = shared.execute_phase(engine, stage, out, phase, test, selected, expected, previous, + policy, contracts, parse_paths, public, protected, root, outer, markers, diagnostics, owners, audio) + archive_profiles(out, row) + phases.append(row) + if test: + row.update(sequence=len(rows), godot=engine) + rows.append(row) + (out / 'all-tests.json').write_text(json.dumps(rows, ensure_ascii=False, indent=2) + '\n') + print(('PASS ' if row['passed'] else 'FAIL ') + test, flush=True) + shared.require(row['passed'], 'first failed phase stops without retry: ' + phase) + except BaseException as caught: + error = type(caught).__name__ + ': ' + str(caught) + finally: + cleanup, cleanup_errors = outer.finish_captured(outer.OWNED) + guard_errors = [] + try: + shared.require(shared.source_refs(root) == refs, 'whole source unchanged') + shared.require(shared.sha(contract_path) == contract_sha, 'marker contract unchanged at end') + shared.require(shared.default_player(root) == protected, 'default profile untouched') + shared.require(shared.inventory(stage, stage=True) == dict(expected, **previous), 'whole stage exact') + except BaseException as caught: + guard_errors.append(type(caught).__name__ + ': ' + str(caught)) + ended = bool(outer.OWNED) and all(row['engine_ended'] is True for row in cleanup) + passed = error is None and not cleanup_errors and not guard_errors and ended and len(phases) == len(SUITES) + 2 and all(row['passed'] for row in phases) + context = shared.totals(rows, SUITES) + shared.write(out / 'runner-ended.json', dict(context, protocol='guarded-p07-01-v1', run_id=run_id, + owner_runner_pid=os.getpid(), passed=passed, engine_ended=ended, + actual_captured_PIDs=[process.pid for process in outer.OWNED], final_owned_cleanup=cleanup, + cleanup_errors=cleanup_errors, guard_errors=guard_errors, error=error, phases=phases, + all_tests_sha256=shared.sha(out / 'all-tests.json') if (out / 'all-tests.json').exists() else None, + default_player_after=shared.default_player(root), formal_acceptance=False, + original_unit_complete=False, independent_review='pending', native_Windows='pending')) + if passed: + seal(out, refs, engine, contract_sha) + # Root also exercises precisely the verifier that ordinary Runner uses. + verify(out, refs, markers, contracts, policy, contract_sha, parse_paths) + print(json.dumps(dict(context, passed=passed, error=error, cleanup_errors=cleanup_errors, guard_errors=guard_errors)), flush=True) + return 0 if passed else 1 + + + +# Receipt file names are always relative; absolute paths in native receipts are +# historical identity strings only and are never opened by the verifier. +def require(ok, reason): + shared.require(ok, reason) + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'), + ensure_ascii=False).encode()).hexdigest() + + +def content_map(refs): + return {name: {'bytes': pin['bytes'], 'sha256': pin['sha256']} + for name, pin in refs.items()} + + +def plain_path(path): + path = Path(path) + require('..' not in path.parts, 'parent traversal refused') + absolute = Path(os.path.abspath(path)) + for part in [absolute, *absolute.parents]: + require(not part.is_symlink(), 'symlink path refused: ' + str(part)) + return absolute + + +def confined(out, name): + require(type(name) is str and name and '\\' not in name, 'ordinary relative evidence name') + path = Path(name) + require(not path.is_absolute() and '..' not in path.parts + and path.as_posix() == name and name != '.', 'evidence path escape or alias') + result = plain_path(out / path) + require(result.is_relative_to(out), 'evidence confined to output') + return result + + +def inventory(out): + result = {} + require(out.is_dir(), 'output directory required') + for directory, dirs, files in os.walk(out, followlinks=False): + for name in dirs + files: + p = Path(directory) / name + mode = p.lstat().st_mode + require(stat.S_ISREG(mode) or stat.S_ISDIR(mode), 'nonordinary evidence refused') + if stat.S_ISREG(mode): + require(p.stat().st_nlink == 1, 'hardlinked evidence refused') + result[p.relative_to(out).as_posix()] = { + 'bytes': p.stat().st_size, 'sha256': shared.sha(p)} + return result + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, 'duplicate JSON key') + result[key] = value + return result + + +def read_json(path): + return json.loads(path.read_text(encoding='utf-8'), object_pairs_hook=unique_object, + parse_constant=lambda token: require(False, 'nonfinite JSON ' + token)) + + +def archive_profiles(out, row): + for label in ('startup', 'runtime'): + value = row.get('whole_' + label + '_profile_after') + if value is None: + continue + dest = out / 'profiles' / row['phase'] / label + dest.mkdir(parents=True, exist_ok=False) + source = plain_path(value['path']) + require(shared.inventory(source) == value['files'], 'whole owned profile before archive') + for name in value['files']: + target = confined(out, str((dest / name).relative_to(out))) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / name, target) + require(shared.inventory(dest) == value['files'] == shared.inventory(source), + 'whole owned profile archive exact') + + +def seal(out, refs, engine, contract_sha): + # Last file written: an interrupted collection has no complete receipt. + shutil.copyfile(engine, out / 'engine.bin') + require(shared.sha(out / 'engine.bin') == shared.sha(engine), 'engine archive exact') + source = content_map(refs) + shared.write(out / 'receipt.json', { + 'schema': 'guarded-p07-01-v1', 'source_map': source, + 'parse_checks': len([name for name in refs if name.endswith('.gd')]), + 'source_map_sha256': digest(source), + 'project_settings_sha256': source['project.godot']['sha256'], + 'marker_contract_sha256': contract_sha, + 'engine_sha256': shared.sha(engine), + 'files': inventory(out), + }) + + +def cleanup_gate(value): + require(value['owned_child_created'] is True and value['engine_ended'] is True + and value['actual_process_exit_code'] == 0 and value['errors'] == [], + 'successful actual owned cleanup') + attempts = value['attempts'] + require(1 <= len(attempts) <= 3, 'bounded cleanup attempts') + for index, row in enumerate(attempts): + require(row['attempt'] == index + 1 and row['errors'] == [] + and row['wait_timeout_seconds'] == 5 and row['wait_completed'] is True + and row['wait_returned_code'] == 0 and row['poll_after'] == 0, + 'complete bounded owned wait') + + +def outer_cleanup(rows, pids): + require([r['pid'] for r in rows] == pids, 'exact captured cleanup PID order') + for row in rows: + require(row['engine_ended'] is True and row['outer_cleanup_errors'] == [] + and row['foreign_process_or_group_signals'] == 0 + and row['outer_maximum_helper_invocations'] == 3 + and row['helper_maximum_waits'] == 3 and row['per_wait_timeout_seconds'] == 5, + 'unchanged outer cleanup contract') + cleanup_gate(row['cleanup']) + + +def verify(out, refs, markers, contracts, policy, contract_sha, parse_paths): + receipt_path = confined(out, 'receipt.json') + require(receipt_path.is_file(), 'regular final receipt required') + manifest = read_json(receipt_path) + require(manifest['schema'] == 'guarded-p07-01-v1', 'current receipt schema required') + files = manifest['files'] + require(type(files) is dict and files, 'complete raw manifest') + for name, pin in files.items(): + p = confined(out, name) + require(p.is_file() and set(pin) == {'bytes', 'sha256'} + and type(pin['bytes']) is int and pin['bytes'] >= 0 + and re.fullmatch('[0-9a-f]{64}', pin['sha256']) is not None, + 'ordinary artifact pin') + actual = inventory(out) + receipt_pin = actual.pop('receipt.json') + require(actual == files, 'complete raw file set and SHA-256 equality') + source = content_map(refs) + require(manifest['source_map'] == source and manifest['source_map_sha256'] == digest(source), + 'current whole source content map independent of checkout path') + require(manifest['project_settings_sha256'] == source['project.godot']['sha256'] + and manifest['marker_contract_sha256'] == contract_sha, 'current project/marker pins') + + def read(name): + require(name in files, 'required raw file missing: ' + name) + return read_json(confined(out, name)) + + def text(name): + require(name in files, 'required raw transport missing: ' + name) + return confined(out, name).read_text(encoding='utf-8') + + start, end = read('runner-input.json'), read('runner-ended.json') + require(start['protocol'] == end['protocol'] == 'guarded-p07-01-v1' + and start['run_id'] == end['run_id'] + and re.fullmatch('[0-9a-f]{32}', start['run_id']) is not None, + 'same current run') + require(type(start['owner_runner_pid']) is int and start['owner_runner_pid'] > 0 + and start['owner_runner_pid'] == end['owner_runner_pid'], 'same collector owner') + require(start['roster'] == ROSTER and start['restart_phases'] == RESTART + and start['restart_handoff'] == HANDOFF and len(set(SUITES)) == len(SUITES), + 'exact unique roster and restart tests') + require(start['selected_source_map'] == {n: p['sha256'] for n, p in source.items()} + and content_map(read('source-start.json')) == source, 'whole selected source pins') + for field, name in [('runner_sha256', 'tools/run_p07_weather_tests.py'), + ('shared_runner_sha256', 'tools/run_all_tests.py'), + ('marker_module_sha256', 'tools/strict_test_markers.py')]: + require(start[field] == source[name]['sha256'], 'current collector/shared/marker pin') + require(start['marker_contract_sha256'] == contract_sha, 'marker contract pin') + # Resolve only the locally configured launcher, never the receipt's engine path. + engine = shutil.which('godot') + require(engine and shared.sha(engine) == start['engine_sha256'] + == manifest['engine_sha256'] == files['engine.bin']['sha256'], + 'current native engine and retained bytes pinned') + derived_paths = ['res://' + name for name in sorted(refs) if name.endswith('.gd')] + require(read('parse-paths.json') == parse_paths == derived_paths + and manifest['parse_checks'] == len(derived_paths), + 'all exact source parse paths and receipt count') + require(end['passed'] is True and end['engine_ended'] is True and end['error'] is None + and end['cleanup_errors'] == [] and end['guard_errors'] == [], + 'finished successful collection without guard/cleanup error') + for receipt in (start, end): + require(receipt['formal_acceptance'] is False and receipt['original_unit_complete'] is False, + 'backend evidence never completes original unit') + before = start['default_player_before'] + require(before == end['default_player_after'] and before['logs_and_locks_included'] is True + and type(before['existed']) is bool and type(before['files']) is dict, + 'complete default player before/after equality') + for name, pin in before['files'].items(): + confined(out, name) # Validate the inventory name; never open the player. + require(set(pin) == {'bytes', 'sha256'} and type(pin['bytes']) is int + and pin['bytes'] >= 0 and re.fullmatch('[0-9a-f]{64}', pin['sha256']), + 'whole default player file pin') + require(before['existed'] or before['files'] == {}, 'absent player has no files') + + native = shared.module('p07_verify_native', shared.SUPPORT / 'owned_diagnostic_process.py') + diagnostics = shared.module('p07_verify_diagnostics', shared.SUPPORT / 'diagnostic_gate.py') + owners = shared.module('p07_verify_owners', shared.SUPPORT / 'ordinary_proof_gate.py') + audio = shared.module('p07_verify_audio', shared.SUPPORT / 'audio_evidence_gate.py') + # Helpers are loaded for their pure gates only. No run/probe/cleanup is called. + origin = Path(start['stage']).parent + require(Path(start['stage']) == origin / 'stage' and origin.is_absolute() + and '..' not in origin.parts, 'original output identity') + + def relocated(value): + if isinstance(value, dict): + return {relocated(k): relocated(v) for k, v in value.items()} + if isinstance(value, list): + return [relocated(v) for v in value] + if isinstance(value, str) and value.startswith(str(origin) + '/'): + return str(confined(out, value[len(str(origin)) + 1:])) + return value + + def proof_read(path): + path = plain_path(path) + require(path.is_relative_to(out), 'proof path stays in output') + return relocated(read(path.relative_to(out).as_posix())) + + owners.read = proof_read + # The bounded teardown gate parses its owner proof itself and pins the snapshots by their recorded + # absolute paths; this private module instance reads them relocated, as the ordinary gate does. + audio.json = types.SimpleNamespace(loads=lambda data: relocated(json.loads(data))) + phases = end['phases'] + require(len(phases) == len(SUITES) + 2, 'exact import, parse, roster and restart phases') + tests = read('all-tests.json') + stage_files = shared.inventory(confined(out, 'stage'), stage=True) + require(set(stage_files) == set(source) | {HANDOFF}, 'retained stage is the source and the restart handoff') + handoff = stage_files[HANDOFF] + require(len(tests) == len(SUITES) and end['all_tests_sha256'] == files['all-tests.json']['sha256'], + 'complete test rows') + pids = [] + for index, (phase, test) in enumerate([('import', None), ('parse', None)] + + [(str(i).zfill(3), n) for i, n in enumerate(SUITES)]): + row = read(phase + '-receipt.json') + expected_row = dict(row) + if test: + expected_row.update(sequence=index - 2, godot=start['engine']) + require(tests[index - 2] == expected_row, 'test row matches raw phase receipt') + require(phases[index] == expected_row and row['phase'] == phase and row['test'] == test + and row['passed'] is True and row['failed'] is False + and row['validation_error'] is None and not row.get('guard_error') + and row['default_player_entire_profile_unchanged'] is True, + 'exact successful phase') + # The same proof classification as the shared collector (tools/run_all_tests.py). + p = shared.phase_policy(test, Path(refs[test]['path']), policy['suite_policies']) if test else None + ordinary = bool(p) and p['ordinary_cleanup_prepared'] is True + bounded = bool(p) and p['old_bounded_teardown'] is True + pid = row['actual_pid'] + require(type(pid) is int and pid > 0 and pid not in pids, 'distinct actual PIDs per phase') + pids.append(pid) + process = read(phase + '/process.json') + require(process == row['native_process'] and process['pid'] == pid + and process['owned_child_process_helper_sha256'] == native.HELPER_SHA + == source['tools/regression/p09_owned_engine_process.py']['sha256'], + 'actual phase/process/helper agreement') + stdout, stderr, logger = (text(phase + '/' + n) for n in + ('stdout.log', 'stderr.log', 'engine-native.log')) + raw = stdout + stderr + require(text(row['log']) == raw and row['log_sha256'] == files[row['log']]['sha256'], + 'literal complete raw stream') + require(process['stdout_sha256'] == files[phase + '/stdout.log']['sha256'] + and process['stderr_sha256'] == files[phase + '/stderr.log']['sha256'], + 'process transport SHA') + require(row['raw_native_log'] == dict(files[phase + '/engine-native.log'], + path=str(origin / phase / 'engine-native.log')), 'native logger pin') + review = diagnostics.review_streams(native, { + 'stdout': stdout, 'stderr': stderr, 'native_file': logger}, test) + require(row['finite_original_diagnostic_review'] == review + and diagnostics.diagnostic_result_gate(process, review) + and shared.process_gate(process, review['allowed_e31_error_lines']), + 'strict diagnostics/process gates') + require(native.native_diagnostics(stdout, stderr, row['command']) + == process['strict_native_diagnostics'], 'raw banner/diagnostics recomputed') + command = [start['engine'], '--headless', '--path', start['stage']] + if phase == 'import': + command += ['--editor', '--import', '--quit', '--verbose'] + else: + script = ('bootstrap/parse_full_dependencies.gd' if phase == 'parse' + else 'starters/' + phase + '.gd') + command += ['--script', str(origin / script), '--verbose'] + require(row['starter_sha256'] == files[script]['sha256'], 'exact starter pin') + if test: + expected_starter = ('extends ' + json.dumps('res://' + test) + '\nconst Guard := preload(' + + json.dumps(str(origin / 'bootstrap/profile_guard.gd')) + + ')\nfunc _initialize() -> void:\n\tif not Guard.activate():\n\t\tquit(97)\n\t\treturn\n\tsuper._initialize()\n') + require(text(script) == expected_starter, 'unchanged guarded starter bytes') + command += ['--log-file', str(origin / phase / 'engine-native.log')] + require(row['command'] == process['argv'] == command, 'exact native argv/logger') + for filename, field, event in [('started.json', 'actual_start_binding', 'ENGINE_STARTED'), + ('ended.json', 'actual_end_binding', 'ENGINE_ENDED')]: + binding = read(phase + '/' + filename) + require(binding == process[field] and binding['event'] == event + and binding['actual_pid'] == binding['pid'] == pid + and binding['owner_pid'] == start['owner_runner_pid'] + and binding['command'] == binding['argv'] == command, 'actual START/END ownership') + binding = process['actual_start_binding'] + identity = binding['current_process_identity'] + if identity is None: + require(binding['tracked_child_ended_before_initial_probe'] is True, 'early ended tracked child') + else: + require(binding['live_identity_exact'] is True and identity == { + 'pid': pid, 'ppid': start['owner_runner_pid'], 'pgid': pid, 'args': ' '.join(command)}, + 'recorded live child PID/PPID/PGID/argv') + finish = process['actual_end_binding'] + require(finish['engine_ended'] is True and finish['timed_out'] is False + and finish['lifecycle_error'] is None and finish['exit_code'] == 0 + and finish['actual_process_exit_code'] == 0 + and finish['start_receipt_written'] is True + and finish['monotonic_ns'] >= binding['monotonic_ns'], 'complete native END') + cleanup = read(phase + '/adapter-owned-cleanup.json') + require(cleanup == process['adapter_owned_cleanup'], 'raw adapter cleanup') + cleanup_gate(cleanup) + capture = row['outer_actual_capture'] + require(read(phase + '/outer-owned-finally.json') == { + k: v for k, v in capture.items() + if k not in ('outer_receipt_written', 'outer_receipt_error', 'passed')}, + 'whole raw outer receipt') + require(capture['actual_captured_pids'] == [pid] and capture['passed'] is True + and capture['all_captured_ended'] is True and capture['adapter_exception'] is None + and capture['outer_cleanup_errors'] == [] and capture['outer_receipt_error'] is None + and capture['outer_receipt_written'] is True + and capture['all_cleanup_before_outer_receipt'] is True + and capture['foreign_process_or_group_signals'] == 0 + and row['engine_ended'] is True and row['exit_code'] == 0, 'owned outer capture') + outer_cleanup(capture['cleanup'], [pid]) + startup = read(phase + '-startup-profile-before.json') + require(startup == {'path': row['whole_startup_profile_after']['path'], 'existed': False, + 'files': {}, 'before_native_spawn': True}, 'fresh startup profile') + require(row['whole_startup_profile_after']['fresh_before_engine'] is True, + 'startup fresh before engine') + name = row['profile_startup_name'] + require(re.fullmatch('TheTree-PCK-tests-[0-9a-f]{32}-standard-startup-' + phase, name) + and Path(startup['path']).name == name and row['private_project_fields'] == 2 + and row['private_project_sha256'] == hashlib.sha256(shared.private_project( + (shared.HERE.parent / 'project.godot').read_bytes(), name)).hexdigest(), + 'exact two-field startup isolation') + for label in ('startup', 'runtime'): + if label == 'runtime' and phase == 'import': + continue + whole = row['whole_' + label + '_profile_after'] + archive = confined(out, 'profiles/' + phase + '/' + label) + require(archive.is_dir() and shared.inventory(archive) + == whole['files'], 'complete retained private profile') + if phase != 'import': + guard = read(phase + '-profile.json') + runtime = Path(row['whole_runtime_profile_after']['path']) + name = guard['custom_user_dir_name'] + require(re.fullmatch('TheTree-PCK-tests-[0-9a-f]{32}-standard-runtime-' + phase, name) + and runtime.name == name and guard['verified'] is True + and guard['conditions'] == [True] * 5 and guard['conditions_checked'] == 5 + and guard['fresh_before_parent_initialize'] is True + and guard['profile_writes_before_guard'] == 0 + and guard['actual_user_data_dir'] == guard['expected_user_data_dir'] == str(runtime) + and guard['globalized_probe'] == str(runtime / 'guard-probe') + and guard['project_settings_persisted'] is False, 'all unchanged Guard conditions') + require(row['actual_private_profile'] == { + 'report': guard, 'report_sha256': files[phase + '-profile.json']['sha256'], + 'whole_files': row['whole_runtime_profile_after']['files']}, 'whole raw Guard report') + require(row['controlled_environment'] == { + 'THE_TREE_PACK_PROFILE_NAME': name, 'THE_TREE_PACK_PROFILE_EXPECTED': str(runtime), + 'THE_TREE_PACK_PROFILE_REPORT': str(origin / (phase + '-profile.json')), + 'THE_TREE_FULL_PARSE_PATHS': str(origin / 'parse-paths.json'), + **({'THE_TREE_AUDIO_TEARDOWN_PROOF_DIR': str(origin / (phase + '-teardown/owned-audio')), + 'THE_TREE_AUDIO_FIXTURE_CONTROL_PROOF': str(origin / (phase + '-teardown/control.json'))} + if ordinary else {'THE_TREE_AUDIO_TEARDOWN_PROOF_DIR': str(origin / (phase + '-teardown'))} + if bounded else {})}, + 'exact controlled Guard/proof environment') + else: + require(row['controlled_environment'] == {}, 'fresh import environment') + if phase == 'parse': + require(len(re.findall(r'(?m)^FULL_DEPENDENCY_PARSE\b', raw)) == 1 + and re.findall(r'(?m)^FULL_DEPENDENCY_PARSE checks=([0-9]+) failures=([0-9]+)\n', raw) + == [(str(len(parse_paths)), '0')] and row['actual_parse_checks'] == len(parse_paths), 'unique all-script parse marker') + if test: + parsed = markers.parse_raw(test, raw, contracts, source[test]['sha256']) + require(parsed['passed'] and row['completion'] == parsed + and row['reported_checks'] == parsed['checks'] + and row['reported_failures'] == parsed['failures'] + and row['completion_marker'] == parsed['marker'] and row['collector_errors'] == [], + 'source-pinned raw completion marker') + require(row['source_sha256'] == row['source_sha256_end'] == source[test]['sha256'], + 'exact test source before/after') + require(row['policy'] == p, 'current exact suite policy') + if ordinary: + proof = owners.ordinary_gate(confined(out, phase + '-teardown'), pid, out, + p['ordinary_control_labels']) + require(proof == relocated(row['ordinary_owner_pairs']), 'full ordinary/audio ownership proof') + if bounded: + proof = audio.teardown_gate(confined(out, phase + '-teardown'), pid, out) + require(proof == relocated(row['old_bounded_proof']), 'full bounded audio teardown proof') + require(('ordinary_owner_pairs' in row) == ordinary and ('old_bounded_proof' in row) == bounded + and any(name.startswith(phase + '-teardown/') for name in files) == (ordinary or bounded), + 'teardown proofs exactly for the classified suites') + if test == RESTART[0]: + require(row['generated_reports'] == {HANDOFF: dict(handoff, actual_engine_pid=pid, + producer_test=test, producer_source_sha256=source[test]['sha256'], payload_modified=False)}, + 'the writer generates exactly the retained restart handoff') + require(re.findall(WRITER_LINE, raw) == [(str(pid), handoff['sha256'])] + and read_json(confined(out, 'stage/' + HANDOFF))['writer_pid'] == pid, + 'the writer engine printed and recorded its own handoff') + writer_pid = pid + else: + require(row['generated_reports'] == {}, 'only the restart writer generates a report') + if test == RESTART[1]: + require(re.findall(READER_LINE, raw) == [(str(pid), str(writer_pid), handoff['sha256'])] + and pid != writer_pid, 'a separate reader engine read the exact writer handoff') + require(end['actual_captured_PIDs'] == pids, 'all captured engines') + outer_cleanup(end['final_owned_cleanup'], pids) + require(all(end[k] == v for k, v in shared.totals(tests, SUITES).items()), 'recomputed exact totals') + require(shared.inventory(confined(out, 'stage'), stage=True) == dict(source, **{HANDOFF: handoff}), + 'entire retained source/settings and handoff exact') + for name, pin in shared.read(shared.SUPPORT / 'bootstrap-scripts.json').items(): + require(text('bootstrap/' + name).encode() == pin['text'].encode() + and files['bootstrap/' + name]['sha256'] == pin['sha256'], 'unchanged bootstrap/Guard pins') + require(inventory(out) == dict(files, **{'receipt.json': receipt_pin}), + 'whole raw evidence unchanged during verification') + require(content_map(shared.source_refs(shared.HERE.parent)) == source, + 'whole current source unchanged during verification') + print(json.dumps({'verified': True, 'actual_suites': len(SUITES), 'parse_checks': len(parse_paths), + 'actual_checks': end['actual_all_roster_checks'], 'engines_started': 0, + 'formal_acceptance': False, 'original_unit_complete': False})) + return 0 + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + # No argparse abbreviation, repeated flags, = aliases or positional tokens. + parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False) + parser.add_argument('--output', required=True) + modes = parser.add_mutually_exclusive_group() + modes.add_argument('--collect', action='store_true', help='Root only; fresh native collection') + modes.add_argument('--prepare-only', action='store_true') + seen = set() + i = 0 + while i < len(argv): + flag = argv[i] + require(flag in ('--output', '--collect', '--prepare-only', '--help') + and flag not in seen, 'unknown, duplicate or malformed flag') + seen.add(flag) + if flag == '--output': + i += 1 + require(i < len(argv) and argv[i] and not argv[i].startswith('--'), 'output value required') + i += 1 + args = parser.parse_args(argv) + args.timeout = 1200 + root = shared.HERE.parent + output = args.output + # The registered validation passes the literal '{build_dir}/p07-weather-evidence'. The current Runner + # replaces only {python} in validation argv and gives the task's build directory as BUILD_OUTPUT_DIR. + if output.startswith('{build_dir}/'): + build = os.environ.get('BUILD_OUTPUT_DIR', '') + require(build and Path(build).is_absolute() and Path(build).is_dir() and not Path(build).is_symlink(), + 'registered build directory from BUILD_OUTPUT_DIR') + output = build + output[len('{build_dir}'):] + out = plain_path(output) + refs = shared.source_refs(root) + require(set(SUITES).issubset(shared.roster(root)), 'normal discovery includes narrow roster and restart tests') + discovered = shared.roster(root) + require(discovered.index(RESTART[0]) < discovered.index(RESTART[1]), 'the full roster also runs the writer first') + markers = shared.module('p07_exact_markers', shared.HERE / 'strict_test_markers.py') + contract_path = root / 'docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json' + contract_sha = shared.sha(contract_path) + contracts = markers.load_contracts(contract_path) + require(all(name in contracts for name in SUITES), 'complete marker roster') + policy = shared.read(shared.SUPPORT / 'suite-policy.json') + shared.policy_coverage(SUITES, refs, policy) + for test in SUITES: + shared.phase_policy(test, Path(refs[test]['path']), policy['suite_policies']) + require(test not in policy['outside_routes'], 'no outside report routes in the narrow run') + require(policy['fixed_generated_report_routes'].get(test, []) == ([HANDOFF] if test == RESTART[0] else []), + 'only the restart writer declares one generated report') + for test in ROSTER[:1] + RESTART: + require(contracts[test]['source_sha256_at_schema_audit'] == refs[test]['sha256'], + 'new strict source-hashed marker') + parse_paths = ['res://' + name for name in sorted(refs) if name.endswith('.gd')] + if args.prepare_only: + ancestor = out.parent + while not ancestor.exists(): + ancestor = ancestor.parent + free = shutil.disk_usage(ancestor).free + size = sum(p['bytes'] for p in refs.values()) + print(json.dumps({'roster': ROSTER, 'source_files': len(refs), 'source_bytes': size, + 'free_bytes': free, 'conservative_required_bytes': size + shared.MIN_SPACE, + 'capacity_available': free >= size + shared.MIN_SPACE, 'parse_paths': len(parse_paths), + 'restart_phases': RESTART, 'future_native_phases': len(SUITES) + 2, 'actual_checks': None, 'engines': 0, 'stages': 0, + 'formal_acceptance': False})) + return 0 + if args.collect: + require(not out.exists() and out.parent.is_dir(), 'fresh output with existing parent required') + require(not out.is_relative_to(root), 'private collection outside source tree') + return collect(args, root, out, refs, markers, contracts, policy, + contract_path, contract_sha, parse_paths) + return verify(out, refs, markers, contracts, policy, contract_sha, parse_paths) + + +if __name__ == '__main__': + try: + raise SystemExit(main()) + except (AssertionError, OSError, ValueError, KeyError, TypeError) as error: + print(type(error).__name__ + ': ' + str(error), file=sys.stderr) + raise SystemExit(1)