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: