From 826ef606df6d8a340e22d259caea05473d087be5 Mon Sep 17 00:00:00 2001 From: Takeshi Date: Tue, 20 Oct 2026 08:59:59 +0900 Subject: [PATCH] P06-03: Show Mana supply, consumption and storage; prove new-PID recovery HUD tooltip reports world tree supply, upkeep consumption, net, stored amount and the last boundary's shortage/overflow. A writer/reader pair saves mid-outage, recovers in a new process and matches the writer exactly while DESTROYED and RAIDED suspensions stay in place. Co-Authored-By: Claude Opus 5.5 --- Makefile | 2 + autoload/simulation_runtime.gd | 14 +++ scenes/main/main.gd | 24 +++- tests/s2/test_mana_recovery_new_pid.gd | 146 +++++++++++++++++++++++++ tests/ui/test_main_dashboard.gd | 2 + 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 tests/s2/test_mana_recovery_new_pid.gd diff --git a/Makefile b/Makefile index d8ca962..da852ed 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,8 @@ test-s2: $(GODOT) --headless --path . --script res://tests/s2/test_runtime_mana_reserve.gd $(GODOT) --headless --path . --script res://tests/s2/test_mana_reserve_new_pid.gd -- writer $(GODOT) --headless --path . --script res://tests/s2/test_mana_reserve_new_pid.gd -- reader + $(GODOT) --headless --path . --script res://tests/s2/test_mana_recovery_new_pid.gd -- writer + $(GODOT) --headless --path . --script res://tests/s2/test_mana_recovery_new_pid.gd -- reader $(GODOT) --headless --path . --script res://tests/s2/test_runtime_operations_cycle.gd test-s3: diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index f7fa942..c4eb77e 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -10741,6 +10741,20 @@ func _reservation_references_are_valid( return false return true +## HUD view of the A06 reserve: supply, consumption and storage per interval. +func get_mana_reserve_report() -> Dictionary: + var demand := _mana_reserve_demand() + return { + &"amount": int(_state.get_resource(&"mana")), + &"capacity": ManaReserveCycle.CAPACITY, + &"generation": ManaReserveCycle.WORLD_TREE_GENERATION, + &"interval_minutes": ManaReserveCycle.INTERVAL_MINUTES, + &"demand": demand, + &"net": ManaReserveCycle.WORLD_TREE_GENERATION - demand, + &"last_flow": mana_reserve.get_last_flow(), + } + + ## A06 upkeep demand: every owned consumer, open or not, except destroyed ## facilities. Removed facilities are already unregistered from the network. func _mana_reserve_demand() -> int: diff --git a/scenes/main/main.gd b/scenes/main/main.gd index d304cc9..835aa64 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -2583,6 +2583,7 @@ func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> _synchronize_elevator_pending() _queue_dashboard_refresh() _update_pause_and_speed() + _refresh_mana_tooltip() var operation: Dictionary = _snapshot.get(&"systems", {}).get(&"runtime", {}).get(&"operations", {}).get(&"coordinator", {}) var summaries: Array = operation.get(&"day_summaries", []) if summaries.is_empty(): @@ -2808,7 +2809,7 @@ func _on_resource_changed(resource_name: StringName, value: int) -> void: if resource_name == &"mana": label.text += "/%d" % MANA_RESERVE.CAPACITY label.mouse_filter = Control.MOUSE_FILTER_STOP - label.tooltip_text = "世界樹が%d分ごとに%dマナを生成し、施設の維持マナを先に支払って残りを最大%dまで蓄えます。\n残量0で全館停止。接客・調理・搬入・エレベーターと新規入館が停止します。\n残量と配電が回復すると再開します。破壊・摘発による休止は解除されません。" % [MANA_RESERVE.INTERVAL_MINUTES, MANA_RESERVE.WORLD_TREE_GENERATION, MANA_RESERVE.CAPACITY] + _refresh_mana_tooltip(label) if value <= 0: label.text += "・停止" label.add_theme_color_override("font_color", Color("ffb095")) @@ -2816,6 +2817,27 @@ func _on_resource_changed(resource_name: StringName, value: int) -> void: label.add_theme_color_override("font_color", _visual_color(&"text_light", Color("fff2d0"))) +func _refresh_mana_tooltip(label: Label = null) -> void: + if label == null: + label = resource_labels.get(&"mana") as Label + if label == null: + return + var lines: Array[String] = [] + if _runtime != null and _runtime.has_method(&"get_mana_reserve_report"): + var report: Dictionary = _runtime.call(&"get_mana_reserve_report") + lines.append("供給 +%d/%d分 消費 −%d/%d分 収支 %+d" % [int(report[&"generation"]), int(report[&"interval_minutes"]), int(report[&"demand"]), int(report[&"interval_minutes"]), int(report[&"net"])]) + var flow: Dictionary = report[&"last_flow"] + var stored := "蓄積 %d/%d" % [int(report[&"amount"]), int(report[&"capacity"])] + if not flow.is_empty(): + stored += "(直近:不足 %d・上限超過で破棄 %d)" % [int(flow[&"shortage"]), int(flow[&"overflow"])] + lines.append(stored) + else: + lines.append("世界樹が%d分ごとに%dマナを生成し、施設の維持マナを先に支払って残りを最大%dまで蓄えます。" % [MANA_RESERVE.INTERVAL_MINUTES, MANA_RESERVE.WORLD_TREE_GENERATION, MANA_RESERVE.CAPACITY]) + lines.append("残量0で全館停止。接客・調理・搬入・エレベーターと新規入館が停止します。") + lines.append("残量と配電が回復すると再開します。破壊・摘発による休止は解除されません。") + label.tooltip_text = "\n".join(lines) + + func _button(text_value: String, callback: Callable, minimum_width := 82) -> Button: var button := Button.new() button.text = text_value diff --git a/tests/s2/test_mana_recovery_new_pid.gd b/tests/s2/test_mana_recovery_new_pid.gd new file mode 100644 index 0000000..f82cce7 --- /dev/null +++ b/tests/s2/test_mana_recovery_new_pid.gd @@ -0,0 +1,146 @@ +extends SceneTree + +## P06-03 outage recovery across a real process boundary. "-- writer" saves +## mid-outage and records its own recovery; "-- reader" restores that save in a +## new Godot process, recovers the same way and must match exactly. Recovery +## must never clear DESTROYED or RAIDED suspension. +const ManaOutage = preload("res://tests/helpers/mana_outage.gd") +const OUTAGE_SAVE := "user://saves/p06_03_recovery_new_pid" +const EXPECTED_PATH := "user://saves/p06_03_recovery_new_pid_expected.json" +const RECOVERY_MINUTES := 30 +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var mode := "reader" if "reader" in OS.get_cmdline_user_args() else "writer" + print("S2: P06-03 Mana recovery new-PID %s (pid %d)" % [mode, OS.get_process_id()]) + if mode == "writer": + _write() + else: + _read() + print("%s: %d checks" % ["PASS" if _failures == 0 else "FAIL", _checks]) + quit(_failures) + + +func _new_runtime() -> Array: + 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() + runtime.initialize(clock, state, 6606) + clock.set_paused(true) + return [clock, state, runtime] + + +func _facility(runtime: Variant, definition_id: StringName) -> FacilityInstance: + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id == definition_id: + return facility + return null + + +func _summary(runtime: Variant, state: Variant) -> Dictionary: + var rows := {} + for facility: FacilityInstance in runtime.facility_service.get_instances(): + rows[String(facility.definition_id)] = { + "power": facility.power_available, + "suspension": String(facility.suspension_reason), + "operation": facility.operation_state, + } + return {"mana": state.get_resource(&"mana"), "facilities": rows} + + +func _recover(clock: Variant, state: Variant, runtime: Variant) -> void: + ManaOutage.release(runtime, state, 0) + clock.advance_minutes(RECOVERY_MINUTES) + + +func _write() -> void: + var nodes := _new_runtime() + var clock: Variant = nodes[0] + var state: Variant = nodes[1] + var runtime: Variant = nodes[2] + _expect(runtime.submit_command(&"build_facility", {&"definition_id": &"reception", &"origin": Vector3i(-6, 0, 0)}).is_success(), "build reception") + clock.advance_minutes(120) + _expect(runtime.submit_command(&"build_facility", {&"definition_id": &"standard_room", &"origin": Vector3i(-6, 2, 0)}).is_success(), "build room on supported floor") + var cafeteria_built := false + for x: int in [-2, 2, -8, -10]: + if runtime.submit_command(&"build_facility", {&"definition_id": &"cafeteria", &"origin": Vector3i(x, 0, 0)}).is_success(): + cafeteria_built = true + break + _expect(cafeteria_built, "build cafeteria on ground floor") + clock.advance_minutes(180) + var reception := _facility(runtime, &"reception") + var cafeteria := _facility(runtime, &"cafeteria") + var room := _facility(runtime, &"standard_room") + _expect(reception != null and cafeteria != null and room != null, "facilities complete normally") + if reception == null or cafeteria == null or room == null: + return + runtime.facility_service.set_operational_status(cafeteria.instance_id, {&"suspension_reason": &"DESTROYED"}, clock.absolute_minute) + runtime.facility_service.set_operational_status(room.instance_id, {&"suspension_reason": &"RAIDED"}, clock.absolute_minute) + ManaOutage.hold(runtime, state) + clock.advance_minutes(15) + _expect(not _facility(runtime, &"reception").power_available, "outage stops the tower") + var outage: Dictionary = runtime.create_snapshot() + _expect(SaveService.save_snapshot(OUTAGE_SAVE, outage)[&"ok"], "writer saves mid-outage") + _recover(clock, state, runtime) + var summary := _summary(runtime, state) + _check_recovered(summary) + var file := FileAccess.open(EXPECTED_PATH, FileAccess.WRITE) + _expect(file != null, "writer records recovery") + if file != null: + file.store_string(JSON.stringify({ + "writer_pid": OS.get_process_id(), + "summary": summary, + "digest": SimulationDigest.compute(runtime.create_snapshot()), + })) + file.close() + for node: Variant in nodes: + node.free() + + +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 nodes := _new_runtime() + var clock: Variant = nodes[0] + var state: Variant = nodes[1] + var runtime: Variant = nodes[2] + var loaded := SaveService.load_snapshot(OUTAGE_SAVE) + _expect(loaded[&"ok"] and runtime.restore_snapshot(loaded[&"snapshot"]), "reader restores mid-outage save") + _expect(state.get_resource(&"mana") == 0 and not _facility(runtime, &"reception").power_available, "restored tower is still stopped") + _recover(clock, state, runtime) + var summary := _summary(runtime, state) + _check_recovered(summary) + _expect(JSON.stringify(JSON.parse_string(JSON.stringify(summary))) == JSON.stringify(expected["summary"]), "recovered facility states match the writer") + _expect(SimulationDigest.compute(runtime.create_snapshot()) == String(expected["digest"]), "whole recovered state digest matches the writer") + SaveService.delete_save(OUTAGE_SAVE) + DirAccess.remove_absolute(ProjectSettings.globalize_path(EXPECTED_PATH)) + for node: Variant in nodes: + node.free() + + +func _check_recovered(summary: Dictionary) -> void: + var rows: Dictionary = summary["facilities"] + _expect(int(summary["mana"]) > 0, "world tree refills the reserve after the outage") + _expect(bool(rows["reception"]["power"]) and String(rows["reception"]["suspension"]) == "", "ordinary facility power returns") + _expect(bool(rows["cafeteria"]["power"]) and String(rows["cafeteria"]["suspension"]) == "DESTROYED" and int(rows["cafeteria"]["operation"]) == FacilityInstance.OperationState.SUSPENDED, "recovery does not clear destruction") + _expect(bool(rows["standard_room"]["power"]) and String(rows["standard_room"]["suspension"]) == "RAIDED" and int(rows["standard_room"]["operation"]) == FacilityInstance.OperationState.SUSPENDED, "recovery does not clear raid suspension") + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print(" ✓ %s" % label) + else: + _failures += 1 + push_error(" ✗ %s" % label) diff --git a/tests/ui/test_main_dashboard.gd b/tests/ui/test_main_dashboard.gd index b3b7806..7bf5880 100644 --- a/tests/ui/test_main_dashboard.gd +++ b/tests/ui/test_main_dashboard.gd @@ -64,6 +64,8 @@ func _run() -> void: _state.set_resource(&"mana", 0) _expect("停止" in mana_label.text and "0" in mana_label.text, "resource signal visibly identifies zero-reserve outage") _expect("残量と配電" in mana_label.tooltip_text and "休止は解除されません" in mana_label.tooltip_text, "mana HUD explains recovery without promising suspension reset") + _expect("供給 +20" in mana_label.tooltip_text and "消費 −" in mana_label.tooltip_text and "蓄積 0/300" in mana_label.tooltip_text, "mana HUD shows A06 supply, consumption and storage") + _expect(mana_label.text.ends_with("/300・停止"), "mana HUD shows capacity with outage") if OS.get_environment("THE_TREE_MANA_CAPTURE") == "1": await process_frame await RenderingServer.frame_post_draw