From 0cfaec1626ccc476d2b2973eba5924eb6b178174 Mon Sep 17 00:00:00 2001 From: Takeshi Date: Tue, 20 Oct 2026 10:45:28 +0900 Subject: [PATCH] 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 --- Makefile | 2 + autoload/simulation_runtime.gd | 67 ++++++- data/facilities/infrastructure/mana_warp.tres | 27 +++ .../decisions.json | 7 + .../plan_validation.json | 6 +- .../validate_plan.py | 2 +- scenes/main/main.gd | 1 + scripts/data/facility_catalog.gd | 2 + scripts/transport/warp_service.gd | 103 ++++++++++ scripts/ui/pixel_tower_art.gd | 10 + .../facility_infrastructure_projector.gd | 14 ++ scripts/world/path_service.gd | 9 + tests/transport/test_runtime_warp.gd | 187 ++++++++++++++++++ tests/ui/test_main_dashboard.gd | 3 +- 14 files changed, 431 insertions(+), 9 deletions(-) create mode 100644 data/facilities/infrastructure/mana_warp.tres create mode 100644 scripts/transport/warp_service.gd create mode 100644 tests/transport/test_runtime_warp.gd diff --git a/Makefile b/Makefile index da852ed..3f19e0b 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index c4eb77e..694a4d2 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -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) diff --git a/data/facilities/infrastructure/mana_warp.tres b/data/facilities/infrastructure/mana_warp.tres new file mode 100644 index 0000000..d7b55ce --- /dev/null +++ b/data/facilities/infrastructure/mana_warp.tres @@ -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"]) diff --git a/docs/plans/original-completion-2026-10-02/decisions.json b/docs/plans/original-completion-2026-10-02/decisions.json index 35499a7..ecb90dd 100644 --- a/docs/plans/original-completion-2026-10-02/decisions.json +++ b/docs/plans/original-completion-2026-10-02/decisions.json @@ -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": [ diff --git a/docs/plans/original-completion-2026-10-02/plan_validation.json b/docs/plans/original-completion-2026-10-02/plan_validation.json index 7b64c35..f86463d 100644 --- a/docs/plans/original-completion-2026-10-02/plan_validation.json +++ b/docs/plans/original-completion-2026-10-02/plan_validation.json @@ -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" } diff --git a/docs/plans/original-completion-2026-10-02/validate_plan.py b/docs/plans/original-completion-2026-10-02/validate_plan.py index 4090fb0..0e2b7d6 100644 --- a/docs/plans/original-completion-2026-10-02/validate_plan.py +++ b/docs/plans/original-completion-2026-10-02/validate_plan.py @@ -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)) diff --git a/scenes/main/main.gd b/scenes/main/main.gd index 835aa64..b358813 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -47,6 +47,7 @@ const MVP_FACILITY_IDS: Array[StringName] = [ &"service_elevator", &"guest_stairs", &"service_stairs", + &"mana_warp", &"staff_door", &"guest_corridor", &"service_corridor", diff --git a/scripts/data/facility_catalog.gd b/scripts/data/facility_catalog.gd index 7129ba3..bec37e9 100644 --- a/scripts/data/facility_catalog.gd +++ b/scripts/data/facility_catalog.gd @@ -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 diff --git a/scripts/transport/warp_service.gd b/scripts/transport/warp_service.gd new file mode 100644 index 0000000..d22bab8 --- /dev/null +++ b/scripts/transport/warp_service.gd @@ -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 diff --git a/scripts/ui/pixel_tower_art.gd b/scripts/ui/pixel_tower_art.gd index 702c06d..4b541d7 100644 --- a/scripts/ui/pixel_tower_art.gd +++ b/scripts/ui/pixel_tower_art.gd @@ -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")) diff --git a/scripts/world/facility_infrastructure_projector.gd b/scripts/world/facility_infrastructure_projector.gd index ce43024..4936f8b 100644 --- a/scripts/world/facility_infrastructure_projector.gd +++ b/scripts/world/facility_infrastructure_projector.gd @@ -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, diff --git a/scripts/world/path_service.gd b/scripts/world/path_service.gd index 13a062d..e16c565 100644 --- a/scripts/world/path_service.gd +++ b/scripts/world/path_service.gd @@ -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 {} diff --git a/tests/transport/test_runtime_warp.gd b/tests/transport/test_runtime_warp.gd new file mode 100644 index 0000000..66e2e26 --- /dev/null +++ b/tests/transport/test_runtime_warp.gd @@ -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) diff --git a/tests/ui/test_main_dashboard.gd b/tests/ui/test_main_dashboard.gd index 7bf5880..50d6c49 100644 --- a/tests/ui/test_main_dashboard.gd +++ b/tests/ui/test_main_dashboard.gd @@ -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: