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:
co-authored by
Claude Opus 5.5
parent
db5ca62e42
commit
826ef606df
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+23
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user