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 <noreply@anthropic.com>
This commit is contained in:
Takeshi
2026-10-20 08:59:59 +09:00
co-authored by Claude Opus 5.5
parent db5ca62e42
commit 826ef606df
5 changed files with 187 additions and 1 deletions
+2
View File
@@ -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_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 -- writer
$(GODOT) --headless --path . --script res://tests/s2/test_mana_reserve_new_pid.gd -- reader $(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 $(GODOT) --headless --path . --script res://tests/s2/test_runtime_operations_cycle.gd
test-s3: test-s3:
+14
View File
@@ -10741,6 +10741,20 @@ func _reservation_references_are_valid(
return false return false
return true 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 ## A06 upkeep demand: every owned consumer, open or not, except destroyed
## facilities. Removed facilities are already unregistered from the network. ## facilities. Removed facilities are already unregistered from the network.
func _mana_reserve_demand() -> int: func _mana_reserve_demand() -> int:
+23 -1
View File
@@ -2583,6 +2583,7 @@ func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") ->
_synchronize_elevator_pending() _synchronize_elevator_pending()
_queue_dashboard_refresh() _queue_dashboard_refresh()
_update_pause_and_speed() _update_pause_and_speed()
_refresh_mana_tooltip()
var operation: Dictionary = _snapshot.get(&"systems", {}).get(&"runtime", {}).get(&"operations", {}).get(&"coordinator", {}) var operation: Dictionary = _snapshot.get(&"systems", {}).get(&"runtime", {}).get(&"operations", {}).get(&"coordinator", {})
var summaries: Array = operation.get(&"day_summaries", []) var summaries: Array = operation.get(&"day_summaries", [])
if summaries.is_empty(): if summaries.is_empty():
@@ -2808,7 +2809,7 @@ func _on_resource_changed(resource_name: StringName, value: int) -> void:
if resource_name == &"mana": if resource_name == &"mana":
label.text += "/%d" % MANA_RESERVE.CAPACITY label.text += "/%d" % MANA_RESERVE.CAPACITY
label.mouse_filter = Control.MOUSE_FILTER_STOP 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: if value <= 0:
label.text += "・停止" label.text += "・停止"
label.add_theme_color_override("font_color", Color("ffb095")) 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"))) 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: func _button(text_value: String, callback: Callable, minimum_width := 82) -> Button:
var button := Button.new() var button := Button.new()
button.text = text_value button.text = text_value
+146
View File
@@ -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)
+2
View File
@@ -64,6 +64,8 @@ func _run() -> void:
_state.set_resource(&"mana", 0) _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.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("残量と配電" 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": if OS.get_environment("THE_TREE_MANA_CAPTURE") == "1":
await process_frame await process_frame
await RenderingServer.frame_post_draw await RenderingServer.frame_post_draw