diff --git a/Makefile b/Makefile index 3f19e0b..878109e 100644 --- a/Makefile +++ b/Makefile @@ -102,6 +102,8 @@ test-s6: test-mvp: $(GODOT) --headless --path . --script res://tests/mvp/test_premium_room_guest_flow.gd $(GODOT) --headless --path . --script res://tests/mvp/test_runtime_mana_depletion.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_whole_tower_outage.gd -- writer + $(GODOT) --headless --path . --script res://tests/mvp/test_whole_tower_outage.gd -- reader $(GODOT) --headless --path . --script res://tests/mvp/test_premium_room.gd $(GODOT) --headless --path . --script res://tests/mvp/test_original_room_zones.gd $(GODOT) --headless --path . --script res://tests/mvp/test_upkeep_loss_streak.gd diff --git a/tests/mvp/test_whole_tower_outage.gd b/tests/mvp/test_whole_tower_outage.gd new file mode 100644 index 0000000..2ce4424 --- /dev/null +++ b/tests/mvp/test_whole_tower_outage.gd @@ -0,0 +1,193 @@ +extends SceneTree + +## P06-02 whole-tower stop under the A06 finite reserve, reached naturally: the +## tower's own upkeep demand exceeds the world tree's 20 Mana per 10 minutes. +## "-- writer" (default) builds an ordinary operating tower, waits for natural +## depletion, checks every domain during the outage and saves mid-outage; +## "-- reader" restores that save in a new process and must match exactly. +const SAVE_PATH := "user://saves/p06_02_whole_outage" +const EXPECTED_PATH := "user://saves/p06_02_whole_outage_expected.json" +const OUTAGE_OBSERVE_MINUTES := 60 +const CONTINUE_MINUTES := 60 +var _checks := 0 +var _failures := 0 +var _serial := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var mode := "reader" if "reader" in OS.get_cmdline_user_args() else "writer" + print("Whole tower outage %s (pid %d)" % [mode, OS.get_process_id()]) + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + _runtime.initialize(_clock, _state, 6602) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _clock.set_paused(true) + _expect(bool(_runtime.enable_production_operations().get(&"ok", false)), "natural operations start at Day 1 06:00") + if mode == "writer": + _write() + else: + _read() + _runtime.free(); _clock.free(); _state.free() + print("%s: %d checks" % ["PASS" if _failures == 0 else "FAIL", _checks]) + quit(_failures) + + +func _cmd(kind: StringName, payload: Dictionary) -> CommandResult: + _serial += 1 + return _runtime.submit_command(kind, payload, &"player", &"", 0, _clock.absolute_minute, StringName("p0602.%d" % _serial)) + + +func _build(id: StringName, origin: Vector3i) -> bool: + var result := _cmd(&"build_facility", {&"definition_id": id, &"origin": origin}) + _expect(result.is_success(), "build %s at %s %s" % [id, origin, result.message]) + return result.is_success() + + +func _facility(id: StringName) -> FacilityInstance: + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == id: + return facility + return null + + +## Everything an outage must not create, consume or lose. +func _ledger() -> Dictionary: + var revenue := 0 + var wallets := {} + for guest: GuestRuntime in _runtime.guest_service.get_guests(): + wallets[String(guest.guest_id)] = _runtime.economy_service.get_balance(StringName("GUEST_WALLET:%s" % guest.guest_id)) + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + revenue += _runtime.economy_service.get_balance(StringName("FACILITY_REVENUE:%s" % facility.instance_id)) + var meals := 0 + var stock := 0 + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + meals += _runtime.inventory_service.total_quantity(facility.local_inventory_id, SupplyCatalog.BASIC_MEAL) + stock += _runtime.inventory_service.total_quantity(facility.local_inventory_id, SupplyCatalog.RAW_FOOD) + var guest_ids: Array[String] = [] + for guest: GuestRuntime in _runtime.guest_service.get_guests(): + guest_ids.append(String(guest.guest_id)) + guest_ids.sort() + return {"revenue": revenue, "wallets": wallets, "meals": meals, "raw_food": stock, + "guests": guest_ids, "cash": _runtime.economy_service.get_balance(&"PLAYER_CASH"), "staff": _runtime.staff_service.get_staff_members().size()} + + +func _events_since(minute: int, types: Array) -> int: + var count := 0 + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if event.absolute_minute > minute and event.event_type in types: + count += 1 + return count + + +func _write() -> void: + _expect(_cmd(&"change_resources", {&"deltas": {&"gold": 30000, &"materials": 300}}).is_success(), "fund the mixed tower fixture") + # FRONT: warps x-6..-5, massage x-4..-1, elevator stack x0-1, reception/rooms x2-5, cafeteria x6-7. + _build(&"guest_elevator", Vector3i(0, 0, 0)) + _build(&"reception", Vector3i(2, 0, 0)) + _build(&"cafeteria", Vector3i(6, 0, 0)) + _build(&"mana_warp", Vector3i(-6, 0, 0)) + _build(&"massage_room", Vector3i(-4, 0, 0)) + _build(&"massage_room", Vector3i(-2, 0, 0)) + _build(&"warehouse", Vector3i(2, 0, 1)) + _build(&"security_station", Vector3i(-2, 0, 1)) + _clock.advance_minutes(360) + _build(&"guest_elevator", Vector3i(0, 2, 0)) + _build(&"standard_room", Vector3i(2, 2, 0)) + _build(&"mana_warp", Vector3i(-6, 2, 0)) + _clock.advance_minutes(360) + var cafe := _facility(&"cafeteria") + if cafe != null: + var kitchen: Dictionary = load("res://tests/fixtures/required_kitchen_setup.gd").ensure(_runtime, _clock, cafe.instance_id, false) + _expect(bool(kitchen.get(&"ok", false)), "kitchen linked to cafeteria") + var reception := _facility(&"reception") + _expect(reception != null and _cmd(&"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success(), "hire receptionist") + if reception != null: + _expect(_cmd(&"set_staff_work_assignment", {&"staff_id": &"staff_akane", &"facility_id": reception.instance_id, &"template_id": &"DAY"}).is_success(), "assign receptionist") + var demand: int = _runtime.call(&"_mana_reserve_demand") + print("FIXTURE demand=%d mana=%d minute=%d" % [demand, _state.get_resource(&"mana"), _clock.absolute_minute]) + _expect(demand > 20, "ordinary tower upkeep exceeds world tree generation (natural depletion)") + + # Run normally until the reserve reaches 0 through upkeep alone. + var depleted_at := -1 + for minute: int in range(3000): + _clock.advance_minutes(1) + if _state.get_resource(&"mana") == 0: + depleted_at = _clock.absolute_minute + break + _expect(depleted_at > 0, "reserve reaches 0 naturally") + if depleted_at < 0: + return + var flow: Dictionary = _runtime.mana_reserve.get_last_flow() + _expect(int(flow[&"after"]) == 0 and int(flow[&"demand"]) > int(flow[&"generation"]) and int(flow[&"generation"]) == 20, "depletion comes from upkeep exceeding generation, not a test input") + _clock.advance_minutes(1) + var before := _ledger() + var started: int = _clock.absolute_minute + _clock.advance_minutes(OUTAGE_OBSERVE_MINUTES) + var after := _ledger() + _expect(_state.get_resource(&"mana") == 0, "outage persists while upkeep exceeds generation") + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + _expect(not facility.power_available, "%s is stopped" % facility.definition_id) + var wallets_kept := true + for guest_id: String in after["wallets"]: + if before["wallets"].has(guest_id) and before["wallets"][guest_id] != after["wallets"][guest_id]: + wallets_kept = false + _expect(after["revenue"] == before["revenue"] and wallets_kept, "no unconditional sales during the outage (revenue and every remaining wallet unchanged)") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") <= int(before["cash"]), "no income reaches the tower during the outage") + _expect(after["meals"] == before["meals"] and after["raw_food"] == before["raw_food"], "no production and no lost stock during the outage") + _expect(_events_since(started, [&"GUEST_SPAWNED", &"GUEST_AUTO_ARRIVED"]) == 0, "no admissions during the outage") + _expect(_events_since(started, [&"WARP_TRAVELED"]) == 0, "no warp travel during the outage") + _expect(_events_since(started, [&"ELEVATOR_BOARDED"]) == 0, "no elevator boarding during the outage") + var departed := {} + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if event.absolute_minute > started and event.event_type in [&"GUEST_LEFT", &"GUEST_DESPAWNED"]: + departed[String(event.entity_ids[0])] = true + for guest_id: String in before["guests"]: + _expect(_runtime.guest_service.get_guest(StringName(guest_id)) != null or departed.has(guest_id), "guest %s stays or leaves through an ordinary departure" % guest_id) + for trip: Dictionary in _runtime.elevators.get_report()[&"trips"]: + _expect(trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED, "only held boarded riders remain on a stopped cabin") + + var snapshot: Dictionary = _runtime.create_snapshot() + _expect(SaveService.save_snapshot(SAVE_PATH, snapshot)[&"ok"], "save mid-outage") + _clock.advance_minutes(CONTINUE_MINUTES) + var file := FileAccess.open(EXPECTED_PATH, FileAccess.WRITE) + file.store_string(JSON.stringify({ + "writer_pid": OS.get_process_id(), + "ledger": JSON.stringify(_ledger()), + "digest": SimulationDigest.compute(_runtime.create_snapshot()), + })) + file.close() + + +func _read() -> void: + var expected: Variant = JSON.parse_string(FileAccess.get_file_as_string(EXPECTED_PATH)) + _expect(expected is Dictionary, "reader finds writer record") + if not expected is Dictionary: + return + _expect(int(expected["writer_pid"]) != OS.get_process_id(), "reader runs in a different process") + var loaded := SaveService.load_snapshot(SAVE_PATH) + _expect(loaded[&"ok"] and _runtime.restore_snapshot(loaded[&"snapshot"]), "reader restores mid-outage save") + _expect(_state.get_resource(&"mana") == 0, "restored tower is still stopped") + _clock.advance_minutes(CONTINUE_MINUTES) + _expect(JSON.stringify(_ledger()) == String(expected["ledger"]), "continued outage ledger matches the writer") + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == String(expected["digest"]), "whole continued state digest matches") + SaveService.delete_save(SAVE_PATH) + DirAccess.remove_absolute(ProjectSettings.globalize_path(EXPECTED_PATH)) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + else: + _failures += 1 + push_error(" ✗ %s" % label)