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
+2
View File
@@ -238,6 +238,8 @@ test-transport:
$(GODOT) --headless --path . --script res://tests/transport/test_transport_density_migration.gd
$(GODOT) --headless --path . --script res://tests/transport/test_transport_boundaries.gd
$(GODOT) --headless --path . --script res://tests/transport/test_runtime_transport.gd
$(GODOT) --headless --path . --script res://tests/transport/test_runtime_warp.gd -- writer
$(GODOT) --headless --path . --script res://tests/transport/test_runtime_warp.gd -- reader
capture-transport:
THE_TREE_TRANSPORT_CAPTURE=1 $(GODOT) --path . --script res://tests/transport/test_runtime_transport.gd
+63 -4
View File
@@ -30,6 +30,7 @@ const CLEANING_RUNTIME_SNAPSHOT_VERSION := 12
const WEATHER_RUNTIME_SNAPSHOT_VERSION := 11
const FacilityCleanliness = preload("res://scripts/environment/facility_cleanliness_service.gd")
const ManaReserveCycle = preload("res://scripts/mana/mana_reserve_cycle.gd")
const WarpService = preload("res://scripts/transport/warp_service.gd")
const CleaningBridge = preload("res://scripts/environment/facility_cleaning_bridge.gd")
const CleaningPolicy = preload("res://scripts/environment/facility_cleaning_policy.gd")
const UPKEEP_RUNTIME_SNAPSHOT_VERSION := 10
@@ -1257,6 +1258,8 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
if not _validate_elevator_crosslinks(restored_elevators, restored_path, restored_construction,
restored_guests, restored_staff, restored_runtime_domain_minute):
return false
if not WarpService.connectors_match(restored_path, restored_construction.get_facilities()):
return false
for facility: FacilityInstance in restored_facilities.get_instances():
for staff_id: StringName in facility.active_staff_ids:
if not restored_elevators.get_actor_trip(_staff_path_actor_id(staff_id)).is_empty() and not CleaningBridge.is_deployed(restored_staff.get_staff(staff_id), facility, restored_staff):
@@ -7844,7 +7847,8 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult:
demolished.command_id = command.command_id
var transport_ok := false
if demolished.is_success():
transport_ok = bool(_synchronize_elevators(_runtime_event_minute()).get(&"ok", false)) and _prune_legacy_stairs_and_movement()
transport_ok = bool(_synchronize_elevators(_runtime_event_minute()).get(&"ok", false)) \
and bool(_synchronize_warps().get(&"ok", false)) and _prune_legacy_stairs_and_movement()
if not transport_ok or not demolished.is_success() \
or not _validate_runtime_path_crosslinks(path_service, guest_service, staff_service, _movement_credits, _runtime_event_minute()) \
or facility_infrastructure_projector.has_projection(instance_id) \
@@ -8359,6 +8363,11 @@ func _apply_completed_facility_internal(instance: Dictionary, absolute_minute: i
var transport := _synchronize_elevators(absolute_minute)
if not bool(transport.get(&"ok", false)):
return {&"ok": false, &"code": &"ELEVATOR_SYNC_FAILED", &"reason": elevators.last_error, &"details": transport}
if definition.id == WarpService.DEFINITION_ID:
_register_facility_mana_consumer(instance)
var warp_sync := _synchronize_warps()
if not bool(warp_sync.get(&"ok", false)):
return {&"ok": false, &"code": &"WARP_SYNC_FAILED", &"reason": String(warp_sync.get(&"reason", "")), &"details": warp_sync}
if not _prune_legacy_stairs_and_movement():
return {&"ok": false, &"code": &"LEGACY_STAIR_PRUNE_FAILED", &"reason": "Stair topology could not be updated."}
if not _publish_domain_event(SimulationDomainEvent.new(
@@ -8976,7 +8985,8 @@ func _advance_guest_path_travel(
actor_id, guest.current_cell, absolute_minute
)
accumulated = mini(accumulated, required_credit + MOVEMENT_CREDIT_SCALE - 1)
if accumulated < required_credit:
var guest_warp := _warp_step_connector(path[0], path[1], [])
if accumulated < required_credit or not _warp_fare_affordable(guest_warp, 1):
_record_blocked_actor_wait(
actor_id,
guest.guest_id,
@@ -9000,6 +9010,7 @@ func _advance_guest_path_travel(
guest.recovery_kind = previous_recovery_kind
_report_path_integration_failure(actor_id, guest.guest_id, &"guest", previous_cell)
return
_charge_warp_fare(guest_warp, [actor_id], previous_cell, guest.current_cell)
var remaining_credit := accumulated - required_credit
_movement_credits[actor_id] = {
&"credit": remaining_credit,
@@ -9102,7 +9113,9 @@ func _advance_staff_task_travel(
actor_id, staff.current_cell, absolute_minute
)
accumulated = mini(accumulated, required_credit + MOVEMENT_CREDIT_SCALE - 1)
if accumulated < required_credit:
var staff_warp := _warp_step_connector(path[0], path[1], [&"staff"])
var warp_riders := 2 if task.task_type == &"rescue_transport" else 1
if accumulated < required_credit or not _warp_fare_affordable(staff_warp, warp_riders):
_record_blocked_actor_wait(
actor_id,
staff.staff_id,
@@ -9126,6 +9139,9 @@ func _advance_staff_task_travel(
return
patient.current_cell = staff.current_cell
_record_expected_actor_wait(_staff_path_actor_id(patient.staff_id), patient.current_cell, absolute_minute)
_charge_warp_fare(staff_warp, [actor_id, _staff_path_actor_id(patient.staff_id)], previous_cell, staff.current_cell)
else:
_charge_warp_fare(staff_warp, [actor_id], previous_cell, staff.current_cell)
var remaining_credit := accumulated - required_credit
_movement_credits[actor_id] = {
&"credit": remaining_credit,
@@ -10802,6 +10818,11 @@ func _advance_mana(absolute_minute: int) -> void:
preload("res://scripts/mana/mana_power_projection.gd").apply(
facility_service, mana_network_service, int(_state.get_resource(&"mana")), absolute_minute
)
var warp_sync := _synchronize_warps()
if not bool(warp_sync.get(&"ok", false)):
_latch_infrastructure_failure(&"WARP_SYNC_FAILED", absolute_minute,
"Warp connectors could not follow the current power state.", warp_sync)
return
for change: Dictionary in report.get(&"state_changes", []):
var consumer_id := StringName(change.get(&"consumer_id", ""))
_publish_domain_event(SimulationDomainEvent.new(
@@ -12821,7 +12842,8 @@ func advance_staff_commute(staff_id: StringName, target_cell: Vector3i, minute:
var required_credit := _step_movement_credit(path_service, path[0], path[1], [&"staff"])
var credit := _movement_credit_after_one_minute(actor_id, staff.current_cell, minute)
credit = mini(credit, required_credit + MOVEMENT_CREDIT_SCALE - 1)
if credit < required_credit:
var commute_warp := _warp_step_connector(path[0], path[1], [&"staff"])
if credit < required_credit or not _warp_fare_affordable(commute_warp, 1):
_record_expected_actor_wait(actor_id, staff.current_cell, minute)
_movement_credits[actor_id] = {&"credit": credit, &"updated_at": minute}
return {&"ok": true, &"moved": false}
@@ -12830,6 +12852,7 @@ func advance_staff_commute(staff_id: StringName, target_cell: Vector3i, minute:
_report_path_integration_failure(actor_id, staff_id, &"staff", previous)
return {&"ok": false}
staff.current_cell = path[1]
_charge_warp_fare(commute_warp, [actor_id], previous, staff.current_cell)
_movement_credits[actor_id] = {&"credit": credit - required_credit, &"updated_at": minute}
_publish_actor_event(&"STAFF_MOVED", &"staff_commute", [staff_id, actor_id], {
&"path_actor_id": actor_id, &"from_cell": previous, &"cell": staff.current_cell,
@@ -12913,6 +12936,42 @@ func _execute_set_elevator_operation(command: SimulationCommand) -> CommandResul
return CommandResult.succeeded(command.command_id, {&"shaft_id": shaft_id, &"operator_open": open_value})
## A07: warp connectors exist for every completed warp pair and are usable only
## while both warps are powered and the reserve can pay one fare.
func _synchronize_warps() -> Dictionary:
var available := {}
var reserve_ok := int(_state.get_resource(&"mana")) > 0
for warp: Dictionary in WarpService.warps_from_facilities(construction_service.get_facilities()):
var state := mana_network_service.get_consumer_state(warp[&"instance_id"])
available[warp[&"instance_id"]] = reserve_ok and state in [ManaNetworkService.ConsumerState.ACTIVE, ManaNetworkService.ConsumerState.DEGRADED]
return WarpService.synchronize(path_service, construction_service.get_facilities(), available,
int(_state.get_resource(&"mana")) >= WarpService.MANA_PER_TRIP)
func _warp_step_connector(from: Vector3i, to: Vector3i, access: Array[StringName]) -> StringName:
var connector_id := path_service.get_step_connector(from, to, access)
return connector_id if WarpService.is_warp_connector(connector_id) else &""
func _warp_fare_affordable(connector_id: StringName, riders: int) -> bool:
return connector_id.is_empty() or int(_state.get_resource(&"mana")) >= WarpService.MANA_PER_TRIP * riders
## Charged once, in the same tick as the committed step; affordability was
## checked before the step, so the deduction cannot fail here.
func _charge_warp_fare(connector_id: StringName, actor_ids: Array, from: Vector3i, to: Vector3i) -> void:
if connector_id.is_empty():
return
var fare := WarpService.MANA_PER_TRIP * actor_ids.size()
_state.apply_resource_deltas({&"mana": -fare})
var subjects: Array[StringName] = [connector_id]
for actor_id: Variant in actor_ids:
subjects.append(StringName(actor_id))
_publish_actor_event(&"WARP_TRAVELED", &"warp", subjects, {
&"connector_id": connector_id, &"from_cell": from, &"cell": to,
&"riders": actor_ids.size(), &"mana": fare})
func _synchronize_elevators(minute: int) -> Dictionary:
return elevators.synchronize(ElevatorService.configurations_from_facilities(
construction_service.get_facilities(), definition_registry), minute)
@@ -0,0 +1,27 @@
[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"]
[resource]
script = ExtResource("1")
id = &"mana_warp"
display_name = "マナワープ"
description = "建設済みの全ワープと瞬時につながる表層の転移陣。客と接客中の従業員が1回3マナで移動する。"
zone = 0
layer = 0
footprint = Vector2i(2, 2)
construction_cost = {&"gold": 3000, &"materials": 30}
build_minutes = 360
weight_per_cell = 3
pass_through = true
maintenance_gold = 15
maintenance_mana = 1
power_priority = 80
staff_min = 0
staff_max = 0
capacity = 4
queue_capacity = 0
base_service_minutes = 1
base_quality = 50
base_price = 0
tags = Array[StringName]([&"guest_access", &"movement", &"schema_facility_proxy", &"warp"])
@@ -41,6 +41,13 @@
"origin": "user_reply_in_conversation",
"approved_at_jst": "2026-10-19",
"reference": "execution/mana-proposal/PROPOSAL.md"
},
{
"id": "A07",
"title": "マナワープの初期値(推薦案)",
"decision": "2x2・表層。建設3000G・資材30、工期360分、維持費15G/日、維持マナ1/10分。建設済みワープは全て相互接続。利用者は客と接客中の従業員。1人1回3マナを出発時に一回だけ差し引く。残量不足・停電・到着先不可のときは経路から除外し、費用と移動を発生させない。途中保存・再開で費用と移動を二重反映しない。",
"origin": "user_reply_in_conversation",
"approved_at_jst": "2026-10-20"
}
],
"pending": [
@@ -84,7 +84,7 @@
"passed": true
},
{
"name": "all6_confirmed_decisions",
"name": "all7_confirmed_decisions",
"passed": true
},
{
@@ -238,7 +238,7 @@
"build_atomic_requirements.py": "6d6ea9e4216372dbd0f9fed3f885a4481b21a3f539a26f64cf98a6830af984c5",
"build_facility_value_contracts.py": "35027b23ce75ce9e4e0cdfc5132fd2eb771ba9f598d97750d7ecf9ecb257f769",
"current_evidence_links.json": "db1837bcc8497201134297e312f4b266a297102285307ec3075d4e23632e26c2",
"decisions.json": "7771ef179b73f061363685a218f7b0d300847fc5881ce0c8b108ca62b2076dfe",
"decisions.json": "b0e4483e453703d0d5587ba340a2ab7f61cb95b34bd9cd1f1a411b1b8f52c866",
"facility_value_contracts.json": "447388ba03a29bfac25f2bcd068665aedbb07ba7641ce86f2db93d44f4c9f326",
"facility_value_contracts_validation.json": "bf490a9ca585c48fc318e76c4a069ef244827d828d46e6415399f7710590dd16",
"performance_audit.json": "0b630b15a392c3e0366f1362bfe4ddacb968f915f54549f82e4d6fa315384762",
@@ -251,7 +251,7 @@
"source_atomic_coverage.json": "2ab7b9e6a2536ef248d4f7ccd695bf75a082b47018aca7a5a1b6b706fe1907bc",
"validate_atomic_requirements.py": "5b2d0b7d72cb124830fbb0a12d1434e6db698148cc5263d33fd5bd7c2953a1e0",
"validate_facility_value_contracts.py": "91640e2a76cd6cc9186f7b85c0bf379b24b6cac0d48962815235ee628e7ae43e",
"validate_plan.py": "e31316aff6c814d1e1660a97b91cc0c0016d2b722c4d4092a71859a85bd0e65d",
"validate_plan.py": "13d6c95ec17e87215cefc3a94f398b10dd9b63516cd0039bc6e77c3940998d84",
"validator_update_checks.json": "f997d0f3de2cea29550840b1d5cb23647d64f81329de46758551e2b6c8fecc27",
"work_packages.json": "d2549dae70c8240cef62c5447d570d67789a46f8b944422b5155d326aab13984"
}
@@ -172,7 +172,7 @@ def main():
pending = {d["id"] for d in decisions["pending"]}
check("all10_pending_decision_groups", pending == {f"D{i:02d}" for i in range(1, 11)})
check("known_decision_references", all(set(p["decisions"]) <= pending for p in packages))
check("all6_confirmed_decisions", {d["id"] for d in decisions["confirmed"]} == {f"A{i:02d}" for i in range(1, 7)})
check("all7_confirmed_decisions", {d["id"] for d in decisions["confirmed"]} == {f"A{i:02d}" for i in range(1, 8)})
check("confirmed_decision_references_exist", all((PLAN / d["reference"]).is_file() for d in decisions["confirmed"] if "reference" in d))
state_counts = dict(collections.Counter(p["state"] for p in packages))
check("valid_package_states", all(p["state"] in states for p in packages))
+1
View File
@@ -47,6 +47,7 @@ const MVP_FACILITY_IDS: Array[StringName] = [
&"service_elevator",
&"guest_stairs",
&"service_stairs",
&"mana_warp",
&"staff_door",
&"guest_corridor",
&"service_corridor",
+2
View File
@@ -59,11 +59,13 @@ const RESCUE_FACILITY_PATHS: Array[String] = ["res://data/facilities/rescue/infi
const INFRASTRUCTURE_FACILITY_IDS: Array[StringName] = [
&"guest_stairs",
&"service_stairs",
&"mana_warp",
]
const INFRASTRUCTURE_FACILITY_PATHS: Array[String] = [
"res://data/facilities/infrastructure/guest_stairs.tres",
"res://data/facilities/infrastructure/service_stairs.tres",
"res://data/facilities/infrastructure/mana_warp.tres",
]
## Optional post-MVP expansion definitions. They live below a subdirectory so
+103
View File
@@ -0,0 +1,103 @@
extends RefCounted
## A07 Mana warp: every completed warp is linked to every other warp by one
## path connector, so crossing is a single instant step. Connectors are derived
## from construction state; their operational state follows the reserve and the
## warps' own power. Fares are charged by the Runtime when an actor steps.
const PathType := preload("res://scripts/world/path_service.gd")
const DEFINITION_ID := &"mana_warp"
const PREFIX := "warp.connector:"
const MANA_PER_TRIP := 3
const PATH_COST := 400
static func is_warp_connector(connector_id: StringName) -> bool:
return String(connector_id).begins_with(PREFIX)
## Completed warps sorted by instance id: [{instance_id, origin}].
static func warps_from_facilities(facilities: Array) -> Array[Dictionary]:
var warps: Array[Dictionary] = []
for value: Variant in facilities:
if not value is Dictionary:
continue
var facility: Dictionary = value
if StringName(str(facility.get(&"definition_id", ""))) != DEFINITION_ID:
continue
var origin: Variant = facility.get(&"origin")
if not origin is Vector3i:
continue
warps.append({&"instance_id": StringName(str(facility.get(&"instance_id", ""))), &"origin": origin})
warps.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return String(a[&"instance_id"]) < String(b[&"instance_id"]))
return warps
static func expected_connectors(facilities: Array) -> Array[Dictionary]:
var warps := warps_from_facilities(facilities)
var result: Array[Dictionary] = []
for i: int in warps.size():
for j: int in range(i + 1, warps.size()):
result.append({
&"id": StringName("%s%s|%s" % [PREFIX, warps[i][&"instance_id"], warps[j][&"instance_id"]]),
&"from": warps[i][&"origin"],
&"to": warps[j][&"origin"],
&"endpoints": [warps[i][&"instance_id"], warps[j][&"instance_id"]],
})
return result
## Rebuilds warp connectors from construction state and applies availability.
## available: instance_id -> bool. Atomic: on failure the path is restored. The
## rollback snapshot is taken only when a change is actually needed.
static func synchronize(path: PathService, facilities: Array, available: Dictionary, fare_affordable: bool) -> Dictionary:
var expected := expected_connectors(facilities)
var expected_ids: Dictionary = {}
for connector: Dictionary in expected:
expected_ids[connector[&"id"]] = true
var before: Variant = null
for connector_id: StringName in path.get_connector_ids_with_prefix(PREFIX):
if expected_ids.has(connector_id):
continue
if before == null:
before = path.create_snapshot()
if not path.remove_connector(connector_id):
path.restore_snapshot(before)
return {&"ok": false, &"code": &"WARP_CONNECTOR_REMOVE_FAILED", &"reason": path.last_error}
for connector: Dictionary in expected:
var connector_id: StringName = connector[&"id"]
var existing := path.get_connector(connector_id)
var endpoints: Array = connector[&"endpoints"]
var usable := fare_affordable and bool(available.get(endpoints[0], false)) and bool(available.get(endpoints[1], false))
var state := PathType.CONNECTOR_OPERATIONAL if usable else PathType.CONNECTOR_OUTAGE
if not existing.is_empty() and existing[&"from"] == connector[&"from"] and existing[&"to"] == connector[&"to"] \
and existing[&"operational_state"] == state:
continue
if before == null:
before = path.create_snapshot()
if existing.is_empty() or existing[&"from"] != connector[&"from"] or existing[&"to"] != connector[&"to"]:
if not existing.is_empty():
path.remove_connector(connector_id)
if not path.configure_connector(connector_id, connector[&"from"], connector[&"to"], PATH_COST, true, &"", true,
{&"movement_type": PathType.MOVEMENT_CONNECTOR}):
path.restore_snapshot(before)
return {&"ok": false, &"code": &"WARP_CONNECTOR_CONFIGURE_FAILED", &"reason": path.last_error}
if not path.set_connector_operational_state(connector_id, state):
path.restore_snapshot(before)
return {&"ok": false, &"code": &"WARP_CONNECTOR_STATE_FAILED", &"reason": path.last_error}
return {&"ok": true, &"connector_count": expected.size()}
## Saved paths must hold exactly the derived warp connectors.
static func connectors_match(path: PathService, facilities: Array) -> bool:
var expected := expected_connectors(facilities)
var ids := path.get_connector_ids_with_prefix(PREFIX)
if ids.size() != expected.size():
return false
for connector: Dictionary in expected:
var existing := path.get_connector(connector[&"id"])
if existing.is_empty() or existing[&"from"] != connector[&"from"] or existing[&"to"] != connector[&"to"] \
or int(existing[&"cost"]) != PATH_COST or StringName(existing[&"access_id"]) != &"" \
or existing[&"movement_type"] != PathType.MOVEMENT_CONNECTOR:
return false
return true
+10
View File
@@ -89,6 +89,16 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
table(canvas, at + Vector2(-2, -7)*p, p)
&"chapel":
chapel_furnishings(canvas, rect, p)
&"mana_warp":
# Original code-drawn transfer circle and rising motes; existing palette.
var ring := Vector2(mid, floor_y - 3.0 * p)
canvas.draw_circle(ring, 9.0 * p, INK)
canvas.draw_circle(ring, 8.0 * p, TEAL)
canvas.draw_circle(ring, 6.0 * p, Color("c1e0d4"))
canvas.draw_circle(ring, 4.0 * p, TEAL)
for i in 5:
var x := mid + (i - 2) * 4.0 * p
canvas.draw_rect(Rect2(x, floor_y - (9 + (i % 3) * 5) * p, p, 2.0 * p), CREAM)
&"public_bath":
canvas.draw_rect(Rect2(rect.position.x+4*p, floor_y-7*p, w-8*p, 7*p), CREAM)
canvas.draw_rect(Rect2(rect.position.x+6*p, floor_y-6*p, w-12*p, 4*p), Color("72b6b8"))
@@ -35,6 +35,7 @@ const KIND_GUEST_ELEVATOR: StringName = &"guest_elevator"
const KIND_SERVICE_ELEVATOR: StringName = &"service_elevator"
const KIND_GUEST_STAIRS: StringName = &"guest_stairs"
const KIND_SERVICE_STAIRS: StringName = &"service_stairs"
const KIND_MANA_WARP: StringName = &"mana_warp"
const SUPPORTED_KINDS: Array[StringName] = [
KIND_GUEST_CORRIDOR,
KIND_SERVICE_CORRIDOR,
@@ -43,6 +44,7 @@ const SUPPORTED_KINDS: Array[StringName] = [
KIND_SERVICE_ELEVATOR,
KIND_GUEST_STAIRS,
KIND_SERVICE_STAIRS,
KIND_MANA_WARP,
]
const KNOWN_KINDS: Array[StringName] = [
KIND_GUEST_CORRIDOR,
@@ -52,6 +54,7 @@ const KNOWN_KINDS: Array[StringName] = [
KIND_SERVICE_ELEVATOR,
KIND_GUEST_STAIRS,
KIND_SERVICE_STAIRS,
KIND_MANA_WARP,
]
const UNSUPPORTED_VERTICAL_KINDS: Array[StringName] = []
@@ -692,6 +695,17 @@ static func _kind_schema(kind: StringName) -> Dictionary:
&"guest_access", &"movement", &"schema_facility_proxy", &"vertical_connector",
]),
}
KIND_MANA_WARP:
# Walkable FRONT cells; WarpService links every warp pair (A07).
return {
&"layer": PathType.FRONT,
&"footprint": Vector2i(2, 2),
&"pass_through": true,
&"staff_only": false,
&"tags": _sorted_name_array([
&"guest_access", &"movement", &"schema_facility_proxy", &"warp",
]),
}
KIND_SERVICE_ELEVATOR:
return {
&"layer": PathType.BACK,
+9
View File
@@ -1420,6 +1420,15 @@ func has_connector(connector_id: StringName) -> bool:
return _connectors.has(connector_id)
func get_connector_ids_with_prefix(prefix: String) -> Array[StringName]:
var ids: Array[StringName] = []
for connector_id: Variant in _connectors:
if String(connector_id).begins_with(prefix):
ids.append(StringName(connector_id))
ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
return ids
func get_connector(connector_id: StringName) -> Dictionary:
if not _connectors.has(connector_id):
return {}
+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: