P14-02: Add buildable Mana warp (A07)

2x2 FRONT warp at 3000G/30 materials/360 min, 15G/day and 1 Mana upkeep.
Every completed warp pair is linked by one path connector, so crossing is a
single step; a 3-Mana fare per rider is charged once in the step's tick.
Warps close while either end is unpowered, the reserve is empty or below one
fare, and blocked arrivals offer no step. Saves validate the derived links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Takeshi
2026-10-20 10:45:28 +09:00
co-authored by Claude Opus 5.5
parent 7e6e540837
commit 0cfaec1626
14 changed files with 431 additions and 9 deletions
+187
View File
@@ -0,0 +1,187 @@
extends SceneTree
## A07 Mana warp through ordinary construction, natural guests and the reserve.
## "-- writer" (default) runs the mechanics and saves mid-operation; "-- reader"
## restores that save in a new process and must reproduce the writer exactly.
const Warp = preload("res://scripts/transport/warp_service.gd")
const ManaOutage = preload("res://tests/helpers/mana_outage.gd")
const SAVE_PATH := "user://saves/p14_02_warp_new_pid"
const EXPECTED_PATH := "user://saves/p14_02_warp_new_pid_expected.json"
const CONTINUE_MINUTES := 120
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("Warp runtime %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, 8808)
_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("warp.test.%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 _warp_connectors() -> Array[StringName]:
return _runtime.path_service.get_connector_ids_with_prefix(Warp.PREFIX)
func _warp_events() -> Array[Dictionary]:
var rows: Array[Dictionary] = []
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
if event.event_type == &"WARP_TRAVELED":
rows.append({&"minute": event.absolute_minute, &"mana": int(event.payload[&"mana"])})
return rows
func _write() -> void:
_expect(_cmd(&"change_resources", {&"deltas": {&"gold": 6000, &"materials": 40}}).is_success(), "fund two warps for the fixture")
var gold_before: int = _state.get_resource(&"gold")
if not _build(&"mana_warp", Vector3i(0, 0, 0)) or not _build(&"reception", Vector3i(2, 0, 0)):
return
_expect(gold_before - int(_state.get_resource(&"gold")) >= 3000, "warp construction charges its approved 3000G")
_clock.advance_minutes(359)
_expect(_warp_connectors().is_empty(), "a single warp has no destination")
_clock.advance_minutes(1)
if not _build(&"mana_warp", Vector3i(0, 2, 0)) or not _build(&"standard_room", Vector3i(2, 2, 0)):
return
_clock.advance_minutes(360)
_expect(_runtime.path_service.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0)).is_empty(), "new warp waits for its first power evaluation")
_clock.advance_minutes(10 - int(_clock.absolute_minute) % 10)
var ids := _warp_connectors()
_expect(ids.size() == 1, "two completed warps are linked by one connector")
if ids.size() != 1:
return
var connector: Dictionary = _runtime.path_service.get_connector(ids[0])
_expect(connector[&"from"] == Vector3i(0, 0, 0) and connector[&"to"] == Vector3i(0, 2, 0) and connector[&"operational_state"] == PathService.CONNECTOR_OPERATIONAL, "connector joins both warp origins and is operational")
_expect(_runtime.path_service.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0)) == [Vector3i(0, 0, 0), Vector3i(0, 2, 0)], "path crosses floors in one instant step")
_expect(_runtime.mana_network_service.get_total_consumer_demand() >= 2, "each warp registers 1 Mana upkeep")
# Outage and an unaffordable fare both remove the warp from routing.
var before_outage: Dictionary = _runtime.create_snapshot()
ManaOutage.hold(_runtime, _state)
_clock.advance_minutes(1)
_expect(_runtime.path_service.get_connector(ids[0])[&"operational_state"] == PathService.CONNECTOR_OUTAGE, "reserve outage stops the warp")
_expect(_runtime.path_service.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0)).is_empty(), "stopped warp offers no route")
ManaOutage.release(_runtime, _state, Warp.MANA_PER_TRIP - 1)
_clock.advance_minutes(1)
_expect(_runtime.path_service.get_connector(ids[0])[&"operational_state"] == PathService.CONNECTOR_OUTAGE, "reserve below one fare keeps the warp closed")
_expect(_runtime.restore_snapshot(before_outage), "rewind outage drill")
_clock.advance_minutes(1)
var mana_blocked: int = _state.get_resource(&"mana")
_runtime.path_service.set_cell_temporary_block(Vector3i(0, 2, 0), true)
_expect(_runtime.path_service.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0)).is_empty()
and _runtime.call(&"_warp_step_connector", Vector3i(0, 0, 0), Vector3i(0, 2, 0), [] as Array[StringName]) == &"", "blocked arrival offers no warp step and no fare")
_expect(_state.get_resource(&"mana") == mana_blocked, "blocked arrival charges nothing")
_expect(_runtime.restore_snapshot(before_outage), "rewind arrival drill")
# Natural guests use the warp; each crossing charges exactly 3 Mana once.
_expect(_cmd(&"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success(), "hire receptionist")
var reception: FacilityInstance = null
for facility: FacilityInstance in _runtime.facility_service.get_instances():
if facility.definition_id == &"reception": reception = facility
_expect(_cmd(&"set_staff_work_assignment", {&"staff_id": &"staff_akane", &"facility_id": reception.instance_id, &"template_id": &"DAY"}).is_success(), "assign receptionist")
var saved := false
var crossings := 0
for minute: int in range(1200):
var mana_before: int = _state.get_resource(&"mana")
var events_before := _warp_events().size()
_clock.advance_minutes(1)
var new_events := _warp_events().size() - events_before
if new_events > 0:
crossings += new_events
var boundary: bool = int(_clock.absolute_minute) % 10 == 0
if not boundary:
_expect(mana_before - int(_state.get_resource(&"mana")) == Warp.MANA_PER_TRIP * new_events, "warp crossing charges 3 Mana per rider")
if not saved:
saved = true
var snapshot: Dictionary = _runtime.create_snapshot()
_expect(SaveService.save_snapshot(SAVE_PATH, snapshot)[&"ok"], "save right after a paid crossing")
_clock.advance_minutes(CONTINUE_MINUTES)
var file := FileAccess.open(EXPECTED_PATH, FileAccess.WRITE)
file.store_string(JSON.stringify({
"writer_pid": OS.get_process_id(),
"saved_events": events_before + new_events,
"mana": _state.get_resource(&"mana"),
"digest": SimulationDigest.compute(_runtime.create_snapshot()),
"events": JSON.stringify(_warp_events()),
}))
file.close()
break
if crossings == 0:
for guest: GuestRuntime in _runtime.guest_service.get_guests():
print("GUEST ", guest.guest_id, " ", guest.state, " ", guest.current_cell)
for facility: FacilityInstance in _runtime.facility_service.get_instances():
print("FAC ", facility.definition_id, " ", facility.state, " ", facility.active_staff_ids)
_expect(crossings > 0, "a natural guest crosses by warp")
_expect(saved, "mid-operation save written")
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "warp state round trips")
# Demolishing one end removes the link.
var upper: StringName = &""
for facility: Dictionary in _runtime.construction_service.get_facilities():
if facility[&"definition_id"] == &"mana_warp" and facility[&"origin"] == Vector3i(0, 2, 0):
upper = facility[&"instance_id"]
var occupied := false
for guest: GuestRuntime in _runtime.guest_service.get_guests():
if guest.current_cell.x <= 1 and guest.current_cell.y >= 2: occupied = true
if not occupied:
_expect(_cmd(&"demolish_facility", {&"instance_id": upper}).is_success(), "demolish upper warp")
_expect(_warp_connectors().is_empty(), "demolition removes the warp link")
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "post-demolition state restores")
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-operation save")
_expect(_warp_events().size() == int(expected["saved_events"]), "restored crossing history is not replayed")
_clock.advance_minutes(CONTINUE_MINUTES)
_expect(_state.get_resource(&"mana") == int(expected["mana"]), "continued Mana matches: no double fare")
_expect(JSON.stringify(_warp_events()) == String(expected["events"]), "continued crossings match 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)
+2 -1
View File
@@ -131,7 +131,8 @@ func _run() -> void:
)
await _test_defense_notice()
var palette := _main.find_child("BuildPalette", true, false) as GridContainer
_expect(palette.get_child_count() == 23, "all23 facilities including the cleaning room appear in the build palette")
_expect(palette.get_child_count() == 24, "all24 facilities including the cleaning room and A07 warp appear in the build palette")
_expect((_main.get("build_buttons") as Dictionary).has(&"mana_warp"), "Mana warp is buildable from the ordinary palette")
var iconized_build_buttons := 0
for build_button_value: Variant in (_main.get("build_buttons") as Dictionary).values():
if (build_button_value as Button).icon != null: