P07-01-WX: 仕上がった天候の機能の受入:自然天候・雨・嵐・世界樹と成人客のマナ霧・上部の表示と予報・保存と移行(マナ炉の産出 120 を除く。P07-01 の補助の作業。原単位への加算 0)

This commit is contained in:
Agent Control Runner
2026-10-24 05:04:48 +09:00
parent 7ac2224e47
commit c50136017b
25 changed files with 1868 additions and 28 deletions
@@ -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)
@@ -0,0 +1 @@
uid://bz9x67zdqdrc6
@@ -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)
@@ -0,0 +1 @@
uid://bni9w8axk6f97
@@ -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)
@@ -0,0 +1 @@
uid://bsaoksmz5s26w
+7 -2
View File
@@ -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")
+5 -1
View File
@@ -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()
+1 -1
View File
@@ -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")
@@ -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()
@@ -0,0 +1 @@
uid://bczrqi1qiv5zk