diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index 7dbdafa..a174f28 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -1295,6 +1295,9 @@ 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: the saved day's draw must be the draw rule's own result (SPEC section 5.3). + if not NaturalWeather.is_consistent_draw(restored_weather.get_today_draw()): + 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: 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 e9f4854..8120b82 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 @@ -3964,7 +3964,7 @@ "template": "PASS: checks", "required": true, "fixed_check_count_used": false, - "source_sha256_at_schema_audit": "11caac7e652213a708c270e136853b0fa053bcda502576da741f1f5d67cdd64b", + "source_sha256_at_schema_audit": "0dfba98e091f015f74fc7897f8a7b7ce8e286826dc2a17785095ab4e0d51a946", "authority": { "path": "docs/plans/original-completion-2026-10-02/execution/p07-01/SPEC.md", "scope": "new P07-01 suite marker; native execution pending" @@ -3975,7 +3975,7 @@ "template": "PASS: checks", "required": true, "fixed_check_count_used": false, - "source_sha256_at_schema_audit": "ad8067f42348b803ce7736bee2f55f4b0789fefaeaaac702ff8a8800d926d8ea", + "source_sha256_at_schema_audit": "39ca0eb47eef46a5c2c73cb165b300360753ce74010b6512901c0f921d2e00a6", "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" @@ -3986,7 +3986,7 @@ "template": "PASS: checks", "required": true, "fixed_check_count_used": false, - "source_sha256_at_schema_audit": "fb0a633c08cca1de66dc549f760f2a8e822c6c14c54f58e609de7f78c22fb469", + "source_sha256_at_schema_audit": "dd7c41952ced7edf38d2fbc5517128bc7b955c49ddccca90eb933062f4b8e8a7", "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" diff --git a/docs/plans/original-completion-2026-10-02/execution/p07-01/wx/EXPECTATION_CHANGES.json b/docs/plans/original-completion-2026-10-02/execution/p07-01/wx/EXPECTATION_CHANGES.json new file mode 100644 index 0000000..7fffb91 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p07-01/wx/EXPECTATION_CHANGES.json @@ -0,0 +1,18 @@ +{ + "task": "P07-01-WX", + "spec": "docs/plans/original-completion-2026-10-02/execution/p07-01/wx/SPEC.md", + "rule": "SPEC WX8: every changed expectation of an existing suite is listed with the value before and after and the reason. The four changes carried from the original task stay listed in ../EXPECTATION_CHANGES.json (byte for byte); this file lists the changes made in P07-01-WX.", + "changes": [ + { + "suite": "tests/business/test_required_kitchen.gd", + "check": "whole typed loaded state unchanged except complete identity/saveID-bound IO1: legacy14-load (and the byte-identical-copy legacy migration control, which uses the same mapped expectation)", + "before": "_approved_runtime_migrations maps A06 (mana reserve fresh start), P14-03 (elevators v1 to v2) and P05-02 A12 (empty fund accounts) into the old checkpoint before the whole typed comparison", + "after": "it also maps the P07-01 A13 migration, each part checked against its own contract before it is mapped: the weather snapshot v1 to v2 gains only the natural state (last_draw_day counts every day whose 06:00 has passed, today empty) and empty rain_holds, with minute, kind, since and pending unchanged; rng_streams gains only the weather stream from the world seed, every other stream byte-identical", + "reason": "A13 (SPEC section 5.2) migrates a save from before natural weather on load. The genuine legacy14 fixture is such a save, so its loaded state differs from the old checkpoint exactly in these two fields. Found by the S c501360 full roster (phase 003, 163 checks, 2 failures) and the independent review finding 1." + } + ], + "found_by": { + "full_roster": "FULL_ROSTER_C501360_FAILURE_R01.json", + "review": "p07-01-wx-independent-review-c501360-r01/DESKTOP_RESULT.json (sha256 aea0cc4f2c00a6a093ca256154c62812a4d83749d075c6a4a9d36c1270026da7)" + } +} 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 index bfdb987..47f427b 100644 --- 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 @@ -51,3 +51,43 @@ - 炉 120 と `AR.weather.fog_magic` は受け入れない(SPEC §6)。 - 他の開示は、持ち越した `../IMPLEMENTATION_NOTES.md` §2・§3(P06 の句を除く)と `../EXPECTATION_CHANGES.json` のとおり。 +- 出来事の一覧の表示: 毎日 06:00 の出来事 `WEATHER_FORECAST_DRAWN` には、`scripts/ui/event_log_dialog.gd` の `EVENT_NAMES` に日本語の名前が無い。そのため、プレイヤーが見る出来事の一覧では「未識別の記録」と出る。 + - このファイルは契約の `allowed_paths` の外なので、この作業では直さない。 + - 名前を足す作業は、別の単位として提案する。 +- 照合の ordinary の分岐: 狭い roster に `ordinary_cleanup_prepared` の suite が無い。そのため、照合の ordinary の分岐は、この validation では実地には動かない。 + - 実地に動くのは、old_bounded(000・011)と、後片付けの無い段だけ。 + - ordinary の分岐は、共通の収集と同じ環境と証明の照合を、コードの上で求めている。 + +## 6. 直し 1(S `c5013601` の独立レビューと全 roster の後。repair 1) + +- 契機: + - S `c5013601` の全 roster が、段 003 の `tests/business/test_required_kitchen.gd` で止まった(`FULL_ROSTER_C501360_FAILURE_R01.json`)。 + - 独立レビュー(`local_21295ab0`)は changes_requested で、high 1・medium 1・low 4。 + - 管理の記録は `COMMANDER_REPAIR_PLAN_P07_01_WX_R01.md`。 +- 直したもの: + 1. high: kitchen の試験の `_approved_runtime_migrations` に、A13 の移行を足した。2つの部分を、それぞれの契約で照合してから写す(`./EXPECTATION_CHANGES.json`)。 + - 天候 v1→v2 では、`natural` と空の `rain_holds` だけが足される。 + - `rng_streams` には、`world_seed` から作った `weather` だけが足される。 + 2. medium: 再開の台本に霧の場面を足した(`tests/helpers/p07_weather_restart_fixture.gd`、writer、reader)。 + - seed 1592(0 日目 06:00〜10:00 がマナ霧)を使う。 + - 06:58 と 09:58 に成人確認済みの客が着く。この世界には施設が無いので、客は数分で帰る。そのため、保存の時刻に館内に居るように着く時刻を選んだ。 + - 保存は 419・420・421・599・600・601 分。 + - 比べる状態に、客の `needs`(性欲)と、マナの貯蔵の snapshot を足した。 + - writer は、straight の進行で次を確かめる。 + - 成人の客の性欲が、霧の間は1分に4、霧の後は3上がる。 + - 10 分ごとのマナの境界が、霧の間は 24、霧の後は 20。 + - writer は、同じ process で各保存ファイルを読み直す。読んだ直後と1分後、最後の状態が、straight と bytes で一致することを確かめる。 + - reader は、別の process で同じことを確かめる。 + - これで、霧の産出と性欲の効果が二重に効かないことを示す。 + 3. low(試験の不足): + - (a) 霧の間に帰りの時刻になった客は延びない。 + - (b) 延びた客は、10:02 の決定(`_advance_guest_ai_decisions`)で PLANNED_EXIT で帰り始める。施設の無いこの世界では、客は延長の前に次の決定で施設が無いので帰る。そのため writer の雨の場面では、hold の記録の後も今の規則どおり(`NO_SUITABLE_FACILITY`)帰ることを、厳密に確かめた。元の「or has left since」は外した。 + - (c) 上部の表示で、嵐とマナ霧の名前・予報・効果の案内を確かめる。霧の案内が炉を主張しないことも確かめる。 + 4. low(§5.3 の細部): 製品の `scripts/environment/natural_weather_policy.gd` に `is_consistent_draw` を足した。 + - 保存されたその日の抽選について、roll と種類、slot と開始、終わりと続く時間の対応を照合する。 + - Runtime の復元(`autoload/simulation_runtime.gd`)は、矛盾した記録を拒否し、状態を保つ。 + - 拒否の試験を3つ足した。 + 5. 開示: 上の §5 の2つ。 +- 比べる状態の `needs` は、名前を文字の順に並べる。読み直した客は needs のキーの順が違うことがあり、`StringName` の並べ替えは文字の順ではなく、process ごとに違うため。 +- 固定の値: 変えた3本(`test_p07_weather_runtime.gd`・writer・reader)の source の SHA を、`tools/regression/suite-policy.json` と `completion_marker_contracts.json` で更新した。 +- 確かめ(局所。正式の証拠ではない): scratch の stage で、変えた suite と狭い roster の 12 本、kitchen を直接動かして合格した(`P07_01_WX_REPAIR1_LOCAL_CHECKS_R01.json`)。正式の証拠は、新しい S の native の収集、登録の validation、全 roster、独立レビュー。 diff --git a/scripts/environment/natural_weather_policy.gd b/scripts/environment/natural_weather_policy.gd index 5f387fd..f83ee59 100644 --- a/scripts/environment/natural_weather_policy.gd +++ b/scripts/environment/natural_weather_policy.gd @@ -43,6 +43,30 @@ static func draw(rng: DeterministicRng, day: int) -> Dictionary: return {&"day": day, &"kind": kind, &"roll": roll, &"slot": slot, &"start": start, &"end": start + int(DURATION_MINUTES[kind])} +## A saved day's draw must be this rule's own result (SPEC section 5.3): the roll +## picks the kind, a clear day has no slot, and any other day starts on its slot +## and lasts its kind's duration. An empty draw (no day drawn yet) is consistent. +static func is_consistent_draw(draw: Dictionary) -> bool: + if draw.is_empty(): + return true + var roll := int(draw[&"roll"]) + var kind := StringName(draw[&"kind"]) + var expected: StringName = &"clear" + if roll >= FOG_BELOW: + expected = &"storm" + elif roll >= RAIN_BELOW: + expected = &"mana_fog" + elif roll >= CLEAR_BELOW: + expected = &"rain" + if kind != expected: + return false + if kind == &"clear": + return int(draw[&"slot"]) == -1 and int(draw[&"start"]) == -1 and int(draw[&"end"]) == -1 + var slot := int(draw[&"slot"]) + var start := int(draw[&"day"]) * DAY_MINUTES + FIRST_START_MINUTE_OF_DAY + slot * START_SLOT_MINUTES + return slot >= 0 and slot < START_SLOTS and int(draw[&"start"]) == start \ + and int(draw[&"end"]) == start + int(DURATION_MINUTES[kind]) + static func arrival_percent(kind: StringName) -> int: return RAIN_ARRIVAL_PERCENT if kind == &"rain" else 100 diff --git a/tests/business/test_required_kitchen.gd b/tests/business/test_required_kitchen.gd index 93a34ae..e15525b 100644 --- a/tests/business/test_required_kitchen.gd +++ b/tests/business/test_required_kitchen.gd @@ -276,8 +276,9 @@ func _legacy_controls(writer: Dictionary, reader: Dictionary) -> void: ## Approved later save migrations applied when an older save is loaded, each ## checked against its own contract and then mapped into `expected` in place: -## A06 starts a fresh finite Mana reserve at the restored boundary, and P14-03 -## raises the elevator snapshot from v1 to v2 with every other field unchanged. +## A06 starts a fresh finite Mana reserve at the restored boundary, P14-03 +## raises the elevator snapshot from v1 to v2 with every other field unchanged, +## and P07-01 A13 migrates the weather snapshot and adds the weather stream. func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> Dictionary: var old: Dictionary = expected[&"systems"][&"runtime"] var now: Dictionary = reader[&"systems"][&"runtime"] @@ -322,6 +323,35 @@ func _approved_runtime_migrations(expected: Dictionary, reader: Dictionary) -> D old[&"economy"] = economy fields.append("economy_A12_empty_fund_accounts") proof["economy_A12"] = {"added_accounts": added.keys(), "only_accounts_added": only_accounts_added} + # P07-01 A13 (SPEC section 5.2): a save from before natural weather keeps its + # weather and pending schedule, counts every day whose 06:00 has passed as + # drawn (no past draw), holds no rain extension, and starts the weather + # stream from the world seed; every other field stays byte-identical. + var old_weather: Variant = old.get(&"weather") + var now_weather: Variant = now.get(&"weather") + if old_weather is Dictionary and int(old_weather.get(&"version", 0)) == 1 and now_weather is Dictionary and int(now_weather.get(&"version", 0)) == 2: + var weather_minute := int(old_weather[&"minute"]) + var last_draw_day := floori(weather_minute / 1440.0) - (0 if posmod(weather_minute, 1440) > 360 else 1) + var migrated: Dictionary = (old_weather as Dictionary).duplicate(true) + migrated[&"version"] = 2 + migrated[&"natural"] = {&"last_draw_day": last_draw_day, &"today": {}} + migrated[&"rain_holds"] = {} + var only_natural_state_added: bool = migrated == now_weather + ok = ok and only_natural_state_added + old[&"weather"] = (now_weather as Dictionary).duplicate(true) + fields.append("weather_v1_to_v2_A13") + proof["weather_A13"] = {"last_draw_day": last_draw_day, "only_natural_state_added": only_natural_state_added} + var old_streams: Variant = old.get(&"rng_streams") + var now_streams: Variant = now.get(&"rng_streams") + if old_streams is Dictionary and now_streams is Dictionary and not (old_streams as Dictionary).has("weather") and (now_streams as Dictionary).has("weather"): + var seeded: Dictionary = DeterministicRng.from_root_seed(int(old[&"world_seed"]), &"weather").snapshot() + var others: Dictionary = (now_streams as Dictionary).duplicate(true) + others.erase("weather") + var only_weather_stream_added: bool = now_streams["weather"] == seeded and var_to_bytes(others) == var_to_bytes(old_streams) + ok = ok and only_weather_stream_added + old[&"rng_streams"] = (now_streams as Dictionary).duplicate(true) + fields.append("rng_streams_weather_from_world_seed_A13") + proof["rng_streams_A13"] = {"only_weather_stream_added": only_weather_stream_added} if not fields.is_empty(): # Saves keep the Runtime's own key order, so the mapped tree follows it. ok = ok and old.size() == now.size() diff --git a/tests/environment/test_p07_weather_restart_1_write.gd b/tests/environment/test_p07_weather_restart_1_write.gd index a9459c5..a02f294 100644 --- a/tests/environment/test_p07_weather_restart_1_write.gd +++ b/tests/environment/test_p07_weather_restart_1_write.gd @@ -4,6 +4,9 @@ extends SceneTree ## 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 fog scene (WX7) writes saves around the 07:00 fog mana boundary and the +## fog end, reloads each one in this process and continues it, and hands those +## packages to the reader too. ## The handoff is the declared generated report res://artifacts/p07-01/restart-handoff.json ## (tools/regression/suite-policy.json fixed_generated_report_routes). @@ -22,9 +25,17 @@ func _run() -> void: 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)") + var held_guest: Variant = straight_runtime.guest_service.get_guest(Fixture.HELD_GUEST) + var held := {"hold": {}, "reason": &""} + fixtures.play(straight, Fixture.FIRST_MINUTE, Fixture.LAST_MINUTE, func(_minute: int) -> void: + if (held["hold"] as Dictionary).is_empty(): + held["hold"] = straight_runtime.weather.get_rain_hold(Fixture.HELD_GUEST) + if StringName(held["reason"]).is_empty(): + held["reason"] = held_guest.exit_reason) + # The hold only moves the planned exit; every other current rule still applies, + # so in this world without facilities the guest leaves for lack of one. + _expect(held["hold"] == {"original_exit": 362, "extended_to": 602} and held_guest.planned_exit_at == 602 and held["reason"] == &"NO_SUITABLE_FACILITY", + "the guest due at 06:02 was held to 10:02 and still left by the current rules (%s)" % String(held["reason"])) _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"] @@ -40,18 +51,9 @@ func _run() -> void: _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 saves := _packages(Fixture.SAVE_MINUTES, saved_paths, saved_states, Fixture.WRITER_SAVE) var final_state := fixtures.state_bytes(straight_runtime) + var fog := _fog_scene(fixtures) var handoff := { "schema": Fixture.HANDOFF_SCHEMA, "writer_pid": OS.get_process_id(), @@ -61,6 +63,7 @@ func _run() -> void: "straight_final_state_sha256": Fixture.sha256(final_state), "straight_final_state_base64": Marshalls.raw_to_base64(final_state), "saves": saves, + "fog": fog, } DirAccess.make_dir_recursive_absolute(Fixture.HANDOFF_PATH.get_base_dir()) var file := FileAccess.open(Fixture.HANDOFF_PATH, FileAccess.WRITE) @@ -79,6 +82,95 @@ func _run() -> void: quit(_failures) +## WX7 fog scene: the uninterrupted run, the run that writes the save files, and +## a same-process reload of every save file continued to the end. +func _fog_scene(fixtures: Variant) -> Dictionary: + var straight: Dictionary = fixtures.new_fixture(false, Fixture.FOG_SEED) + var runtime: Variant = straight[&"runtime"] + _expect(runtime.weather.get_kind() == &"mana_fog" and int(runtime.weather.get_today_draw().get("end", -1)) == 600, "seed 1592 opens the fog scene with mana fog 06:00-10:00") + var seen := {"intimacy": {}, "rises": 0, "fog_rises": 0, "rises_ok": true, "flows": 0, "fog_flows": 0, "flows_ok": true, "after": {}} + fixtures.play(straight, Fixture.FIRST_MINUTE, Fixture.FOG_LAST_MINUTE, func(minute: int) -> void: + var fog_now: bool = runtime.weather.get_kind() == &"mana_fog" + var last: Dictionary = seen["intimacy"] + for guest_id: StringName in Fixture.FOG_ADULTS.values(): + var guest: Variant = runtime.guest_service.get_guest(guest_id) + if guest == null: + continue + var now := int(guest.needs[&"intimacy"]) + if last.has(guest_id) and now < 100: + seen["rises"] += 1 + seen["rises_ok"] = seen["rises_ok"] and now - int(last[guest_id]) == (4 if fog_now else 3) + if fog_now: + seen["fog_rises"] += 1 + last[guest_id] = now + if minute % 10 == 0: + seen["flows"] += 1 + seen["flows_ok"] = seen["flows_ok"] and int(runtime.mana_reserve.get_last_flow().get(&"generation", -1)) == (24 if fog_now else 20) + if fog_now: + seen["fog_flows"] += 1 + if minute - 1 in Fixture.FOG_SAVE_MINUTES: + seen["after"][minute] = fixtures.state_bytes(runtime)) + _expect(seen["rises_ok"] and int(seen["fog_rises"]) >= 3 and int(seen["rises"]) - int(seen["fog_rises"]) >= 2, + "adult-verified intimacy rises by 4 a minute in fog and 3 after it (%d rises, %d in fog)" % [int(seen["rises"]), int(seen["fog_rises"])]) + _expect(seen["flows_ok"] and int(seen["fog_flows"]) >= 20 and int(seen["flows"]) > int(seen["fog_flows"]), + "each 10-minute mana boundary adds 24 in fog and 20 after it (%d boundaries, %d in fog)" % [int(seen["flows"]), int(seen["fog_flows"])]) + var saving: Dictionary = fixtures.new_fixture(false, Fixture.FOG_SEED) + var saving_runtime: Variant = saving[&"runtime"] + var states := {} + var paths := {} + fixtures.play(saving, Fixture.FIRST_MINUTE, Fixture.FOG_LAST_MINUTE, func(minute: int) -> void: + if minute in Fixture.FOG_SAVE_MINUTES: + var path := Fixture.FOG_WRITER_SAVE % minute + states[minute] = fixtures.state_bytes(saving_runtime) + _expect(bool(saving_runtime.save_to_path(path).get(&"ok", false)), "fog save file at minute %d" % minute) + paths[minute] = path) + var final_state: PackedByteArray = fixtures.state_bytes(runtime) + _expect(fixtures.state_bytes(saving_runtime) == final_state, "writing the fog save files leaves the state as without saving") + for minute: int in Fixture.FOG_SAVE_MINUTES: + var reload: Dictionary = fixtures.new_fixture(false, Fixture.FOG_SEED) + var reload_runtime: Variant = reload[&"runtime"] + _expect(bool(reload_runtime.load_from_path(str(paths.get(minute, Fixture.FOG_WRITER_SAVE % minute))).get(&"ok", false)) + and fixtures.state_bytes(reload_runtime) == states.get(minute, PackedByteArray()) and reload[&"clock"].absolute_minute == minute, + "this process reloads the fog save file of minute %d exactly" % minute) + fixtures.play(reload, minute, minute + 1, Callable()) + _expect(fixtures.state_bytes(reload_runtime) == seen["after"].get(minute + 1, PackedByteArray()), + "one minute after the minute %d fog reload equals the uninterrupted run (the fog yield and intimacy apply once)" % minute) + fixtures.play(reload, minute + 1, Fixture.FOG_LAST_MINUTE, Callable()) + _expect(fixtures.state_bytes(reload_runtime) == final_state, "the minute %d fog reload continues to the uninterrupted final state" % minute) + fixtures.free_fixture(reload) + var after := {} + for minute: int in seen["after"]: + var bytes: PackedByteArray = seen["after"][minute] + after[str(minute)] = {"sha256": Fixture.sha256(bytes), "base64": Marshalls.raw_to_base64(bytes)} + var saves := _packages(Fixture.FOG_SAVE_MINUTES, paths, states, Fixture.FOG_WRITER_SAVE) + fixtures.free_fixture(straight) + fixtures.free_fixture(saving) + return { + "seed": Fixture.FOG_SEED, + "last_minute": Fixture.FOG_LAST_MINUTE, + "straight_final_state_sha256": Fixture.sha256(final_state), + "straight_final_state_base64": Marshalls.raw_to_base64(final_state), + "after_states": after, + "saves": saves, + } + + +## The exact save packages and the compared state at each save minute. +func _packages(minutes: Array[int], paths: Dictionary, states: Dictionary, path_format: String) -> Array: + var saves: Array = [] + for minute: int in minutes: + var directory := ProjectSettings.globalize_path(str(paths.get(minute, path_format % minute))) + _expect(DirAccess.dir_exists_absolute(directory) and DirAccess.get_directories_at(directory).is_empty(), "save package %s holds only files" % (path_format % 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 %s has its manifest and world" % (path_format % minute)) + var state: PackedByteArray = states.get(minute, PackedByteArray()) + saves.append({"minute": minute, "files": files, "state_sha256": Fixture.sha256(state), "state_base64": Marshalls.raw_to_base64(state)}) + return saves + + func _expect(condition: bool, message: String) -> void: _checks += 1 if not condition: diff --git a/tests/environment/test_p07_weather_restart_2_read.gd b/tests/environment/test_p07_weather_restart_2_read.gd index 8b9c6d8..4d898ab 100644 --- a/tests/environment/test_p07_weather_restart_2_read.gd +++ b/tests/environment/test_p07_weather_restart_2_read.gd @@ -3,7 +3,10 @@ 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. +## state, with every remaining 06:00 draw happening exactly once. The fog scene +## (WX7) packages must also equal the writer's state one minute after each save, +## so the fog mana yield and the adult intimacy apply exactly once across the +## process boundary. const Fixture := preload("res://tests/helpers/p07_weather_restart_fixture.gd") @@ -20,6 +23,7 @@ func _run() -> void: var handoff := _read_handoff() if not handoff.is_empty(): _check_restarts(handoff) + _check_fog(handoff.get("fog", {})) await process_frame if _failures == 0: print("PASS: %d checks" % _checks) @@ -54,18 +58,7 @@ func _check_restarts(handoff: Dictionary) -> void: 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) + _expect(_write_package(save, path), "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"] @@ -85,6 +78,50 @@ func _check_restarts(handoff: Dictionary) -> void: SaveService.delete_save(path) +func _check_fog(fog: Dictionary) -> void: + var fixtures := Fixture.new(_expect) + _expect(int(fog.get("seed", -1)) == Fixture.FOG_SEED and int(fog.get("last_minute", -1)) == Fixture.FOG_LAST_MINUTE, "same fog scene and minute script") + var final_state := Marshalls.base64_to_raw(str(fog.get("straight_final_state_base64", ""))) + _expect(not final_state.is_empty() and Fixture.sha256(final_state) == str(fog.get("straight_final_state_sha256", "")), "the writer's fog final state arrived intact") + var saves: Array = fog.get("saves", []) + _expect(saves.size() == Fixture.FOG_SAVE_MINUTES.size(), "every fog save package was handed off") + var after: Dictionary = fog.get("after_states", {}) + for save: Variant in saves: + var minute := int((save as Dictionary).get("minute", -1)) + var path := Fixture.FOG_READER_SAVE % minute + _expect(_write_package(save, path), "fog save package at minute %d arrived byte-exact" % minute) + var expected_state := Marshalls.base64_to_raw(str((save as Dictionary).get("state_base64", ""))) + var next_row: Dictionary = after.get(str(minute + 1), {}) + var next_state := Marshalls.base64_to_raw(str(next_row.get("base64", ""))) + _expect(not next_state.is_empty() and Fixture.sha256(next_state) == str(next_row.get("sha256", "")), "the writer's state one minute after %d arrived intact" % minute) + var fixture := fixtures.new_fixture(false, Fixture.FOG_SEED) + var runtime: Variant = fixture[&"runtime"] + _expect(bool(runtime.load_from_path(path).get(&"ok", false)) and fixtures.state_bytes(runtime) == expected_state and fixture[&"clock"].absolute_minute == minute, + "the reader resumes the minute %d fog save exactly" % minute) + fixtures.play(fixture, minute, minute + 1, Callable()) + _expect(fixtures.state_bytes(runtime) == next_state, "one minute after the minute %d fog save the reader equals the uninterrupted run (no double fog yield or intimacy)" % minute) + fixtures.play(fixture, minute + 1, Fixture.FOG_LAST_MINUTE, Callable()) + _expect(fixtures.state_bytes(runtime) == final_state, "continuing the minute %d fog save reaches the uninterrupted final state" % minute) + fixtures.free_fixture(fixture) + SaveService.delete_save(path) + + +## Writes one handed-off save package byte for byte; true when every file matches its pin. +func _write_package(save: Dictionary, path: String) -> bool: + var directory := ProjectSettings.globalize_path(path) + DirAccess.make_dir_recursive_absolute(directory) + var files: Dictionary = save.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() + return intact + + func _expect(condition: bool, message: String) -> void: _checks += 1 if not condition: diff --git a/tests/environment/test_p07_weather_runtime.gd b/tests/environment/test_p07_weather_runtime.gd index 9a02c41..972fc02 100644 --- a/tests/environment/test_p07_weather_runtime.gd +++ b/tests/environment/test_p07_weather_runtime.gd @@ -29,6 +29,7 @@ func _run() -> void: _test_daily_natural_draw() _test_rain_arrivals() _test_rain_stay_and_saves() + _test_rain_hold_exit() _test_storm() _test_mana_fog() _test_migration_and_rejection() @@ -227,6 +228,24 @@ func _test_rain_stay_and_saves() -> void: _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") + var fog_runtime: Variant = _new_runtime(SEED_FOG)[&"runtime"] + var misty: Variant = _spawn(fog_runtime, &"p07_fog_due_guest", &"traveler", 361) + _expect(fog_runtime.weather.get_kind() == &"mana_fog" and not fog_runtime.call("_hold_guest_for_rain", misty, 361) and misty.planned_exit_at == 361, "no hold in mana fog") + + +## F3: after its hold the guest leaves by the current rule. A guest without a +## suitable facility leaves at its next decision, so the ordinary decision step +## itself runs at the extended time: it is not held again and begins leaving +## with the ordinary PLANNED_EXIT. +func _test_rain_hold_exit() -> void: + var rainy := _new_runtime(SEED_RAIN) + var runtime: Variant = rainy[&"runtime"] + var guest: Variant = _spawn(runtime, &"p07_exit_guest", &"traveler", 362) + rainy[&"clock"].advance_minutes(2) + _expect(guest.state == GuestRuntime.State.DECIDING and runtime.weather.get_kind() == &"rain", "the guest decides at 06:02 in rain") + _expect(runtime.call("_hold_guest_for_rain", guest, 362) and guest.planned_exit_at == 602 and runtime.weather.get_rain_hold(&"p07_exit_guest") == {"original_exit": 362, "extended_to": 602}, "the rain holds the guest due at 06:02 to 10:02") + runtime.call("_advance_guest_ai_decisions", 602) + _expect(guest.exit_reason == &"PLANNED_EXIT" and guest.state == GuestRuntime.State.LEAVING and guest.planned_exit_at == 602, "at 10:02 the held guest's decision begins leaving with PLANNED_EXIT (%s)" % String(guest.exit_reason)) ## F4: natural storm keeps zero new arrivals. @@ -313,6 +332,16 @@ func _test_migration_and_rejection() -> void: 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") + # The saved rain draw of seed 1106 (roll 60-84, slot 0, 06:00-12:00) changed in one field + # each: a roll of a clear day, a slot that does not match the start, a wrong duration. + var today: Dictionary = saved[&"systems"][&"runtime"][&"weather"][&"natural"][&"today"] + _expect(NaturalWeather.is_consistent_draw(today) and String(today["kind"]) == "rain" and int(today["slot"]) == 0, "the saved opening draw follows the draw rule") + for change: Dictionary in [{"roll": 10}, {"slot": 1}, {"end": 700}]: + var inconsistent := saved.duplicate(true) + var drawn: Dictionary = inconsistent[&"systems"][&"runtime"][&"weather"][&"natural"][&"today"] + for key: String in change: + drawn[key] = change[key] + _expect(not runtime.restore_snapshot(inconsistent) and runtime.create_snapshot() == migrated, "an internally inconsistent saved draw is refused atomically (%s)" % str(change)) ## F6: the ordinary Main HUD shows the weather, the forecast and the effect guidance. @@ -339,6 +368,19 @@ func _test_hud_forecast() -> void: 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 now: int = clock.absolute_minute + _expect(runtime.weather.schedule(&"storm", now + 1)[&"ok"] and runtime.weather.schedule(&"mana_fog", now + 2)[&"ok"], "schedule a storm and then mana fog on the ordinary Runtime") + clock.advance_minutes(1) + main.call("_refresh_dashboard") + report = runtime.get_weather_report() + var fog_minute := posmod(now + 2, 1440) + _expect(weather_label.text == "嵐" and report[&"kind"] == &"storm" and String(report[&"effect_text"]) in weather_label.tooltip_text and "新規来客はありません" in weather_label.tooltip_text, "HUD shows the storm and its guidance") + _expect(forecast_label.text == "次: マナ霧 %02d:%02d" % [fog_minute / 60, fog_minute % 60], "HUD forecast shows the next change to mana fog (%s)" % forecast_label.text) + clock.advance_minutes(1) + main.call("_refresh_dashboard") + report = runtime.get_weather_report() + _expect(weather_label.text == "マナ霧" and report[&"kind"] == &"mana_fog" and String(report[&"effect_text"]) in weather_label.tooltip_text + and "1.2倍" in weather_label.tooltip_text and "1.5倍" in weather_label.tooltip_text and not "炉" in weather_label.tooltip_text, "HUD shows the mana fog and its guidance without a furnace claim") 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") diff --git a/tests/helpers/p07_weather_restart_fixture.gd b/tests/helpers/p07_weather_restart_fixture.gd index ac41066..3f0f905 100644 --- a/tests/helpers/p07_weather_restart_fixture.gd +++ b/tests/helpers/p07_weather_restart_fixture.gd @@ -5,8 +5,12 @@ extends RefCounted ## (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. +## The fog scene (WX7): seed 1592 opens day 0 with mana fog 06:00-10:00, two +## adult-verified guests arrive at 06:58 and 09:58 (with no facility in this +## world a guest leaves after a few minutes, so each one is present at its +## saves), and the saves bracket the 07:00 mana boundary and the fog end. const HANDOFF_PATH := "res://artifacts/p07-01/restart-handoff.json" -const HANDOFF_SCHEMA := "p07-01-restart-handoff-v1" +const HANDOFF_SCHEMA := "p07-01-restart-handoff-v2" 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" @@ -17,6 +21,13 @@ 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" +const FOG_SEED := 1592 +const FOG_WRITER_SAVE := "user://p07_restart_write/fog_minute_%d" +const FOG_READER_SAVE := "user://p07_restart_read/fog_minute_%d" +# Just before, at and after the 07:00 mana boundary in fog; just before, at and after the fog end. +const FOG_SAVE_MINUTES: Array[int] = [419, 420, 421, 599, 600, 601] +const FOG_LAST_MINUTE := 630 +const FOG_ADULTS := {418: &"p07_restart_fog_adult_a", 598: &"p07_restart_fog_adult_b"} var _expect: Callable @@ -25,18 +36,18 @@ func _init(expect: Callable) -> void: _expect = expect -func new_fixture(with_guest := true) -> Dictionary: +func new_fixture(with_guest := true, seed := SEED) -> 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") + _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 fixture := {&"clock": clock, &"state": state, &"runtime": runtime, &"draws": {&"count": 0}, &"seed": seed} + if with_guest and seed == SEED: var result: CommandResult = runtime.submit_command(&"spawn_guest", { &"guest_id": HELD_GUEST, &"guest_type_id": &"traveler", @@ -65,22 +76,51 @@ func free_fixture(fixture: Dictionary) -> void: ## Advances one minute at a time after `after_minute` through `through_minute` -## and calls `on_minute` after each minute (the writer saves there). +## and calls `on_minute` after each minute (the writer saves there). In the fog +## scene the adult-verified guests arrive at their minutes in every run. 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 fixture[&"seed"] == FOG_SEED and FOG_ADULTS.has(minute): + _spawn_adult(fixture[&"runtime"], FOG_ADULTS[minute]) if on_minute.is_valid(): on_minute.call(minute) +func _spawn_adult(runtime: Variant, guest_id: StringName) -> void: + var result: CommandResult = runtime.submit_command(&"spawn_guest", { + &"guest_id": guest_id, + &"guest_type_id": &"adventurer", + &"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": 2000, + }, + }, &"world", guest_id, 10, -1, StringName("p07.restart." + String(guest_id))) + _expect.call(result.is_success(), "the fog scene spawns the adult-verified guest %s" % guest_id) + + ## The weather-facing state compared across processes: weather and its stream, -## guest plans, the reserve flow and the arrival totals. +## guest plans and needs (the fog intimacy), the whole reserve with its last +## flow (the fog yield) and the arrival totals. Needs are listed in text order of +## their names, because a loaded guest may keep its needs in another key order +## (StringName ordering is not text ordering and differs between processes). func state_value(runtime: Variant) -> Dictionary: var plans := {} + var needs := {} for guest: GuestRuntime in runtime.guest_service.get_guests(): plans[String(guest.guest_id)] = guest.planned_exit_at + var names: Array[String] = [] + for name: Variant in guest.needs: + names.append(String(name)) + names.sort() + var row := {} + for name: String in names: + row[name] = guest.needs[name] + needs[String(guest.guest_id)] = row var arrivals := 0 for report: Dictionary in runtime.operations_coordinator.get_arrival_reports(): arrivals += int(report[&"total_count"]) @@ -89,6 +129,8 @@ func state_value(runtime: Variant) -> Dictionary: "weather": runtime.weather.create_snapshot(), "stream": runtime.get_rng_stream(&"weather").snapshot(), "plans": plans, + "needs": needs, + "reserve": runtime.mana_reserve.create_snapshot(), "reserve_flow": runtime.mana_reserve.get_last_flow(), "arrivals": arrivals, } diff --git a/tools/regression/suite-policy.json b/tools/regression/suite-policy.json index bf3e27a..03b091a 100644 --- a/tools/regression/suite-policy.json +++ b/tools/regression/suite-policy.json @@ -440,21 +440,21 @@ } , "tests/environment/test_p07_weather_runtime.gd": { - "source_sha256": "11caac7e652213a708c270e136853b0fa053bcda502576da741f1f5d67cdd64b", + "source_sha256": "0dfba98e091f015f74fc7897f8a7b7ce8e286826dc2a17785095ab4e0d51a946", "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", + "source_sha256": "39ca0eb47eef46a5c2c73cb165b300360753ce74010b6512901c0f921d2e00a6", "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", + "source_sha256": "dd7c41952ced7edf38d2fbc5517128bc7b955c49ddccca90eb933062f4b8e8a7", "ordinary_cleanup_prepared": false, "ordinary_control_labels": [], "old_bounded_teardown": false,