Files
TheTree_mock/tests/environment/test_p07_weather_runtime.gd

404 lines
28 KiB
GDScript

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_rain_hold_exit()
_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")
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 natural := _new_runtime(SEED_RAIN)
var natural_runtime: Variant = natural[&"runtime"]
var departing: Variant = _spawn(natural_runtime, &"p07_departing_guest", &"traveler", 362)
natural[&"clock"].advance_minutes(3)
_expect(natural_runtime.weather.get_rain_hold(&"p07_departing_guest") == {"original_exit": 362, "extended_to": 602} and departing.state == GuestRuntime.State.LEAVING
and departing.exit_reason == &"NO_SUITABLE_FACILITY", "in ordinary minutes the held guest still leaves by the current rules")
natural[&"clock"].advance_minutes(1)
var departed: Dictionary = natural_runtime.create_snapshot()
_expect(natural_runtime.guest_service.get_guest(&"p07_departing_guest") == null and natural_runtime.weather.get_rain_holds().is_empty()
and natural_runtime.restore_snapshot(departed) and natural_runtime.create_snapshot() == departed, "the hold leaves with the guest, so the minute it left restores")
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.
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")
# 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.
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 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")
func _expect(condition: bool, message: String) -> void:
_checks += 1
if condition:
return
_failures += 1
push_error(message)