404 lines
28 KiB
GDScript
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)
|