From fb4ad23190f59ebbfc33a39e2f5210128393a971 Mon Sep 17 00:00:00 2001 From: Takeshi Date: Wed, 9 Sep 2026 14:26:38 +0900 Subject: [PATCH] Add explicit stairs and measure elevator congestion with real actors --- Makefile | 15 + .../japanese_fantasy/visual-theme-v1.tres | 8 +- autoload/simulation_runtime.gd | 219 ++++++++++-- .../infrastructure/guest_stairs.tres | 27 ++ .../infrastructure/service_stairs.tres | 27 ++ docs/DESIGN_DECISIONS.md | 13 + docs/DEVELOPMENT_BACKLOG.md | 12 +- docs/FACILITY_DATA_DESIGN.md | 12 + docs/IMPLEMENTATION_STATUS.md | 17 +- docs/PLAYTEST_GUIDE.md | 18 +- docs/RECOVERY_GUIDE.md | 2 + docs/TEST_MATRIX.md | 9 + docs/qa/P2_STAIRS_AND_COMPARISON.md | 84 +++++ docs/qa/P2_TRANSPORT.md | 5 +- scenes/main/main.gd | 76 +++- scenes/main/world_preview.gd | 57 ++- scripts/data/facility_catalog.gd | 24 +- scripts/ui/inn_opening_guide.gd | 24 +- scripts/ui/pixel_tower_art.gd | 27 ++ scripts/ui/pixel_tower_demo.gd | 16 +- .../facility_infrastructure_projector.gd | 82 ++++- scripts/world/legacy_stair_service.gd | 290 +++++++++++++++ scripts/world/legacy_stair_service.gd.uid | 1 + scripts/world/path_service.gd | 163 ++++++++- tests/mvp/test_runtime_content_progression.gd | 9 +- .../mvp/test_runtime_operations_acceptance.gd | 44 +-- .../test_runtime_operations_integration.gd | 8 +- tests/s0/test_runtime_integration.gd | 1 + tests/s1/test_path_congestion.gd | 8 +- ...test_runtime_infrastructure_integration.gd | 47 ++- tests/s3/test_runtime_path_traffic.gd | 35 +- tests/s5/test_runtime_defense_cycle.gd | 3 + tests/supply/test_normal_supply_chain.gd | 4 +- tests/supply/test_supply_save_migration.gd | 28 +- tests/transport/test_runtime_stairs.gd | 165 +++++++++ tests/transport/test_runtime_stairs.gd.uid | 1 + tests/transport/test_runtime_transport.gd | 2 +- .../test_runtime_transport_comparison.gd | 337 ++++++++++++++++++ .../test_runtime_transport_comparison.gd.uid | 1 + tests/transport/test_transport_comparison.gd | 271 ++++++++++++++ .../test_transport_comparison.gd.uid | 1 + .../test_transport_density_migration.gd | 157 ++++++++ .../test_transport_density_migration.gd.uid | 1 + tests/transport/test_transport_save.gd | 22 +- tests/ui/test_first_operating_day.gd | 11 +- tests/ui/test_main_dashboard.gd | 6 +- tests/ui/test_stair_transport_details.gd | 152 ++++++++ tests/ui/test_stair_transport_details.gd.uid | 1 + tests/world/test_elevator_topology.gd | 4 +- tests/world/test_legacy_stairs.gd | 213 +++++++++++ tests/world/test_legacy_stairs.gd.uid | 1 + tests/world/test_path_transit_density.gd | 120 +++++++ tests/world/test_path_transit_density.gd.uid | 1 + tests/world/test_path_vertical_policy.gd | 105 ++++++ tests/world/test_path_vertical_policy.gd.uid | 1 + tests/world/test_stair_infrastructure.gd | 203 +++++++++++ tests/world/test_stair_infrastructure.gd.uid | 1 + 57 files changed, 3030 insertions(+), 162 deletions(-) create mode 100644 data/facilities/infrastructure/guest_stairs.tres create mode 100644 data/facilities/infrastructure/service_stairs.tres create mode 100644 docs/qa/P2_STAIRS_AND_COMPARISON.md create mode 100644 scripts/world/legacy_stair_service.gd create mode 100644 scripts/world/legacy_stair_service.gd.uid create mode 100644 tests/transport/test_runtime_stairs.gd create mode 100644 tests/transport/test_runtime_stairs.gd.uid create mode 100644 tests/transport/test_runtime_transport_comparison.gd create mode 100644 tests/transport/test_runtime_transport_comparison.gd.uid create mode 100644 tests/transport/test_transport_comparison.gd create mode 100644 tests/transport/test_transport_comparison.gd.uid create mode 100644 tests/transport/test_transport_density_migration.gd create mode 100644 tests/transport/test_transport_density_migration.gd.uid create mode 100644 tests/ui/test_stair_transport_details.gd create mode 100644 tests/ui/test_stair_transport_details.gd.uid create mode 100644 tests/world/test_legacy_stairs.gd create mode 100644 tests/world/test_legacy_stairs.gd.uid create mode 100644 tests/world/test_path_transit_density.gd create mode 100644 tests/world/test_path_transit_density.gd.uid create mode 100644 tests/world/test_path_vertical_policy.gd create mode 100644 tests/world/test_path_vertical_policy.gd.uid create mode 100644 tests/world/test_stair_infrastructure.gd create mode 100644 tests/world/test_stair_infrastructure.gd.uid diff --git a/Makefile b/Makefile index 3eec3b2..45abb20 100644 --- a/Makefile +++ b/Makefile @@ -160,10 +160,12 @@ capture-business: .PHONY: test-transport capture-transport preview-transport test-transport: + $(MAKE) test-stairs $(GODOT) --headless --path . --script res://tests/world/test_elevator_topology.gd $(GODOT) --headless --path . --script res://tests/transport/test_elevator_service.gd $(GODOT) --headless --path . --script res://tests/staff/test_staff_commute.gd $(GODOT) --headless --path . --script res://tests/transport/test_transport_save.gd + $(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 @@ -172,3 +174,16 @@ capture-transport: preview-transport: THE_TREE_TRANSPORT_PREVIEW=1 $(GODOT) --path . --script res://tests/transport/test_runtime_transport.gd + +.PHONY: test-stairs test-transport-comparison +test-stairs: + $(GODOT) --headless --path . --script res://tests/world/test_path_vertical_policy.gd + $(GODOT) --headless --path . --script res://tests/world/test_path_transit_density.gd + $(GODOT) --headless --path . --script res://tests/world/test_stair_infrastructure.gd + $(GODOT) --headless --path . --script res://tests/world/test_legacy_stairs.gd + $(GODOT) --headless --path . --script res://tests/transport/test_runtime_stairs.gd + $(GODOT) --headless --path . --script res://tests/ui/test_stair_transport_details.gd + +test-transport-comparison: + $(GODOT) --headless --path . --script res://tests/transport/test_transport_comparison.gd + $(GODOT) --headless --path . --script res://tests/transport/test_runtime_transport_comparison.gd diff --git a/assets/themes/japanese_fantasy/visual-theme-v1.tres b/assets/themes/japanese_fantasy/visual-theme-v1.tres index 7ea5b53..9cb9ace 100644 --- a/assets/themes/japanese_fantasy/visual-theme-v1.tres +++ b/assets/themes/japanese_fantasy/visual-theme-v1.tres @@ -1,4 +1,4 @@ -[gd_resource type="Resource" script_class="VisualThemeProfile" load_steps=74 format=3] +[gd_resource type="Resource" script_class="VisualThemeProfile" load_steps=75 format=3] [ext_resource type="Script" path="res://scripts/ui/visual_theme_profile.gd" id="1_theme"] [ext_resource type="Texture2D" path="res://assets/themes/japanese_fantasy/world-front-v1.png" id="2_front"] @@ -207,6 +207,10 @@ region = Rect2(627, 940.5, 313.5, 313.5) atlas = ExtResource("5_system") region = Rect2(940.5, 940.5, 313.5, 313.5) +[sub_resource type="AtlasTexture" id="F_stairs"] +atlas = ExtResource("8_pixel_dungeon") +region = Rect2(48, 48, 16, 16) + [resource] script = ExtResource("1_theme") pixel_dungeon_tiles = ExtResource("8_pixel_dungeon") @@ -228,6 +232,8 @@ facility_icons = { "massage_room": SubResource("F21"), "guest_elevator": SubResource("F22"), "service_elevator": SubResource("F23"), +"guest_stairs": SubResource("F_stairs"), +"service_stairs": SubResource("F_stairs"), "staff_door": SubResource("F30"), "guest_corridor": SubResource("F31"), "service_corridor": SubResource("F32"), diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index b61b523..dc7d6db 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -15,7 +15,8 @@ const CONSTRUCTION_EXPENSE_ACCOUNT := &"CONSTRUCTION_EXPENSE" const OPERATING_EXPENSE_ACCOUNT := &"OPERATING_EXPENSE" const AUTHORITY_FINE_ACCOUNT := &"AUTHORITY_FINE_EXPENSE" const WAGE_EXPENSE_ACCOUNT := &"WAGE_EXPENSE" -const RUNTIME_SNAPSHOT_VERSION := 8 +const RUNTIME_SNAPSHOT_VERSION := 9 +const TRANSPORT_RUNTIME_SNAPSHOT_VERSION := 8 const SUPPLY_RUNTIME_SNAPSHOT_VERSION := 7 const INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION := 6 const LEGACY_RUNTIME_SNAPSHOT_VERSION := 1 @@ -41,6 +42,7 @@ const DEFAULT_OPERATIONS_DEMAND_PROFILES := [ {&"facility_tag": &"underground", &"guest_class": &"rogue", &"time_band": &"NIGHT"}, ] const MOVEMENT_CREDIT_SCALE := 1_000 +const STAIR_MOVEMENT_CREDIT := 1_500 const MIN_MOVEMENT_CREDIT_PER_MINUTE := 250 const DEFAULT_AUTOSAVE_PATH := "user://saves/autosave" const SAVE_LIFECYCLE_VERSION := 1 @@ -92,6 +94,7 @@ var kitchen_production := KitchenProductionService.new() var supply_management := SupplyManagementService.new() var supply_logistics := SupplyLogisticsBridge.new() var elevators := ElevatorService.new() +var legacy_stairs := LegacyStairService.new() var staff_commute := StaffCommuteService.new() var facility_service := FacilityService.new() var guest_service := GuestService.new() @@ -190,6 +193,9 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S domain_events = DomainEventStream.new() world_service = WorldService.new() path_service = PathService.new() + path_service.set_vertical_walk_requires_connector(true) + legacy_stairs = LegacyStairService.new() + legacy_stairs.configure(path_service) _movement_credits.clear() _connect_path_performance_signal() mana_network_service = ManaNetworkService.new() @@ -376,7 +382,8 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S Callable(self, "_process_save_checkpoints") ) _initialized = true - var report := definition_registry.rebuild() + var report := definition_registry.rebuild_from_paths( + FacilityCatalog.FACILITY_PATHS + FacilityCatalog.INFRASTRUCTURE_FACILITY_PATHS) var catalog_registration_ok := _catalog_definitions_are_registered(guest_service) report[&"content_catalog"] = mvp_content_report.duplicate(true) report[&"progression_initialized"] = initial_progression_ok @@ -471,6 +478,7 @@ func create_snapshot() -> Dictionary: &"inventory": inventory_service.create_snapshot(), &"kitchen_production": kitchen_production.create_snapshot(), &"elevators": elevators.create_snapshot(), + &"legacy_stairs": legacy_stairs.create_snapshot(), &"logistics_dispatcher": logistics_dispatcher.create_snapshot(), &"logistics_requests": _serialize_logistics_requests(), &"facilities": facility_service.create_snapshot(), @@ -536,6 +544,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo OPERATIONS_RUNTIME_SNAPSHOT_VERSION, INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION, SUPPLY_RUNTIME_SNAPSHOT_VERSION, + TRANSPORT_RUNTIME_SNAPSHOT_VERSION, RUNTIME_SNAPSHOT_VERSION, ]: return false @@ -565,8 +574,10 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo ]) if runtime_schema_version >= SUPPLY_RUNTIME_SNAPSHOT_VERSION: runtime_current_keys.append(&"kitchen_production") - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION: runtime_current_keys.append(&"elevators") + if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + runtime_current_keys.append(&"legacy_stairs") if not _dictionary_has_exact_canonical_keys(runtime_data, runtime_current_keys) \ or typeof(runtime_data.get(&"runtime_schema_version", null)) != TYPE_INT \ or typeof(runtime_data.get(&"world_seed", null)) != TYPE_INT \ @@ -598,9 +609,13 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo return false if runtime_schema_version >= SUPPLY_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"kitchen_production", null) is Dictionary: return false - if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"elevators"): + if runtime_schema_version < TRANSPORT_RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"elevators"): return false - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"elevators", null) is Dictionary: + if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"elevators", null) is Dictionary: + return false + if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"legacy_stairs"): + return false + if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"legacy_stairs", null) is Dictionary: return false var restored_catalog := MvpContentCatalog.new() var restored_catalog_report := restored_catalog.rebuild() @@ -719,6 +734,20 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo restored_route_adapter, restored_spatial, restored_path ): return false + if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION: + var legacy_path_data: Dictionary = runtime_data[&"path"] + if legacy_path_data.has(&"vertical_walk_requires_connector") or legacy_path_data.has(&"transit_actor_ids"): + return false + var restored_legacy_stairs := LegacyStairService.new() + restored_legacy_stairs.configure(restored_path) + if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if runtime_data[&"path"].get(&"schema_version", -1) != PathService.SNAPSHOT_VERSION \ + or not restored_path.get_vertical_walk_requires_connector() \ + or not restored_legacy_stairs.restore_snapshot(runtime_data[&"legacy_stairs"]): + return false + else: + if restored_path.get_vertical_walk_requires_connector() or not restored_legacy_stairs.migrate_existing_vertical_walks(): + return false var restored_mana := ManaNetworkService.new() if not restored_mana.restore_snapshot(runtime_data[&"mana"]): return false @@ -1042,7 +1071,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo for staff_id: StringName in facility.active_staff_ids: var worker := restored_staff.get_staff(staff_id) if not StringName(restored_staff.get_work_assignment(staff_id).get(&"facility_id", &"")).is_empty() and not StaffCommuteService.is_at_facility(worker, facility): - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION: return false if worker.role_id != &"porter" and worker.current_task_id.is_empty(): worker.state = StaffRuntime.State.COMMUTING @@ -1053,7 +1082,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo {&"active_staff_ids": present_ids}, restored_runtime_domain_minute) var restored_elevators := ElevatorService.new() restored_elevators.configure(restored_path) - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION: if not restored_elevators.restore_snapshot(runtime_data[&"elevators"]): return false else: @@ -1124,6 +1153,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo guest_service = restored_guests staff_service = restored_staff elevators = restored_elevators + legacy_stairs = restored_legacy_stairs staff_commute.configure(self) supply_logistics.configure(self) mvp_content_catalog = restored_catalog @@ -1739,6 +1769,7 @@ func _build_operations_external_state() -> Dictionary: &"world": world_service.create_snapshot(), &"path": path_service.create_snapshot(), &"elevators": elevators.create_snapshot(), + &"legacy_stairs": legacy_stairs.create_snapshot(), &"excavation": excavation_service.create_snapshot(), &"spatial_subslots": spatial_subslot_service.create_snapshot(), &"route_projection": route_subslot_path_adapter.create_snapshot(), @@ -1785,7 +1816,7 @@ func _restore_operations_external_state(token: Variant) -> bool: var saved: Dictionary = token var required_dictionary_keys: Array[StringName] = [ &"game_state", &"command_queue", &"economy", &"inventory", &"kitchen_production", &"facilities", &"guests", - &"staff", &"relationships", &"events", &"authority", &"world", &"path", &"elevators", + &"staff", &"relationships", &"events", &"authority", &"world", &"path", &"elevators", &"legacy_stairs", &"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure", &"movement_credits", &"domain_events", &"id_generator", &"rng_streams", @@ -1838,6 +1869,7 @@ func _restore_operations_external_state(token: Variant) -> bool: and path_service.rebuild_graph(projected_walkable) var path_ok := topology_ok and path_service.restore_snapshot(saved[&"path"]) path_ok = elevators.restore_snapshot(saved[&"elevators"]) and path_ok + path_ok = legacy_stairs.restore_snapshot(saved[&"legacy_stairs"]) and path_ok var route_ok := path_ok and route_subslot_path_adapter.restore_snapshot(route_snapshot) var infrastructure_ok := route_ok and facility_infrastructure_projector.restore_snapshot( saved[&"facility_infrastructure"] @@ -1938,6 +1970,7 @@ func _operations_external_state_matches(saved: Dictionary) -> bool: or not _strict_variants_equal(world_service.create_snapshot(), saved[&"world"]) \ or not _strict_variants_equal(path_service.create_snapshot(), saved[&"path"]) \ or not _strict_variants_equal(elevators.create_snapshot(), saved[&"elevators"]) \ + or not _strict_variants_equal(legacy_stairs.create_snapshot(), saved[&"legacy_stairs"]) \ or not _strict_variants_equal(excavation_service.create_snapshot(), saved[&"excavation"]) \ or not _strict_variants_equal(spatial_subslot_service.create_snapshot(), saved[&"spatial_subslots"]) \ or not _strict_variants_equal(route_subslot_path_adapter.create_snapshot(), saved[&"route_projection"]) \ @@ -4681,8 +4714,8 @@ func rebuild_path_graph_from_world() -> bool: ), walkable_cells ) - return synchronized.is_success() - return path_service.rebuild_graph(walkable_cells) + return synchronized.is_success() and _prune_legacy_stairs_and_movement() + return path_service.rebuild_graph(walkable_cells) and _prune_legacy_stairs_and_movement() func _initialize_world(rank: int) -> void: var bounds := _bounds_for_rank(rank) @@ -5069,6 +5102,7 @@ func _capture_rank_external_state() -> Dictionary: &"world": world_service.create_snapshot(), &"path": path_service.create_snapshot(), &"elevators": elevators.create_snapshot(), + &"legacy_stairs": legacy_stairs.create_snapshot(), &"excavation": excavation_service.create_snapshot(), &"spatial_subslots": spatial_subslot_service.create_snapshot(), &"route_projection": route_subslot_path_adapter.create_snapshot(), @@ -5132,6 +5166,7 @@ func _rollback_rank_external_state(snapshot: Dictionary) -> bool: restored = path_service.rebuild_graph(projected_walkable) and restored restored = path_service.restore_snapshot(snapshot[&"path"]) and restored restored = elevators.restore_snapshot(snapshot[&"elevators"]) and restored + restored = legacy_stairs.restore_snapshot(snapshot[&"legacy_stairs"]) and restored restored = route_subslot_path_adapter.restore_snapshot(route_snapshot) and restored restored = facility_infrastructure_projector.restore_snapshot( snapshot[&"facility_infrastructure"] @@ -5146,6 +5181,8 @@ func _rollback_rank_external_state(snapshot: Dictionary) -> bool: return ( _strict_variants_equal(world_service.create_snapshot(), snapshot[&"world"]) and _strict_variants_equal(path_service.create_snapshot(), snapshot[&"path"]) + and _strict_variants_equal(elevators.create_snapshot(), snapshot[&"elevators"]) + and _strict_variants_equal(legacy_stairs.create_snapshot(), snapshot[&"legacy_stairs"]) and _strict_variants_equal( excavation_service.create_snapshot(), snapshot[&"excavation"] ) @@ -5893,6 +5930,7 @@ func _execute_hire_staff_candidate(command: SimulationCommand) -> CommandResult: &"staff": staff_service.create_snapshot(), &"path": path_service.create_snapshot(), &"elevators": elevators.create_snapshot(), + &"legacy_stairs": legacy_stairs.create_snapshot(), &"movement_credits": _movement_credits.duplicate(true), &"domain_events": domain_events.create_snapshot(), &"id_generator": id_generator.create_snapshot(), @@ -6021,6 +6059,7 @@ func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool: restored = staff_service.restore_snapshot(snapshot[&"staff"]) and restored restored = path_service.restore_snapshot(snapshot[&"path"]) and restored restored = elevators.restore_snapshot(snapshot[&"elevators"]) and restored + restored = legacy_stairs.restore_snapshot(snapshot[&"legacy_stairs"]) and restored _movement_credits = (snapshot[&"movement_credits"] as Dictionary).duplicate(true) restored = domain_events.restore_snapshot(snapshot[&"domain_events"]) and restored restored = id_generator.restore_snapshot(snapshot[&"id_generator"]) and restored @@ -6865,6 +6904,8 @@ func _execute_build_facility(command: SimulationCommand) -> CommandResult: if origin_value == null: return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "origin must be Vector3i or [x, y, layer].") var origin: Vector3i = origin_value + if definition.id in [&"guest_stairs", &"service_stairs"] and not path_service.has_cell(origin + Vector3i(0, -1, 0)): + return _staff_assignment_failure(command.command_id, &"STAIR_LANDING_MISSING", "先に階段の一つ下の階に通路を作ってください。") for shaft: Dictionary in elevators.get_report()[&"shafts"]: if _elevator_touches_module(shaft, definition, origin) and _elevator_shaft_busy(shaft): return _staff_assignment_failure(command.command_id, &"ELEVATOR_BUSY", "Wait until the shaft is idle before extending it.") @@ -6947,6 +6988,13 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult: for shaft: Dictionary in elevators.get_report()[&"shafts"]: if instance_id in shaft[&"module_ids"] and _elevator_shaft_busy(shaft): return _staff_assignment_failure(command.command_id, &"ELEVATOR_BUSY", "Wait until passengers have left before demolition.") + var lost_landing_cells := _demolition_lost_route_cells(instance_id) + for actor: Dictionary in path_service.create_snapshot()[&"actor_cells"]: + if actor[&"cell"] in lost_landing_cells: + return _staff_assignment_failure(command.command_id, &"FACILITY_OCCUPIED", "この通路・階段に人がいます。移動し終わってから撤去してください。") + var stair_dependents := _stair_demolition_dependents(instance_id) + if not stair_dependents.is_empty(): + return _staff_assignment_failure(command.command_id, &"STAIR_LANDING_IN_USE", "この通路につながる上の階段を先に撤去するか、建設を中止してください。") var defense_owner: StringName = _defense_lock_owner(instance_id, &"ASSET") if not defense_owner.is_empty(): return CommandResult.failed( @@ -6993,8 +7041,9 @@ 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)) + transport_ok = bool(_synchronize_elevators(_runtime_event_minute()).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) \ or not construction_service.get_facility(instance_id).is_empty() \ or not world_service.get_entity_cells(instance_id).is_empty() \ @@ -7034,6 +7083,7 @@ func _capture_infrastructure_demolition_state() -> Dictionary: &"world": world_service.create_snapshot(), &"path": path_service.create_snapshot(), &"elevators": elevators.create_snapshot(), + &"legacy_stairs": legacy_stairs.create_snapshot(), &"construction": construction_service.create_snapshot(), &"excavation": excavation_service.create_snapshot(), &"spatial_subslots": spatial_subslot_service.create_snapshot(), @@ -7049,7 +7099,7 @@ func _capture_infrastructure_demolition_state() -> Dictionary: func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool: for key: StringName in [ - &"game_state", &"economy", &"world", &"path", &"elevators", &"construction", + &"game_state", &"economy", &"world", &"path", &"elevators", &"legacy_stairs", &"construction", &"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure", &"facilities", &"mana", &"movement_credits", &"domain_events", &"id_generator", @@ -7067,6 +7117,7 @@ func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool: restored = path_service.rebuild_graph(projected_walkable) and restored restored = path_service.restore_snapshot(snapshot[&"path"]) and restored restored = elevators.restore_snapshot(snapshot[&"elevators"]) and restored + restored = legacy_stairs.restore_snapshot(snapshot[&"legacy_stairs"]) and restored restored = route_subslot_path_adapter.restore_snapshot(route_snapshot) and restored restored = facility_infrastructure_projector.restore_snapshot( snapshot[&"facility_infrastructure"] @@ -7097,6 +7148,7 @@ func _infrastructure_demolition_state_matches(snapshot: Dictionary) -> bool: and _strict_variants_equal(world_service.create_snapshot(), snapshot.get(&"world", {})) \ and _strict_variants_equal(path_service.create_snapshot(), snapshot.get(&"path", {})) \ and _strict_variants_equal(elevators.create_snapshot(), snapshot.get(&"elevators", {})) \ + and _strict_variants_equal(legacy_stairs.create_snapshot(), snapshot.get(&"legacy_stairs", {})) \ and _strict_variants_equal(construction_service.create_snapshot(), snapshot.get(&"construction", {})) \ and _strict_variants_equal(excavation_service.create_snapshot(), snapshot.get(&"excavation", {})) \ and _strict_variants_equal(spatial_subslot_service.create_snapshot(), snapshot.get(&"spatial_subslots", {})) \ @@ -7491,6 +7543,8 @@ 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 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( id_generator.next_id(&"domain_event"), &"FACILITY_COMPLETED", @@ -7965,7 +8019,7 @@ func _record_blocked_actor_wait( _report_path_integration_failure(actor_id, entity_id, kind, cell) return _movement_credits[actor_id] = { - &"credit": clampi(credit, 0, MOVEMENT_CREDIT_SCALE - 1), + &"credit": clampi(credit, 0, _cell_movement_credit_limit(path_service, cell) - 1), &"updated_at": absolute_minute, } if int(recorded.get(&"minutes_without_progress", 0)) >= PathService.STUCK_THRESHOLD_MINUTES: @@ -8010,10 +8064,12 @@ func _advance_guest_path_travel( return if _request_elevator_step(actor_id, path[0], path[1], absolute_minute, []): return + var required_credit := _step_movement_credit(path_service, path[0], path[1], []) var accumulated := _movement_credit_after_one_minute( actor_id, guest.current_cell, absolute_minute ) - if accumulated < MOVEMENT_CREDIT_SCALE: + accumulated = mini(accumulated, required_credit + MOVEMENT_CREDIT_SCALE - 1) + if accumulated < required_credit: _record_blocked_actor_wait( actor_id, guest.guest_id, @@ -8037,7 +8093,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 - var remaining_credit := accumulated - MOVEMENT_CREDIT_SCALE + var remaining_credit := accumulated - required_credit _movement_credits[actor_id] = { &"credit": remaining_credit, &"updated_at": absolute_minute, @@ -8116,10 +8172,12 @@ func _advance_staff_task_travel( return if _request_elevator_step(actor_id, path[0], path[1], absolute_minute, [&"staff"]): return + var required_credit := _step_movement_credit(path_service, path[0], path[1], [&"staff"]) var accumulated := _movement_credit_after_one_minute( actor_id, staff.current_cell, absolute_minute ) - if accumulated < MOVEMENT_CREDIT_SCALE: + accumulated = mini(accumulated, required_credit + MOVEMENT_CREDIT_SCALE - 1) + if accumulated < required_credit: _record_blocked_actor_wait( actor_id, staff.staff_id, @@ -8135,7 +8193,7 @@ func _advance_staff_task_travel( staff.current_cell = previous_cell _report_path_integration_failure(actor_id, staff.staff_id, &"staff", previous_cell) return - var remaining_credit := accumulated - MOVEMENT_CREDIT_SCALE + var remaining_credit := accumulated - required_credit _movement_credits[actor_id] = { &"credit": remaining_credit, &"updated_at": absolute_minute, @@ -9107,6 +9165,7 @@ func _capture_event_action_token() -> Dictionary: &"guests": guest_service.create_snapshot(), &"path": path_service.create_snapshot(), &"elevators": elevators.create_snapshot(), + &"legacy_stairs": legacy_stairs.create_snapshot(), &"movement_credits": _movement_credits.duplicate(true), &"domain_events": domain_events.create_snapshot(), &"id_generator": id_generator.create_snapshot(), @@ -9124,6 +9183,8 @@ func _restore_event_action_token(token: Variant) -> Dictionary: var facilities_ok: bool = facility_service.restore_snapshot(token.get(&"facilities", {})) var guests_ok: bool = guest_service.restore_snapshot(token.get(&"guests", {})) var path_ok: bool = path_service.restore_snapshot(token.get(&"path", {})) + path_ok = elevators.restore_snapshot(token.get(&"elevators", {})) and path_ok + path_ok = legacy_stairs.restore_snapshot(token.get(&"legacy_stairs", {})) and path_ok var credits_value: Variant = token.get(&"movement_credits", null) var credits_ok := credits_value is Dictionary if credits_ok: @@ -10202,7 +10263,7 @@ func _deserialize_movement_credits(raw_entries: Array, absolute_minute: int) -> var credit := int(credit_value) var updated_at := int(updated_at_value) if actor_id.is_empty() or restored.has(actor_id) \ - or credit < 0 or credit >= MOVEMENT_CREDIT_SCALE \ + or credit < 0 or credit >= STAIR_MOVEMENT_CREDIT \ or updated_at < 0 or updated_at > absolute_minute: return null restored[actor_id] = { @@ -10311,7 +10372,7 @@ func _validate_runtime_path_crosslinks( var last_progress_at := int(last_progress_value) var last_observed_at := int(last_observed_value) var stuck_reported_at := int(stuck_reported_value) - if credit < 0 or credit >= MOVEMENT_CREDIT_SCALE \ + if credit < 0 or credit >= _cell_movement_credit_limit(path, cell) \ or updated_at < 0 or updated_at > absolute_minute \ or progress_entry.get(&"last_cell", null) != cell \ or last_progress_at < 0 or last_progress_at > last_observed_at \ @@ -10321,7 +10382,8 @@ func _validate_runtime_path_crosslinks( stuck_reported_at < last_progress_at or stuck_reported_at > last_observed_at )): return false - expected_density[cell] = int(expected_density.get(cell, 0)) + 1 + if not path.is_actor_in_transit(actor_id): + expected_density[cell] = int(expected_density.get(cell, 0)) + 1 for raw_cell: Variant in raw_traffic: if not raw_cell is Dictionary: return false @@ -10341,7 +10403,7 @@ func _path_snapshot_cells_match_world( &"schema_version", path_snapshot.get("schema_version", null) ) - if not schema_value is int or int(schema_value) not in [PathService.ELEVATOR_INDEX_SNAPSHOT_VERSION, PathService.SNAPSHOT_VERSION]: + if not schema_value is int or int(schema_value) not in [PathService.ELEVATOR_INDEX_SNAPSHOT_VERSION, PathService.SHAFT_SNAPSHOT_VERSION, PathService.SNAPSHOT_VERSION]: return false var raw_traffic: Variant = path_snapshot.get( &"cell_traffic", @@ -11430,8 +11492,10 @@ func advance_staff_commute(staff_id: StringName, target_cell: Vector3i, minute: return {&"ok": true, &"moved": false, &"reason": &"COMMUTE_ROUTE_BLOCKED"} if _request_elevator_step(actor_id, path[0], path[1], minute, [&"staff"]): return {&"ok": true, &"moved": false, &"reason": &"ELEVATOR_WAIT"} + 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) - if credit < MOVEMENT_CREDIT_SCALE: + credit = mini(credit, required_credit + MOVEMENT_CREDIT_SCALE - 1) + if credit < required_credit: _record_expected_actor_wait(actor_id, staff.current_cell, minute) _movement_credits[actor_id] = {&"credit": credit, &"updated_at": minute} return {&"ok": true, &"moved": false} @@ -11440,7 +11504,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] - _movement_credits[actor_id] = {&"credit": credit - MOVEMENT_CREDIT_SCALE, &"updated_at": minute} + _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, &"target_cell": target_cell, &"reason": &"COMMUTE"}) @@ -11464,6 +11528,10 @@ func _request_elevator_step(actor_id: StringName, from: Vector3i, to: Vector3i, func _advance_elevators(minute: int) -> void: + var queued_before: Dictionary = {} + for trip: Dictionary in elevators.get_report()[&"trips"]: + if trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED: + queued_before[trip[&"request_id"]] = true for shaft: Dictionary in elevators.get_report()[&"shafts"]: var powered := true for module_id: StringName in shaft[&"module_ids"]: @@ -11472,6 +11540,12 @@ func _advance_elevators(minute: int) -> void: powered = false _apply_elevator_arrivals(elevators.set_powered(shaft[&"shaft_id"], powered, minute), minute) _apply_elevator_arrivals(elevators.advance_one_minute(minute), minute) + for trip: Dictionary in elevators.get_report()[&"trips"]: + if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED and queued_before.has(trip[&"request_id"]): + var payload := trip.duplicate(true) + payload[&"boarded_at"] = minute + payload[&"wait_minutes"] = minute - int(trip[&"enqueued_at"]) + _publish_actor_event(&"ELEVATOR_BOARDED", &"elevators", [trip[&"actor_id"], trip[&"shaft_id"]], payload) func _apply_elevator_arrivals(arrivals: Array[Dictionary], minute: int) -> void: @@ -11575,7 +11649,8 @@ func is_staff_in_transit(staff_id: StringName) -> bool: func _estimated_actor_path_minutes(path: Array, access: Array[StringName]) -> int: for index: int in range(1, path.size()): - if elevators.owns_connector(path_service.get_step_connector(path[index - 1], path[index], access)): + if elevators.owns_connector(path_service.get_step_connector(path[index - 1], path[index], access)) \ + or _step_movement_credit(path_service, path[index - 1], path[index], access) > MOVEMENT_CREDIT_SCALE: return ceili(float(path_service.calculate_path_cost(path, access)) / PathService.ORTHOGONAL_MOVE_COST) return maxi(path.size() - 1, 0) @@ -11598,3 +11673,99 @@ func _prune_expired_staff_elevator_waits(minute: int) -> void: if not staff.can_take_shift() or shift == null or (not shift.contains_minute(minute) and not shift.is_commute_window(minute)): elevators.cancel_actor(actor_id) _publish_actor_event(&"ELEVATOR_COMMUTE_CANCELLED", &"staff_commute", [staff.staff_id, actor_id], {&"reason": &"SHIFT_ENDED_OR_UNAVAILABLE"}) + + +## Stairs carry half-minute progress between ticks. Doors keep their established +## one-step walking behavior, and elevator time is owned by the cabin service. +static func _step_movement_credit(path: PathService, from: Vector3i, to: Vector3i, + access: Array[StringName]) -> int: + var connector_id := path.get_step_connector(from, to, access) + if String(connector_id).begins_with("infra.connector:") and from.x == to.x \ + and from.z == to.z and absi(from.y - to.y) == 1: + var connector := path.get_connector(connector_id) + if connector.get(&"movement_type", &"") == PathService.MOVEMENT_CONNECTOR \ + and int(connector.get(&"cost", 0)) == 150: + return STAIR_MOVEMENT_CREDIT + return MOVEMENT_CREDIT_SCALE + + +static func _cell_movement_credit_limit(path: PathService, cell: Vector3i) -> int: + # Temporary blockades must not invalidate progress saved on a staircase. + # Inspect structural edges here instead of querying currently usable routes. + for edge: Dictionary in path.get_neighbors(cell): + var next: Vector3i = edge[&"to"] + if String(edge[&"connector_id"]).begins_with("infra.connector:") \ + and edge[&"movement_type"] == PathService.MOVEMENT_CONNECTOR \ + and int(edge[&"base_cost"]) == 150 and cell.x == next.x \ + and cell.z == next.z and absi(cell.y - next.y) == 1: + return STAIR_MOVEMENT_CREDIT + return MOVEMENT_CREDIT_SCALE + + +func _prune_legacy_stairs_and_movement() -> bool: + if not legacy_stairs.prune_invalid(): + return false + # A removed staircase discards only progress that can no longer be used. + for actor_id: StringName in _movement_credits: + if path_service.has_actor_projection(actor_id): + var cell: Vector3i = path_service.get_actor_projected_cell(actor_id) + _movement_credits[actor_id][&"credit"] = mini(int(_movement_credits[actor_id][&"credit"]), + _cell_movement_credit_limit(path_service, cell) - 1) + return true + + +func get_elevator_wait_statistics(shaft_id: StringName) -> Dictionary: + var samples: Array[int] = [] + var oldest_wait := 0 + var minute := _runtime_event_minute() + for event: SimulationDomainEvent in domain_events.recent_events(): + if event.event_type != &"ELEVATOR_BOARDED" or event.absolute_minute < minute - 60 \ + or event.absolute_minute > minute or event.payload.get(&"shaft_id", &"") != shaft_id: + continue + var wait: int = int(event.payload.get(&"wait_minutes", -1)) + if wait >= 0: + samples.append(wait) + for trip: Dictionary in elevators.get_report()[&"trips"]: + if trip[&"shaft_id"] == shaft_id and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED: + oldest_wait = maxi(oldest_wait, minute - int(trip[&"enqueued_at"])) + samples.sort() + var total := 0 + for wait: int in samples: + total += wait + return {&"sample_count": samples.size(), &"window_minutes": 60, + &"event_history_limit": DomainEventStream.RECENT_EVENT_LIMIT, + &"mean_wait_minutes": float(total) / samples.size() if not samples.is_empty() else 0.0, + &"p95_wait_minutes": samples[ceili(samples.size() * 0.95) - 1] if not samples.is_empty() else 0, + &"oldest_wait_minutes": oldest_wait} + + +func _demolition_lost_route_cells(instance_id: StringName) -> Array[Vector3i]: + var lost_cells: Array[Vector3i] = [] + var projection := facility_infrastructure_projector.get_projection(instance_id) + var base := route_subslot_path_adapter.get_base_walkable_cells() + for cell: Vector3i in projection.get(&"cells", []): + if cell not in base: + lost_cells.append(cell) + return lost_cells + + +func _stair_demolition_dependents(instance_id: StringName) -> Array[StringName]: + var lost_cells := _demolition_lost_route_cells(instance_id) + var dependents := facility_infrastructure_projector.get_stair_dependents(lost_cells) + dependents.erase(instance_id) + # A staircase under construction already relies on its lower landing too. + for job: ConstructionJob in construction_service.get_jobs(): + if not job.is_terminal() and job.definition_id in [&"guest_stairs", &"service_stairs"] \ + and job.origin + Vector3i(0, -1, 0) in lost_cells: + dependents.append(job.job_id) + return dependents + + +## Use the same entrance and horizontal access cells as ordinary Guest AI. +## Facility access_available is an operating override, not a topology cache. +func get_facility_access_report(facility_id: StringName) -> Dictionary: + var facility := facility_service.get_instance(facility_id) + var entrance: Variant = _operations_entrance_cell() + if facility == null or entrance == null or not facility.access_available: + return {&"reachable": false, &"path": []} + return _find_facility_access_path(entrance, facility) diff --git a/data/facilities/infrastructure/guest_stairs.tres b/data/facilities/infrastructure/guest_stairs.tres new file mode 100644 index 0000000..867e6b2 --- /dev/null +++ b/data/facilities/infrastructure/guest_stairs.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 = &"guest_stairs" +display_name = "客用階段" +description = "上階の踊り場を設置し、直下の表層通路へつなぐ階段。客と従業員が待たずに昇り降りする。" +zone = 0 +layer = 0 +footprint = Vector2i(1, 1) +allowed_rotations = Array[int]([0]) +construction_cost = {&"gold": 40, &"materials": 0} +build_minutes = 10 +weight_per_cell = 1 +pass_through = true +maintenance_mana = 0 +power_priority = 10 +staff_min = 0 +staff_max = 0 +capacity = 3 +queue_capacity = 0 +base_service_minutes = 1 +base_quality = 50 +base_price = 0 +tags = Array[StringName]([&"guest_access", &"movement", &"route_subslot", &"schema_facility_proxy", &"stairs", &"vertical_connector"]) diff --git a/data/facilities/infrastructure/service_stairs.tres b/data/facilities/infrastructure/service_stairs.tres new file mode 100644 index 0000000..f31d4f1 --- /dev/null +++ b/data/facilities/infrastructure/service_stairs.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 = &"service_stairs" +display_name = "業務用階段" +description = "上階の踊り場を設置し、直下の裏層通路へつなぐ従業員専用の階段。物資運搬にも使う。" +zone = 0 +layer = 1 +footprint = Vector2i(1, 1) +allowed_rotations = Array[int]([0]) +construction_cost = {&"gold": 50, &"materials": 0} +build_minutes = 10 +weight_per_cell = 1 +pass_through = true +maintenance_mana = 0 +power_priority = 10 +staff_min = 0 +staff_max = 0 +capacity = 2 +queue_capacity = 0 +base_service_minutes = 1 +base_quality = 50 +base_price = 0 +tags = Array[StringName]([&"movement", &"route_subslot", &"schema_facility_proxy", &"staff_only", &"stairs", &"vertical_connector"]) diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index be9ce39..b04a00b 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -273,3 +273,16 @@ - 素材: 既存のKenney人物を車内でも使い、レール・扉・かごは既存のコード描画によるドット表現へ追加する。外部画像の新規導入はない。 - 撤去の保存修正: 施設撤去の成功時は対応する完成済み工事を現役の工事一覧と進捗時刻表から取り除く。これまで完成工事だけが残り「完成施設がない」と保存検証で拒否されていた。IDの最大値、台帳、DomainEventの履歴は維持し、工事IDを再利用しない。延伸後の部分撤去・全撤去と、失敗時の巻き戻しを検証する。 + + +## D-036 明示階段と車内密度、旧通路の一回限りの移行 + +- 状態: 採用(2026-09-09。承認済みMOVE-01・02・06の続き)。D-035の「昇降路以外は上下徒歩を維持」を新ゲームについて更新する。 +- 新ゲーム: Pathの全階間WALKを禁止し、階段CONNECTORまたは物理昇降機を通る。左右の同階通路、staffドア、昇降路の所有者付き遮断は維持する。独立Path部品は旧呼出し互換のため既定false、Runtimeは必ずtrue。 +- 階段: 客用40G・通行容量3、業務用50G・容量2、双方資材0・建設10分・維持魔力0。1×1の上部踊り場を置き、同じx/layerの一つ下の通行可能セルへ双方向接続する。業務用はstaff権限を要求する。下部足場は先に完成させ、依存する階段があれば撤去を拒否する。 +- 移動: 新階段は1階cost150・通常速度で1.5ゲーム分。1分tickの移動creditを繰越し、2階を3分で移動する。途中の1000~1499creditは階段端点でのみ保存できる。閉塞で端点に留まっていても保存でき、撤去で使えなくなった端数は切り詰める。経路評価のcostと実移動を接続するが、「3階以内を優先」の距離別選好は今回追加しない。 +- 保存: Runtime v9/Path v5。Runtime v8以下の正当な旧Path3/4等を読み、既存の垂直WALK辺のみを `legacy.stairs:::` CONNECTORへ一度だけ実体化する。旧通路のcost100・空accessを維持し、古いBACKの権限も変更しない。新しい階段施設へ無償で置換する方式ではなく、既設接続を独立所有する。Pathと所有記録を厳密照合する。 +- 撤去: 旧接続は端点消失・昇降路への変更で除去し、その位置へ後から普通の通路を作っても再生成しない。Construction・Operations・Rankの巻戻しにはPathと旧階段所有者を同時に含める。階間の復旧誘導も実際の接続があるセルだけを候補にする。 +- 車内: BOARDEDの実Actorを床の混雑人数から除外する。待機者は床に数える。位置の所有アンカーは降車まで乗車元へ残し、車内所有・描画・密度はその状態から導く。旧v4保存では旧方式の全Actor密度が正しいことを先に検証し、その後に乗客を除く。新v5の保存はtransit集合と密度の双方を厳密照合する。 +- 表示・測定: 乗車確定時にELEVATOR_BOARDEDを記録する。選択詳細の平均・P95は保持中の直近256出来事から過去60分の乗車を抽出したサンプルであり、全履歴の集計とは表示しない。現在の最長待ちは実待機者から算出する。比較試験は別の完全traceで48人全員を記録し、UIの保持上限に依存しない。 +- 比較の範囲: 同Seed・需要traceで客用昇降機1台と2台を比較。自然来客AI、離脱改善、3階以上の混雑比較やS14の総合合格とは区別する。追加設備代・資材・維持魔力、および動的再経路選択による実歩数増も結果に含める。 diff --git a/docs/DEVELOPMENT_BACKLOG.md b/docs/DEVELOPMENT_BACKLOG.md index 9fe97e7..281bd78 100644 --- a/docs/DEVELOPMENT_BACKLOG.md +++ b/docs/DEVELOPMENT_BACKLOG.md @@ -44,16 +44,16 @@ P0の保存物はGit管理対象の制作ファイル。`.godot/`、`build/`、` ## P2:上下移動と混雑 -完成条件: 3階以上で実乗降し、同じ人数・需要のまま設備と動線の改善で待ち時間・離脱が減ること。**現在は運行未統合。** +完成条件: 3階以上で実乗降し、同じ人数・需要のまま設備と動線の改善で待ち時間・離脱が減ること。**基本運行・明示階段・実通勤を統合済み。同需要48人の設備比較を追加。自然来客の離脱比較は継続。** | ID | 実装済みの根拠 | 未接続・仕様差・検証残と次の確認 | |---|---|---| -| MOVE-01 | **部分統合**。Path v4の所有者付き縦徒歩抑制を昇降路の全マスへ投影。旧Path3保存は移行 | 昇降路以外の上下通路は階段相当を維持。全階間の徒歩禁止と明示的階段への移行が残る。D-035 | -| MOVE-02 | **基本運行接続済み**。同じ昇降路を1台で運行、客用4人/業務用8人、扉・実移動時間・FIFO同目的地のまとめ乗り | 途中階の相乗り、複数かご運行は未実装。車内人数は論理上乗車元の密度に残る | +| MOVE-01 | **統合済み**。新ゲームは全階間の通常徒歩を禁止し、客用/業務用階段を通常建設。旧上下通路を一度だけ階段接続へ移行 | 1階150cost・1.5分の端数移動、下部足場の撤去防止、旧接続の撤去後無再生成。3階以内優先の特別経路規則は未導入。D-036 | +| MOVE-02 | **基本運行接続済み**。同じ昇降路を1台で運行、客用4人/業務用8人、扉・実移動時間・FIFO同目的地のまとめ乗り | 途中階の相乗り、同一昇降路の複数かご運行は未実装。車内人数は床密度から除外し、待機者だけ乗場の混雑に数える | | MOVE-03 | **建設・UI接続済み**。連結モジュール、停止階、空車時延伸・撤去、BACK権限、魔力供給、停止/再開 | 通常初期予算から4停止階の建設を受入。工事中の新規乗車は完了まで保留 | | MOVE-04 | **運行描画接続済み**。実かご、扉、Kenney乗客、待機表示。乗車中は徒歩側の重複表示を除外 | 施設ごとの専用アニメーション、混雑時の人物配置は改善を継続 | -| MOVE-05 | **基本異常系・保存検証済み**。満員、停止、延伸拒否、安全な降車、停留所閉塞、所有不整合、途中Saveの厳密再生 | 長期の大量需要、対照試遊、明示階段移行時の閉じ込め対策は継続 | -| MOVE-06 | 既存の混雑密度/容量表示、[MVP受入](MVP_ACCEPTANCE_SCENARIOS.md)の比較条件 | **未検証**。同一Seed・需要・人数・施設数で改善前後を比較し、画面と移動時間P95・離脱で記録する | +| MOVE-05 | **基本異常系・保存検証済み**。満員、停止、延伸拒否、安全な降車、停留所閉塞、所有不整合、途中Saveの厳密再生 | 階段途中の保存、旧v8保存移行、待機/乗車中の再生を確認。全AIの長期大量需要と帰宅は継続 | +| MOVE-06 | **限定条件の比較済み**。同Seed・48人・480分・同一最短歩行距離で客用昇降機1台→2台。実Actorの総移動P95は224→73分、両方48人到着 | 固定需要の実移動比較であり自然来客AIではない。未完了0→0で離脱改善は未検証。途中階・夕方混雑・BACK物流等のS14条件は継続。[比較QA](qa/P2_STAIRS_AND_COMPARISON.md) | | MOVE-07 | **実通勤接続済み**。同じ階の職場へ到着してから稼働。混雑端数、再配属、夜勤、運搬員出勤、Saveと旧論理配属の移行 | 帰宅経路と出退勤演出、移動設備改善による遅刻削減の対照試遊は未実施 | ## P3:営みと初見の操作 @@ -111,4 +111,4 @@ P0の保存物はGit管理対象の制作ファイル。`.godot/`、`build/`、` 営業設定追加後の通常供給3営業日も49項目合格。受付売上0G、食堂3,000G・宿泊1,200G、給与未払0、残金2,895G。初日の契約金を除く2・3日目は各+715G。実装 `b3b82ef`。 -P2の基本昇降機・実通勤の証跡は [P2_TRANSPORT.md](qa/P2_TRANSPORT.md)。MOVE-01の全階間移行とMOVE-06の同需要比較を完了するまでP2全体を合格とはしない。 +P2の基本昇降機・実通勤の証跡は [P2_TRANSPORT.md](qa/P2_TRANSPORT.md)、階段・車内密度・48人比較は [P2_STAIRS_AND_COMPARISON.md](qa/P2_STAIRS_AND_COMPARISON.md)。自然来客の離脱とS14条件を含む対照試遊が残るためP2全体を合格とはしない。 diff --git a/docs/FACILITY_DATA_DESIGN.md b/docs/FACILITY_DATA_DESIGN.md index e6caf4f..91afa93 100644 --- a/docs/FACILITY_DATA_DESIGN.md +++ b/docs/FACILITY_DATA_DESIGN.md @@ -193,3 +193,15 @@ version 2では、ID形式、Access範囲、Upgrade件数、営業時間キー | 拡張16施設 | 1 | 通常パレット未解放、順次移行 | 次の移行順は、一般客室・食堂・酒場・浴場、倉庫・警備、通路・昇降機、地下拡張施設とする。売上施設を先に移行すると、客AI・価格・在庫の縦断検証を早い段階で固定できる。 + + +## 明示階段(D-036、2026-09-09) + +基礎16施設と任意拡張16施設の定義順は保持し、追加インフラ2種を `data/facilities/infrastructure/` と `FacilityCatalog.INFRASTRUCTURE_FACILITY_PATHS` に分ける。通常Runtimeの登録数と建築パレットは基礎16+階段2の18種。既存Registryの既定ディレクトリ単独走査は基礎16のまま。 + +| ID | 表示 | サイズ | 設置層 | 建設G/資材/分 | 通行容量 | cost/維持魔力 | +|---|---|---:|---|---|---:|---| +| guest_stairs | 客用階段 | 1×1 | FRONT | 40/0/10 | 3 | 150/0 | +| service_stairs | 業務用階段 | 1×1 | BACK | 50/0/10 | 2 | 150/0 | + +置くセルが上部踊り場で、同じx/layerのy-1へ接続する。下部足場が必要。業務用はstaff権限。旧保存の既設階段は施設定義へ置換せず、旧速度・権限を持つ別の接続所有記録として保存する。 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 2e73d4b..d017b9e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -141,7 +141,7 @@ - 上部HUDに営業日、時刻、時間帯、来客、宿泊予定、所持金、Mana、資材、Faith、Heat、停止、x1/x2/x4を表示する。 - 実World Cell、占有、工事、施設、Guest、StaffをFRONT/BACK別に描く。ホイールでカーソル基準ズーム、中ボタンドラッグでパン、Homeで全体表示、Gで格子と実混雑表示を切り替える。 - PathServiceの密度/容量を緑・黄・赤の経路に、FacilityServiceの待機人数を小さなBadgeに表示する。施設クリックで右パネルを開き、状態、reason、待機列、Service、配置人員を確認する。 -- MVP施設16種を選べる下部の横スクロール建築パレット、回転、配置Preview、失敗理由、建設Commandを接続。客用/業務用昇降機は安全な階間Topologyが未統合のため、現在は建設を拒否する。 +- MVP施設16種と階段2種を選べる下部の横スクロール建築パレット、回転、配置Preview、失敗理由、建設Commandを接続。客用/業務用昇降機と明示階段は通常建設・実移動へ統合済み。 - 掘削モードでは費用、入口接続、支保、Rank/運営gate、進捗を表示し、FRONT/BACK同時解放Commandへ接続する。 - 雇用候補20人の氏名、役割、勤務帯、契約金、日給、4技能を表示。原子的な雇用、未払/破綻gate、二重クリック防止、予約実行結果、保存したCommand履歴との整合を扱う。その後のP1追加で、雇用済み人物の配属・再配属、DAY/NIGHTの継続勤務計画、未配属、反映予定と変更不可理由を通常UIへ接続した。配属先までの経路を検証するが、実通勤して到着してから稼働する経路は未統合。 - 本番運営の明示起動、個人AP、未払給与、前日損益、要確認/破綻、失敗境界の再試行表示、foreground Eventの日本語Choice、下部の手動Save/Loadを接続する。P1追加で6段階の「開業の手引き」と、営業日選択、現金入出金、建築出金、確定損益、給与・未払、施設別損益、照合を示す帳簿を接続した。建築出金と営業利益を分け、内部のcost_of_goodsは雇用契約金等を含むため「運営費」と表示する。 @@ -189,7 +189,7 @@ - 全Actor AI、全Event、監査、経路移動を含む20,160分の実TickによるS14-CORE受入 - ユニーク2人の配置・接触判定と、そこから生成する14日Event列 -- 通常隣接Edgeを安全に抑制する垂直Topologyと、客用/業務用昇降機 +- 昇降機の途中階相乗り、同一昇降路の複数かご、自然来客・多階層混雑の改善比較 - 配置と同一Shiftから関係Event候補を生成する接触判定と事前リスクPreview - 夕方混雑、FRONT/BACK比較、健全/地下収支を直接比較するScenario Test - 100客・30従業員の実描画Frame試験 @@ -221,4 +221,15 @@ - 断面に実際のかごとKenney人物を表示する。完成した昇降機を選ぶと現在階・目的階・人数・待機・魔力と運行操作が出る。 - 継続配属は歩いて職場へ到着してから稼働する。運搬も同じ階のアクセス点で受渡しし、上下階越しの到着や受取を防ぐ。 - Path v4/Runtime v8。旧保存移行、乗車途中の復元、変更拒否の原子性を検証する。 -- 残課題は全階間の徒歩規則、途中階相乗り、車内を分離した混雑密度、同じ需要での改善比較。P2全体の完成とは扱わない。詳細と実測は [昇降機QA](qa/P2_TRANSPORT.md)。 +- この保存点の詳細と実測は [昇降機QA](qa/P2_TRANSPORT.md)。全階間徒歩規則・車内密度・固定需要比較は次項のv9で追加した。P2全体の完成とは扱わない。 + + +## P2 階段・車内密度と同需要比較(2026-09-09) + +- 客用/業務用階段を通常建設・Kenneyアイコン・断面・選択詳細へ接続。新ゲームは階段か昇降機で階を移動し、上に普通の通路を置くだけでは移動できない。 +- Runtime v9/Path v5。旧上下通路を一度だけ既設階段として保持する。階段途中の端数、旧乗車中の密度移行、撤去と巻戻しを保存へ接続する。 +- 車内の人物を床密度から除外。昇降機詳細には最近の乗車記録の平均・P95待ちと現在の最長待ちを表示する。 +- 同じ48人を480分流す実GuestRuntime比較では1台→2台で平均待ち105.60→23.58分、総移動P95 224→73分、両方48人到着。追加建設1500G・資材15・維持魔力5。2台側では動的な経路再選択で5人に迂回が発生し、その時間も含めた。 +- 通常開業・供給の上階動線も明示階段に更新。自然来客の離脱、途中階相乗り、帰宅、S14の多条件比較は継続する。設定根拠はD-036、検証は [階段と比較QA](qa/P2_STAIRS_AND_COMPARISON.md)。 + +階段追加後の通常3営業日も49項目合格。食堂3000G・宿泊1200G、原材料配送30便・料理72便、残金2855G、3日とも未払0・照合済み。旧版の残金2895Gと比較すると新しい階段2セルの追加費用40Gを反映している。初日ガイドは実Pathで到達を判定し、描画・営業・保存再開41項目も成功。 diff --git a/docs/PLAYTEST_GUIDE.md b/docs/PLAYTEST_GUIDE.md index afd49d7..0e3dc14 100644 --- a/docs/PLAYTEST_GUIDE.md +++ b/docs/PLAYTEST_GUIDE.md @@ -16,6 +16,9 @@ | 仕入れ・厨房・配送をすぐ試遊 | `make preview-supply` | 初期予算から通常建設・実購入した宿を12:20まで進め、停止して開く。Spaceで再開。保存なし | | 有料仕入れ・厨房・配送の検証 | `make test-supply` | 通常予算から供給を接続し、配送途中保存と3営業日の運営を検証 | | 稼働中の商品パネルを撮影 | `make capture-supply` | 通常建設・実購入で用意した初日の仕入れ/厨房/配送をPNG出力 | +| 昇降機の乗降を試遊 | `make preview-transport` | 通常予算から4停止階を建設。自然に来た客の乗車場面で停止。保存なし | +| 階段・車内密度・旧保存の検証 | `make test-stairs` | 上下移動の規則、階段途中保存、互換移行、UIを検証 | +| 同需要の昇降機比較 | `make test-transport-comparison` | 同じ48人・480分で1台と2台を比較、全員traceを保存 | | 通常開業の画面を出力 | `make capture-opening` | 同じ単日受入を描画し、開始・配属後・決算のPNGを生成 | 見学は実施設7件と通路、従業員4人をRuntimeコマンドで組み立て、食堂へ食材32個を用意した再現場面です。保存・読込・自動保存は無効で、通常の初期状態には反映しません。準備中は少し待ってから操作します。PNGの生成には描画可能なデスクトップ環境が必要です。 @@ -56,7 +59,7 @@ 4. `G`で格子と混雑を表示し、施設をクリックして右側の営業状態・待機・人員と照合します。 5. `Tab`でBACKの厨房を確認します。中ボタンドラッグとズームを試した後、`Home`で全体表示へ戻します。 -この見学は収支・補充・配送の完成を保証する試験ではありません。昇降機の乗降は未実装で、見学の上下移動は既存の通路接続を使っています。 +この見学は収支・補充・配送の完成を保証する試験ではありません。昇降機の実乗降を見る場合は `make preview-transport` を使います。 ## 通常UIで最初の宿を開業する @@ -65,8 +68,8 @@ 次は単日受入と同じ小さな宿です。座標は `(x, y)`、すべてFRONTです。画面では `y=0` が地表の1F、`y=2` が3F、`y=3` が4Fに当たります。`G`で格子を出し、空きセルを選ぶと右パネルで座標を確認できます。配置するたびに停止し、完工を待つと進めやすくなります。 1. `B`で「受付」を選び、`(-8, 0)`へ配置します。受付は4×2セルを占有します。`Space`で再開して120ゲーム分進め、完成したら再び停止します。 -2. 「一般客室」を `(-8, 2)` と `(-6, 2)` に1室ずつ配置します。完成した受付のすぐ上、左右に並ぶ2室です。同時に「客用通路」を `(-4, 1)` へ配置し、60ゲーム分進めます。 -3. 「客用通路」を `(-4, 2)` へ配置し、10ゲーム分進めます。続いて客室の上側をつなぐように `(-8, 3)`、`(-7, 3)`、`(-6, 3)`、`(-5, 3)`、`(-4, 3)` へ置き、さらに10ゲーム分進めます。通路は合計7セルです。 +2. 「一般客室」を `(-8, 2)` と `(-6, 2)` に1室ずつ配置します。完成した受付のすぐ上、左右に並ぶ2室です。同時に「客用階段」を `(-4, 1)` へ配置し、60ゲーム分進めます。 +3. 「客用階段」を `(-4, 2)` へ配置し、10ゲーム分進めます。続いて「客用階段」を `(-4, 3)` に、「客用通路」を `(-8, 3)`、`(-7, 3)`、`(-6, 3)`、`(-5, 3)` に置き、さらに10ゲーム分進めます。階段は3セル、横の通路は4セルです。 4. `Esc`で建築を解除します。左の「従業員」から「候補を見る」または `H` で雇用候補を開き、受付係のアカネを雇用します。契約金と日給を確認してください。 5. 「雇用済み」を開いてアカネを選び、配属先を建てた受付、勤務帯を「日勤 06:00〜18:00」にして「配属・勤務帯を変更」を押します。成功表示を確認し、時間進行中の予約Commandなら次の処理まで待ちます。勤務帯は翌日以降も繰り返す計画として保存されます。 6. `Space`で再開し、必要なら `1`または`4`で速度を選んで、自然に到着した客の宿泊を観察します。現在の宿泊サービスは完了時に売上を記帳するため、到着直後に宿泊売上が出るとは限りません。客が来ない・利用されない場合は施設を選び、接続と人員の理由を確認します。 @@ -150,3 +153,12 @@ Windows書き出しコマンドの存在はWindows実機合格を意味しませ `make preview-transport` で、通常初期資源から建設した4停止階の宿を開く。検証と保存再開の確認後に一時停止して表示する。Spaceで再開し、客が扉の前で待ち、かごで上階へ移る様子を見る。完成した昇降機をクリックすると、現在階・人数・待機列と停止/再開の操作が出る。保存先は試験専用、通常の自動保存は無効。 新規ゲームでは、客用昇降機を同じx座標で上へ積み重ねると停止階を延ばせる。運行・待機中の延伸と撤去は拒否するため、空になってから着工する。工事中の新規乗車は完成まで待機する。通常の配属後は、従業員が同じ階の職場まで歩いてから施設が稼働する。 + + +## 階段と昇降機を使う + +上下階の移動には階段または昇降機を使います。階段は上の階へ置く1セルの施設です。同じxの一つ下のセルに通路・地表などの足場を先に用意します。客用は40G、業務用は50G、建設10分・資材と維持魔力は不要です。通常速度では1階を1.5ゲーム分で昇り降りし、業務用は従業員だけが使えます。 + +完成した昇降機をクリックすると、かごの現在階、人数、待機人数、運行の停止/再開が表示されます。最近の乗車記録の平均待ちと95%の待ち時間、現在の最長待ちも確認できます。平均・95%は保持中の乗車サンプルなので、詳しい集計範囲は表示のツールチップを確認してください。 + +以前のセーブの上下通路は、読み込むと「既設の階段」として同じ移動時間・利用権限で使えます。小さな階段マークをクリックして接続階を確認できます。その足場を撤去した後に新しい普通の通路を置いても階段は戻らないため、必要なら階段施設を建設します。 diff --git a/docs/RECOVERY_GUIDE.md b/docs/RECOVERY_GUIDE.md index 4b29500..049ba53 100644 --- a/docs/RECOVERY_GUIDE.md +++ b/docs/RECOVERY_GUIDE.md @@ -107,3 +107,5 @@ make preview-pixel 営業設定版は `the-tree-business-management-2026-09-09.bundle` と同名 `.bundle.sha256` を使用する。`git bundle list-heads` の `HEAD` がこの保存点のコミット。復元後は `make check test-business`、試遊は `make preview-business`。画像・検証ログは `artifacts/business-management/` に別保管し、bundleへは含めない。 昇降機・実通勤版の実装コミットは `0f12c74`。`the-tree-transport-2026-09-09.bundle` と同名 `.bundle.sha256` を使用し、`git bundle list-heads` の `HEAD` で資料更新を含む最終保存点を確認する。復元後は `make check test-transport test-ui`、試遊は `make preview-transport`。Runtime v8/Path v4。検証結果は [P2昇降機QA](qa/P2_TRANSPORT.md)、画像とログは `artifacts/transport/` に保存し、bundleには含めない。 + +階段・車内密度・同需要比較版は `the-tree-stairs-2026-09-09.bundle` と同名 `.bundle.sha256` を使用する。保管先は同じ `/Users/korizatou114/Projects/the-tree-backups/`。`git bundle list-heads` の `HEAD` で最終保存点を確認する。Runtime v9/Path v5、基礎施設16+明示階段2。旧v8の上下通路と乗車中の密度を移行する。復元後は `make check test-transport`、数値比較は `make test-transport-comparison`、試遊は `make preview-transport` または `make preview-pixel`。検証記録は [P2階段と比較QA](qa/P2_STAIRS_AND_COMPARISON.md)。以前のbundleは上書きせず残す。 diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 8c77750..8615e43 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -104,3 +104,12 @@ System単体の合格と、ゲームとしてのMVP合格を混同しないた - ヒロイン/ライバルの配置変更前に、接触・Shield・EventリスクPreviewが変化することを確認する。 - 100客、30従業員を表示した状態で基準PCの実Frame rateを測定する。 - 初見Playtestで地上/地下とFRONT/BACKの違いを30分以内に説明できるか観察する。 + + +## P2 明示階段と車内混雑(Runtime v9/Path v5) + +- `make test-stairs`: 全階間Path policy、transit密度、階段投影と下部足場、一回限りの旧通路移行、実移動端数と保存、占有中の撤去拒否、階段と待ち統計のUI。 +- `make test-transport`: 上記に加え、既存の実かご・実通勤・工事勤務境界・自然4停止階の乗降、旧乗車中保存の密度移行を検証。 +- `make test-transport-comparison`: 同Seed/同需要48人/480分で1台と2台を比較。Path+かご版と実GuestRuntime版を区別し、P95・全員の完了・実歩数・途中再生・設備費を記録。 +- `make test-opening`/`make test-supply`: 上階動線を明示階段へ更新した通常初期予算の営業。Guideは実Path到達を使う。 +- 条件・数値・限界は [P2階段と比較QA](qa/P2_STAIRS_AND_COMPARISON.md)。固定需要比較を自然来客の離脱改善やS14全条件の合格としない。 diff --git a/docs/qa/P2_STAIRS_AND_COMPARISON.md b/docs/qa/P2_STAIRS_AND_COMPARISON.md new file mode 100644 index 0000000..0b975b3 --- /dev/null +++ b/docs/qa/P2_STAIRS_AND_COMPARISON.md @@ -0,0 +1,84 @@ +# P2 明示階段・車内密度・同需要比較 + +2026-09-09、ローカル正本、Godot 4.7.2。D-036に対応する追加実装。P2全体・自然来客の離脱改善・S14総合受入の完了ではない。 + +## 実装範囲 + +- 新しい宿は階段か昇降機で階間を移動する。客用/業務用階段を通常予算、建築パレット、Kenney Tiny Dungeonの階段アイコン、断面、接続階の詳細へ接続。 +- 1階150cost・1.5ゲーム分。2階分を3分で移動し、階段途中の1000creditを保存・再生できる。 +- 下部足場を先に完成させる。上部階段や建設中の階段が使う足場、人物が立っている階段・通路の撤去は事前拒否する。後段でもActor/Path所有の整合を検証する。 +- 旧Runtime v8/Path v4等は正当な旧構造を検証後、一度だけ上下通路を既設階段へ移行。従来の速度と権限を維持し、足場を撤去した後には自動再生成しない。 +- BOARDEDの実Actorを床の混雑密度から除外。QUEUEDは乗場へ数える。旧保存は旧方式の密度が正しいことを検証してから移行する。 +- 昇降機詳細の平均・P95待ちは保持中の直近256出来事から過去60分の乗車を抽出したサンプル。全期間の集計とは表示しない。現在の最長待ちは実待機者全員から算出。 +- 開業ガイドは実際の入口から客室の左右の入口へ到達できるかをGuest AIと同じPathで確認する。施設の既定accessフラグだけで案内を先へ飛ばさない。上階への道が足りなければ客用階段を案内する。 + +## 48人の設備比較 + +`make test-transport-comparison` で二種類の検証を実行する。評価対象は同じ固定需要であり、自然来客の料金・目的選択AIによる需要ではない。 + +両者の共通条件: + +- Seed `806042`、48人、480ゲーム分(実Runtimeは絶対時刻670~1150)。同じ到着時刻列と目的地。 +- 需要SHA-256 `5bafa62275370c2a6ce5b4d4f283c3d7679c679a2b3fc6b054b681f2647456cf`。 +- 共通出発 `(2,0,FRONT)` → 共通到着 `(2,1,FRONT)`。最短の徒歩は両側とも乗場まで2歩+降車後2歩。 +- 1台の客用昇降機を、同じ仕様の客用昇降機2台へ増やす。別々の昇降路であり、同じ昇降路の複数かごではない。 +- 実Runtime版は通常初期予算5000G・資材50から通常建設する。既存2×2客用モジュールと上階通路2セルを使う。1台側1540G・資材15・維持魔力需要5、2台側3040G・資材30・維持魔力需要10。 +- 到着を観測後、通常begin_leaving/despawn_guestで退出させ、完了者を後続の渋滞に残さない。 +- P95は観測済み全員のnearest-rank。未到着を黙って集計から落とさず、未完了人数を別記し、両側の全員完了を改善判定の条件にする。 + +### 実GuestRuntime・実混雑による結果 + +| 指標(ゲーム分) | 1台 | 2台 | +|---|---:|---:| +| 平均の乗車待ち | 105.60 | 23.58 | +| 乗車待ちP95 | 197 | 48 | +| 出発~最終目的地の総移動P95 | 224 | 73 | +| 完了/需要 | 48/48 | 48/48 | +| 未完了 | 0 | 0 | +| 実歩数の平均 | 4.00 | 4.25 | +| 実歩数の最大 | 4 | 8 | +| 迂回した人数 | 0 | 5 | +| かご別利用人数 | 48 | 23/25 | + +総移動P95は67.4%短縮した。2台側では5人が動的な混雑評価で乗場を選び直し、4人が6歩、1人が8歩を歩いた。その時間も上表へ含む。経路を固定して迂回を隠す処理や特別な階間遮断をRuntimeへ挿入していない。 + +`test_runtime_transport_comparison.gd` は **51項目合格**。同一構成の全区間再生に加え、QUEUED/BOARDINGの各checkpointから12分間の全Runtime Snapshotと全観測traceが完全一致する。記録は `artifacts/transport/runtime-comparison.json` と `runtime-comparison-{1,2}-trace.json`。 + +### Path+かごの部品比較 + +`test_transport_comparison.gd` は **30項目合格**。同じ需要で、到着時に選んだ経路を保持する部品検証。乗場到着時に初めて乗車要求を出す。全員4歩で、待ち平均105.60→28.44分、待ちP95 197→49分、総移動P95 224→75分、両方48人完了、2台利用26/22人。実Runtimeの毎分再経路選択を含む上表とは分けて扱う。部品版の結果は `artifacts/transport/path-comparison.json`。 + +## 保存・異常系・操作の検証 + +| テスト | 合格項目 | 内容 | +|---|---:|---| +| Path vertical policy | 34 | 全階間WALK禁止、Connector・権限・旧版・経路復旧 | +| Path transit density | 46 | 車内除外、待機密度、降車/取消/退出、旧版の密度検証 | +| Stair infrastructure | 106 | 定義、階段接続、下部足場、原子的な投影・撤去 | +| Legacy stairs | 85 | 一回移行、旧権限、再生成禁止、所有不整合、部分失敗・隔離 | +| Runtime stairs | 65 | 通常建設、実1.5分、途中保存、旧v8、占有中の撤去拒否 | +| Transport density migration | 36 | 4人乗車+1人待機の真のv8保存移行、全5人の保持、各到着1回、Event rollback | + +`make test-stairs`、`make test-transport`で再現する。UIの階段・旧接続・待ち統計、既存PaletteとPixelの検証も含む。画像は `artifacts/transport/stairs-inspector.png`、`elevator-inspector.png`、`pixel-stairs-demo.png`。新ガイドの画像は `artifacts/first-operating-day/stairs-guidance.png`。 + +## 残る確認 + +- 固定需要の1階分比較では自然来客の離脱や満足低下を評価していない。未完了0→0を離脱改善と読み替えない。 +- 4停止階の自然乗降は別の既存受入があるが、多階層の同需要混雑比較やS14の夕方/BACK物流条件とは別。 +- 途中階の相乗り、同一昇降路の複数かご、帰宅経路、3階以内で階段を優先する特別規則は未実装。 +- 2台側で乗場を選び直して戻る動きが5人に出た。短期的な経路安定化は後続の改善候補。 +- 旧階段は互換のため1階100costを保持する。新設階段の150costと速度が異なる。 + +## 最終回帰と通常営業 + +- `make check test-transport` が成功終了。Editor読込、通常起動、階段・かご・実通勤・保存・撤去境界・自然来客の4停止階乗降にSCRIPT ERRORなし。追加した待ち統計の実イベント検証も36項目成功。 +- 既存S0・S1・S2・S3・S4・S5・MVP・acceptanceとUI・営業の各対象が成功。S3は714項目、上下迂回の旧テストは通常建設の階段を使う91項目へ更新した。旧版固定データは新設フィールドを除いて正当な旧形状を作る。 +- `make test-stairs test-supply test-s6` が成功。最新の追加撤去・閉塞・旧版偽装検証は別途 `runtime-stairs.log` の65項目、Legacyは最終85項目、旧供給保存は最後の独立再実行で20項目。途中の古いテスト件数と混同しない。 +- **通常初期予算・明示階段で3営業日49項目合格**。食堂売上3000G、宿泊1200G、受付収入0、原材料配送30便・料理配送72便、残金2855G。3日すべて未払0・決算balanced=true。途中配送の専用ディスク保存と再生、最後の全Snapshot復元も成功。 +- 3日間のデータは `artifacts/supply-chain/normal-play-report.json`。前のv8時点の `artifacts/transport/normal-supply-3days.json` は履歴として残し、この結果の根拠にはしない。 +- 開業ガイドの実経路修正後は初日描画・営業・保存復元が41項目成功(通常37+画像4)。宿泊240G、現金3540G、未払0、決算一致。施設のaccess_available全体を地形から毎分再設定する変更は行わず、Guideと実Guestの到達判定を揃えた。 +- Pixel238、Main121、従業員46、商品37、営業55、昇降機20、新階段UI25を確認。階段付き見学、ガイド保留、実乗客の車内表示と床との重複除外を実描画でも確認。 +- 性能のDigest71項目とDomain smoke24項目が成功。これは実UIのfps、全Actorの14日20,160tick、Windows実機性能の証明ではない。 +- 最新試遊は `artifacts/transport/stairs-final-preview.log` の35項目と `TRANSPORT_PREVIEW_READY` を確認。4停止階の自然な最初の乗車へ戻して停止し、Spaceで再開できる。試遊の通常保存は無効。 + +主なログは `artifacts/transport/stairs-final-transport.log`、`stairs-density-migration.log`、`runtime-stairs.log`、`stair-supply-save-regression.log`、`stairs-regression-*.log`。検証ログ・PNG・JSONはローカル `artifacts/` に残し、ソース用Git bundleには含めない。 diff --git a/docs/qa/P2_TRANSPORT.md b/docs/qa/P2_TRANSPORT.md index 23f6707..dbea5a1 100644 --- a/docs/qa/P2_TRANSPORT.md +++ b/docs/qa/P2_TRANSPORT.md @@ -38,10 +38,13 @@ 集約ログは `artifacts/transport/`、3営業日の数値は `normal-supply-3days.json`。初回に古い期待値で失敗した回帰ログも残してあるが、該当スクリプトの修正後の成功ログを確認している。新しい運行・描画に由来するエラーを合格扱いにしていない。 -## 残る課題 +## この保存点からの残る課題 - MOVE-01は昇降路内の上下徒歩抑制まで。既存の上下通路は階段相当で残る。全階間の規則と明示的な階段施設への移行が必要。 - 配車は先着順、同じ発着階のまとめ乗り。途中階の相乗り・複数かごは未実装。 - 車内の論理位置とPath密度は乗車元に残る。描画は車内へ移るが、混雑密度を車内と分離する改善が必要。 - 同じSeed・需要・人数を用いた設備改善前後のP95移動時間・離脱比較(MOVE-06)は未実施。 - 帰宅経路・専用ドット動作・音・初見試遊、宿泊の精算方式と全AIを含む長期描画性能は後続。 + + +後続のRuntime v9/Path v5で全階間徒歩の規則、明示階段、車内密度の分離、48人の設備比較を追加した。更新後の条件・実測・残課題は [階段と比較QA](P2_STAIRS_AND_COMPARISON.md) を参照する。上記v8の数値は当時の保存点の記録として保持する。 diff --git a/scenes/main/main.gd b/scenes/main/main.gd index 8f8baf5..12ddf2f 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -21,6 +21,8 @@ const MVP_FACILITY_IDS: Array[StringName] = [ &"massage_room", &"guest_elevator", &"service_elevator", + &"guest_stairs", + &"service_stairs", &"staff_door", &"guest_corridor", &"service_corridor", @@ -532,6 +534,7 @@ func _create_side_panel() -> Control: selected_entity_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE selected_row.add_child(selected_entity_icon) selected_detail_label = Label.new() + selected_detail_label.mouse_filter = Control.MOUSE_FILTER_PASS selected_detail_label.text = "地図上の施設をクリックすると、営業状態を確認できます。" selected_detail_label.custom_minimum_size.y = 112 selected_detail_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -751,11 +754,11 @@ func _set_business_panel_open(open: bool) -> void: func _on_opening_action(action: StringName) -> void: match action: - &"build_reception", &"build_room", &"build_corridor": + &"build_reception", &"build_room", &"build_corridor", &"build_stairs": _set_ledger_panel_open(false) _set_management_panel_open(false) _set_build_palette_open(true) - var definitions := {&"build_reception": &"reception", &"build_room": &"standard_room", &"build_corridor": &"guest_corridor"} + var definitions := {&"build_reception": &"reception", &"build_room": &"standard_room", &"build_corridor": &"guest_corridor", &"build_stairs": &"guest_stairs"} _select_build_definition(definitions[action]) &"staff": _activate_management_mode(&"staff") @@ -773,6 +776,7 @@ func _connect_signals() -> void: GameClock.phase_changed.connect(_on_phase_changed) GameState.resource_changed.connect(_on_resource_changed) world_preview.facility_selected.connect(_on_facility_selected) + world_preview.legacy_stair_selected.connect(_on_legacy_stair_selected) world_preview.construction_selected.connect(_on_construction_selected) world_preview.cell_clicked.connect(_on_map_cell_clicked) world_preview.build_preview_changed.connect(_on_build_preview_changed) @@ -1290,6 +1294,17 @@ func _on_facility_selected(instance_id: StringName) -> void: _refresh_selected_entity() +func _on_legacy_stair_selected(stair_id: StringName) -> void: + _set_ledger_panel_open(false) + _set_supply_panel_open(false) + _set_management_panel_open(true) + _set_excavation_mode(false, false) + _selected_entity_id = stair_id + _selected_entity_kind = &"legacy_stairs" + world_preview.selected_entity_id = stair_id + _refresh_selected_entity() + + func _on_construction_selected(job_id: StringName) -> void: _set_ledger_panel_open(false) _set_supply_panel_open(false) @@ -1520,6 +1535,7 @@ func _operations_dashboard_text() -> String: func _refresh_selected_entity() -> void: selected_elevator_controls.visible = false + selected_detail_label.tooltip_text = "" if _selected_entity_id.is_empty(): _move_selected_detail_first(false) selected_entity_icon.texture = null @@ -1537,6 +1553,8 @@ func _refresh_selected_entity() -> void: _move_selected_detail_first(false) _refresh_selected_excavation() return + if _refresh_selected_stairs(): + return var shaft := _selected_elevator_report() if not shaft.is_empty(): _refresh_selected_elevator(shaft) @@ -1581,6 +1599,45 @@ func _refresh_selected_entity() -> void: ) +func _refresh_selected_stairs() -> bool: + var from_cell := Vector3i.ZERO + var to_cell := Vector3i.ZERO + var definition_id: StringName = &"" + var legacy := _selected_entity_kind == &"legacy_stairs" + var legacy_access: StringName = &"" + if legacy: + for stair: Dictionary in world_preview.get_legacy_stair_records(): + if StringName(stair[&"id"]) == _selected_entity_id: + from_cell = stair[&"from"] + legacy_access = StringName(stair.get(&"access_id", &"")) + to_cell = stair[&"to"] + definition_id = &"guest_stairs" if from_cell.z == 0 else &"service_stairs" + break + else: + var facility := _construction_facility(_selected_entity_id) + definition_id = StringName(facility.get(&"definition_id", &"")) + if definition_id not in [&"guest_stairs", &"service_stairs"]: + return false + var origin: Variant = facility.get(&"origin", Vector3i.ZERO) + if origin is Array and origin.size() == 3: + origin = Vector3i(int(origin[0]), int(origin[1]), int(origin[2])) + to_cell = origin + from_cell = to_cell+Vector3i(0,-1,0) + if definition_id.is_empty(): + return false + _move_selected_detail_first(true) + var staff_only := legacy_access == &"staff" if legacy else definition_id == &"service_stairs" + selected_heading_label.text = "既設の階段" if legacy else ("業務用階段" if staff_only else "客用階段") + selected_entity_icon.texture = visual_theme.facility_icon(definition_id) if visual_theme != null else null + selected_detail_label.text = ( + "接続階 %s ↔ %s\n" % [_elevator_floor_name(mini(from_cell.y,to_cell.y)), _elevator_floor_name(maxi(from_cell.y,to_cell.y))] + + "利用者 %s\n" % ("従業員専用・物資運搬可" if staff_only else "客・従業員用") + + "階段で昇り降りします。魔力は不要です。\n" + + "座標 (%d, %d) %s" % [to_cell.x,maxi(from_cell.y,to_cell.y),"BACK" if to_cell.z == 1 else "FRONT"] + ) + return true + + func _selected_elevator_report() -> Dictionary: if _selected_entity_kind != &"facility" or _selected_entity_id.is_empty(): return {} @@ -1620,12 +1677,27 @@ func _refresh_selected_elevator(shaft: Dictionary) -> void: + "停止階 %s\n" % stop_text + "魔力 %s  %s" % ["供給中" if powered else "不足", "客・従業員用" if definition_id == &"guest_elevator" else "従業員・物資用"] ) + selected_detail_label.text += _elevator_wait_text(StringName(shaft.get(&"shaft_id", &""))) selected_elevator_controls.visible = true elevator_operation_button.disabled = _pending_elevator_commands.values().has(shaft.get(&"shaft_id", &"")) elevator_operation_button.text = "指示を受理済み…" if elevator_operation_button.disabled else ("昇降機を停止" if operator_open else "運行を再開") elevator_operation_hint.text = "停止すると最寄りの階で降車し、待機中の呼び出しを解除します。" if operator_open else ("再開すると新しい呼び出しを受け付けます。" if powered else "魔力が供給されると運行を再開できます。") +func _elevator_wait_text(shaft_id: StringName) -> String: + selected_detail_label.tooltip_text = "保持している直近256件の出来事のうち、過去60分の乗車を集計。現在の最長待ちは、いま並んでいる人の待ち時間です。" + if _runtime == null or not _runtime.has_method(&"get_elevator_wait_statistics"): + return "\n最近の乗車記録 計測待ち" + var statistics: Dictionary = _runtime.call(&"get_elevator_wait_statistics", shaft_id) + var samples := int(statistics.get(&"sample_count", 0)) + var text := "\n最近の乗車記録 %d人" % samples + if samples == 0: + text += "(計測待ち)" + else: + text += "\n平均待ち %.1f分 / 95%% %.1f分" % [float(statistics.get(&"mean_wait_minutes",0)),float(statistics.get(&"p95_wait_minutes",0))] + return text+"\n現在の最長待ち %d分" % int(statistics.get(&"oldest_wait_minutes",0)) + + func _move_selected_detail_first(first: bool) -> void: var column := selected_heading_label.get_parent() var selected_row := selected_detail_label.get_parent() diff --git a/scenes/main/world_preview.gd b/scenes/main/world_preview.gd index c1497af..c61a069 100644 --- a/scenes/main/world_preview.gd +++ b/scenes/main/world_preview.gd @@ -3,6 +3,7 @@ extends Control signal facility_selected(instance_id: StringName) signal construction_selected(job_id: StringName) +signal legacy_stair_selected(stair_id: StringName) signal cell_clicked(cell: Vector3i) signal hovered_cell_changed(cell: Variant) signal build_preview_changed(report: Dictionary) @@ -320,6 +321,12 @@ func _gui_input(event: InputEvent) -> void: cell_clicked.emit(cell) accept_event() return + var stair_id := legacy_stair_at_canvas(event.position) + if not stair_id.is_empty(): + selected_entity_id = stair_id + legacy_stair_selected.emit(stair_id) + accept_event() + return var world: Variant = _service(&"world_service") if world != null and world.has_method(&"get_occupant"): var occupant_id := StringName(world.call(&"get_occupant", cell)) @@ -353,6 +360,7 @@ func _draw() -> void: if show_grid: _draw_congestion_overlay(bounds) _draw_world_entity_icons() + _draw_legacy_stairs() _draw_elevators() _draw_construction_labels() _draw_facility_labels() @@ -537,6 +545,53 @@ func _draw_world_entity_icons() -> void: draw_rect(rect.grow(-p),Color("f5d582"),false,2*p) +func get_legacy_stair_records() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var service: Variant = _service(&"legacy_stairs") + if service == null or not service.has_method(&"create_snapshot"): + return result + var snapshot: Variant = service.call(&"create_snapshot") + if not snapshot is Dictionary: + return result + for row: Variant in snapshot.get(&"stairs", []): + if row is Dictionary and row.get(&"from") is Vector3i and row.get(&"to") is Vector3i: + var record: Dictionary = row.duplicate(true) + var path: Variant = _service(&"path_service") + var connector: Dictionary = path.call(&"get_connector", StringName(row[&"id"])) if path != null else {} + record[&"access_id"] = connector.get(&"access_id", &"") + result.append(record) + return result + + +func legacy_stair_at_canvas(canvas_position: Vector2) -> StringName: + for stair: Dictionary in get_legacy_stair_records(): + var upper: Vector3i = stair[&"to"] if stair[&"to"].y > stair[&"from"].y else stair[&"from"] + if upper.z == layer_mode and _legacy_stair_rect(upper).has_point(canvas_position): + return StringName(stair[&"id"]) + return &"" + + +func _legacy_stair_rect(upper: Vector3i) -> Rect2: + var rect := get_cell_rect(upper) + # A small target leaves the rest of an existing room available for selection. + return Rect2(rect.position+rect.size*Vector2(0.49,0.24), rect.size*Vector2(0.5,0.7)) + + +func _draw_legacy_stairs() -> void: + for stair: Dictionary in get_legacy_stair_records(): + var upper: Vector3i = stair[&"to"] if stair[&"to"].y > stair[&"from"].y else stair[&"from"] + if upper.z != layer_mode: + continue + var rect := _legacy_stair_rect(upper) + if not rect.intersects(Rect2(Vector2.ZERO, size)): + continue + var p := rect.size.x/14.0 + draw_rect(rect, Color("3c4e40",0.5)) + PixelArt.stairs(self,rect,p,upper.z == LayerMode.BACK,true) + if StringName(stair[&"id"]) == selected_entity_id: + draw_rect(rect,Color("f5d582"),false,maxf(p,1)) + + func _cell_bounds_rect(cells: Array[Vector3i]) -> Rect2: if cells.is_empty(): return Rect2() @@ -605,7 +660,7 @@ func _draw_facility_labels() -> void: var selected := StringName(facility.get(&"instance_id",&""))==selected_entity_id if not hovered and not selected and rect.size.x<100: continue - if id in [&"guest_corridor",&"service_corridor",&"staff_door"] and not hovered and not selected: + if id in [&"guest_corridor",&"service_corridor",&"staff_door",&"guest_stairs",&"service_stairs"] and not hovered and not selected: continue var definition: Variant = registry.call(&"get_facility",id) if registry != null else null var label := String(definition.display_name) if definition != null else String(id) diff --git a/scripts/data/facility_catalog.gd b/scripts/data/facility_catalog.gd index f5b2291..872b752 100644 --- a/scripts/data/facility_catalog.gd +++ b/scripts/data/facility_catalog.gd @@ -44,6 +44,18 @@ const FACILITY_PATHS: Array[String] = [ "res://data/facilities/adult_private_room.tres", ] +## Required movement infrastructure added after the original sixteen-facility +## catalog. Separate paths preserve the baseline design table and legacy scans. +const INFRASTRUCTURE_FACILITY_IDS: Array[StringName] = [ + &"guest_stairs", + &"service_stairs", +] + +const INFRASTRUCTURE_FACILITY_PATHS: Array[String] = [ + "res://data/facilities/infrastructure/guest_stairs.tres", + "res://data/facilities/infrastructure/service_stairs.tres", +] + ## Optional post-MVP expansion definitions. They live below a subdirectory so ## DefinitionRegistry's baseline directory scan remains exactly compatible with ## existing saves and the sixteen-facility acceptance fixture. @@ -101,6 +113,14 @@ static func load_expansion() -> Array[FacilityDefinition]: result.append(definition) return result +static func load_infrastructure() -> Array[FacilityDefinition]: + var result: Array[FacilityDefinition] = [] + for path in INFRASTRUCTURE_FACILITY_PATHS: + var definition := load(path) as FacilityDefinition + if definition != null: + result.append(definition) + return result + static func load_available(include_expansion := false) -> Array[FacilityDefinition]: var result := load_all() if include_expansion: @@ -108,7 +128,9 @@ static func load_available(include_expansion := false) -> Array[FacilityDefiniti return result static func by_id(facility_id: StringName) -> FacilityDefinition: - for definition in load_available(true): + var definitions := load_available(true) + definitions.append_array(load_infrastructure()) + for definition in definitions: if definition.id == facility_id: return definition return null diff --git a/scripts/ui/inn_opening_guide.gd b/scripts/ui/inn_opening_guide.gd index f90eded..fcfb0cb 100644 --- a/scripts/ui/inn_opening_guide.gd +++ b/scripts/ui/inn_opening_guide.gd @@ -82,12 +82,15 @@ func _read_progress() -> Dictionary: if reception == null: return _build_step(&"reception", &"reception", "1/6 受付をつくる", "表の地表階に受付を配置します。完成した受付の上に客室を増築できます。", &"build_reception") if rooms.is_empty(): - return _build_step(&"room", &"standard_room", "2/6 客室をつくる", "受付が支えになる位置に一般客室を配置します。", &"build_room") + return _build_step(&"room", &"standard_room", "2/6 客室をつくる", "受付が支えになる位置に一般客室を配置します。上階へは客用階段で道をつなぎます。", &"build_room") var accessible_room := false for room: FacilityInstance in rooms: - accessible_room = accessible_room or room.access_available - if not reception.access_available or not accessible_room: - return _step(&"access", "3/6 道をつなぐ", "客室の出入口まで客用通路をつなぎます。施設を選択すると、通行不可など営業できない理由を確認できます。", &"build_corridor", "客用通路を選ぶ") + accessible_room = accessible_room or _can_reach_facility(room) + if not _can_reach_facility(reception) or not accessible_room: + var upper_room := false + for room: FacilityInstance in rooms: + upper_room = upper_room or room.origin.y != 0 + return _step(&"access", "3/6 道をつなぐ", "上下階は客用階段でつなぎます。上階のマスに置くと、直下の通路へ接続します。同じ階は客用通路を延ばし、客室の左右の出入口まで道をつなぎましょう。", &"build_stairs" if upper_room else &"build_corridor", "客用階段を選ぶ" if upper_room else "客用通路を選ぶ") if not _has_reception_assignment(reception): return _step(&"staff", "4/6 受付係を配属", "従業員から受付係を雇い、雇用済み一覧でこの受付と勤務帯を選び「配属・勤務帯を変更」を押します。", &"staff", "従業員を開く") if first_sale < 0: @@ -95,8 +98,19 @@ func _read_progress() -> Dictionary: return _step(&"close", "6/6 翌朝の決算へ", "最初の宿泊売上が入りました。06:00に営業日が締まり、給与・利益・未払いを帳簿で確認できます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る") +func _can_reach_facility(facility: FacilityInstance) -> bool: + if not facility.access_available: + return false + # Runtime uses the same public entrance and access cells as real guests. + # Component-only guide fixtures retain their explicit availability flags. + if _runtime.has_method(&"get_facility_access_report"): + var report: Dictionary = _runtime.call(&"get_facility_access_report", facility.instance_id) + return bool(report.get(&"reachable", false)) + return true + + func _reception_score(facility: FacilityInstance) -> int: - return (2 if facility.access_available else 0) + (1 if _has_reception_assignment(facility) else 0) + return (2 if _can_reach_facility(facility) else 0) + (1 if _has_reception_assignment(facility) else 0) func _has_reception_assignment(reception: FacilityInstance) -> bool: diff --git a/scripts/ui/pixel_tower_art.gd b/scripts/ui/pixel_tower_art.gd index fdb73d5..6e84ce9 100644 --- a/scripts/ui/pixel_tower_art.gd +++ b/scripts/ui/pixel_tower_art.gd @@ -17,6 +17,9 @@ static func tile(canvas: CanvasItem, texture: Texture2D, index: int, target: Rec static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0) -> void: var p := pixel + if id in [&"guest_stairs", &"service_stairs"]: + stairs(canvas, rect, p, id == &"service_stairs") + return if id in [&"guest_corridor", &"service_corridor"]: canvas.draw_rect(rect, Color("c6b999") if not back else Color("a6b6a0")) canvas.draw_rect(Rect2(rect.position.x,rect.end.y-4*p,rect.size.x,3*p),WOOD_LIGHT) @@ -101,6 +104,30 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, table(canvas, Vector2(mid-6*p, floor_y-7*p), p) +## The upper landing owns a short flight down to the floor below. Six square +## treads and a stepped handrail keep the shape legible at the 16 px room scale. +static func stairs(canvas: CanvasItem, rect: Rect2, p: float, staff_only: bool, compact := false) -> void: + if not compact: + canvas.draw_rect(rect, INK) + canvas.draw_rect(rect.grow(-p), Color("c7c6a6") if not staff_only else Color("a8b6aa")) + canvas.draw_rect(Rect2(rect.position.x, rect.end.y-4*p, rect.size.x, 3*p), WOOD) + canvas.draw_rect(Rect2(rect.position.x, rect.end.y-p, rect.size.x, p), INK) + var at := Vector2(rect.get_center().x-6*p, rect.end.y-5*p) + var tread := Color("d8bf89") if not staff_only else Color("aec0bf") + var rise := Color("8a7155") if not staff_only else Color("617f82") + for step: int in range(6): + var top := at + Vector2(step*2, -step*2)*p + canvas.draw_rect(Rect2(top, Vector2(2, 2+step*2)*p), rise) + canvas.draw_rect(Rect2(top, Vector2(3, 1)*p), tread) + canvas.draw_rect(Rect2(top+Vector2(0,-5)*p, Vector2(2,1)*p), INK) + if step % 2 == 0: + canvas.draw_rect(Rect2(top+Vector2(0,-4)*p, Vector2(1,4)*p), WOOD if not staff_only else TEAL) + canvas.draw_rect(Rect2(at+Vector2(10,-11)*p, Vector2(3,2)*p), tread) + if staff_only and not compact: + canvas.draw_rect(Rect2(rect.position+Vector2(2,2)*p, Vector2(3,3)*p), TEAL) + canvas.draw_rect(Rect2(rect.position+Vector2(3,3)*p, Vector2(1,1)*p), CREAM) + + ## Static shaft rails remain when a car is stopped or the transport service is ## unavailable. The car itself is drawn once from its authoritative trip state. static func elevator_shaft(canvas: CanvasItem, rect: Rect2, p: float, back: bool) -> void: diff --git a/scripts/ui/pixel_tower_demo.gd b/scripts/ui/pixel_tower_demo.gd index f663690..e4e2d5a 100644 --- a/scripts/ui/pixel_tower_demo.gd +++ b/scripts/ui/pixel_tower_demo.gd @@ -31,26 +31,26 @@ func prepare(runtime: Variant, clock: Variant, tree: SceneTree) -> bool: return false for facility: Dictionary in _runtime.construction_service.get_facilities(): facility_ids[facility[&"definition_id"]] = facility[&"instance_id"] - # Four rooms sit on the completed reception and dining structure. Corridor - # cells are ordinary supported construction; unfinished elevator topology is - # deliberately excluded from this scenario. + # Four rooms sit on the completed reception and dining structure. One + # continuous, paid staircase in the center joins all three upper landings; + # same-floor corridor construction links the rooms on either side. for x: int in [-8, -6, 2, 4]: if not _build(&"standard_room", Vector3i(x, 2, 0)): return false for x: int in range(-4, 2): - if not _build(&"guest_corridor", Vector3i(x, 1, 0)): + if not _build(&"guest_stairs" if x == -4 else &"guest_corridor", Vector3i(x, 1, 0)): return false if not await advance(60): return false for x: int in range(-4, 2): - if not _build(&"guest_corridor", Vector3i(x, 2, 0)): + if not _build(&"guest_stairs" if x == -4 else &"guest_corridor", Vector3i(x, 2, 0)): return false if not await advance(10): return false - # This walkway follows the upper side of the rooms. Its authoritative Path - # projection allows real guests and workers to move between the three levels. + # This walkway follows the upper side of the rooms. The final stair segment + # gives real guests and the cleaner a legal route to this floor. for x: int in range(-8, 6): - if not _build(&"guest_corridor", Vector3i(x, 3, 0)): + if not _build(&"guest_stairs" if x == -4 else &"guest_corridor", Vector3i(x, 3, 0)): return false if not await advance(10): return false diff --git a/scripts/world/facility_infrastructure_projector.gd b/scripts/world/facility_infrastructure_projector.gd index c6689bf..f5e1bea 100644 --- a/scripts/world/facility_infrastructure_projector.gd +++ b/scripts/world/facility_infrastructure_projector.gd @@ -33,12 +33,16 @@ const KIND_SERVICE_CORRIDOR: StringName = &"service_corridor" const KIND_STAFF_DOOR: StringName = &"staff_door" 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 SUPPORTED_KINDS: Array[StringName] = [ KIND_GUEST_CORRIDOR, KIND_SERVICE_CORRIDOR, KIND_STAFF_DOOR, KIND_GUEST_ELEVATOR, KIND_SERVICE_ELEVATOR, + KIND_GUEST_STAIRS, + KIND_SERVICE_STAIRS, ] const KNOWN_KINDS: Array[StringName] = [ KIND_GUEST_CORRIDOR, @@ -46,10 +50,14 @@ const KNOWN_KINDS: Array[StringName] = [ KIND_STAFF_DOOR, KIND_GUEST_ELEVATOR, KIND_SERVICE_ELEVATOR, + KIND_GUEST_STAIRS, + KIND_SERVICE_STAIRS, ] const UNSUPPORTED_VERTICAL_KINDS: Array[StringName] = [] const VERTICAL_KINDS: Array[StringName] = [KIND_GUEST_ELEVATOR, KIND_SERVICE_ELEVATOR] +const STAIR_KINDS: Array[StringName] = [KIND_GUEST_STAIRS, KIND_SERVICE_STAIRS] +const CONNECTOR_KINDS: Array[StringName] = [KIND_STAFF_DOOR, KIND_GUEST_STAIRS, KIND_SERVICE_STAIRS] const REASON_UNSUPPORTED_VERTICAL_TOPOLOGY: StringName = &"UNSUPPORTED_VERTICAL_TOPOLOGY" const QUARANTINE_TRANSACTION_ROLLBACK: StringName = &"TRANSACTION_ROLLBACK_UNCERTAIN" const QUARANTINE_RESTORE_ROLLBACK: StringName = &"RESTORE_ROLLBACK_UNCERTAIN" @@ -65,6 +73,7 @@ const ADAPTER_PROJECT_REQUEST_PREFIX := "infra.project:" const ADAPTER_UNPROJECT_REQUEST_PREFIX := "infra.unproject:" const STAFF_ACCESS_ID: StringName = &"staff" const STAFF_DOOR_CONNECTOR_COST := 120 +const STAIR_CONNECTOR_COST := 150 const _INSTANCE_KEYS: Array[StringName] = [ &"instance_id", &"definition_id", &"origin", &"rotation", &"layer", @@ -193,6 +202,8 @@ func project_facility( return _failure(CommandResultType.Code.DUPLICATE_COMMAND_ID, "Stable infrastructure ID collides with existing authoritative state.") if not _dependencies_match_current_state(): return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Facility infrastructure dependencies are stale or invalid.") + if kind in STAIR_KINDS and not _path.has_cell(_stair_lower_landing(record)): + return _failure(CommandResultType.Code.VALIDATION_FAILED, "Stairs require an existing passable landing directly below their origin.") var owned_before := _capture_owned_state() _transaction_active = true @@ -218,23 +229,22 @@ func project_facility( dependency_capture, "ROUTE projection failed or reentrant callback tampering was detected." ) - if kind == KIND_STAFF_DOOR: - var back_cell: Vector3i = record[&"cells"][0] - var front_cell := Vector3i(back_cell.x, back_cell.y, PathType.FRONT) + if kind in CONNECTOR_KINDS: + var connector := _expected_stair_connector(record) if kind in STAIR_KINDS else _expected_staff_connector(record) var connector_ok := _path.configure_connector( StringName(record[&"connector_id"]), - front_cell, - back_cell, - STAFF_DOOR_CONNECTOR_COST, + connector[&"from"], + connector[&"to"], + connector[&"cost"], true, - STAFF_ACCESS_ID, + connector[&"access_id"], true ) if not connector_ok or not _owned_state_matches(guard_state): return _rollback_failure( owned_before, dependency_capture, - "Staff-door connector projection failed or callback tampering was detected." + "Infrastructure connector projection failed or callback tampering was detected." ) if kind in VERTICAL_KINDS: var shaft_cells: Array[Vector3i] = [] @@ -327,7 +337,7 @@ func unproject_facility(request_id_value: Variant, instance_id_value: Variant) - return _rollback_failure( owned_before, dependency_capture, - "Staff-door connector removal failed or callback tampering was detected." + "Infrastructure connector removal failed or callback tampering was detected." ) if record[&"kind"] in VERTICAL_KINDS: if not _path.remove_vertical_walk_blockers(StringName(SHAFT_OWNER_PREFIX + String(instance_id))) or not _owned_state_matches(guard_state): @@ -396,6 +406,21 @@ func get_projection_ids() -> Array[StringName]: return _sorted_names(_projections) +## Before removing a ROUTE/base cell, the owner can reject demolition that +## would leave a staircase without a lower landing. Overlapping remaining +## passable sources may still keep that landing valid. +func get_stair_dependents(cells: Array[Vector3i]) -> Array[StringName]: + var selected: Dictionary = {} + for cell: Vector3i in cells: + selected[cell] = true + var result: Array[StringName] = [] + for instance_id: StringName in _sorted_names(_projections): + var record: Dictionary = _projections[instance_id] + if record[&"kind"] in STAIR_KINDS and selected.has(_stair_lower_landing(record)): + result.append(instance_id) + return result + + func get_processed_request_ids() -> Array[StringName]: return _sorted_names(_processed_requests) @@ -511,6 +536,7 @@ func _canonicalize_live_project_input(definition_value: Variant, instance_value: or definition.footprint != schema[&"footprint"] or definition.pass_through != bool(schema[&"pass_through"]) or canonical_tags != schema[&"tags"] + or (definition.id in STAIR_KINDS and definition.allowed_rotations != [0]) ): return {&"ok": false, &"message": "Facility definition layer, footprint, pass-through flag, or tags diverge from its infrastructure schema."} var canonical_definition := { @@ -565,6 +591,7 @@ func _canonicalize_live_instance( if not _cell_is_valid(origin) \ or rotation not in [0, 90, 180, 270] \ or rotation not in allowed_rotations \ + or (definition[&"id"] in STAIR_KINDS and (rotation != 0 or not _cell_is_valid(origin + Vector3i(0, -1, 0)))) \ or layer != int(definition[&"layer"]) \ or origin.z != layer \ or completed_at < 0 \ @@ -601,6 +628,17 @@ func _canonicalize_live_instance( static func _kind_schema(kind: StringName) -> Dictionary: match kind: + KIND_GUEST_STAIRS, KIND_SERVICE_STAIRS: + var staff_only := kind == KIND_SERVICE_STAIRS + var tags: Array[StringName] = [&"movement", &"route_subslot", &"schema_facility_proxy", &"stairs", &"vertical_connector"] + tags.append(&"staff_only" if staff_only else &"guest_access") + return { + &"layer": PathType.BACK if staff_only else PathType.FRONT, + &"footprint": Vector2i.ONE, + &"pass_through": true, + &"staff_only": staff_only, + &"tags": _sorted_name_array(tags), + } KIND_GUEST_CORRIDOR: return { &"layer": PathType.FRONT, @@ -664,7 +702,7 @@ static func _projection_from_project_input(input: Dictionary) -> Dictionary: &"definition_id": kind, &"kind": kind, &"route_entity_id": StringName(ROUTE_ENTITY_PREFIX + String(instance_id)), - &"connector_id": StringName(CONNECTOR_PREFIX + String(instance_id)) if kind == KIND_STAFF_DOOR else &"", + &"connector_id": StringName(CONNECTOR_PREFIX + String(instance_id)) if kind in CONNECTOR_KINDS else &"", &"layer": int(definition[&"layer"]), &"cells": (instance[&"occupied_cells"] as Array).duplicate(), &"staff_only": bool(_kind_schema(kind)[&"staff_only"]), @@ -973,6 +1011,11 @@ func _projection_live_matches(record: Dictionary) -> bool: or _source.get_entity_cells(route_entity_id) != record[&"cells"]: return false var connector_id: StringName = record[&"connector_id"] + if record[&"kind"] in STAIR_KINDS: + return _path.has_cell(_stair_lower_landing(record)) \ + and not connector_id.is_empty() \ + and _path.has_connector(connector_id) \ + and _path.get_connector(connector_id) == _expected_stair_connector(record) if record[&"kind"] == KIND_STAFF_DOOR: if connector_id.is_empty() or not _path.has_connector(connector_id): return false @@ -1010,6 +1053,21 @@ static func _expected_staff_connector(record: Dictionary) -> Dictionary: } +static func _stair_lower_landing(record: Dictionary) -> Vector3i: + return (record[&"origin"] as Vector3i) + Vector3i(0, -1, 0) + + +static func _expected_stair_connector(record: Dictionary) -> Dictionary: + var connector := _expected_staff_connector(record) + var upper_cell: Vector3i = record[&"origin"] + connector[&"from"] = upper_cell + connector[&"to"] = _stair_lower_landing(record) + connector[&"cost"] = STAIR_CONNECTOR_COST + connector[&"access_id"] = STAFF_ACCESS_ID if record[&"kind"] == KIND_SERVICE_STAIRS else &"" + connector[&"current_floor"] = upper_cell.y + return connector + + static func _has_managed_namespace_collision( source_service: SpatialSubslotService, path_service: PathService, @@ -1257,7 +1315,7 @@ static func _parse_projection(raw: Variant) -> Dictionary: or kind not in SUPPORTED_KINDS \ or definition_id != kind \ or route_entity_id != StringName(ROUTE_ENTITY_PREFIX + String(instance_id)) \ - or connector_id != (StringName(CONNECTOR_PREFIX + String(instance_id)) if kind == KIND_STAFF_DOOR else &"") \ + or connector_id != (StringName(CONNECTOR_PREFIX + String(instance_id)) if kind in CONNECTOR_KINDS else &"") \ or not _name_is_valid(source_job_id, MAX_INSTANCE_ID_LENGTH): return {&"ok": false, &"message": "Facility infrastructure projection stable-ID crosslink is invalid."} var schema := _kind_schema(kind) @@ -1297,6 +1355,7 @@ static func _parse_projection(raw: Variant) -> Dictionary: or not _cell_is_valid(origin) or origin.z != int(record[&"layer"]) or rotation not in [0, 90, 180, 270] + or (kind in STAIR_KINDS and (rotation != 0 or not _cell_is_valid(origin + Vector3i(0, -1, 0)))) or completed_at < 0 or completed_at > MAX_SAFE_INTEGER or record[&"cells"] != _footprint_cells(origin, record[&"footprint"], rotation) @@ -1461,6 +1520,7 @@ static func _parse_snapshot_project_input(raw: Variant) -> Dictionary: or origin.z != layer \ or layer != int(definition[&"layer"]) \ or rotation not in [0, 90, 180, 270] \ + or (definition_id in STAIR_KINDS and (rotation != 0 or not _cell_is_valid(origin + Vector3i(0, -1, 0)))) \ or completed_at < 0 \ or completed_at > MAX_SAFE_INTEGER: return {&"ok": false, &"message": "Snapshot completed-facility semantic fields are invalid."} diff --git a/scripts/world/legacy_stair_service.gd b/scripts/world/legacy_stair_service.gd new file mode 100644 index 0000000..c83921a --- /dev/null +++ b/scripts/world/legacy_stair_service.gd @@ -0,0 +1,290 @@ +class_name LegacyStairService +extends RefCounted + +## Owns only the stairs materialized once from pre-policy vertical WALK edges. +## Their original route score and access remain unchanged. Demolished landings +## permanently remove their compatibility connector; new floors need stairs. + +const PathType = preload("res://scripts/world/path_service.gd") +const SNAPSHOT_VERSION := 1 +const CONNECTOR_PREFIX := "legacy.stairs:" +const CONNECTOR_COST := 100 +const MAX_STAIRS := 131_072 +const MAX_COORDINATE_ABS := 100_000 + +var last_error := "" +var _path: PathService +var _stairs: Dictionary = {} +var _migration_closed := false +var _transaction_active := false +var _quarantined := false + + +## Bind before or after Path restoration. restore_snapshot performs the exact +## namespace and topology crosslink checks after all dependencies are loaded. +func configure(path_value: Variant) -> bool: + if _transaction_active or _path != null: + return _fail("Legacy stairs are already configured or transacting.") + if not path_value is PathService: + return _fail("Legacy stairs require PathService.") + _path = path_value + last_error = "" + return true + + +func migrate_existing_vertical_walks() -> bool: + if not _available(): + return false + if _path.get_vertical_walk_requires_connector(): + if not _live_matches(_stairs, true): + return _fail("Current legacy stairs do not match Path ownership or landings.") + _migration_closed = true + last_error = "" + return true + if _migration_closed or not _stairs.is_empty(): + return _fail("Legacy vertical WALK migration cannot be run again.") + if not _live_matches({}, false): + return _fail("Legacy stair connector namespace contains unowned state.") + var capture := _capture_path() + if capture.is_empty(): + return false + var blocked := _shaft_cells() + var next_stairs: Dictionary = {} + for from_cell: Vector3i in capture[&"cells"]: + for raw_edge: Variant in _path.get_neighbors(from_cell): + var edge: Dictionary = raw_edge + var to_cell: Vector3i = edge[&"to"] + if edge[&"movement_type"] != PathType.MOVEMENT_WALK \ + or not StringName(edge[&"connector_id"]).is_empty() \ + or to_cell != from_cell + Vector3i(0, 1, 0) \ + or blocked.has(from_cell) or blocked.has(to_cell): + continue + if not _valid_cell(from_cell) or not _valid_cell(to_cell): + return _fail("Legacy vertical WALK endpoints exceed the supported coordinate range.") + var stair_id := _stable_id(from_cell) + next_stairs[stair_id] = {&"id": stair_id, &"from": from_cell, &"to": to_cell} + if next_stairs.size() > MAX_STAIRS: + return _fail("Legacy stair migration exceeds the supported capacity.") + return _apply_candidate(next_stairs, capture) + + +## Called after authoritative cells/shafts change. Missing endpoints and new +## shafts retire records; no adjacency scan is ever performed here. +func prune_invalid() -> bool: + if not _available(): + return false + if not _live_matches(_stairs, true, false): + return _fail("Legacy stair ownership or vertical policy was altered.") + var blocked := _shaft_cells() + var next_stairs: Dictionary = {} + for stair_id: StringName in _sorted_ids(_stairs): + var record: Dictionary = _stairs[stair_id] + if _landing_is_valid(record, blocked): + next_stairs[stair_id] = record.duplicate(true) + if next_stairs == _stairs: + last_error = "" + return true + var capture := _capture_path() + if capture.is_empty(): + return false + return _apply_candidate(next_stairs, capture) + + +func create_snapshot() -> Dictionary: + if _path == null: + return {} + var rows: Array[Dictionary] = [] + for stair_id: StringName in _sorted_ids(_stairs): + var record: Dictionary = _stairs[stair_id] + rows.append({&"id": String(stair_id), &"from": record[&"from"], &"to": record[&"to"]}) + return {&"schema_version": SNAPSHOT_VERSION, &"stairs": rows} + + +func restore_snapshot(data: Dictionary) -> bool: + if _path == null or _transaction_active: + return _fail("Legacy stair restore requires an idle configured service.") + var parsed := _parse_snapshot(data) + if not bool(parsed[&"ok"]): + return _fail(String(parsed[&"error"])) + var next_stairs: Dictionary = parsed[&"stairs"] + if not _live_matches(next_stairs, true): + return _fail("Legacy stair snapshot does not exactly match Path connectors and landings.") + _stairs = next_stairs.duplicate(true) + _migration_closed = true + _quarantined = false + last_error = "" + return true + + +func validate_invariants() -> Dictionary: + var ok := _path != null and not _quarantined and _live_matches(_stairs, true) + return {&"ok": ok, &"stair_count": _stairs.size(), &"quarantined": _quarantined} + + +## Assemble one Path snapshot so migration remains linear in graph size and +## preserves every unrelated connector, actor, traffic row, and shaft owner. +func _apply_candidate(next_stairs: Dictionary, capture: Dictionary) -> bool: + var before: Dictionary = capture[&"snapshot"] + var next_snapshot := before.duplicate(true) + var connectors: Array = [] + for raw_connector: Dictionary in before[&"connectors"]: + if not String(raw_connector[&"id"]).begins_with(CONNECTOR_PREFIX): + connectors.append(raw_connector.duplicate(true)) + for stair_id: StringName in _sorted_ids(next_stairs): + connectors.append(_expected_connector(next_stairs[stair_id])) + connectors.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: return String(left[&"id"]) < String(right[&"id"])) + next_snapshot[&"connectors"] = connectors + next_snapshot[&"vertical_walk_requires_connector"] = true + next_snapshot[&"graph_revision"] = int(before[&"graph_revision"]) + 1 + var verifier := PathType.new() + if not verifier.restore_snapshot(next_snapshot) or not verifier.rebuild_graph(capture[&"cells"]): + return _fail("Legacy stair candidate Path snapshot is invalid: %s" % verifier.last_error) + var expected := verifier.create_snapshot() + var stairs_before := _stairs.duplicate(true) + var closed_before := _migration_closed + var path_before := _path + _transaction_active = true + var applied := _path.restore_snapshot(expected.duplicate(true)) + if not applied or _path != path_before or not _transaction_active or _stairs != stairs_before \ + or _migration_closed != closed_before \ + or _path.create_snapshot() != expected \ + or not _live_matches(next_stairs, true): + _path = path_before + var restored := false + for _attempt: int in range(2): + _path.rebuild_graph(capture[&"cells"]) + _path.restore_snapshot(before.duplicate(true)) + _path.rebuild_graph(capture[&"cells"]) + if _path.create_snapshot() == before: + restored = true + break + _stairs = stairs_before + _migration_closed = closed_before + _transaction_active = false + _quarantined = not restored + return _fail("Legacy stair transaction failed; exact rollback %s." % ("restored" if restored else "is uncertain")) + _stairs = next_stairs.duplicate(true) + _migration_closed = true + _transaction_active = false + last_error = "" + return true + + +func _live_matches(stairs: Dictionary, require_policy: bool, require_landings := true) -> bool: + if _path == null or (require_policy and not _path.get_vertical_walk_requires_connector()): + return false + var seen: Dictionary = {} + for raw_connector: Dictionary in _path.create_snapshot().get(&"connectors", []): + var stair_id := StringName(raw_connector[&"id"]) + if not String(stair_id).begins_with(CONNECTOR_PREFIX): + continue + if not stairs.has(stair_id) or _path.get_connector(stair_id) != _expected_connector(stairs[stair_id]): + return false + seen[stair_id] = true + if seen.size() != stairs.size(): + return false + if require_landings: + var blocked := _shaft_cells() + for record: Dictionary in stairs.values(): + if not _landing_is_valid(record, blocked): + return false + return true + + +func _landing_is_valid(record: Dictionary, blocked: Dictionary) -> bool: + return _path.has_cell(record[&"from"]) and _path.has_cell(record[&"to"]) \ + and not blocked.has(record[&"from"]) and not blocked.has(record[&"to"]) + + +func _shaft_cells() -> Dictionary: + var result: Dictionary = {} + for cells: Array in _path.get_vertical_walk_blockers().values(): + for cell: Vector3i in cells: + result[cell] = true + return result + + +func _capture_path() -> Dictionary: + var snapshot := _path.create_snapshot() + var cells: Array[Vector3i] = [] + for row: Dictionary in snapshot.get(&"cell_traffic", []): + var cell: Vector3i = row[&"cell"] + if not _path.has_cell(cell): + _fail("Legacy stair migration requires the materialized Path graph.") + return {} + cells.append(cell) + return {&"snapshot": snapshot, &"cells": cells} + + +static func _parse_snapshot(data: Dictionary) -> Dictionary: + if not _keys_match(data, [&"schema_version", &"stairs"]) \ + or typeof(data.get(&"schema_version")) != TYPE_INT \ + or data[&"schema_version"] != SNAPSHOT_VERSION \ + or not data.get(&"stairs") is Array \ + or data[&"stairs"].size() > MAX_STAIRS: + return {&"ok": false, &"error": "Legacy stair snapshot fields or version are invalid."} + var stairs: Dictionary = {} + var previous_id := "" + for raw: Variant in data[&"stairs"]: + if not raw is Dictionary or not _keys_match(raw, [&"id", &"from", &"to"]) \ + or typeof(raw.get(&"id")) != TYPE_STRING \ + or not _valid_cell(raw.get(&"from")) or not _valid_cell(raw.get(&"to")): + return {&"ok": false, &"error": "Legacy stair record types are invalid."} + var lower: Vector3i = raw[&"from"] + var upper: Vector3i = raw[&"to"] + var stair_id := StringName(raw[&"id"]) + if upper != lower + Vector3i(0, 1, 0) or stair_id != _stable_id(lower) \ + or (not previous_id.is_empty() and String(stair_id) <= previous_id): + return {&"ok": false, &"error": "Legacy stair geometry, stable ID, or order is invalid."} + stairs[stair_id] = {&"id": stair_id, &"from": lower, &"to": upper} + previous_id = String(stair_id) + return {&"ok": true, &"stairs": stairs} + + +static func _expected_connector(record: Dictionary) -> Dictionary: + var lower: Vector3i = record[&"from"] + return { + &"id": record[&"id"], &"from": lower, &"to": record[&"to"], + &"cost": CONNECTOR_COST, &"bidirectional": true, &"access_id": &"", &"enabled": true, + &"movement_type": PathType.MOVEMENT_CONNECTOR, &"dynamic_cost": 0, + &"operational_state": PathType.CONNECTOR_OPERATIONAL, + &"cabin_capacity": 8, &"queue_capacity": 256, &"cabin_state": PathType.CABIN_IDLE, + &"current_floor": lower.y, &"travel_minutes_per_floor": 1, &"request_queue": [], + } + + +static func _stable_id(lower: Vector3i) -> StringName: + return StringName("%s%d:%d:%d" % [CONNECTOR_PREFIX, lower.z, lower.x, lower.y]) + + +static func _valid_cell(value: Variant) -> bool: + return value is Vector3i and value.z in [PathType.FRONT, PathType.BACK] \ + and absi(value.x) <= MAX_COORDINATE_ABS and absi(value.y) <= MAX_COORDINATE_ABS + + +static func _keys_match(value: Dictionary, expected: Array[StringName]) -> bool: + if value.size() != expected.size(): + return false + for key: Variant in value: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(key) not in expected: + return false + return true + + +static func _sorted_ids(value: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for stair_id: StringName in value: + result.append(stair_id) + result.sort_custom(func(left: StringName, right: StringName) -> bool: return String(left) < String(right)) + return result + + +func _available() -> bool: + if _path == null or _transaction_active or _quarantined: + return _fail("Legacy stairs require an idle, configured, available service.") + return true + + +func _fail(message: String) -> bool: + last_error = message + return false diff --git a/scripts/world/legacy_stair_service.gd.uid b/scripts/world/legacy_stair_service.gd.uid new file mode 100644 index 0000000..83edcc2 --- /dev/null +++ b/scripts/world/legacy_stair_service.gd.uid @@ -0,0 +1 @@ +uid://b0ti0lvvkubaj diff --git a/scripts/world/path_service.gd b/scripts/world/path_service.gd index e41df1d..63a80d9 100644 --- a/scripts/world/path_service.gd +++ b/scripts/world/path_service.gd @@ -17,7 +17,8 @@ const CONGESTION_COST_PER_EXCESS := 75 const MINIMUM_SPEED_MULTIPLIER := 0.25 const STUCK_THRESHOLD_MINUTES := 30 const ELEVATOR_STARVATION_MINUTES := 60 -const SNAPSHOT_VERSION := 4 +const SNAPSHOT_VERSION := 5 +const SHAFT_SNAPSHOT_VERSION := 4 const ELEVATOR_INDEX_SNAPSHOT_VERSION := 3 const MAX_VERTICAL_BLOCKER_OWNERS := 4_096 const MAX_VERTICAL_BLOCKER_CELLS := 262_144 @@ -83,6 +84,8 @@ var _path_cache: Dictionary = {} var _cell_traffic: Dictionary = {} var _actor_cells: Dictionary = {} var _actor_progress: Dictionary = {} +var _vertical_walk_requires_connector := false +var _transit_actor_ids: Dictionary = {} var _known_elevator_request_ids: Dictionary = {} var _active_elevator_units: Dictionary = {} var _pending_graph_revision := -1 @@ -183,6 +186,28 @@ func get_vertical_walk_blockers() -> Dictionary: return _vertical_walk_blockers.duplicate(true) +func set_vertical_walk_requires_connector(required: bool) -> bool: + if _vertical_walk_requires_connector == required: + last_error = "" + return true + _vertical_walk_requires_connector = required + _mark_graph_changed() + last_error = "" + return true + + +func get_vertical_walk_requires_connector() -> bool: + return _vertical_walk_requires_connector + + +func get_transit_actor_ids() -> Array[StringName]: + return _sorted_name_keys(_transit_actor_ids) + + +func is_actor_in_transit(actor_id: StringName) -> bool: + return _transit_actor_ids.has(actor_id) + + func _rebuild_vertical_walk_blocked_cells() -> void: _vertical_walk_blocked_cells.clear() for cells: Array in _vertical_walk_blockers.values(): @@ -254,6 +279,7 @@ func configure_connector( if active[&"state"] == ELEVATOR_REQUEST_BOARDED: next_active[unit_id] = active.duplicate(true) _active_elevator_units = next_active + _refresh_transit_actor_ids() for request_id: Variant in index_result[&"known_request_ids"]: _known_elevator_request_ids[request_id] = true _rebuild_derived_graph() @@ -326,6 +352,7 @@ func set_connectors(configurations: Array) -> bool: return false _connectors = next_connectors _active_elevator_units = normalized_collection[&"active_units"] + _refresh_transit_actor_ids() for request_id: Variant in normalized_collection[&"known_request_ids"]: _known_elevator_request_ids[request_id] = true _rebuild_derived_graph() @@ -577,17 +604,23 @@ func project_actor_position( var changed_cells: Array[Vector3i] = [] if _actor_cells.has(actor_id): var previous: Vector3i = _actor_cells[actor_id] - if not _decrement_density(previous): + if not is_actor_in_transit(actor_id) and not _decrement_density(previous): last_error = "Actor density projection is inconsistent for: %s" % actor_id return false - changed_cells.append(previous) + if not is_actor_in_transit(actor_id): + changed_cells.append(previous) _actor_cells[actor_id] = cell - _increment_density(cell) - changed_cells.append(cell) + if _active_elevator_units.get(actor_id, {}).get(&"state", &"") == ELEVATOR_REQUEST_BOARDED: + _transit_actor_ids[actor_id] = true + else: + _transit_actor_ids.erase(actor_id) + _increment_density(cell) + changed_cells.append(cell) # Leaving the previous cell can make an unselected alternative cheaper, so a # move requires broad cache invalidation even though congestion itself does # not advance graph_revision. - _mark_traffic_changed(changed_cells, changed_cells.size() > 1) + if not changed_cells.is_empty(): + _mark_traffic_changed(changed_cells, changed_cells.size() > 1) if absolute_minute >= 0: record_actor_progress(actor_id, cell, absolute_minute) last_error = "" @@ -603,13 +636,16 @@ func actor_leave_cell(actor_id: StringName, clear_progress: bool = true) -> bool last_error = "Actor has no projected cell: %s" % actor_id return false var previous: Vector3i = _actor_cells[actor_id] - if not _decrement_density(previous): + var was_in_transit := is_actor_in_transit(actor_id) + if not was_in_transit and not _decrement_density(previous): last_error = "Actor density projection is inconsistent for: %s" % actor_id return false _actor_cells.erase(actor_id) + _transit_actor_ids.erase(actor_id) if clear_progress: _actor_progress.erase(actor_id) - _mark_traffic_changed([previous], true) + if not was_in_transit: + _mark_traffic_changed([previous], true) last_error = "" return true @@ -651,7 +687,8 @@ func project_occupancy(entries: Array, clear_unprojected_progress: bool = true) last_error = "Occupancy projection contains a duplicate actor or blocked cell." return false next_actor_cells[actor_id] = cell - next_densities[cell] = int(next_densities.get(cell, 0)) + 1 + if _active_elevator_units.get(actor_id, {}).get(&"state", &"") != ELEVATOR_REQUEST_BOARDED: + next_densities[cell] = int(next_densities.get(cell, 0)) + 1 var changed: Dictionary = {} var may_make_routes_cheaper := false @@ -667,6 +704,7 @@ func project_occupancy(entries: Array, clear_unprojected_progress: bool = true) traffic[&"current_density"] = next_density _cell_traffic[cell] = traffic _actor_cells = next_actor_cells + _transit_actor_ids = _derive_transit_actor_ids(_actor_cells, _active_elevator_units) if clear_unprojected_progress: var stale_progress_ids: Array = [] for actor_id: Variant in _actor_progress: @@ -762,6 +800,7 @@ func cancel_elevator_request(connector_id: StringName, request_id: StringName) - connector[&"request_queue"] = queue _connectors[connector_id] = connector _active_elevator_units.erase(request[&"unit_id"]) + _refresh_transit_actor_ids() # Request ids are tombstoned permanently; cancellation permits a new # id for the same unit but never reuse of the cancelled id. _mark_connector_traffic_changed(connector, true) @@ -838,6 +877,7 @@ func dequeue_elevator_boarding( if not active.is_empty() and active[&"request_id"] == request[&"request_id"]: active[&"state"] = ELEVATOR_REQUEST_BOARDED _active_elevator_units[unit_id] = active + _refresh_transit_actor_ids() _mark_connector_traffic_changed(connector, true) last_error = "" return boarded @@ -858,6 +898,7 @@ func complete_elevator_request(connector_id: StringName, request_id: StringName) last_error = "Only a boarded elevator request can complete." return false _active_elevator_units.erase(unit_id) + _refresh_transit_actor_ids() last_error = "" return true last_error = "Unknown active elevator request id: %s" % request_id @@ -1220,6 +1261,15 @@ func find_nearest_walkable_recovery( return _coordinate_less(left, right) ) var target: Vector3i = candidates[0] + if _vertical_walk_requires_connector: + var reachable := false + for candidate: Vector3i in candidates: + if _route_exists_with_overrides(origin, candidate, layers, [], true, false): + target = candidate + reachable = true + break + if not reachable: + return {&"found": false, &"reason": &"NO_SAFE_WALKABLE_CELL"} return { &"found": true, &"reason": &"SAFE_WALKABLE_CELL", @@ -1293,17 +1343,30 @@ func create_snapshot() -> Dictionary: &"known_elevator_request_ids": saved_known_requests, &"active_elevator_units": saved_active_units, &"vertical_walk_blockers": saved_vertical_blockers, + &"vertical_walk_requires_connector": _vertical_walk_requires_connector, + &"transit_actor_ids": get_transit_actor_ids(), } func restore_snapshot(snapshot: Dictionary) -> bool: var version := int(_dictionary_value(snapshot, &"schema_version", -1)) - if version not in [LEGACY_SNAPSHOT_VERSION, TRAFFIC_SNAPSHOT_VERSION, ELEVATOR_INDEX_SNAPSHOT_VERSION, SNAPSHOT_VERSION]: + if version not in [LEGACY_SNAPSHOT_VERSION, TRAFFIC_SNAPSHOT_VERSION, ELEVATOR_INDEX_SNAPSHOT_VERSION, SHAFT_SNAPSHOT_VERSION, SNAPSHOT_VERSION]: last_error = "Unsupported PathService snapshot version: %d" % version return false - if version < SNAPSHOT_VERSION and (snapshot.has(&"vertical_walk_blockers") or snapshot.has("vertical_walk_blockers")): + if version < SHAFT_SNAPSHOT_VERSION and (snapshot.has(&"vertical_walk_blockers") or snapshot.has("vertical_walk_blockers")): last_error = "Legacy PathService snapshots cannot contain shaft ownership fields." return false + if version < SNAPSHOT_VERSION and (snapshot.has(&"vertical_walk_requires_connector") or snapshot.has("vertical_walk_requires_connector") \ + or snapshot.has(&"transit_actor_ids") or snapshot.has("transit_actor_ids")): + last_error = "Legacy PathService snapshots cannot contain vertical policy or transit fields." + return false + var next_vertical_policy := false + if version >= SNAPSHOT_VERSION: + var policy_value: Variant = _dictionary_value(snapshot, &"vertical_walk_requires_connector", null) + if typeof(policy_value) != TYPE_BOOL: + last_error = "PathService vertical movement policy must be a bool." + return false + next_vertical_policy = bool(policy_value) var raw_connectors: Variant = _dictionary_value(snapshot, &"connectors", null) if not raw_connectors is Array: last_error = "PathService snapshot connectors must be an Array." @@ -1317,8 +1380,10 @@ func restore_snapshot(snapshot: Dictionary) -> bool: var next_active_units: Dictionary = connector_result[&"active_units"] if version == LEGACY_SNAPSHOT_VERSION: - var changed := next_connectors != _connectors or not _vertical_walk_blockers.is_empty() + var changed := next_connectors != _connectors or not _vertical_walk_blockers.is_empty() or _vertical_walk_requires_connector _vertical_walk_blockers.clear() + _vertical_walk_requires_connector = false + _transit_actor_ids.clear() _rebuild_vertical_walk_blocked_cells() _connectors = next_connectors _cell_traffic.clear() @@ -1387,13 +1452,19 @@ func restore_snapshot(snapshot: Dictionary) -> bool: next_known_requests = index_result[&"known_request_ids"] next_active_units = index_result[&"active_units"] var next_vertical_blockers: Dictionary = {} - if version >= SNAPSHOT_VERSION: + if version >= SHAFT_SNAPSHOT_VERSION: var blockers_result := _normalize_snapshot_vertical_blockers(_dictionary_value(snapshot, &"vertical_walk_blockers", null), next_traffic) if not bool(blockers_result[&"ok"]): last_error = String(blockers_result[&"error"]) return false next_vertical_blockers = blockers_result[&"blockers"] - if not _snapshot_densities_match(next_traffic, next_actor_cells): + var next_transit := _derive_transit_actor_ids(next_actor_cells, next_active_units) + if version >= SNAPSHOT_VERSION: + var transit_value: Variant = _dictionary_value(snapshot, &"transit_actor_ids", null) + if not _transit_snapshot_matches(transit_value, next_transit): + last_error = "PathService transit actors must match projected boarded elevator units." + return false + if not _snapshot_densities_match(next_traffic, next_actor_cells, next_transit if version >= SNAPSHOT_VERSION else {}): last_error = "PathService snapshot density does not match actor projections." return false if not _passable_cells.is_empty(): @@ -1407,6 +1478,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool: _connectors = next_connectors _vertical_walk_blockers = next_vertical_blockers + _vertical_walk_requires_connector = next_vertical_policy + _transit_actor_ids = next_transit _rebuild_vertical_walk_blocked_cells() _cell_traffic = next_traffic _actor_cells = next_actor_cells @@ -1414,6 +1487,11 @@ func restore_snapshot(snapshot: Dictionary) -> bool: _known_elevator_request_ids = next_known_requests _active_elevator_units = next_active_units traffic_revision = saved_traffic_revision + # Older snapshots counted passengers at their boarding anchor. Validate that + # old rule above before a one-time migration to physical floor occupancy. + if version < SNAPSHOT_VERSION and not _transit_actor_ids.is_empty(): + _recompute_all_densities() + traffic_revision += 1 _pending_graph_revision = saved_graph_revision if _passable_cells.is_empty() else -1 _traffic_loaded_without_graph = _passable_cells.is_empty() and not next_traffic.is_empty() if not _passable_cells.is_empty(): @@ -1519,6 +1597,7 @@ func _run_a_star( var neighbor: Vector3i = edge[&"to"] if neighbor.z not in allowed_layers \ or _cell_is_temporarily_blocked(neighbor) \ + or not _edge_meets_vertical_policy(current, edge) \ or not _edge_is_accessible(edge, allowed_access_ids): continue var tentative_cost: int = int(g_score[current]) + _edge_total_cost(edge) @@ -1573,6 +1652,11 @@ func _reconstruct_path(came_from: Dictionary, goal: Vector3i) -> Array[Vector3i] return reversed_path +func _edge_meets_vertical_policy(from_cell: Vector3i, edge: Dictionary) -> bool: + return not _vertical_walk_requires_connector or from_cell.y == (edge[&"to"] as Vector3i).y \ + or not StringName(edge[&"connector_id"]).is_empty() or edge[&"movement_type"] != MOVEMENT_WALK + + func _edge_is_accessible(edge: Dictionary, allowed_access_ids: Array[StringName]) -> bool: var required_access: StringName = edge[&"access_id"] if not required_access.is_empty() and required_access not in allowed_access_ids: @@ -1612,6 +1696,8 @@ func _find_edge( var edge: Dictionary = edge_value if edge[&"to"] != to_cell: continue + if not _edge_meets_vertical_policy(from_cell, edge): + continue if allowed_access_ids is Array and not _edge_is_accessible(edge, allowed_access_ids): continue var cost := _edge_total_cost(edge) @@ -1705,7 +1791,8 @@ func _route_exists_with_overrides( var neighbor_set: Dictionary = {} for direction: Vector3i in _CARDINAL_DIRECTIONS: var neighbor := current + direction - if _passable_cells.has(neighbor) and _ordinary_walk_edge_allowed(current, neighbor): + if _passable_cells.has(neighbor) and _ordinary_walk_edge_allowed(current, neighbor) \ + and (not _vertical_walk_requires_connector or current.y == neighbor.y): neighbor_set[neighbor] = true for connector_id: StringName in connector_ids: var connector: Dictionary = _connectors[connector_id] @@ -1759,6 +1846,7 @@ func _ensure_cell_traffic_for_graph(cells: Dictionary, reset_missing: bool) -> v invalid_actor_ids.append(actor_id) for actor_id: Variant in invalid_actor_ids: _actor_cells.erase(actor_id) + _transit_actor_ids = _derive_transit_actor_ids(_actor_cells, _active_elevator_units) _recompute_all_densities() @@ -1796,10 +1884,49 @@ func _recompute_all_densities() -> void: _cell_traffic[cell_value] = traffic for actor_id: Variant in _actor_cells: var cell: Vector3i = _actor_cells[actor_id] - if _cell_traffic.has(cell): + if _cell_traffic.has(cell) and not is_actor_in_transit(actor_id): _increment_density(cell) +static func _derive_transit_actor_ids(actor_cells: Dictionary, active_units: Dictionary) -> Dictionary: + var transit: Dictionary = {} + for actor_id: Variant in active_units: + if actor_cells.has(actor_id) and active_units[actor_id][&"state"] == ELEVATOR_REQUEST_BOARDED: + transit[actor_id] = true + return transit + + +func _refresh_transit_actor_ids() -> void: + var next := _derive_transit_actor_ids(_actor_cells, _active_elevator_units) + if next == _transit_actor_ids: + return + var changed_cells: Dictionary = {} + var may_make_routes_cheaper := false + for actor_id: Variant in next: + if not _transit_actor_ids.has(actor_id): + changed_cells[_actor_cells[actor_id]] = true + may_make_routes_cheaper = true + for actor_id: Variant in _transit_actor_ids: + if not next.has(actor_id) and _actor_cells.has(actor_id): + changed_cells[_actor_cells[actor_id]] = true + _transit_actor_ids = next + _recompute_all_densities() + if not changed_cells.is_empty(): + _mark_traffic_changed(changed_cells.keys(), may_make_routes_cheaper) + + +static func _transit_snapshot_matches(raw_ids: Variant, expected: Dictionary) -> bool: + if not raw_ids is Array or raw_ids.size() != expected.size(): + return false + var previous := "" + for value: Variant in raw_ids: + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME] or String(value).is_empty() \ + or not expected.has(StringName(value)) or (not previous.is_empty() and String(value) <= previous): + return false + previous = String(value) + return true + + func _mark_traffic_changed(cells: Variant, may_make_routes_cheaper: bool = false) -> void: traffic_revision += 1 if may_make_routes_cheaper: @@ -2306,9 +2433,11 @@ func _normalize_snapshot_actor_progress(raw_entries: Array) -> Dictionary: return {&"ok": true, &"progress": progress} -func _snapshot_densities_match(traffic: Dictionary, actor_cells: Dictionary) -> bool: +func _snapshot_densities_match(traffic: Dictionary, actor_cells: Dictionary, transit: Dictionary = {}) -> bool: var expected: Dictionary = {} for actor_id: Variant in actor_cells: + if transit.has(actor_id): + continue var cell: Vector3i = actor_cells[actor_id] expected[cell] = int(expected.get(cell, 0)) + 1 for cell_value: Variant in traffic: diff --git a/tests/mvp/test_runtime_content_progression.gd b/tests/mvp/test_runtime_content_progression.gd index 6f541e7..11656a5 100644 --- a/tests/mvp/test_runtime_content_progression.gd +++ b/tests/mvp/test_runtime_content_progression.gd @@ -381,8 +381,11 @@ func _test_v3_migration_and_v4_tamper_guards() -> void: v3_runtime[&"runtime_schema_version"] = 3 v3_runtime.erase(&"kitchen_production") v3_runtime.erase(&"elevators") + v3_runtime.erase(&"legacy_stairs") v3_runtime[&"path"][&"schema_version"] = 3 v3_runtime[&"path"].erase(&"vertical_walk_blockers") + v3_runtime[&"path"].erase(&"vertical_walk_requires_connector") + v3_runtime[&"path"].erase(&"transit_actor_ids") v3_runtime.erase(&"operations") v3_runtime.erase(&"catalog_manifest") v3_runtime.erase(&"progression") @@ -399,10 +402,10 @@ func _test_v3_migration_and_v4_tamper_guards() -> void: _expect(int(_runtime.progression_service.metrics[&"guest_lifetime"]) == 2, "v3 migration derives lifetime Guests from active plus persistent records") _expect(_runtime.get_mvp_guest_definition_ids().size() == 32, "v3 migration registers the current 32 catalog definitions") var migrated: Dictionary = _runtime.create_snapshot() - _expect(int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 8, "v3 migration emits canonical Runtime schema v8") + _expect(int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 9, "v3 migration emits canonical Runtime schema v9") var digest := SimulationDigest.compute(migrated) - _expect(_runtime.restore_snapshot(migrated), "canonical v8 snapshot restores") - _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v8 restore is exact") + _expect(_runtime.restore_snapshot(migrated), "canonical v9 snapshot restores") + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v9 restore is exact") var progressed_legacy: Dictionary = v3.duplicate(true) progressed_legacy[&"game_state"][&"world_tree_rank"] = 2 diff --git a/tests/mvp/test_runtime_operations_acceptance.gd b/tests/mvp/test_runtime_operations_acceptance.gd index ab6154b..f03f16c 100644 --- a/tests/mvp/test_runtime_operations_acceptance.gd +++ b/tests/mvp/test_runtime_operations_acceptance.gd @@ -1,7 +1,7 @@ extends SceneTree const FOURTEEN_DAYS_MINUTES := 14 * SimulationKernel.MINUTES_PER_DAY -const RUNTIME_SNAPSHOT_VERSION := 8 +const RUNTIME_SNAPSHOT_VERSION := 9 var _checks := 0 var _failures := 0 @@ -15,7 +15,7 @@ func _run() -> void: print("MVP: production Operations acceptance") _test_waiting_choice_and_command_exactly_once() _test_twenty_four_hour_actual_tick_smoke() - _test_full_partial_payroll_bankruptcy_and_v8_tamper() + _test_full_partial_payroll_bankruptcy_and_v9_tamper() _test_fourteen_day_boundary_snapshot_determinism() await process_frame if _failures == 0: @@ -39,7 +39,7 @@ func _new_fixture(seed: int, enable_operations := true) -> Dictionary: int(initial_snapshot[&"systems"][&"runtime"].get( &"runtime_schema_version", -1 )) == RUNTIME_SNAPSHOT_VERSION, - "fixture emits Runtime snapshot schema v8" + "fixture emits Runtime snapshot schema v9" ) if enable_operations: var enabled: Dictionary = runtime.enable_production_operations() @@ -217,7 +217,7 @@ func _test_waiting_choice_and_command_exactly_once() -> void: var command_snapshot: Dictionary = runtime.create_snapshot() _expect( runtime.restore_snapshot(command_snapshot), - "command history survives an exact v8 snapshot round trip" + "command history survives an exact v9 snapshot round trip" ) var restored_retry: CommandResult = runtime.submit_command( &"change_resources", @@ -257,12 +257,12 @@ func _test_twenty_four_hour_actual_tick_smoke() -> void: _expect( runtime.restore_snapshot(snapshot) and runtime.latest_digest == SimulationDigest.compute(snapshot), - "24-hour actual-tick v8 snapshot round trip is exact" + "24-hour actual-tick v9 snapshot round trip is exact" ) _free_fixture(fixture) -func _test_full_partial_payroll_bankruptcy_and_v8_tamper() -> void: +func _test_full_partial_payroll_bankruptcy_and_v9_tamper() -> void: var fixture := _new_fixture(73_002, false) var runtime: Variant = fixture[&"runtime"] var clock: Variant = fixture[&"clock"] @@ -349,7 +349,7 @@ func _test_full_partial_payroll_bankruptcy_and_v8_tamper() -> void: var terminal_digest := SimulationDigest.compute(terminal_snapshot) _expect( runtime.restore_snapshot(terminal_snapshot), - "BANKRUPT terminal v8 snapshot restores without clearing terminal state" + "BANKRUPT terminal v9 snapshot restores without clearing terminal state" ) clock.advance_minutes(180) _expect( @@ -362,32 +362,32 @@ func _test_full_partial_payroll_bankruptcy_and_v8_tamper() -> void: "BANKRUPT cannot be acknowledged away" ) - _assert_v8_tamper_rejected( + _assert_v9_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: tampered[&"systems"][&"runtime"][&"operations"][&"personal_action_points"] = 99, - "v8 rejects an out-of-range Operations action-point total" + "v9 rejects an out-of-range Operations action-point total" ) - _assert_v8_tamper_rejected( + _assert_v9_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: tampered[&"systems"][&"runtime"][&"next_command_id"] = int( tampered[&"systems"][&"runtime"][&"next_command_id"] ) + 1, - "v8 rejects command high-water gaps" + "v9 rejects command high-water gaps" ) - _assert_v8_tamper_rejected( + _assert_v9_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: var accepted: Array = tampered[&"systems"][&"runtime"][&"command_queue"][&"accepted"] if not accepted.is_empty(): accepted[0][&"idempotency_key"] = &"tampered.accepted.key", - "v8 rejects accepted-command/idempotency cross-link tampering" + "v9 rejects accepted-command/idempotency cross-link tampering" ) - _assert_v8_tamper_rejected( + _assert_v9_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: @@ -395,26 +395,26 @@ func _test_full_partial_payroll_bankruptcy_and_v8_tamper() -> void: summaries[0][&"category_totals"][&"wages"] = int( summaries[0][&"category_totals"][&"wages"] ) + 1, - "v8 rejects DaySummary/payroll wage reconciliation tampering" + "v9 rejects DaySummary/payroll wage reconciliation tampering" ) - _assert_v8_tamper_rejected( + _assert_v9_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: - tampered[&"systems"][&"runtime"][&"unexpected_v8_field"] = true, - "v8 rejects unknown Runtime fields" + tampered[&"systems"][&"runtime"][&"unexpected_v9_field"] = true, + "v9 rejects unknown Runtime fields" ) - _assert_v8_tamper_rejected( + _assert_v9_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: tampered[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"unexpected_field"] = true, - "v8 rejects unknown Coordinator fields" + "v9 rejects unknown Coordinator fields" ) _free_fixture(fixture) -func _assert_v8_tamper_rejected( +func _assert_v9_tamper_rejected( runtime: Variant, canonical: Dictionary, mutator: Callable, @@ -463,7 +463,7 @@ func _test_fourteen_day_boundary_snapshot_determinism() -> void: var resumed_clock: Variant = resumed[&"clock"] var resumed_runtime: Variant = resumed[&"runtime"] var restored: bool = resumed_runtime.restore_snapshot(midpoint) - _expect(restored, "fresh Runtime restores the enabled v8 midpoint snapshot") + _expect(restored, "fresh Runtime restores the enabled v9 midpoint snapshot") var resumed_advanced := 0 if restored: resumed_advanced = _advance_operations_boundary_slice( diff --git a/tests/mvp/test_runtime_operations_integration.gd b/tests/mvp/test_runtime_operations_integration.gd index 58b574f..4d65497 100644 --- a/tests/mvp/test_runtime_operations_integration.gd +++ b/tests/mvp/test_runtime_operations_integration.gd @@ -57,7 +57,7 @@ func _test_disabled_compatibility_and_enable() -> void: _expect(not disabled[&"enabled"], "Operations remain disabled for existing Runtime tests") var disabled_snapshot: Dictionary = _runtime.create_snapshot() var disabled_runtime: Dictionary = disabled_snapshot[&"systems"][&"runtime"] - _expect(int(disabled_runtime[&"runtime_schema_version"]) == 8, "Runtime snapshot advances to schema v8") + _expect(int(disabled_runtime[&"runtime_schema_version"]) == 9, "Runtime snapshot advances to schema v9") _expect( disabled_runtime[&"operations"] == { &"wrapper_version": 1, @@ -362,8 +362,14 @@ func _test_daily_payroll_financials_and_crosslink_tamper() -> void: downgraded[&"systems"][&"runtime"][&"runtime_schema_version"] = 4 downgraded[&"systems"][&"runtime"].erase(&"kitchen_production") downgraded[&"systems"][&"runtime"].erase(&"elevators") + downgraded[&"systems"][&"runtime"].erase(&"legacy_stairs") + for infrastructure_key: StringName in [&"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure"]: + downgraded[&"systems"][&"runtime"].erase(infrastructure_key) downgraded[&"systems"][&"runtime"][&"path"][&"schema_version"] = 3 downgraded[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_blockers") + downgraded[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_requires_connector") + downgraded[&"systems"][&"runtime"][&"path"].erase(&"transit_actor_ids") + _expect(_runtime._legacy_infrastructure_history_is_absent(downgraded[&"systems"][&"runtime"]), "Operations downgrade fixture excludes unrelated later infrastructure owners") _expect( _rejects_atomically(downgraded), "relabelled enabled v5 cannot discard Operations history through v4 migration" diff --git a/tests/s0/test_runtime_integration.gd b/tests/s0/test_runtime_integration.gd index e69d5f9..a6f980b 100644 --- a/tests/s0/test_runtime_integration.gd +++ b/tests/s0/test_runtime_integration.gd @@ -66,6 +66,7 @@ func _test_tick_command_execution() -> void: func _test_starter_world_layers() -> void: _reset_runtime() + _expect(_runtime.definition_registry.facility_count() == 18, "Runtime loads baseline sixteen plus two required stair definitions") var front_start := Vector3i(-2, 0, WorldService.LAYER_FRONT) var back_goal := Vector3i(2, 0, WorldService.LAYER_BACK) var guest_path: Array[Vector3i] = _runtime.path_service.find_path( diff --git a/tests/s1/test_path_congestion.gd b/tests/s1/test_path_congestion.gd index 479b686..5026dd6 100644 --- a/tests/s1/test_path_congestion.gd +++ b/tests/s1/test_path_congestion.gd @@ -384,13 +384,13 @@ func _test_snapshot_roundtrip_and_atomic_rejection() -> void: ) original.record_actor_progress(&"off_graph_actor", Vector3i(4, 4, 0), 12) var snapshot := original.create_snapshot() - _expect(int(snapshot[&"schema_version"]) == 4, "shaft-safe traffic snapshot uses schema version 4") + _expect(int(snapshot[&"schema_version"]) == 5, "traffic snapshot uses schema version 5 with floor policy and transit ownership") _expect(snapshot.has(&"cell_traffic") and snapshot.has(&"actor_cells"), "snapshot persists traffic and occupancy projection") _expect(not snapshot.has(&"graph") and not snapshot.has(&"cache"), "snapshot still excludes derived graph and cache") var restored = PathServiceScript.new() - _expect(restored.restore_snapshot(snapshot), "v4 snapshot restores before authoritative world graph") + _expect(restored.restore_snapshot(snapshot), "v5 snapshot restores before authoritative world graph") _expect(restored.rebuild_graph([from_cell, to_cell]), "restored traffic validates against authoritative cells") - _expect(restored.create_snapshot() == snapshot, "v4 snapshot roundtrip is exact") + _expect(restored.create_snapshot() == snapshot, "v5 snapshot roundtrip is exact") _expect(restored.get_current_density(from_cell) == 1, "restored density is derived consistently from actors") _expect(restored.elevator_queue_size(&"lift_save") == 1, "restored elevator retains its request queue") @@ -408,6 +408,8 @@ func _test_snapshot_roundtrip_and_atomic_rejection() -> void: v2_snapshot.erase(&"known_elevator_request_ids") v2_snapshot.erase(&"active_elevator_units") v2_snapshot.erase(&"vertical_walk_blockers") + v2_snapshot.erase(&"vertical_walk_requires_connector") + v2_snapshot.erase(&"transit_actor_ids") var v2_restored = PathServiceScript.new() _expect(v2_restored.restore_snapshot(v2_snapshot), "schema v2 snapshot remains readable") _expect(v2_restored.rebuild_graph([from_cell, to_cell]), "schema v2 traffic validates against authoritative cells") diff --git a/tests/s1/test_runtime_infrastructure_integration.gd b/tests/s1/test_runtime_infrastructure_integration.gd index cfee3cc..ae4dce8 100644 --- a/tests/s1/test_runtime_infrastructure_integration.gd +++ b/tests/s1/test_runtime_infrastructure_integration.gd @@ -1,6 +1,6 @@ extends SceneTree -const RUNTIME_SNAPSHOT_VERSION := 8 +const RUNTIME_SNAPSHOT_VERSION := 9 const INFRASTRUCTURE_SNAPSHOT_KEYS: Array[StringName] = [ &"excavation", &"spatial_subslots", @@ -113,14 +113,14 @@ func _test_runtime_surface(fixture: Dictionary) -> bool: var snapshot: Dictionary = runtime.create_snapshot() var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] var current_version := int(runtime_data.get(&"runtime_schema_version", -1)) == RUNTIME_SNAPSHOT_VERSION - _expect(current_version, "Runtime emits schema v8 with infrastructure state") + _expect(current_version, "Runtime emits schema v9 with infrastructure state") ready = ready and current_version for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: var valid_dictionary := runtime_data.get(key, null) is Dictionary _expect(valid_dictionary, "current Runtime stores strict %s state" % key) ready = ready and valid_dictionary if not ready: - print(" PENDING: Runtime v8 infrastructure surface is not connected yet; behavioral cases skipped.") + print(" PENDING: Runtime v9 infrastructure surface is not connected yet; behavioral cases skipped.") return ready @@ -347,12 +347,17 @@ func _test_completed_proxy_projection() -> void: _expect(not _complete_excavation(fixture, front_a, &"infra.proxy.dig.a").is_empty(), "first proxy cell is excavated") _expect(not _complete_excavation(fixture, front_b, &"infra.proxy.dig.b").is_empty(), "adjacent proxy cell is excavated") runtime.world_service.set_support(front_a, 10, true) + runtime.world_service.set_support(front_b, 10, true) runtime.world_service.set_support(Vector3i(0, -1, PathType.BACK), 10, true) runtime.world_service.set_support(Vector3i(1, -1, PathType.BACK), 10, true) var guest := _complete_proxy( fixture, &"guest_corridor", front_a, &"infra.proxy.guest" ) + var adjacent_guest := _complete_proxy( + fixture, &"guest_corridor", front_b, &"infra.proxy.guest.adjacent" + ) + _expect(adjacent_guest[&"ok"], "same-floor guest corridor completes beside the proxy") var service := _complete_proxy( fixture, &"service_corridor", @@ -397,8 +402,12 @@ func _test_completed_proxy_projection() -> void: var door_back := Vector3i(0, -1, PathType.BACK) var service_back := Vector3i(1, -1, PathType.BACK) _expect( - runtime.path_service.find_path(entrance, front_a) == [entrance, front_a], - "guest corridor becomes a real FRONT Path edge" + runtime.path_service.find_path(front_b, front_a) == [front_b, front_a], + "guest corridors form a real same-floor FRONT Path edge" + ) + _expect( + runtime.path_service.find_path(entrance, front_a).is_empty(), + "corridor projection does not create an implicit staircase from ground level" ) _expect( runtime.path_service.find_path( @@ -728,10 +737,10 @@ func _test_current_snapshot_strictness() -> void: _expect(built[&"ok"], "snapshot fixture stores one completed projection") var snapshot: Dictionary = runtime.create_snapshot() var expected_digest := SimulationDigest.compute(snapshot) - _expect(runtime.restore_snapshot(snapshot), "valid Runtime v8 snapshot restores") + _expect(runtime.restore_snapshot(snapshot), "valid Runtime v9 snapshot restores") _expect( runtime.create_snapshot() == snapshot and runtime.latest_digest == expected_digest, - "Runtime v8 round trip is exact" + "Runtime v9 round trip is exact" ) _assert_runtime_restore_rejected( @@ -739,7 +748,7 @@ func _test_current_snapshot_strictness() -> void: snapshot, func(candidate: Dictionary) -> void: candidate[&"systems"][&"runtime"][&"unknown_infrastructure"] = {}, - "unknown v8 Runtime field" + "unknown v9 Runtime field" ) for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: _assert_runtime_restore_rejected( @@ -817,12 +826,15 @@ func _test_v5_proxy_migration_determinism() -> void: legacy_runtime[&"runtime_schema_version"] = 5 legacy_runtime.erase(&"kitchen_production") legacy_runtime.erase(&"elevators") + legacy_runtime.erase(&"legacy_stairs") legacy_runtime.erase(&"staff_commute") for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: legacy_runtime.erase(key) legacy_runtime[&"path"] = baseline[&"runtime"].path_service.create_snapshot() legacy_runtime[&"path"][&"schema_version"] = 3 legacy_runtime[&"path"].erase(&"vertical_walk_blockers") + legacy_runtime[&"path"].erase(&"vertical_walk_requires_connector") + legacy_runtime[&"path"].erase(&"transit_actor_ids") _free_fixture(baseline) _expect( stripped_v6_events > 0 and _legacy_v5_infrastructure_shape_is_clean(legacy_runtime), @@ -837,8 +849,8 @@ func _test_v5_proxy_migration_determinism() -> void: _expect(first_runtime.restore_snapshot(legacy), "v5 proxy snapshot migrates through Runtime restore") var first_migrated: Dictionary = first_runtime.create_snapshot() _expect( - int(first_migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 8, - "v5 migration is immediately re-emitted as v8" + int(first_migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 9, + "v5 migration is immediately re-emitted as v9" ) _expect( first_runtime.facility_infrastructure_projector.has_projection(instance_id) @@ -876,6 +888,9 @@ func _test_rank_expansion_preserves_route_union() -> void: if not built[&"ok"] or not runtime.path_service.has_cell(route_cell): _free_fixture(fixture) return + var adjacent_cell := Vector3i(1, 1, PathType.FRONT) + var adjacent := _complete_proxy(fixture, &"guest_corridor", adjacent_cell, &"infra.expansion.adjacent") + _expect(adjacent[&"ok"], "rank expansion fixture has a same-floor corridor connection") _expect(_promote_rank_two(fixture, &"infra.expansion.rank"), "rank expansion commits with an existing route") _expect( runtime.route_subslot_path_adapter.get_projected_route_cells().has(route_cell) @@ -890,10 +905,12 @@ func _test_rank_expansion_preserves_route_union() -> void: "rank expansion replaces the adapter base with all expanded World walkability" ) _expect( - runtime.path_service.find_path( - Vector3i(0, 0, PathType.FRONT), route_cell - ) == [Vector3i(0, 0, PathType.FRONT), route_cell], - "expanded base plus pre-existing route remains reachable" + runtime.path_service.find_path(adjacent_cell, route_cell) == [adjacent_cell, route_cell], + "expanded base preserves the pre-existing same-floor route" + ) + _expect( + runtime.path_service.find_path(Vector3i(0, 0, PathType.FRONT), route_cell).is_empty(), + "rank expansion does not add implicit vertical walking" ) _free_fixture(fixture) @@ -1039,7 +1056,7 @@ func _strip_v6_infrastructure_domain_history(runtime_data: Dictionary) -> int: func _legacy_v5_infrastructure_shape_is_clean(runtime_data: Dictionary) -> bool: - if runtime_data.has(&"kitchen_production") or runtime_data.has(&"elevators") or runtime_data.has(&"staff_commute"): + if runtime_data.has(&"kitchen_production") or runtime_data.has(&"elevators") or runtime_data.has(&"legacy_stairs") or runtime_data.has(&"staff_commute"): return false for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: if runtime_data.has(key) or runtime_data.has(String(key)): diff --git a/tests/s3/test_runtime_path_traffic.gd b/tests/s3/test_runtime_path_traffic.gd index 46f5ac0..24768fc 100644 --- a/tests/s3/test_runtime_path_traffic.gd +++ b/tests/s3/test_runtime_path_traffic.gd @@ -3,6 +3,7 @@ extends SceneTree const POST_V2_RUNTIME_KEYS := [ &"kitchen_production", &"elevators", + &"legacy_stairs", &"movement_credits", &"catalog_manifest", &"progression", @@ -159,11 +160,22 @@ func _test_congestion_credit_and_alternate_route() -> void: _reset_runtime(8_103) for x in range(0, 3): - _runtime.world_service.set_walkable(Vector3i(x, 1, WorldService.LAYER_FRONT), true) - _expect(_runtime.rebuild_path_graph_from_world(), "parallel walkable lane rebuilds into PathService") + var definition_id: StringName = &"guest_corridor" if x == 1 else &"guest_stairs" + var built: CommandResult = _runtime.submit_command( + &"build_facility", {&"definition_id": definition_id, &"origin": Vector3i(x, 1, WorldService.LAYER_FRONT)}, + &"manager", &"", 0, -1, StringName("path.parallel.build.%d" % x) + ) + _expect(built.is_success(), "parallel lane uses normal %s construction at x=%d" % [definition_id, x]) + if not built.is_success(): + return + _clock.advance_minutes(10) + _expect(_runtime.construction_service.get_job(built.details[&"job_id"]).state == ConstructionJob.State.COMPLETED, "parallel lane construction completes") + _expect(_runtime.path_service.get_vertical_walk_requires_connector(), "parallel lane retains strict floor policy") var congested := Vector3i(1, 0, WorldService.LAYER_FRONT) _runtime.path_service.configure_cell_traffic(congested, 1, 0, false) - for blocker_index in range(4): + # Two 150-cost stairs make the upper route 500; six occupants make the + # direct two-cell lane 575, so the stair detour remains strictly cheaper. + for blocker_index in range(6): _expect( _register_idle_staff(StringName("blocker_%d" % blocker_index), congested), "alternate-route blocker %d registers" % blocker_index @@ -181,6 +193,9 @@ func _test_congestion_credit_and_alternate_route() -> void: ) _clock.advance_one_minute() var picker: GuestRuntime = _runtime.guest_service.get_guest(&"route_picker") + _expect(picker.current_cell == Vector3i(0, 0, 0) and _runtime.get_actor_movement_credit(&"guest:route_picker")[&"credit"] == 1000, "first stair minute preserves incomplete movement credit") + _clock.advance_one_minute() + picker = _runtime.guest_service.get_guest(&"route_picker") _expect( picker != null and picker.current_cell == Vector3i(0, 1, 0), "live congestion makes Runtime choose the cheaper parallel lane (actual %s)" % ( @@ -303,11 +318,11 @@ func _test_current_snapshot_crosslinks_and_legacy_v2() -> void: _clock.advance_one_minute() var snapshot: Dictionary = _runtime.create_snapshot() var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] - _expect(int(runtime_data[&"runtime_schema_version"]) == 8, "Runtime snapshot advertises schema v8") + _expect(int(runtime_data[&"runtime_schema_version"]) == 9, "Runtime snapshot advertises schema v9") _expect(not runtime_data[&"movement_credits"].is_empty(), "mid-move snapshot persists fixed-point movement credits") var digest := SimulationDigest.compute(snapshot) - _expect(_runtime.restore_snapshot(snapshot), "valid mid-move v8 snapshot restores") - _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "mid-move v8 save/load is exact") + _expect(_runtime.restore_snapshot(snapshot), "valid mid-move v9 snapshot restores") + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "mid-move v9 save/load is exact") var corruptions: Array[Dictionary] = [] var bad_actor := snapshot.duplicate(true) @@ -345,6 +360,8 @@ func _test_current_snapshot_crosslinks_and_legacy_v2() -> void: _strip_post_v2_runtime_state(legacy_data) legacy_data[&"path"][&"schema_version"] = 3 legacy_data[&"path"].erase(&"vertical_walk_blockers") + legacy_data[&"path"].erase(&"vertical_walk_requires_connector") + legacy_data[&"path"].erase(&"transit_actor_ids") legacy_data[&"path"][&"actor_cells"] = [] legacy_data[&"path"][&"actor_progress"] = [] for traffic: Dictionary in legacy_data[&"path"][&"cell_traffic"]: @@ -353,16 +370,16 @@ func _test_current_snapshot_crosslinks_and_legacy_v2() -> void: traffic[&"temporary_block"] = true _expect( _is_true_runtime_v2_shape(legacy_data), - "legacy fixture removes every v3-v8 aggregate and dedicated history instead of relabelling v8" + "legacy fixture removes every v3-v9 aggregate and dedicated history instead of relabelling v9" ) _expect(_runtime.restore_snapshot(legacy_v2), "legacy Runtime v2 deterministically rebuilds Path actors and credits") var migrated: Dictionary = _runtime.create_snapshot() _expect( - int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 8 + int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 9 and migrated[&"systems"][&"runtime"][&"path"][&"actor_cells"].size() == 2 and migrated[&"systems"][&"runtime"][&"movement_credits"].size() == 2 and bool(_runtime.path_service.get_cell_traffic(origin)[&"temporary_block"]), - "legacy migration emits canonical v8 ownership while preserving blocked traffic state" + "legacy migration emits canonical v9 ownership while preserving blocked traffic state" ) diff --git a/tests/s5/test_runtime_defense_cycle.gd b/tests/s5/test_runtime_defense_cycle.gd index 128068f..f76b74f 100644 --- a/tests/s5/test_runtime_defense_cycle.gd +++ b/tests/s5/test_runtime_defense_cycle.gd @@ -795,8 +795,11 @@ func _test_snapshot_replay_corruption_and_legacy_migration() -> void: legacy[&"systems"][&"runtime"].erase(&"runtime_schema_version") legacy[&"systems"][&"runtime"].erase(&"kitchen_production") legacy[&"systems"][&"runtime"].erase(&"elevators") + legacy[&"systems"][&"runtime"].erase(&"legacy_stairs") legacy[&"systems"][&"runtime"][&"path"][&"schema_version"] = 3 legacy[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_blockers") + legacy[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_requires_connector") + legacy[&"systems"][&"runtime"][&"path"].erase(&"transit_actor_ids") legacy[&"systems"][&"runtime"].erase(&"operations") legacy[&"systems"][&"runtime"].erase(&"defense") legacy[&"systems"][&"runtime"][&"rng_streams"].erase("defense") diff --git a/tests/supply/test_normal_supply_chain.gd b/tests/supply/test_normal_supply_chain.gd index 19e08d9..a47880d 100644 --- a/tests/supply/test_normal_supply_chain.gd +++ b/tests/supply/test_normal_supply_chain.gd @@ -42,9 +42,9 @@ func _run() -> void: return await _advance(180) _command(&"build_facility", {&"definition_id": &"standard_room", &"origin": Vector3i(-8, 2, 0)}) - _command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(-4, 1, 0)}) + _command(&"build_facility", {&"definition_id": &"guest_stairs", &"origin": Vector3i(-4, 1, 0)}) await _advance(60) - _command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(-4, 2, 0)}) + _command(&"build_facility", {&"definition_id": &"guest_stairs", &"origin": Vector3i(-4, 2, 0)}) await _advance(10) for x: int in [-5, -6]: _command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(x, 2, 0)}) diff --git a/tests/supply/test_supply_save_migration.gd b/tests/supply/test_supply_save_migration.gd index 81ea8bd..105064c 100644 --- a/tests/supply/test_supply_save_migration.gd +++ b/tests/supply/test_supply_save_migration.gd @@ -9,7 +9,7 @@ func _initialize() -> void: func _run() -> void: - print("P1: Runtime v8 supply save migration") + print("P1: Runtime v9 supply save migration") var source := _fixture(7301) var first := _fixture(7302) var second := _fixture(7303) @@ -19,31 +19,36 @@ func _run() -> void: source[&"clock"].advance_minutes(3) var canonical: Dictionary = source_runtime.create_snapshot() var canonical_data: Dictionary = canonical[&"systems"][&"runtime"] - _expect(canonical_data[&"runtime_schema_version"] == 8, "current Runtime emits schema v8") + _expect(canonical_data[&"runtime_schema_version"] == 9, "current Runtime emits schema v9") _expect(canonical_data[&"kitchen_production"][&"current_minute"] == source[&"clock"].absolute_minute, "production clock follows Runtime clock without existing kitchens") var legacy := canonical.duplicate(true) var legacy_data: Dictionary = legacy[&"systems"][&"runtime"] legacy_data[&"runtime_schema_version"] = 6 legacy_data.erase(&"kitchen_production") legacy_data.erase(&"elevators") + legacy_data.erase(&"legacy_stairs") legacy_data[&"path"][&"schema_version"] = 3 legacy_data[&"path"].erase(&"vertical_walk_blockers") + legacy_data[&"path"].erase(&"vertical_walk_requires_connector") + legacy_data[&"path"].erase(&"transit_actor_ids") _expect(legacy_data.has(&"facility_infrastructure") and legacy_data.has(&"route_projection"), "v6 fixture retains infrastructure introduced in v6") - _expect(first_runtime.restore_snapshot(legacy), "genuine v6 state restores into v8") + _expect(first_runtime.restore_snapshot(legacy), "genuine v6 state restores into v9") var migrated: Dictionary = first_runtime.create_snapshot() - _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 8, "v6 migration re-emits current schema") + _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 9, "v6 migration re-emits current schema") var kitchen: Dictionary = migrated[&"systems"][&"runtime"][&"kitchen_production"] _expect(kitchen[&"plans"].is_empty() and kitchen[&"active_batches"].is_empty(), "legacy migration does not infer kitchen production plans or batches") _expect(kitchen[&"current_minute"] == source[&"clock"].absolute_minute, "legacy production clock binds to the restored Runtime minute") - _expect(migrated == canonical, "v6 migration preserves non-supply state exactly") + var expected := canonical.duplicate(true) + expected[&"systems"][&"runtime"][&"path"][&"graph_revision"] += 1 + _expect(migrated == expected, "v6 migration preserves all state except the one-time strict topology revision") _expect(second_runtime.restore_snapshot(legacy) and second_runtime.create_snapshot() == migrated, "v6 migration is deterministic across Runtime instances") - _expect(first_runtime.restore_snapshot(migrated) and first_runtime.create_snapshot() == migrated, "canonical v8 round trip is exact") + _expect(first_runtime.restore_snapshot(migrated) and first_runtime.create_snapshot() == migrated, "canonical v9 round trip is exact") var missing_supply := canonical.duplicate(true) missing_supply[&"systems"][&"runtime"].erase(&"kitchen_production") - _expect(_rejects_atomically(first_runtime, missing_supply), "v8 cannot omit its supply aggregate") - var relabelled := canonical.duplicate(true) - relabelled[&"systems"][&"runtime"][&"runtime_schema_version"] = 6 - _expect(_rejects_atomically(first_runtime, relabelled), "relabelled v8 with retained kitchen state cannot masquerade as v6") + _expect(_rejects_atomically(first_runtime, missing_supply), "v9 cannot omit its supply aggregate") + var relabelled := legacy.duplicate(true) + relabelled[&"systems"][&"runtime"][&"kitchen_production"] = canonical_data[&"kitchen_production"].duplicate(true) + _expect(_rejects_atomically(first_runtime, relabelled), "relabelled v9 with retained kitchen state cannot masquerade as v6") var bad_clock := canonical.duplicate(true) bad_clock[&"systems"][&"runtime"][&"kitchen_production"][&"current_minute"] -= 1 _expect(_rejects_atomically(first_runtime, bad_clock), "production and Runtime clock disagreement is rejected") @@ -61,8 +66,11 @@ func _run() -> void: orphaned[&"systems"][&"runtime"][&"runtime_schema_version"] = 6 orphaned[&"systems"][&"runtime"].erase(&"kitchen_production") orphaned[&"systems"][&"runtime"].erase(&"elevators") + orphaned[&"systems"][&"runtime"].erase(&"legacy_stairs") orphaned[&"systems"][&"runtime"][&"path"][&"schema_version"] = 3 orphaned[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_blockers") + orphaned[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_requires_connector") + orphaned[&"systems"][&"runtime"][&"path"].erase(&"transit_actor_ids") _expect(_rejects_atomically(first_runtime, orphaned), "legacy label cannot hide input holds for a discarded production batch") for fixture: Dictionary in [source, first, second]: fixture[&"runtime"].free() diff --git a/tests/transport/test_runtime_stairs.gd b/tests/transport/test_runtime_stairs.gd new file mode 100644 index 0000000..e9708fe --- /dev/null +++ b/tests/transport/test_runtime_stairs.gd @@ -0,0 +1,165 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _runtime: Variant +var _clock: Variant +var _state: Variant +var _serial := 0 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _reset() + _expect(_runtime.path_service.get_vertical_walk_requires_connector(), "new Runtime requires explicit vertical transport") + _expect(_runtime.definition_registry.facility_count() == 18, "Runtime registers baseline16 and two staircase definitions") + var before: Dictionary = _runtime.create_snapshot() + var invalid := _command(&"build_facility", {&"definition_id": &"guest_stairs", &"origin": Vector3i(4, 3, 0)}) + _expect(not invalid.is_success() and invalid.details.get(&"reason") == &"STAIR_LANDING_MISSING", "stairs without a lower landing reject before any construction") + _expect(_runtime.construction_service.get_jobs().is_empty() and _state.get_resource(&"gold") == 5000, "missing landing spends no money and starts no job") + _build(&"guest_stairs", Vector3i(4, 1, 0)) + _build(&"guest_stairs", Vector3i(4, 2, 0)) + var route: Array = _runtime.path_service.find_path(Vector3i(4, 0, 0), Vector3i(4, 2, 0)) + _expect(route.size() == 3 and _runtime.path_service.calculate_path_cost(route) == 300, "two staircase floors cost three walking minutes") + _expect(_runtime._estimated_actor_path_minutes(route, Array([], TYPE_STRING_NAME, &"", null)) == 3, "actor arrival estimate includes stair time") + var spawn := _command(&"spawn_guest", {&"guest_id": &"stair.guest", &"guest_type_id": &"traveler", &"gold": 100, + &"options": {&"current_cell": Vector3i(4, 0, 0), &"state": GuestRuntime.State.MOVING, + &"state_entered_at": _clock.absolute_minute - 1, &"planned_exit_at": _clock.absolute_minute + 2000, + &"path_handle": {&"target_cell": Vector3i(4, 2, 0)}}}) + _expect(spawn.is_success(), "public guest spawn creates a real stair traveller") + _clock.advance_minutes(1) + var guest: GuestRuntime = _runtime.guest_service.get_guest(&"stair.guest") + _expect(guest.current_cell == Vector3i(4, 0, 0), "after one minute guest is still completing the first staircase") + var mid: Dictionary = _runtime.create_snapshot() + _expect(mid[&"systems"][&"runtime"][&"movement_credits"][0][&"credit"] == 1000, "partial staircase persists one minute of movement credit") + _expect(_runtime.restore_snapshot(mid) and _runtime.create_snapshot() == mid, "half-finished staircase restores exactly") + _clock.advance_minutes(2) + var arrived: Dictionary = _runtime.create_snapshot() + _expect(_runtime.guest_service.get_guest(&"stair.guest").current_cell == Vector3i(4, 2, 0), "two floors complete in three actual simulation minutes") + _expect(_runtime.restore_snapshot(mid), "restore partial staircase for deterministic replay") + _clock.advance_minutes(2) + _expect(_runtime.create_snapshot() == arrived, "fractional stair travel replays every aggregate exactly") + var bad := mid.duplicate(true) + bad[&"systems"][&"runtime"][&"movement_credits"][0][&"credit"] = 1500 + _expect(not _runtime.restore_snapshot(bad) and _runtime.create_snapshot() == arrived, "unspent complete stair step is rejected atomically") + bad = mid.duplicate(true) + bad[&"systems"][&"runtime"][&"path"][&"vertical_walk_requires_connector"] = false + _expect(not _runtime.restore_snapshot(bad), "current Runtime cannot save unrestricted vertical walking") + bad = mid.duplicate(true) + bad[&"systems"][&"runtime"].erase(&"legacy_stairs") + _expect(not _runtime.restore_snapshot(bad), "current Runtime requires stair ownership metadata") + bad = mid.duplicate(true) + bad[&"systems"][&"runtime"][&"runtime_schema_version"] = 8 + bad[&"systems"][&"runtime"].erase(&"legacy_stairs") + bad[&"systems"][&"runtime"][&"path"][&"vertical_walk_requires_connector"] = false + _expect(not _runtime.restore_snapshot(bad), "older Runtime label cannot smuggle current Path policy fields") + var blockade := mid.duplicate(true) + for traffic: Dictionary in blockade[&"systems"][&"runtime"][&"path"][&"cell_traffic"]: + if traffic[&"cell"] == Vector3i(4, 1, 0): + traffic[&"temporary_block"] = true + _expect(_runtime.restore_snapshot(blockade), "temporary blockade preserves valid fractional staircase credit") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "blocked partial staircase re-saves canonically") + _reset() + _build(&"guest_corridor", Vector3i(4, 1, 0)) + _build(&"guest_stairs", Vector3i(4, 2, 0)) + var lower_id := _facility_at(Vector3i(4, 1, 0)) + var upper_id := _facility_at(Vector3i(4, 2, 0)) + var blocked := _command(&"demolish_facility", {&"instance_id": lower_id}) + _expect(not blocked.is_success() and blocked.details.get(&"reason") == &"STAIR_LANDING_IN_USE", "occupied lower landing rejects demolition before a broken stair is created") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "rejected stair demolition remains saveable") + _expect(_command(&"demolish_facility", {&"instance_id": upper_id}).is_success(), "upper staircase can be removed first") + _expect(_command(&"demolish_facility", {&"instance_id": lower_id}).is_success(), "unused lower corridor can then be removed") + _reset() + _build(&"service_stairs", Vector3i(4, 1, 1)) + _expect(_runtime.path_service.find_path(Vector3i(4, 0, 1), Vector3i(4, 1, 1)).is_empty(), "guests cannot use service stairs") + _expect(_runtime.path_service.find_path(Vector3i(4, 0, 1), Vector3i(4, 1, 1), [0, 1], [&"staff"]).size() == 2, "staff can use service stairs") + _test_legacy() + _test_occupied_stair_demolition() + print("Runtime stairs: %d checks, %d failures" % [_checks, _failures]) + _runtime.free() + _clock.free() + _state.free() + quit(1 if _failures else 0) + +func _test_legacy() -> void: + _reset() + _build(&"guest_corridor", Vector3i(4, 1, 0)) + _build(&"guest_corridor", Vector3i(4, 2, 0)) + _expect(_runtime.path_service.find_path(Vector3i(4, 0, 0), Vector3i(4, 2, 0)).is_empty(), "new corridors do not act as invisible stairs") + var legacy: Dictionary = _runtime.create_snapshot() + var data: Dictionary = legacy[&"systems"][&"runtime"] + data[&"runtime_schema_version"] = 8 + data.erase(&"legacy_stairs") + data[&"path"][&"schema_version"] = 4 + data[&"path"].erase(&"vertical_walk_requires_connector") + data[&"path"].erase(&"transit_actor_ids") + _expect(_runtime.restore_snapshot(legacy), "actual v8 structure migrates existing vertical corridors") + var records: Array = _runtime.legacy_stairs.create_snapshot()[&"stairs"] + _expect(records.size() == 2 and _runtime.path_service.get_vertical_walk_requires_connector(), "migration materializes only the two original vertical links") + var route: Array = _runtime.path_service.find_path(Vector3i(4, 0, 0), Vector3i(4, 2, 0)) + _expect(route.size() == 3 and _runtime.path_service.calculate_path_cost(route) == 200, "old corridor stairs retain their original speed and reachability") + var canonical: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(canonical) and _runtime.create_snapshot() == canonical, "migrated save is canonical and migration does not repeat") + var malformed := canonical.duplicate(true) + malformed[&"systems"][&"runtime"][&"legacy_stairs"][&"stairs"].pop_back() + _expect(not _runtime.restore_snapshot(malformed) and _runtime.create_snapshot() == canonical, "orphan legacy connector is rejected atomically") + var upper_id := _facility_at(Vector3i(4, 2, 0)) + _expect(_command(&"demolish_facility", {&"instance_id": upper_id}).is_success(), "legacy upper landing can be demolished normally") + _expect(_runtime.legacy_stairs.create_snapshot()[&"stairs"].size() == 1, "demolition retires that stair's ownership record") + _build(&"guest_corridor", Vector3i(4, 2, 0)) + _expect(_runtime.path_service.find_path(Vector3i(4, 1, 0), Vector3i(4, 2, 0)).is_empty(), "rebuilding a plain corridor never regenerates a legacy staircase") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "pruned and rebuilt topology remains saveable") + +func _reset() -> void: + _clock.reset(1, 6, 0) + _state.reset() + _expect(_runtime.initialize(_clock, _state, 90416)[&"valid"], "stairs fixture initializes") + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _clock.set_paused(true) + +func _build(id: StringName, origin: Vector3i) -> void: + var built := _command(&"build_facility", {&"definition_id": id, &"origin": origin}) + _expect(built.is_success(), "normal construction accepts %s %s: %s" % [id, origin, built.message]) + _clock.advance_minutes(10) + _expect(not _facility_at(origin).is_empty(), "construction completes %s %s" % [id, origin]) + +func _facility_at(origin: Vector3i) -> StringName: + for facility: Dictionary in _runtime.construction_service.get_facilities(): + if facility[&"origin"] == origin: + return facility[&"instance_id"] + return &"" + +func _command(type: StringName, payload: Dictionary) -> CommandResult: + _serial += 1 + return _runtime.submit_command(type, payload, &"manager", &"", 0, -1, StringName("runtime.stairs.%d" % _serial)) + +func _expect(ok: bool, label: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(label) + else: + print("PASS: ", label) + + +func _test_occupied_stair_demolition() -> void: + _reset() + _build(&"guest_stairs", Vector3i(4, 1, 0)) + var stair_id := _facility_at(Vector3i(4, 1, 0)) + _expect(_command(&"spawn_guest", {&"guest_id": &"occupied.stair", &"guest_type_id": &"traveler", &"gold": 100, + &"options": {&"current_cell": Vector3i(4, 1, 0), &"state": GuestRuntime.State.MOVING, + &"state_entered_at": _clock.absolute_minute - 1, &"planned_exit_at": _clock.absolute_minute + 100, + &"path_handle": {&"target_cell": Vector3i(4, 0, 0)}}}).is_success(), "real guest is standing on upper staircase landing") + var paths: Dictionary = _runtime.path_service.create_snapshot() + var rejected := _command(&"demolish_facility", {&"instance_id": stair_id}) + _expect(not rejected.is_success() and rejected.details.get(&"reason") == &"FACILITY_OCCUPIED", "occupied staircase cannot delete a real guest's landing") + _expect(_runtime.path_service.create_snapshot() == paths and _runtime.guest_service.get_guest(&"occupied.stair").current_cell == Vector3i(4, 1, 0), "demolition rejection preserves actor projection and position") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "occupied staircase rejection leaves a valid canonical save") + _clock.advance_minutes(2) + _expect(_runtime.guest_service.get_guest(&"occupied.stair").current_cell == Vector3i(4, 0, 0), "guest physically finishes descending before demolition") + _expect(_command(&"demolish_facility", {&"instance_id": stair_id}).is_success(), "empty staircase can be removed after the guest leaves") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "post-descent demolition remains saveable") diff --git a/tests/transport/test_runtime_stairs.gd.uid b/tests/transport/test_runtime_stairs.gd.uid new file mode 100644 index 0000000..046114b --- /dev/null +++ b/tests/transport/test_runtime_stairs.gd.uid @@ -0,0 +1 @@ +uid://blt3w5drpduju diff --git a/tests/transport/test_runtime_transport.gd b/tests/transport/test_runtime_transport.gd index ab67657..aa8c444 100644 --- a/tests/transport/test_runtime_transport.gd +++ b/tests/transport/test_runtime_transport.gd @@ -93,7 +93,7 @@ func _verify_mid_ride() -> bool: _expect(bool(_runtime.save_to_path(save_path).get(&"ok", false)), "boarded save writes isolated disk slot") _expect(bool(_runtime.load_from_path(save_path).get(&"ok", false)), "boarded disk slot reloads") var snapshot: Dictionary = _runtime.create_snapshot() - _expect(snapshot[&"systems"][&"runtime"][&"runtime_schema_version"] == 8, "runtime schema8 records elevator state") + _expect(snapshot[&"systems"][&"runtime"][&"runtime_schema_version"] == 9, "runtime schema9 records elevator state and explicit vertical routing") _expect(_runtime.restore_snapshot(snapshot), "mid-ride Runtime snapshot restores actor/cabin/Path ownership") var shaft: Dictionary = _runtime.elevators.get_report()[&"shafts"][0] var busy := _cmd(&"demolish_facility", {&"instance_id": shaft[&"module_ids"][1]}) diff --git a/tests/transport/test_runtime_transport_comparison.gd b/tests/transport/test_runtime_transport_comparison.gd new file mode 100644 index 0000000..ef14921 --- /dev/null +++ b/tests/transport/test_runtime_transport_comparison.gd @@ -0,0 +1,337 @@ +extends SceneTree + +## Fixed-demand MOVE-06 acceptance. Guests use public spawning plus a fixed +## MOVING destination, as in test_runtime_path_traffic. Production Runtime owns +## every path choice, walking credit, congestion delay and elevator transition. +## This isolates equipment capacity; it does not exercise natural Guest demand, +## service purchasing or economic return on investment. +const SEED := 806042 +const POPULATION := 48 +const WINDOW_MINUTES := 480 +const START := Vector3i(2, 0, 0) +const GOAL := Vector3i(2, 1, 0) +const DEMAND_HASH := "5bafa62275370c2a6ce5b4d4f283c3d7679c679a2b3fc6b054b681f2647456cf" +const REPORT_PATH := "res://artifacts/transport/runtime-comparison.json" + +var _checks := 0 +var _failures := 0 +var _runtime: Variant +var _clock: Variant +var _state: Variant +var _start_minute := 0 +var _records: Dictionary = {} +var _trace: Array[Dictionary] = [] +var _errors: Array[String] = [] +var _demand_rows: Array[Dictionary] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var global_clock := root.get_node_or_null("GameClock") + if global_clock != null: global_clock.set_paused(true) + _demand_rows = _demand() + _expect(SimulationDigest.compute(_demand_rows) == DEMAND_HASH, "Runtime and pure-service fixtures share the exact seeded 48-person demand manifest") + var before := _observe(1, true) + var after := _observe(2, true) + if before.is_empty() or after.is_empty(): + _finish() + return + _expect(before[&"demand_hash"] == after[&"demand_hash"] and before[&"start_minute"] == after[&"start_minute"] and before[&"window_minutes"] == after[&"window_minutes"], "same seed, arrivals, endpoints and absolute observation window in both layouts") + _expect(before[&"arrived_count"] == POPULATION and after[&"arrived_count"] == POPULATION, "both layouts receive all 48 real GuestRuntime actors") + _expect(before[&"completed_count"] == POPULATION and after[&"completed_count"] == POPULATION, "both layouts physically complete all 48 journeys inside the fixed window") + _expect(before[&"unfinished_count"] == 0 and after[&"unfinished_count"] == 0, "no incomplete passenger is omitted from the comparison") + _expect(before[&"shortest_walking_steps"] == 4 and after[&"shortest_walking_steps"] == 4, "both physical layouts have equal four-step shortest walking distance to each cabin") + _expect(before[&"walking_steps_min"] >= 4 and after[&"walking_steps_min"] >= 4, "actual Runtime guests never shorten the common four-step geometry by a hidden route or teleport") + _expect(before[&"walking_steps"][&"samples"] == POPULATION and after[&"walking_steps"][&"samples"] == POPULATION, "actual walking and detour statistics retain all completed guests") + _expect(after[&"shaft_usage"].size() == 2, "normal Runtime path choice sends passengers to both installed cabins") + _expect(before[&"queue_wait_minutes"][&"samples"] == POPULATION and after[&"queue_wait_minutes"][&"samples"] == POPULATION, "wait statistics include the entire boarded cohort") + _expect(_is_improved(before, after), "additional identical cabin improves wait and journey P95 with equal fully completed demand") + var replay := _observe(2, false) + _expect(after == replay, "fresh same-seed Runtime replay reproduces all outcomes and the full movement event trace hash") + var incomplete := after.duplicate(true) + incomplete[&"completed_count"] = 0 + incomplete[&"unfinished_count"] = POPULATION + incomplete[&"queue_wait_minutes"] = _stats([]) + incomplete[&"journey_minutes"] = _stats([]) + _expect(not _is_improved(before, incomplete), "an unfinished zero-completion result cannot be accepted as faster") + var report := { + &"scope": "Fixed-demand public-spawn GuestRuntime harness. Actual Runtime routing, movement credit, congestion and paid construction; not natural economic Guest AI or complete P2/S14 acceptance.", + &"route_policy": "Production Runtime find_path re-evaluates each walking minute; global explicit vertical connectors; no benchmark blockers or debug simulation jumps. Congestion-driven route reconsideration may add walking; actual detours are reported, never frozen or discarded.", + &"completion_policy": "Record actual common-destination arrival, then normal begin_leaving/despawn_guest so completed actors no longer obstruct later arrivals.", + &"percentile_policy": "Nearest-rank P95 over observed boarding waits/completed journeys, with unfinished arrivals separately censored. Improvement requires both layouts to finish the entire equal cohort.", + &"seed": SEED, &"demand_hash": DEMAND_HASH, &"demand": _demand_rows, + &"before": before, &"after": after, + &"equipment_cost_difference": {&"gold": after[&"costs"][&"gold"] - before[&"costs"][&"gold"], + &"materials": after[&"costs"][&"materials"] - before[&"costs"][&"materials"], + &"maintenance_mana_demand": after[&"costs"][&"maintenance_mana_demand"] - before[&"costs"][&"maintenance_mana_demand"]}, + &"improvement_accepted": _is_improved(before, after), &"checks": _checks, &"failures": _failures} + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://artifacts/transport")) + var output := FileAccess.open(REPORT_PATH, FileAccess.WRITE) + if output != null: output.store_string(JSON.stringify(report, "\t") + "\n") + print("RUNTIME_TRANSPORT_COMPARISON ", JSON.stringify({&"before": _summary(before), &"after": _summary(after), &"improved": report[&"improvement_accepted"]})) + _finish() + + +func _demand() -> Array[Dictionary]: + var rng := RandomNumberGenerator.new() + rng.seed = SEED + var result: Array[Dictionary] = [] + for index: int in range(POPULATION): + result.append({&"actor_id": StringName("comparison/%03d" % index), + &"arrived_at": (index / 4) * 15 + rng.randi_range(0, 2), + &"from": [START.x, START.y, START.z], &"to": [GOAL.x, GOAL.y, GOAL.z]}) + result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + if a[&"arrived_at"] != b[&"arrived_at"]: return a[&"arrived_at"] < b[&"arrived_at"] + return str(a[&"actor_id"]) < str(b[&"actor_id"])) + return result + + +func _prepare(cabins: int) -> Dictionary: + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + _expect(_runtime.initialize(_clock, _state, SEED)[&"valid"], "%d-cabin Runtime initializes at normal starting resources" % cabins) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _clock.set_paused(true) + var builds: Array = [[&"guest_elevator", Vector3i.ZERO], + [&"guest_corridor", Vector3i(2, 1, 0)], [ &"guest_corridor", Vector3i(3, 1, 0)]] + if cabins == 2: builds.append([&"guest_elevator", Vector3i(4, 0, 0)]) + var success := true + for index: int in range(builds.size()): + var result: CommandResult = _runtime.submit_command(&"build_facility", { + &"definition_id": builds[index][0], &"origin": builds[index][1]}, + &"player", &"", 0, _clock.absolute_minute, StringName("comparison.build.%d" % index)) + success = success and result.is_success() + _expect(success, "%d-cabin layout is purchased through normal public construction" % cabins) + _clock.advance_minutes(310) + var report: Dictionary = _runtime.elevators.get_report() + var maintenance := 0 + var equal_walking := true + for shaft: Dictionary in report[&"shafts"]: + success = success and shaft[&"operational"] and shaft[&"capacity"] == 4 and shaft[&"travel_minutes_per_floor"] == 10 + var lower: Vector3i = shaft[&"stops"][0] + var upper: Vector3i = shaft[&"stops"][1] + var approach: Array[Vector3i] = _runtime.path_service.find_path(START, lower) + var departure: Array[Vector3i] = _runtime.path_service.find_path(upper, GOAL) + equal_walking = equal_walking and approach.size() == 3 and departure.size() == 3 + for cell: Vector3i in approach: equal_walking = equal_walking and cell.y == START.y + for cell: Vector3i in departure: equal_walking = equal_walking and cell.y == GOAL.y + for module: StringName in shaft[&"module_ids"]: + var facility: Dictionary = _runtime.construction_service.get_facility(module) + var definition: FacilityDefinition = _runtime.definition_registry.get_facility(facility[&"definition_id"]) + maintenance += definition.maintenance_mana + _expect(success and report[&"shafts"].size() == cabins, "%d identical four-person cabins complete normally and receive real network power" % cabins) + _expect(equal_walking, "%d-cabin layout has two real approach and two departure walking steps at every boarding position" % cabins) + _expect(_runtime.path_service.get_vertical_walk_requires_connector(), "fixture uses production explicit vertical connector policy") + _start_minute = _clock.absolute_minute + _records = {} + _trace = [] + _errors = [] + _subscribe() + return {&"gold": 5000 - _state.get_resource(&"gold"), &"materials": 50 - _state.get_resource(&"materials"), &"maintenance_mana_demand": maintenance} if success else {} + + +func _observe(cabins: int, check_midride: bool) -> Dictionary: + var costs := _prepare(cabins) + if costs.is_empty(): + _dispose() + return {} + _spawn_arrivals(0) + var checked_phases: Dictionary = {} + for offset: int in range(1, WINDOW_MINUTES + 1): + _tick(offset) + if check_midride: + for shaft: Dictionary in _runtime.elevators.get_report()[&"shafts"]: + var phase: StringName = shaft[&"phase"] + var kind: StringName = &"QUEUED" if shaft[&"waiting_count"] > 0 else phase + if kind in [&"QUEUED", &"BOARDING"] and not checked_phases.has(kind): + checked_phases[kind] = true + _check_replay(offset, kind) + if check_midride: _expect(checked_phases.has(&"QUEUED") and checked_phases.has(&"BOARDING"), "%d-cabin comparison exercises both queued and boarding snapshot checkpoints" % cabins) + _expect(_errors.is_empty(), "%d-cabin actors retain valid lifecycle, projection, event timing and physical floor transitions: %s" % [cabins, _errors]) + var result := _report(cabins, costs) + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://artifacts/transport")) + var trace_output := FileAccess.open("res://artifacts/transport/runtime-comparison-%d-trace.json" % cabins, FileAccess.WRITE) + if trace_output != null: trace_output.store_string(JSON.stringify(_trace, "\t") + "\n") + _expect(_runtime.guest_service.get_guests().size() == result[&"unfinished_count"], "only genuinely unfinished guests remain in live Runtime after the observation window") + _expect(_runtime.path_service.get_transit_actor_ids().size() == result[&"aboard_count"], "Path transit occupancy equals actual unfinished boarded passengers") + _dispose() + return result + + +func _tick(offset: int) -> void: + _clock.advance_minutes(1) + for guest_id: StringName in _records: + var record: Dictionary = _records[guest_id] + if record[&"completed_at"] >= 0: continue + var guest: GuestRuntime = _runtime.guest_service.get_guest(guest_id) + if guest == null: + _errors.append("Guest vanished before reaching destination: %s" % guest_id) + continue + if guest.current_cell != GOAL: continue + record[&"completed_at"] = offset + _trace.append({&"kind": &"COMPLETED", &"guest_id": guest_id, &"minute": offset}) + var leaving: Dictionary = _runtime.guest_service.begin_leaving(guest_id, _clock.absolute_minute, &"COMPARISON_DESTINATION_REACHED") + var despawned: Dictionary = _runtime.guest_service.despawn_guest(guest_id, _clock.absolute_minute) + if not leaving.get(&"ok", false) or not despawned.get(&"ok", false): _errors.append("Normal destination despawn failed: %s" % guest_id) + if _runtime.path_service.has_actor_projection(StringName("guest:" + String(guest_id))): _errors.append("Completed actor retained Path projection: %s" % guest_id) + _spawn_arrivals(offset) + + +func _spawn_arrivals(offset: int) -> void: + for row: Dictionary in _demand_rows: + if row[&"arrived_at"] != offset: continue + var guest_id: StringName = row[&"actor_id"] + _records[guest_id] = {&"guest_id": guest_id, &"arrived_at": offset, &"requested_at": -1, + &"boarded_at": -1, &"alighted_at": -1, &"completed_at": -1, &"walking_steps": 0, &"shaft_id": &""} + var result: CommandResult = _runtime.submit_command(&"spawn_guest", { + &"guest_id": guest_id, &"guest_type_id": &"traveler", &"gold": 100, + &"options": {&"current_cell": START, &"state": GuestRuntime.State.MOVING, + &"state_entered_at": _clock.absolute_minute - 1, + &"planned_exit_at": _clock.absolute_minute + 2000, + &"path_handle": {&"target_cell": GOAL}}}, + &"world", guest_id, 10, -1, StringName("comparison.spawn.%s" % guest_id)) + if not result.is_success(): _errors.append("Public spawn rejected: %s" % guest_id) + _trace.append({&"kind": &"SPAWNED", &"guest_id": guest_id, &"minute": offset}) + + +func _subscribe() -> void: + _runtime.domain_events.subscribe(&"*", &"transport.comparison", Callable(self, "_on_event")) + + +func _on_event(event: SimulationDomainEvent) -> void: + if event.event_type not in [&"GUEST_MOVED", &"ELEVATOR_TRIP_REQUESTED", &"ELEVATOR_BOARDED", &"ELEVATOR_ARRIVED"]: return + var guest_id := StringName(String(event.entity_ids[0]).trim_prefix("guest:")) + if not _records.has(guest_id): return + var record: Dictionary = _records[guest_id] + var offset := event.absolute_minute - _start_minute + _trace.append({&"kind": event.event_type, &"guest_id": guest_id, &"minute": offset, &"payload": event.payload.duplicate(true)}) + match event.event_type: + &"GUEST_MOVED": + var from: Vector3i = event.payload[&"from_cell"] + var to: Vector3i = event.payload[&"cell"] + if from.y != to.y: _errors.append("Guest walked vertically without a cabin: %s" % guest_id) + record[&"walking_steps"] += 1 + &"ELEVATOR_TRIP_REQUESTED": + if record[&"requested_at"] >= 0: _errors.append("Journey requested more than one ride: %s" % guest_id) + record[&"requested_at"] = offset + record[&"shaft_id"] = event.payload[&"shaft_id"] + &"ELEVATOR_BOARDED": + record[&"boarded_at"] = offset + if int(event.payload.get(&"wait_minutes", -1)) != offset - record[&"requested_at"]: _errors.append("Boarding event wait disagrees with observed request: %s" % guest_id) + &"ELEVATOR_ARRIVED": + record[&"alighted_at"] = offset + if event.payload.get(&"emergency", false) or event.payload.get(&"cancelled", false): _errors.append("Healthy comparison ride unexpectedly cancelled: %s" % guest_id) + + +func _check_replay(offset: int, label: StringName) -> void: + var saved: Dictionary = _runtime.create_snapshot() + var records := _records.duplicate(true) + var trace := _trace.duplicate(true) + var errors := _errors.duplicate() + for step: int in range(offset + 1, offset + 13): _tick(step) + var expected: Dictionary = _runtime.create_snapshot() + var expected_records := _records.duplicate(true) + var expected_trace := _trace.duplicate(true) + var expected_errors := _errors.duplicate() + var restored: bool = _runtime.restore_snapshot(saved) + _expect(restored, "%s mid-journey aggregate Runtime snapshot restores" % label) + if not restored: return + _records = records.duplicate(true) + _trace.assign(trace.duplicate(true)) + _errors.assign(errors) + _subscribe() + for step: int in range(offset + 1, offset + 13): _tick(step) + _expect(_runtime.create_snapshot() == expected and _records == expected_records and _trace == expected_trace and _errors == expected_errors, "%s restore replays the next 12 minutes including scheduled spawns with exact state and event trace" % label) + _expect(_runtime.restore_snapshot(saved), "%s checkpoint restores again before continuing the original observation window" % label) + _records = records + _trace.assign(trace) + _errors.assign(errors) + _subscribe() + + +func _report(cabins: int, costs: Dictionary) -> Dictionary: + var waits: Array[int] = [] + var journeys: Array[int] = [] + var rides: Array[int] = [] + var walks: Array[int] = [] + var completed: Array[Dictionary] = [] + var unfinished: Array[Dictionary] = [] + var usage: Dictionary = {} + var waiting := 0 + var aboard := 0 + var detours := 0 + for guest_id: StringName in _records: + var record: Dictionary = _records[guest_id] + if record[&"boarded_at"] >= 0: waits.append(record[&"boarded_at"] - record[&"requested_at"]) + if record[&"alighted_at"] >= 0: rides.append(record[&"alighted_at"] - record[&"boarded_at"]) + if not String(record[&"shaft_id"]).is_empty(): usage[record[&"shaft_id"]] = int(usage.get(record[&"shaft_id"], 0)) + 1 + if record[&"completed_at"] >= 0: + journeys.append(record[&"completed_at"] - record[&"arrived_at"]) + walks.append(record[&"walking_steps"]) + if record[&"walking_steps"] > 4: detours += 1 + completed.append(record.duplicate(true)) + else: + var trip: Dictionary = _runtime.elevators.get_actor_trip(StringName("guest:" + String(guest_id))) + var phase: StringName = trip.get(&"state", &"WALKING") + if phase == PathService.ELEVATOR_REQUEST_QUEUED: waiting += 1 + if phase == PathService.ELEVATOR_REQUEST_BOARDED: aboard += 1 + unfinished.append({&"guest_id": guest_id, &"state": phase, &"age_minutes": WINDOW_MINUTES - record[&"arrived_at"], + &"observed_wait_minutes": WINDOW_MINUTES - record[&"requested_at"] if phase == PathService.ELEVATOR_REQUEST_QUEUED else 0}) + walks.sort() + return {&"shaft_count": cabins, &"costs": costs, &"seed": SEED, &"start_minute": _start_minute, + &"window_minutes": WINDOW_MINUTES, &"demand_count": POPULATION, &"demand_hash": SimulationDigest.compute(_demand_rows), + &"arrived_count": _records.size(), &"not_arrived_count": POPULATION - _records.size(), + &"completed_count": completed.size(), &"unfinished_count": unfinished.size(), &"failed_count": _errors.size(), + &"waiting_count": waiting, &"aboard_count": aboard, &"walking_count": unfinished.size() - waiting - aboard, + &"queue_wait_minutes": _stats(waits), &"journey_minutes": _stats(journeys), &"ride_minutes": _stats(rides), + &"shortest_walking_steps": 4, &"walking_steps": _stats(walks), &"detour_guest_count": detours, + &"walking_steps_min": walks[0] if not walks.is_empty() else -1, &"walking_steps_max": walks[-1] if not walks.is_empty() else -1, + &"shaft_usage": usage, &"completed_guests": completed, &"unfinished_guests": unfinished, + &"event_trace_hash": SimulationDigest.compute(_trace), &"event_count": _trace.size()} + + +func _stats(values: Array[int]) -> Dictionary: + if values.is_empty(): return {&"samples": 0, &"mean": null, &"p95": null, &"max": null} + var sorted := values.duplicate() + sorted.sort() + var total := 0 + for value: int in sorted: total += value + return {&"samples": sorted.size(), &"mean": float(total) / sorted.size(), &"p95": sorted[ceili(sorted.size() * 0.95) - 1], &"max": sorted[-1]} + + +func _is_improved(before: Dictionary, after: Dictionary) -> bool: + return before[&"demand_hash"] == after[&"demand_hash"] and before[&"window_minutes"] == after[&"window_minutes"] \ + and before[&"completed_count"] == POPULATION and after[&"completed_count"] == POPULATION \ + and before[&"failed_count"] == 0 and after[&"failed_count"] == 0 \ + and before[&"queue_wait_minutes"][&"samples"] == POPULATION and after[&"queue_wait_minutes"][&"samples"] == POPULATION \ + and after[&"queue_wait_minutes"][&"p95"] < before[&"queue_wait_minutes"][&"p95"] \ + and after[&"journey_minutes"][&"p95"] < before[&"journey_minutes"][&"p95"] + + +func _summary(report: Dictionary) -> Dictionary: + return {&"cabins": report[&"shaft_count"], &"completed": report[&"completed_count"], &"unfinished": report[&"unfinished_count"], + &"wait": report[&"queue_wait_minutes"], &"journey": report[&"journey_minutes"], &"walking_steps_min": report[&"walking_steps_min"], + &"walking_steps_max": report[&"walking_steps_max"], &"walking_steps": report[&"walking_steps"], &"detour_guest_count": report[&"detour_guest_count"], &"shaft_usage": report[&"shaft_usage"], &"trace_hash": report[&"event_trace_hash"]} + + +func _dispose() -> void: + _runtime.free() + _clock.free() + _state.free() + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if not condition: _failures += 1 + print("%s: %s" % ["PASS" if condition else "FAIL", label]) + + +func _finish() -> void: + print("Runtime transport comparison: %d checks, %d failures" % [_checks, _failures]) + quit(0 if _failures == 0 else 1) diff --git a/tests/transport/test_runtime_transport_comparison.gd.uid b/tests/transport/test_runtime_transport_comparison.gd.uid new file mode 100644 index 0000000..63bb339 --- /dev/null +++ b/tests/transport/test_runtime_transport_comparison.gd.uid @@ -0,0 +1 @@ +uid://ct2kere2fgrfe diff --git a/tests/transport/test_transport_comparison.gd b/tests/transport/test_transport_comparison.gd new file mode 100644 index 0000000..1be9771 --- /dev/null +++ b/tests/transport/test_transport_comparison.gd @@ -0,0 +1,271 @@ +extends SceneTree + +## MOVE-06 component comparison: fixed arrival demand, actual public construction, and the +## production Path/Elevator services. These are measurement actors, not the +## economic Guest AI. Route choice is committed once at common-lobby arrival. +## Both scenarios use Path's production explicit floor-connector policy. +const SEED := 806042 +const POPULATION := 48 +const WINDOW_MINUTES := 480 +const START := Vector3i(2, 0, 0) +const GOAL := Vector3i(2, 1, 0) +const CREDIT_SCALE := 1000 +const REPORT_PATH := "res://artifacts/transport/path-comparison.json" +const DEMAND_PATH := "res://artifacts/transport/comparison-demand.json" + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var global_clock := root.get_node_or_null("GameClock") + if global_clock != null: global_clock.set_paused(true) + var demand := _demand() + _expect(demand.size() == POPULATION and SimulationDigest.compute(demand) == SimulationDigest.compute(_demand()), "seed fixes all 48 actor IDs, arrival minutes, origins and destinations") + var one := _prepare(1) + var two := _prepare(2) + if one.is_empty() or two.is_empty(): + _finish() + return + var before := _observe(one, demand, WINDOW_MINUTES) + var after := _observe(two, demand, WINDOW_MINUTES) + _expect(before[&"demand_hash"] == after[&"demand_hash"] and before[&"demand_count"] == after[&"demand_count"], "both configurations replay the identical demand manifest") + _expect(before[&"window_minutes"] == after[&"window_minutes"], "both configurations use the same fixed observation window") + _expect(before[&"arrived_count"] == POPULATION and after[&"arrived_count"] == POPULATION, "no passengers are removed to make the improved layout faster") + _expect(before[&"completed_count"] + before[&"unfinished_count"] == POPULATION and after[&"completed_count"] + after[&"unfinished_count"] == POPULATION, "completed and censored unfinished populations account for every arrival") + _expect(before[&"failed_count"] == 0 and after[&"failed_count"] == 0, "every measurement actor has a legal route and conserved Path ownership") + _expect(before[&"walking_steps_min"] == 4 and before[&"walking_steps_max"] == 4 and after[&"walking_steps_min"] == 4 and after[&"walking_steps_max"] == 4, "left and right boarding positions have the same four horizontal walking steps") + _expect(after[&"shaft_usage"].size() == 2, "public shortest-path costs distribute demand to both installed cabins") + _expect(before[&"completed_count"] == POPULATION and after[&"completed_count"] == POPULATION, "both layouts complete the entire cohort inside the fixed window") + _expect(_is_improved(before, after), "second identical cabin improves wait and journey P95 only after both layouts complete equal demand") + var replay := _observe(one, demand, WINDOW_MINUTES) + _expect(replay == before, "same topology snapshot and seed reproduce all measured outcomes exactly") + var censored := _observe(two, demand, 30) + _expect(censored[&"unfinished_count"] > 0 and not _is_improved(before, censored), "short-window incomplete observations cannot masquerade as an improvement") + var report := { + &"scope": "Fixed-demand Path/Elevator actor harness; not natural economic Guest AI or full S14 congestion acceptance.", + &"route_policy": "Public find_path at common-lobby arrival; commit that route for this journey; real shared-cabin boarding and minute ticks.", + &"topology_policy": "Production explicit vertical connectors; no benchmark floor-separation blockers.", + &"seed": SEED, &"demand_count": POPULATION, &"observation_minutes": WINDOW_MINUTES, + &"demand_hash": SimulationDigest.compute(demand), &"before": before, &"after": after, + &"equipment_cost_difference": { + &"gold": two[&"costs"][&"gold"] - one[&"costs"][&"gold"], + &"materials": two[&"costs"][&"materials"] - one[&"costs"][&"materials"], + &"maintenance_mana_demand": two[&"costs"][&"maintenance_mana_demand"] - one[&"costs"][&"maintenance_mana_demand"]}, + &"improvement_accepted": _is_improved(before, after), &"checks": _checks, &"failures": _failures} + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://artifacts/transport")) + var output := FileAccess.open(REPORT_PATH, FileAccess.WRITE) + if output != null: output.store_string(JSON.stringify(report, "\t") + "\n") + var manifest := FileAccess.open(DEMAND_PATH, FileAccess.WRITE) + if manifest != null: manifest.store_string(JSON.stringify(demand, "\t") + "\n") + print("TRANSPORT_COMPARISON ", JSON.stringify({&"before": _summary(before), &"after": _summary(after), &"improved": report[&"improvement_accepted"]})) + _finish() + + +func _demand() -> Array[Dictionary]: + var rng := RandomNumberGenerator.new() + rng.seed = SEED + var result: Array[Dictionary] = [] + for index: int in range(POPULATION): + result.append({&"actor_id": StringName("comparison/%03d" % index), + &"arrived_at": (index / 4) * 15 + rng.randi_range(0, 2), + &"from": [START.x, START.y, START.z], &"to": [GOAL.x, GOAL.y, GOAL.z]}) + result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + if a[&"arrived_at"] != b[&"arrived_at"]: return a[&"arrived_at"] < b[&"arrived_at"] + return str(a[&"actor_id"]) < str(b[&"actor_id"])) + return result + + +func _prepare(cabins: int) -> Dictionary: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1, 6, 0) + state.reset() + _expect(runtime.initialize(clock, state, SEED)[&"valid"], "%d-cabin fixture initializes with normal 5000G/50 materials" % cabins) + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + clock.set_paused(true) + var builds: Array = [ + [&"guest_elevator", Vector3i.ZERO], + [&"guest_corridor", Vector3i(2, 1, 0)], + [&"guest_corridor", Vector3i(3, 1, 0)]] + if cabins == 2: builds.append([&"guest_elevator", Vector3i(4, 0, 0)]) + var success := true + for index: int in range(builds.size()): + var result: CommandResult = runtime.submit_command(&"build_facility", { + &"definition_id": builds[index][0], &"origin": builds[index][1]}, + &"player", &"", 0, clock.absolute_minute, StringName("comparison.build.%d" % index)) + _expect(result.is_success(), "%d-cabin case pays for %s at %s" % [cabins, builds[index][0], builds[index][1]]) + success = success and result.is_success() + clock.advance_minutes(310) + var report: Dictionary = runtime.elevators.get_report() + _expect(report[&"shafts"].size() == cabins, "%d-cabin construction physically completes" % cabins) + success = success and report[&"shafts"].size() == cabins + var maintenance := 0 + for shaft: Dictionary in report[&"shafts"]: + _expect(shaft[&"operational"] and shaft[&"capacity"] == 4 and shaft[&"travel_minutes_per_floor"] == 10, "comparison cabins have identical power, capacity and speed") + for module: StringName in shaft[&"module_ids"]: + var facility: Dictionary = runtime.construction_service.get_facility(module) + var definition: FacilityDefinition = runtime.definition_registry.get_facility(facility[&"definition_id"]) + maintenance += definition.maintenance_mana + var result := {&"shaft_count": cabins, &"minute": clock.absolute_minute, + &"cells": runtime.route_subslot_path_adapter.get_projected_walkable_cells(), + &"path_snapshot": runtime.path_service.create_snapshot(), + &"elevator_snapshot": runtime.elevators.create_snapshot(), + &"costs": {&"gold": 5000 - state.get_resource(&"gold"), &"materials": 50 - state.get_resource(&"materials"), + &"maintenance_mana_demand": maintenance}} + runtime.free() + clock.free() + state.free() + return result if success else {} + + +func _observe(prepared: Dictionary, demand: Array[Dictionary], window: int) -> Dictionary: + var path := PathService.new() + path.rebuild_graph(prepared[&"cells"]) + path.restore_snapshot(prepared[&"path_snapshot"]) + _expect(path.get_vertical_walk_requires_connector(), "prepared Runtime uses production strict floor routing without benchmark overrides") + var elevators := ElevatorService.new() + elevators.configure(path) + elevators.restore_snapshot(prepared[&"elevator_snapshot"]) + var actors: Dictionary = {} + var shaft_usage: Dictionary = {} + var completed: Array[Dictionary] = [] + var failed := 0 + var start_minute: int = prepared[&"minute"] + for offset: int in range(window + 1): + var minute := start_minute + offset + if offset > 0: + for arrival: Dictionary in elevators.advance_one_minute(minute): + var actor: Dictionary = actors[arrival[&"actor_id"]] + actor[&"cell"] = arrival[&"cell"] + actor[&"route_index"] += 1 + actor[&"alighted_at"] = offset + actor[&"next_move_at"] = offset + 1 + path.project_actor_position(arrival[&"actor_id"], arrival[&"cell"], minute) + for row: Dictionary in demand: + if row[&"arrived_at"] != offset: continue + var actor_id: StringName = row[&"actor_id"] + path.actor_enter_cell(actor_id, START, minute) + var route := path.find_path(START, GOAL, [0]) + var actor := {&"actor_id": actor_id, &"arrived_at": offset, &"cell": START, + &"route": route, &"route_index": 0, &"requested_at": -1, &"boarded_at": -1, + &"alighted_at": -1, &"completed_at": -1, &"walking_steps": 0, + &"credit": 0, &"next_move_at": offset + 1, &"failed": route.is_empty()} + actors[actor_id] = actor + if route.is_empty(): failed += 1 + var actor_ids: Array = actors.keys() + actor_ids.sort() + for actor_id: StringName in actor_ids: + var actor: Dictionary = actors[actor_id] + if actor[&"failed"] or actor[&"completed_at"] >= 0: continue + var trip := elevators.get_actor_trip(actor_id) + if not trip.is_empty(): + if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED and actor[&"boarded_at"] < 0: + actor[&"boarded_at"] = offset + continue + if actor[&"cell"] == GOAL: + actor[&"completed_at"] = offset + completed.append(actor.duplicate(true)) + path.despawn_actor(actor_id) + continue + if actor[&"next_move_at"] > offset: continue + var route: Array = actor[&"route"] + var index: int = actor[&"route_index"] + var next: Vector3i = route[index + 1] + var connector := path.get_step_connector(actor[&"cell"], next) + if elevators.owns_connector(connector): + var request := elevators.request_trip(actor_id, actor[&"cell"], next, minute) + if not request[&"ok"]: + actor[&"failed"] = true + failed += 1 + else: + actor[&"requested_at"] = offset + var shaft: StringName = request[&"trip"][&"shaft_id"] + shaft_usage[shaft] = int(shaft_usage.get(shaft, 0)) + 1 + continue + actor[&"credit"] += roundi(path.get_speed_multiplier(actor[&"cell"]) * CREDIT_SCALE) + if actor[&"credit"] < CREDIT_SCALE: continue + if not path.project_actor_position(actor_id, next, minute): + actor[&"failed"] = true + failed += 1 + continue + actor[&"cell"] = next + actor[&"route_index"] += 1 + actor[&"walking_steps"] += 1 + actor[&"credit"] -= CREDIT_SCALE + if next == GOAL: + actor[&"completed_at"] = offset + completed.append(actor.duplicate(true)) + path.despawn_actor(actor_id) + var waits: Array[int] = [] + var journeys: Array[int] = [] + var rides: Array[int] = [] + var walking_steps: Array[int] = [] + var unfinished: Array[Dictionary] = [] + var waiting := 0 + var aboard := 0 + var walking := 0 + for actor_id: StringName in actors: + var actor: Dictionary = actors[actor_id] + if actor[&"boarded_at"] >= 0: waits.append(actor[&"boarded_at"] - actor[&"requested_at"]) + if actor[&"alighted_at"] >= 0: rides.append(actor[&"alighted_at"] - actor[&"boarded_at"]) + if actor[&"completed_at"] >= 0: + journeys.append(actor[&"completed_at"] - actor[&"arrived_at"]) + walking_steps.append(actor[&"walking_steps"]) + else: + var state: StringName = elevators.get_actor_trip(actor_id).get(&"state", &"WALKING") + if state == PathService.ELEVATOR_REQUEST_QUEUED: waiting += 1 + elif state == PathService.ELEVATOR_REQUEST_BOARDED: aboard += 1 + else: walking += 1 + unfinished.append({&"actor_id": actor_id, &"state": state, &"age_minutes": window - actor[&"arrived_at"], + &"observed_wait_minutes": window - actor[&"requested_at"] if state == PathService.ELEVATOR_REQUEST_QUEUED else 0}) + walking_steps.sort() + return {&"shaft_count": prepared[&"shaft_count"], &"costs": prepared[&"costs"], + &"window_minutes": window, &"demand_count": demand.size(), &"demand_hash": SimulationDigest.compute(demand), + &"arrived_count": actors.size(), &"not_arrived_count": demand.size() - actors.size(), + &"completed_count": completed.size(), &"unfinished_count": actors.size() - completed.size(), + &"waiting_count": waiting, &"aboard_count": aboard, &"walking_count": walking, &"failed_count": failed, + &"queue_wait_minutes": _stats(waits), &"journey_minutes": _stats(journeys), &"ride_minutes": _stats(rides), + &"walking_steps_min": walking_steps[0] if not walking_steps.is_empty() else -1, + &"walking_steps_max": walking_steps[-1] if not walking_steps.is_empty() else -1, + &"shaft_usage": shaft_usage, &"completed_actors": completed, &"unfinished_actors": unfinished} + + +func _stats(values: Array[int]) -> Dictionary: + if values.is_empty(): return {&"samples": 0, &"mean": null, &"p95": null, &"max": null} + var sorted := values.duplicate() + sorted.sort() + var total := 0 + for value: int in sorted: total += value + return {&"samples": sorted.size(), &"mean": float(total) / sorted.size(), + &"p95": sorted[maxi(0, ceili(sorted.size() * 0.95) - 1)], &"max": sorted[-1]} + + +func _is_improved(before: Dictionary, after: Dictionary) -> bool: + return before[&"demand_hash"] == after[&"demand_hash"] and before[&"window_minutes"] == after[&"window_minutes"] \ + and before[&"completed_count"] == before[&"demand_count"] and after[&"completed_count"] == before[&"completed_count"] \ + and before[&"failed_count"] == 0 and after[&"failed_count"] == 0 \ + and after[&"queue_wait_minutes"][&"p95"] < before[&"queue_wait_minutes"][&"p95"] \ + and after[&"journey_minutes"][&"p95"] < before[&"journey_minutes"][&"p95"] + + +func _summary(report: Dictionary) -> Dictionary: + return {&"cabins": report[&"shaft_count"], &"completed": report[&"completed_count"], + &"unfinished": report[&"unfinished_count"], &"wait": report[&"queue_wait_minutes"], + &"journey": report[&"journey_minutes"], &"shaft_usage": report[&"shaft_usage"]} + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if not condition: _failures += 1 + print("%s: %s" % ["PASS" if condition else "FAIL", label]) + + +func _finish() -> void: + print("Path and cabin comparison: %d checks, %d failures" % [_checks, _failures]) + quit(0 if _failures == 0 else 1) diff --git a/tests/transport/test_transport_comparison.gd.uid b/tests/transport/test_transport_comparison.gd.uid new file mode 100644 index 0000000..cc809cf --- /dev/null +++ b/tests/transport/test_transport_comparison.gd.uid @@ -0,0 +1 @@ +uid://b5fq4vxbrlmdn diff --git a/tests/transport/test_transport_density_migration.gd b/tests/transport/test_transport_density_migration.gd new file mode 100644 index 0000000..8b34f98 --- /dev/null +++ b/tests/transport/test_transport_density_migration.gd @@ -0,0 +1,157 @@ +extends SceneTree + +## A genuine pre-policy Path4 saved every boarded actor in floor density. +## Rebuild that old representation from five publicly spawned Runtime guests, +## then verify migration and cross-service rollback with occupied physical cars. +var _checks := 0 +var _failures := 0 +var _serial := 0 +var _runtime: Variant +var _clock: Variant +var _state: Variant +var _arrivals: Dictionary = {} +var _original_requests: Dictionary = {} +var _arrival_errors: Array[String] = [] +const GOAL := Vector3i(0, 1, 0) + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var global_clock := root.get_node_or_null("GameClock") + if global_clock != null: global_clock.set_paused(true) + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + _expect(_runtime.initialize(_clock, _state, 90583)[&"valid"], "density migration fixture initializes") + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _clock.set_paused(true) + for build: Array in [[&"guest_elevator", Vector3i.ZERO], [&"guest_corridor", Vector3i(4, 1, 0)], [&"guest_corridor", Vector3i(4, 2, 0)]]: + _expect(_command(&"build_facility", {&"definition_id": build[0], &"origin": build[1]}).is_success(), "normal construction pays for migration topology") + _clock.advance_minutes(10) + _clock.advance_minutes(280) + for index: int in range(5): + var id := StringName("density.guest.%d" % index) + _expect(_command(&"spawn_guest", {&"guest_id": id, &"guest_type_id": &"traveler", &"gold": 100, + &"options": {&"current_cell": Vector3i.ZERO, &"state": GuestRuntime.State.MOVING, + &"state_entered_at": _clock.absolute_minute - 1, &"planned_exit_at": _clock.absolute_minute + 2000, + &"path_handle": {&"target_cell": GOAL}}}).is_success(), "public Runtime spawn creates actual passenger %d" % index) + _clock.advance_minutes(2) + var car: Dictionary = _runtime.elevators.get_report()[&"shafts"][0] + _expect(car[&"phase"] == &"BOARDING" and car[&"passenger_count"] == 4 and car[&"waiting_count"] == 1, "four real guests board while the fifth waits at the same floor") + _expect(_runtime.path_service.get_current_density(Vector3i.ZERO) == 1 and _runtime.path_service.get_transit_actor_ids().size() == 4, "new physical floor density excludes four occupied cabin seats") + var statistics: Dictionary = _runtime.get_elevator_wait_statistics(car[&"shaft_id"]) + _expect(statistics[&"sample_count"] == 4 and statistics[&"mean_wait_minutes"] == 1.0 and statistics[&"p95_wait_minutes"] == 1 and statistics[&"oldest_wait_minutes"] == 1, "live wait read model reports real boarding events and current queue age") + var current: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(current) and _runtime.get_elevator_wait_statistics(car[&"shaft_id"]) == statistics, "wait statistics survive actual Runtime restore without duplicate samples") + var legacy := _as_v8_path4(current) + var data: Dictionary = legacy[&"systems"][&"runtime"] + var old_path: Dictionary = data[&"path"] + var old_density := 0 + for cell: Dictionary in old_path[&"cell_traffic"]: + if cell[&"cell"] == Vector3i.ZERO: old_density = cell[&"current_density"] + _expect(old_density == 5 and not old_path.has(&"transit_actor_ids") and not old_path.has(&"vertical_walk_requires_connector") and not data.has(&"legacy_stairs"), "legacy fixture has genuine Path4 fields and all five old boarding-anchor occupants") + var forged := legacy.duplicate(true) + for cell: Dictionary in forged[&"systems"][&"runtime"][&"path"][&"cell_traffic"]: + if cell[&"cell"] == Vector3i.ZERO: cell[&"current_density"] = 1 + _expect(_rejects(forged), "v8 relabel with new excluded-passenger density is rejected before migration") + _expect(_runtime.restore_snapshot(legacy), "actual v8 plus Path4 aggregate migrates occupied car and queued passengers") + _expect(_runtime.get_elevator_wait_statistics(car[&"shaft_id"])[&"sample_count"] == 0 and _runtime.get_elevator_wait_statistics(car[&"shaft_id"])[&"oldest_wait_minutes"] == 1, "legacy load shows no fabricated boarding samples but retains the actual queue age") + _expect(_runtime.elevators.create_snapshot() == data[&"elevators"], "migration preserves cabin phase, seats, queue IDs, travel clocks and operator power exactly") + _expect(_runtime.path_service.get_current_density(Vector3i.ZERO) == 1 and _runtime.path_service.get_transit_actor_ids().size() == 4, "migration recomputes floor density without removing any actor or cabin seat") + _expect(_runtime.path_service.get_vertical_walk_requires_connector() and _runtime.legacy_stairs.create_snapshot()[&"stairs"].size() == 2, "same migration materializes two old corridor stairs and closes implicit vertical walking") + _expect(_runtime.path_service.find_path(Vector3i(4, 0, 0), Vector3i(4, 2, 0)).size() == 3, "old corridor vertical reachability remains available after policy migration") + for guest: GuestRuntime in _runtime.guest_service.get_guests(): + _expect(guest.current_cell == Vector3i.ZERO and _runtime.path_service.get_actor_projected_cell(StringName("guest:" + String(guest.guest_id))) == Vector3i.ZERO, "migration preserves actual passenger's logical boarding anchor") + var canonical: Dictionary = _runtime.create_snapshot() + _expect(canonical[&"systems"][&"runtime"][&"runtime_schema_version"] == 9 and canonical[&"systems"][&"runtime"][&"path"][&"schema_version"] == 5, "resave emits current Runtime9 and Path5 only") + _expect(_runtime.restore_snapshot(canonical) and _runtime.create_snapshot() == canonical, "canonical migrated save reloads exactly without applying density or stairs twice") + forged = canonical.duplicate(true) + forged[&"systems"][&"runtime"][&"path"][&"transit_actor_ids"].pop_back() + _expect(_rejects(forged), "current transit list cannot omit a real boarded guest") + forged = canonical.duplicate(true) + for cell: Dictionary in forged[&"systems"][&"runtime"][&"path"][&"cell_traffic"]: + if cell[&"cell"] == Vector3i.ZERO: cell[&"current_density"] = 5 + _expect(_rejects(forged), "current save cannot restore old double-counted cabin density") + forged = canonical.duplicate(true) + forged[&"systems"][&"runtime"][&"legacy_stairs"][&"stairs"].pop_back() + _expect(_rejects(forged), "migrated occupied-car snapshot rejects orphan legacy connector metadata") + var token: Dictionary = _runtime._capture_event_action_token() + var first_guest := &"density.guest.0" + _expect(_runtime.guest_service.begin_leaving(first_guest, _clock.absolute_minute, &"TEST_EVENT_EXIT")[&"ok"] and _runtime.guest_service.despawn_guest(first_guest, _clock.absolute_minute)[&"ok"], "event rollback fixture removes a real boarded actor through normal lifecycle") + _expect(_runtime._restore_event_action_token(token)[&"ok"] and _runtime.create_snapshot() == canonical, "event action rollback restores guests, seats, Path density, stair ownership and all snapshot participants exactly") + for trip: Dictionary in _runtime.elevators.get_report()[&"trips"]: + _original_requests[trip[&"request_id"]] = trip[&"actor_id"] + _runtime.domain_events.subscribe(&"ELEVATOR_ARRIVED", &"density.migration.arrivals", Callable(self, "_on_arrival")) + for step: int in range(80): + _clock.advance_minutes(1) + for guest: GuestRuntime in _runtime.guest_service.get_guests(): + if guest.current_cell == GOAL: + _runtime.guest_service.begin_leaving(guest.guest_id, _clock.absolute_minute, &"TEST_DESTINATION_REACHED") + _runtime.guest_service.despawn_guest(guest.guest_id, _clock.absolute_minute) + var exactly_once := _arrivals.size() == 5 + for request_id: StringName in _original_requests: + exactly_once = exactly_once and _arrivals.get(request_id, 0) == 1 + _expect(exactly_once and _arrival_errors.is_empty(), "all five migrated request IDs reach the real destination exactly once") + _expect(_runtime.guest_service.get_guests().is_empty() and _runtime.elevators.get_report()[&"trips"].is_empty() and _runtime.path_service.get_transit_actor_ids().is_empty(), "completed lifecycle leaves no orphan guest, Path transit actor or elevator trip") + var final_state: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(final_state) and _runtime.create_snapshot() == final_state, "post-arrival migrated world remains strictly canonical") + _runtime.free() + _clock.free() + _state.free() + print("Transport density migration: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures else 0) + + +func _as_v8_path4(current: Dictionary) -> Dictionary: + var result := current.duplicate(true) + var data: Dictionary = result[&"systems"][&"runtime"] + data[&"runtime_schema_version"] = 8 + data.erase(&"legacy_stairs") + var path: Dictionary = data[&"path"] + path[&"schema_version"] = 4 + path.erase(&"vertical_walk_requires_connector") + path.erase(&"transit_actor_ids") + var old_densities: Dictionary = {} + for actor: Dictionary in path[&"actor_cells"]: + old_densities[actor[&"cell"]] = int(old_densities.get(actor[&"cell"], 0)) + 1 + for cell: Dictionary in path[&"cell_traffic"]: + cell[&"current_density"] = int(old_densities.get(cell[&"cell"], 0)) + # V8 had no boarding telemetry event. Stable ID high-water marks remain + # monotonic; retiring those observer-only events does not renumber history. + var events: Dictionary = data[&"domain_events"] + var retired: Array = [] + for event: Dictionary in events["recent_events"]: + if event["event_type"] == "ELEVATOR_BOARDED": retired.append(event["event_id"]) + events["recent_events"] = events["recent_events"].filter(func(event: Dictionary) -> bool: return event["event_id"] not in retired) + events["published_ids"] = events["published_ids"].filter(func(id: String) -> bool: return id not in retired) + return result + + +func _on_arrival(event: SimulationDomainEvent) -> void: + var request_id: StringName = event.payload[&"request_id"] + if not _original_requests.has(request_id): + _arrival_errors.append("Unrequested duplicate ride: %s" % request_id) + return + _arrivals[request_id] = int(_arrivals.get(request_id, 0)) + 1 + if event.payload[&"cell"] != GOAL or event.payload.get(&"emergency", false): _arrival_errors.append("Unexpected migrated destination") + + +func _command(type: StringName, payload: Dictionary) -> CommandResult: + _serial += 1 + return _runtime.submit_command(type, payload, &"world", &"", 0, -1, StringName("density.migration.%d" % _serial)) + + +func _rejects(candidate: Dictionary) -> bool: + var before: Dictionary = _runtime.create_snapshot() + return not _runtime.restore_snapshot(candidate) and _runtime.create_snapshot() == before + + +func _expect(ok: bool, label: String) -> void: + _checks += 1 + if not ok: _failures += 1 + print("%s: %s" % ["PASS" if ok else "FAIL", label]) diff --git a/tests/transport/test_transport_density_migration.gd.uid b/tests/transport/test_transport_density_migration.gd.uid new file mode 100644 index 0000000..9977b35 --- /dev/null +++ b/tests/transport/test_transport_density_migration.gd.uid @@ -0,0 +1 @@ +uid://dv2w40du2y6mx diff --git a/tests/transport/test_transport_save.gd b/tests/transport/test_transport_save.gd index 1ddf37a..b3b1f20 100644 --- a/tests/transport/test_transport_save.gd +++ b/tests/transport/test_transport_save.gd @@ -15,21 +15,28 @@ func _run() -> void: var clock: Variant = fixture[&"clock"] clock.advance_minutes(3) var current: Dictionary = runtime.create_snapshot() - _expect(current[&"systems"][&"runtime"][&"runtime_schema_version"] == 8, "Runtime emits schema8 with elevator aggregate") + _expect(current[&"systems"][&"runtime"][&"runtime_schema_version"] == 9, "Runtime emits schema9 with elevator and explicit vertical-route aggregates") var legacy := current.duplicate(true) var data: Dictionary = legacy[&"systems"][&"runtime"] data[&"runtime_schema_version"] = 7 data.erase(&"elevators") + data.erase(&"legacy_stairs") data[&"path"][&"schema_version"] = 3 data[&"path"].erase(&"vertical_walk_blockers") - _expect(runtime.restore_snapshot(legacy), "genuine v7 without lifts migrates from Path3 into Runtime8") - _expect(runtime.create_snapshot() == current, "empty-lift migration preserves every existing system exactly") + data[&"path"].erase(&"vertical_walk_requires_connector") + data[&"path"].erase(&"transit_actor_ids") + _expect(runtime.restore_snapshot(legacy), "genuine v7 without lifts migrates from Path3 into Runtime9") + var migrated: Dictionary = runtime.create_snapshot() + var expected := current.duplicate(true) + # Closing the old implicit-WALK topology bumps only the Path graph revision. + expected[&"systems"][&"runtime"][&"path"][&"graph_revision"] += 1 + _expect(migrated == expected, "empty-lift migration preserves all state except the explicit topology revision") var bad := current.duplicate(true) bad[&"systems"][&"runtime"][&"runtime_schema_version"] = 7 _expect(_rejects(runtime, bad), "outer-only downgrade retaining elevator state is rejected atomically") bad = current.duplicate(true) bad[&"systems"][&"runtime"].erase(&"elevators") - _expect(_rejects(runtime, bad), "Runtime8 cannot omit elevator aggregate") + _expect(_rejects(runtime, bad), "Runtime9 cannot omit elevator aggregate") bad = current.duplicate(true) bad[&"systems"][&"runtime"][&"elevators"][&"current_minute"] -= 1 _expect(_rejects(runtime, bad), "elevator clock must match Runtime domain minute") @@ -53,7 +60,7 @@ func _run() -> void: var trip: Dictionary = runtime.elevators.get_actor_trip(actor_id) _expect(trip.get(&"state") == PathService.ELEVATOR_REQUEST_BOARDED, "save fixture owns a physical cabin seat") current = runtime.create_snapshot() - _expect(runtime.restore_snapshot(current), "Runtime8 restores actual cabin actor and projected origin crosslinks") + _expect(runtime.restore_snapshot(current), "Runtime9 restores actual cabin actor and projected origin crosslinks") bad = current.duplicate(true) bad[&"systems"][&"runtime"][&"elevators"][&"trips"][0][&"actor_id"] = &"staff:missing_person" bad[&"systems"][&"runtime"][&"elevators"][&"shafts"][0][&"boarded"][0] = &"staff:missing_person" @@ -100,13 +107,16 @@ func _test_attendance_migration() -> void: worker.state = StaffRuntime.State.AVAILABLE runtime.facility_service.set_operational_status(facility.instance_id, {&"active_staff_ids": [&"staff_akane"]}, clock.absolute_minute) var logical: Dictionary = runtime.create_snapshot() - _expect(_rejects(runtime, logical), "Runtime8 rejects an AVAILABLE employee counted at a distant workplace") + _expect(_rejects(runtime, logical), "Runtime9 rejects an AVAILABLE employee counted at a distant workplace") var legacy := logical.duplicate(true) var data: Dictionary = legacy[&"systems"][&"runtime"] data[&"runtime_schema_version"] = 7 data.erase(&"elevators") + data.erase(&"legacy_stairs") data[&"path"][&"schema_version"] = 3 data[&"path"].erase(&"vertical_walk_blockers") + data[&"path"].erase(&"vertical_walk_requires_connector") + data[&"path"].erase(&"transit_actor_ids") _expect(runtime.restore_snapshot(legacy), "genuine v7 logical attendance migrates into physical attendance") facility = runtime.facility_service.get_instance(facility.instance_id) worker = runtime.staff_service.get_staff(&"staff_akane") diff --git a/tests/ui/test_first_operating_day.gd b/tests/ui/test_first_operating_day.gd index d724b1c..aeab4f6 100644 --- a/tests/ui/test_first_operating_day.gd +++ b/tests/ui/test_first_operating_day.gd @@ -46,12 +46,17 @@ func _run() -> void: _expect(_guide.get_progress().get(&"stage") == &"room", "completed reception advances room guidance") _build(&"standard_room", Vector3i(-8, 2, 0)) _build(&"standard_room", Vector3i(-6, 2, 0)) - _build(&"guest_corridor", Vector3i(-4, 1, 0)) + _build(&"guest_stairs", Vector3i(-4, 1, 0)) await _advance(60) - _build(&"guest_corridor", Vector3i(-4, 2, 0)) + var access_guide := _guide.get_progress() + _expect(access_guide.get(&"stage") == &"access" and access_guide.get(&"action") == &"build_stairs" and "上下階は客用階段" in access_guide.get(&"detail", ""), "guide explains explicit stairs before the upper rooms become accessible") + await _capture("stairs-guidance") + _main.call("_on_opening_action", access_guide[&"action"]) + _expect(_main.get("world_preview").build_definition.id == &"guest_stairs", "opening-guide action selects the real guest stairs palette entry") + _build(&"guest_stairs", Vector3i(-4, 2, 0)) await _advance(10) for x: int in range(-8, -3): - _build(&"guest_corridor", Vector3i(x, 3, 0)) + _build(&"guest_stairs" if x == -4 else &"guest_corridor", Vector3i(x, 3, 0)) await _advance(10) var reception_id: StringName = &"" for facility: FacilityInstance in _runtime.facility_service.get_instances(): diff --git a/tests/ui/test_main_dashboard.gd b/tests/ui/test_main_dashboard.gd index 07a7baa..92fe70c 100644 --- a/tests/ui/test_main_dashboard.gd +++ b/tests/ui/test_main_dashboard.gd @@ -49,7 +49,7 @@ func _run() -> void: "world preview consumes the scene theme instead of hard-coded asset paths" ) _expect( - visual_theme.facility_icons.size() == 16 + visual_theme.facility_icons.size() == 18 and visual_theme.navigation_icons.size() == 6 and visual_theme.resource_icons.size() == 4 and visual_theme.control_icons.size() == 6, @@ -92,12 +92,12 @@ func _run() -> void: "dashboard exposes action points and payroll arrears" ) var palette := _main.find_child("BuildPalette", true, false) as GridContainer - _expect(palette.get_child_count() == 16, "all sixteen MVP facilities appear in the build palette") + _expect(palette.get_child_count() == 18, "all sixteen MVP facilities and two stairs appear in the build 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: iconized_build_buttons += 1 - _expect(iconized_build_buttons == 16, "all facility catalog buttons consume theme-provided icons") + _expect(iconized_build_buttons == 18, "all facility catalog buttons consume theme-provided icons") var palette_scroll := _main.find_child("BuildPaletteScroll", true, false) as ScrollContainer _expect( palette_scroll != null and palette_scroll.is_ancestor_of(palette), diff --git a/tests/ui/test_stair_transport_details.gd b/tests/ui/test_stair_transport_details.gd new file mode 100644 index 0000000..1e3adfd --- /dev/null +++ b/tests/ui/test_stair_transport_details.gd @@ -0,0 +1,152 @@ +extends SceneTree + +const LegacyType := preload("res://scripts/world/legacy_stair_service.gd") + +class LegacyViewRuntime extends RefCounted: + var world_service := WorldService.new() + var path_service := PathService.new() + var legacy_stairs := LegacyType.new() + +class WaitStatisticsRuntime extends RefCounted: + var samples := 0 + func get_elevator_wait_statistics(_shaft_id: StringName) -> Dictionary: + return {&"sample_count":samples, &"mean_wait_minutes":2.5, &"p95_wait_minutes":7, + &"oldest_wait_minutes":9, &"window_minutes":60, &"event_history_limit":256} + +var _checks := 0 +var _failures := 0 +var _main: Control +var _runtime: Variant +var _clock: Variant +var _preview: WorldPreview +var _guest_stairs: StringName = &"" +var _service_stairs: StringName = &"" +var _elevator: StringName = &"" + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + _clock = root.get_node("GameClock") + _runtime = root.get_node("SimulationRuntime") + var state: Variant = root.get_node("GameState") + _clock.reset(1,6,0) + state.reset() + _expect(_runtime.initialize(_clock,state,947263)[&"valid"], "normal Runtime initializes the transport UI fixture") + _clock.set_paused(true) + root.size = Vector2i(1280,720) + _main = load("res://scenes/main/main.tscn").instantiate() as Control + root.add_child(_main) + _runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + _preview = _main.get("world_preview") + await process_frame + await process_frame + var buttons: Dictionary = _main.get("build_buttons") + _expect(buttons.has(&"guest_stairs") and buttons.has(&"service_stairs"), "both registered stair definitions appear in the building palette") + _expect("客用階段" in buttons[&"guest_stairs"].text and "業務用階段" in buttons[&"service_stairs"].text, "stairs use the localized catalog names") + var icon := buttons[&"guest_stairs"].icon as AtlasTexture + _expect(icon != null and icon.atlas == _preview.visual_theme.pixel_dungeon_tiles and icon.region == Rect2(48,48,16,16), "stair controls reuse the existing real Kenney Tiny Dungeon stair tile") + _main.call("_select_build_definition", &"guest_stairs") + _main.call("_on_map_cell_clicked", Vector3i(-1,1,0)) + _main.call("_set_layer", 1) + _main.call("_select_build_definition", &"service_stairs") + _main.call("_on_map_cell_clicked", Vector3i(-1,1,1)) + _main.call("_set_layer", 0) + _main.call("_select_build_definition", &"guest_elevator") + _main.call("_on_map_cell_clicked", Vector3i(0,0,0)) + _expect(_runtime.construction_service.get_jobs().size() == 3, "normal palette and map clicks pay for two stairs and one elevator") + _clock.advance_minutes(310) + _main.call("_clear_build_selection") + for facility: Dictionary in _runtime.construction_service.get_facilities(): + match facility[&"definition_id"]: + &"guest_stairs": _guest_stairs = facility[&"instance_id"] + &"service_stairs": _service_stairs = facility[&"instance_id"] + &"guest_elevator": _elevator = facility[&"instance_id"] + _expect(not _guest_stairs.is_empty() and not _service_stairs.is_empty() and not _elevator.is_empty(), "all three paid construction projects finish") + if _guest_stairs.is_empty() or _service_stairs.is_empty() or _elevator.is_empty(): + _finish() + return + _main.call("_on_facility_selected", _guest_stairs) + _expect(_main.get("selected_heading_label").text == "客用階段", "finished route-proxy stairs display an inspector instead of a syncing placeholder") + var detail: String = _main.get("selected_detail_label").text + _expect("1F ↔ 2F" in detail and "客・従業員用" in detail and "魔力は不要" in detail, "guest stair inspector explains its floors, users and operation") + _expect(not _main.get("selected_elevator_controls").visible, "stairs have no cabin stop button") + _main.call("_on_facility_selected", _service_stairs) + detail = _main.get("selected_detail_label").text + _expect(_main.get("selected_heading_label").text == "業務用階段" and "従業員専用" in detail and "BACK" in detail, "service stair inspector identifies staff-only back-layer access") + _main.call("_on_facility_selected", _elevator) + detail = _main.get("selected_detail_label").text + _expect("最近の乗車記録 0人(計測待ち)" in detail and "現在の最長待ち 0分" in detail, "unused real elevator labels the sample as pending instead of an observed zero average") + _expect("直近256件" in _main.get("selected_detail_label").tooltip_text and "過去60分" in _main.get("selected_detail_label").tooltip_text, "statistics tooltip explains retained-event sampling") + _test_sample_format() + await _test_legacy_stair_selection() + _main.call("_on_facility_selected", _guest_stairs) + _expect(_main.get("selected_detail_label").tooltip_text.is_empty(), "switching from elevator to stairs removes obsolete wait-statistics tooltip") + _preview.set_zoom(2.5) + _preview.pan_offset += _preview.size*0.5-_preview.cell_to_canvas(Vector3i(0,1,0)) + _preview.queue_redraw() + await process_frame + await process_frame + if OS.get_environment("THE_TREE_STAIRS_UI_CAPTURE") == "1": + await RenderingServer.frame_post_draw + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://artifacts/transport")) + root.get_texture().get_image().save_png("res://artifacts/transport/stairs-inspector.png") + _finish() + +func _test_sample_format() -> void: + var fixture := WaitStatisticsRuntime.new() + _main.set("_runtime", fixture) + var pending: String = _main.call("_elevator_wait_text", &"fixture") + _expect("計測待ち" in pending and not "平均待ち" in pending and "9分" in pending, "no boarding samples still show the current live queue age") + fixture.samples = 4 + var measured: String = _main.call("_elevator_wait_text", &"fixture") + _expect("最近の乗車記録 4人" in measured and "平均待ち 2.5分 / 95% 7.0分" in measured, "sampled average and P95 retain precision and correct labels") + _expect("現在の最長待ち 9分" in measured and not "直近60分の乗車" in measured, "current queue age stays distinct from the retained boarding sample") + _main.set("_runtime", _runtime) + +func _test_legacy_stair_selection() -> void: + var fixture := LegacyViewRuntime.new() + fixture.world_service.reset({&"min_x":-2,&"max_x":2,&"min_y":-1,&"max_y":2}) + fixture.path_service.rebuild_graph([Vector3i.ZERO,Vector3i(0,1,0),Vector3i(0,0,1),Vector3i(0,1,1)]) + _expect(fixture.legacy_stairs.configure(fixture.path_service) and fixture.legacy_stairs.migrate_existing_vertical_walks(), "legacy view uses real canonical stair migration") + var before := SimulationDigest.compute(_runtime.create_snapshot()) + _preview.set_runtime(fixture) + _preview.reset_view() + _preview.layer_mode = WorldPreview.LayerMode.FRONT + var rect: Rect2 = _preview.call("_legacy_stair_rect", Vector3i(0,1,0)) + var stair_id := _preview.legacy_stair_at_canvas(rect.get_center()) + _expect(stair_id == &"legacy.stairs:0:0:0", "legacy stairs are hit-tested on the upper landing") + _expect(_preview.legacy_stair_at_canvas(_preview.get_cell_rect(Vector3i(0,1,0)).position+Vector2.ONE).is_empty(), "small legacy stair target preserves the rest of an existing room's click area") + _expect(_preview.legacy_stair_at_canvas(_preview.cell_to_canvas(Vector3i.ZERO)).is_empty(), "lower floor cannot select the stair glyph drawn upstairs") + _click(rect.get_center()) + _expect(_main.get("_selected_entity_kind") == &"legacy_stairs" and _main.get("_selected_entity_id") == stair_id, "legacy glyph click opens its dedicated selection without clearing its ID") + _expect("既設の階段" == _main.get("selected_heading_label").text and "1F ↔ 2F" in _main.get("selected_detail_label").text, "legacy inspector describes the preserved connection") + _preview.layer_mode = WorldPreview.LayerMode.BACK + _click(rect.get_center()) + _expect(_main.get("_selected_entity_id") == &"legacy.stairs:1:0:0" and "客・従業員用" in _main.get("selected_detail_label").text and "BACK" in _main.get("selected_detail_label").text, "legacy BACK stairs preserve the old public access instead of falsely claiming staff-only access") + var records := _preview.get_legacy_stair_records() + records[0][&"id"] = &"changed.view.copy" + _expect(not str(fixture.legacy_stairs.create_snapshot()).contains("changed.view.copy"), "visual records cannot mutate saved stair ownership") + await process_frame + await process_frame + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == before, "stair painting, selection and inspection never advance or mutate Runtime") + _preview.set_runtime(_runtime) + _main.call("_set_layer", 0) + +func _click(at: Vector2) -> void: + var event := InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = true + event.position = at + _preview.call("_gui_input",event) + +func _finish() -> void: + _clock.set_paused(true) + print("Stair and transport details UI: %d checks, %d failures" % [_checks,_failures]) + quit(_failures) + +func _expect(ok: bool, label: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(label) diff --git a/tests/ui/test_stair_transport_details.gd.uid b/tests/ui/test_stair_transport_details.gd.uid new file mode 100644 index 0000000..82bc4a1 --- /dev/null +++ b/tests/ui/test_stair_transport_details.gd.uid @@ -0,0 +1 @@ +uid://bkmdj2r61ue64 diff --git a/tests/world/test_elevator_topology.gd b/tests/world/test_elevator_topology.gd index 10a1a2f..7ec530d 100644 --- a/tests/world/test_elevator_topology.gd +++ b/tests/world/test_elevator_topology.gd @@ -101,7 +101,7 @@ func _test_snapshot_validation_and_legacy() -> void: path.set_vertical_walk_blockers(&"shaft.a", [cells[0], cells[1]]) path.set_vertical_walk_blockers(&"shaft.b", [cells[2]]) var snapshot := path.create_snapshot() - _expect(snapshot[&"schema_version"] == 4, "Path v4 declares shaft ownership") + _expect(snapshot[&"schema_version"] == 5, "Path v5 retains shaft ownership and explicit floor policy") var restored := PathType.new() _expect(restored.restore_snapshot(snapshot) and restored.rebuild_graph(cells), "restore shaft topology before authoritative graph materialization") _expect(restored.create_snapshot() == snapshot and restored.find_path(cells[0], cells[1]).is_empty(), "round trip restores exact restrictions and revision") @@ -138,6 +138,8 @@ func _test_snapshot_validation_and_legacy() -> void: legacy[&"schema_version"] = version _expect(not restored.restore_snapshot(legacy), "old version with retained shaft field cannot disable safety") legacy.erase(&"vertical_walk_blockers") + legacy.erase(&"vertical_walk_requires_connector") + legacy.erase(&"transit_actor_ids") _expect(restored.restore_snapshot(legacy), "true legacy snapshot lacking shaft field migrates") _expect(restored.get_vertical_walk_blockers().is_empty() and not restored.find_path(cells[0], cells[1]).is_empty(), "legacy plain topology remains compatible") _expect(restored.restore_snapshot(snapshot), "return to current shaft snapshot") diff --git a/tests/world/test_legacy_stairs.gd b/tests/world/test_legacy_stairs.gd new file mode 100644 index 0000000..6fe7513 --- /dev/null +++ b/tests/world/test_legacy_stairs.gd @@ -0,0 +1,213 @@ +extends SceneTree + +const PathType = preload("res://scripts/world/path_service.gd") +const LegacyType = preload("res://scripts/world/legacy_stair_service.gd") + +class ControlledPath: + extends PathService + var fail_restore_once := false + var fail_restore_always := false + var reenter: LegacyStairService + var nested_rejected := false + func restore_snapshot(snapshot: Dictionary) -> bool: + var result := super.restore_snapshot(snapshot) + if reenter != null: + var target := reenter + reenter = null + nested_rejected = not target.migrate_existing_vertical_walks() + if fail_restore_always: + set_vertical_walk_requires_connector(not get_vertical_walk_requires_connector()) + return false + if fail_restore_once: + fail_restore_once = false + return false + return result + +var _checks := 0 +var _failures := 0 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + _test_one_time_migration_and_access() + _test_prune_and_no_regeneration() + _test_snapshot_ownership() + _test_atomic_failure_and_reentry() + print("Legacy stairs: %d checks, %d failures" % [_checks, _failures]) + quit(_failures) + +func _test_one_time_migration_and_access() -> void: + var path := PathType.new() + var cells: Array[Vector3i] = [Vector3i(0, 0, 0), Vector3i(0, 1, 0), Vector3i(0, 2, 0), Vector3i(1, 0, 0), Vector3i(1, 1, 0), Vector3i(2, 0, 0), Vector3i(2, 1, 0), Vector3i(0, 0, 1), Vector3i(0, 1, 1)] + _expect(path.rebuild_graph(cells), "materialize legacy floors") + _expect(path.configure_connector(&"staff.door", Vector3i(0, 0, 0), Vector3i(0, 0, 1), 120, true, &"staff"), "existing staff door remains separately owned") + _expect(path.configure_connector(&"external.express", Vector3i(0, 0, 0), Vector3i(0, 1, 0), 50, false, &"permit"), "existing cheaper one-way connector coexists with WALK") + var shaft_cells: Array[Vector3i] = [Vector3i(2, 0, 0), Vector3i(2, 1, 0)] + _expect(path.set_vertical_walk_blockers(&"infra.shaft:lift", shaft_cells), "existing elevator shaft suppresses ordinary climbing") + _expect(path.set_cell_temporary_block(Vector3i(1, 1, 0), true), "temporary traffic block does not remove structural connection") + _expect(path.project_occupancy([{&"actor_id": &"staff:porter", &"cell": Vector3i(0, 0, 1)}]), "preserve existing occupancy during migration") + var before := path.create_snapshot() + var legacy := LegacyType.new() + _expect(legacy.configure(path), "bind legacy owner") + _expect(legacy.migrate_existing_vertical_walks(), "one migration materializes ordinary vertical WALK edges") + var snapshot := legacy.create_snapshot() + _expect(snapshot[&"stairs"].size() == 4, "both FRONT and BACK WALK edges convert once, without shaft bypass") + _expect(path.get_vertical_walk_requires_connector() and legacy.validate_invariants()[&"ok"], "migration enables strict floor policy") + _expect(path.has_connector(&"legacy.stairs:0:0:0") and path.has_connector(&"legacy.stairs:0:0:1"), "stable IDs encode layer, x and lower floor") + _expect(not path.has_connector(&"legacy.stairs:0:2:0"), "shaft has no free compatibility stairs") + _expect(path.get_connector(&"legacy.stairs:0:1:0")[&"cost"] == 100, "temporary blocked route still migrates at original WALK score") + _expect(path.get_connector(&"legacy.stairs:1:0:0")[&"access_id"] == &"", "legacy BACK route keeps its previous access semantics") + _expect(path.get_connector(&"staff.door") == _connector(before, &"staff.door"), "staff door cost and permission are unchanged") + _expect(path.get_connector(&"external.express") == _connector(before, &"external.express"), "one-way custom connector retains exact configuration") + var after := path.create_snapshot() + for key: StringName in [&"cell_traffic", &"actor_cells", &"actor_progress", &"vertical_walk_blockers", &"active_elevator_units", &"known_elevator_request_ids", &"transit_actor_ids", &"traffic_revision"]: + _expect(after[key] == before[key], "migration preserves unrelated Path field %s" % key) + _expect(path.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0), [0], []).size() == 3, "ordinary guests retain their old multi-floor route") + _expect(path.find_path(Vector3i(0, 0, 0), Vector3i(0, 1, 1), [0, 1], []).is_empty(), "migration does not bypass staff-door access") + _expect(not path.find_path(Vector3i(0, 0, 0), Vector3i(0, 1, 1), [0, 1], [&"staff"]).is_empty(), "staff keep cross-layer commute via original door") + _expect(legacy.migrate_existing_vertical_walks() and legacy.create_snapshot() == snapshot and path.create_snapshot() == after, "repeat migration is an exact no-op") + cells.append(Vector3i(0, 3, 0)) + _expect(path.rebuild_graph(cells), "append newly built upper corridor after migration") + _expect(legacy.migrate_existing_vertical_walks() and legacy.create_snapshot() == snapshot, "new vertical adjacency never receives compatibility stairs") + _expect(path.find_path(Vector3i(0, 2, 0), Vector3i(0, 3, 0)).is_empty(), "new floors require a real stair or elevator") + snapshot[&"stairs"].clear() + _expect(legacy.create_snapshot()[&"stairs"].size() == 4, "snapshot returns defensive metadata") + +func _test_prune_and_no_regeneration() -> void: + var path := PathType.new() + var cells: Array[Vector3i] = [Vector3i(0, 0, 0), Vector3i(0, 1, 0), Vector3i(0, 2, 0), Vector3i(1, 0, 0), Vector3i(1, 1, 0)] + path.rebuild_graph(cells) + var legacy := LegacyType.new() + legacy.configure(path) + _expect(legacy.migrate_existing_vertical_walks(), "migrate prune fixture") + cells.erase(Vector3i(0, 1, 0)) + _expect(path.rebuild_graph(cells), "demolish middle landing") + _expect(not legacy.validate_invariants()[&"ok"], "missing landing marks legacy ownership stale until prune") + _expect(legacy.prune_invalid(), "prune both incident stair edges atomically") + _expect(legacy.create_snapshot()[&"stairs"].size() == 1 and not path.has_connector(&"legacy.stairs:0:0:0") and not path.has_connector(&"legacy.stairs:0:0:1"), "removed landing retires both owned connector records") + cells.append(Vector3i(0, 1, 0)) + _expect(path.rebuild_graph(cells) and legacy.prune_invalid(), "rebuild same coordinates after demolition") + _expect(path.find_path(Vector3i(0, 0, 0), Vector3i(0, 2, 0)).is_empty(), "retired links never resurrect at reused coordinates") + var blocked: Array[Vector3i] = [Vector3i(1, 1, 0)] + _expect(path.set_vertical_walk_blockers(&"new.shaft", blocked), "new elevator shaft covers remaining landing") + _expect(legacy.prune_invalid() and legacy.create_snapshot()[&"stairs"].is_empty(), "new shaft removes touching compatibility connector") + _expect(path.remove_vertical_walk_blockers(&"new.shaft") and legacy.prune_invalid(), "remove elevator shaft later") + _expect(not path.has_connector(&"legacy.stairs:0:1:0"), "removing a shaft does not restore compatibility stairs") + var before := path.create_snapshot() + _expect(legacy.prune_invalid() and path.create_snapshot() == before, "unchanged prune does not advance graph revision") + path.set_vertical_walk_requires_connector(false) + before = path.create_snapshot() + _expect(not legacy.migrate_existing_vertical_walks() and not legacy.prune_invalid(), "policy tampering cannot restart closed migration") + _expect(path.create_snapshot() == before, "rejected repeated migration does not mutate tampered Path") + +func _test_snapshot_ownership() -> void: + var path := PathType.new() + var cells: Array[Vector3i] = [Vector3i(-1, -1, 0), Vector3i(-1, 0, 0), Vector3i(2, 0, 1), Vector3i(2, 1, 1)] + path.rebuild_graph(cells) + var legacy := LegacyType.new() + legacy.configure(path) + _expect(legacy.migrate_existing_vertical_walks(), "migrate negative floor and BACK snapshot fixture") + var saved_path := path.create_snapshot() + var saved := legacy.create_snapshot() + var restored_path := PathType.new() + var restored := LegacyType.new() + _expect(restored.configure(restored_path), "bind destination before restoring Path") + _expect(restored_path.restore_snapshot(saved_path) and restored_path.rebuild_graph(cells), "restore and materialize saved Path") + _expect(restored.restore_snapshot(saved) and restored.create_snapshot() == saved, "strict current legacy snapshot roundtrip") + var malformed: Array[Dictionary] = [] + var row := saved.duplicate(true) + row[&"extra"] = true + malformed.append(row) + row = saved.duplicate(true) + row[&"schema_version"] = 2 + malformed.append(row) + row = saved.duplicate(true) + row[&"schema_version"] = "1" + malformed.append(row) + row = saved.duplicate(true) + row[&"stairs"].reverse() + malformed.append(row) + row = saved.duplicate(true) + row[&"stairs"].append(row[&"stairs"][0].duplicate(true)) + malformed.append(row) + row = saved.duplicate(true) + row[&"stairs"][0][&"id"] = "legacy.stairs:forged" + malformed.append(row) + row = saved.duplicate(true) + row[&"stairs"][0][&"to"] = Vector3i(-1, 1, 0) + malformed.append(row) + row = saved.duplicate(true) + row[&"stairs"][0][&"to"] = Vector3i(-1, 0, 1) + malformed.append(row) + row = saved.duplicate(true) + row[&"stairs"][0][&"cost"] = 100 + malformed.append(row) + row = saved.duplicate(true) + row[&"stairs"].clear() + malformed.append(row) + for bad: Dictionary in malformed: + _expect(not restored.restore_snapshot(bad), "reject malformed or incomplete compatibility ownership") + _expect(restored.create_snapshot() == saved and restored_path.create_snapshot() == saved_path, "legacy snapshot rejection preserves metadata and Path exactly") + restored_path.configure_connector(&"legacy.stairs:orphan", cells[0], cells[1]) + _expect(not restored.restore_snapshot(saved) and not restored.prune_invalid(), "untracked managed connector blocks restore and prune") + restored_path.remove_connector(&"legacy.stairs:orphan") + restored_path.configure_connector(&"legacy.stairs:0:-1:-1", cells[0], cells[1], 100, true, &"staff") + _expect(not restored.restore_snapshot(saved) and not restored.prune_invalid(), "owned connector permission tampering is rejected") + _expect(restored_path.restore_snapshot(saved_path), "repair exact connector configuration") + restored_path.set_vertical_walk_requires_connector(false) + _expect(not restored.restore_snapshot(saved), "current legacy snapshot cannot restore with free vertical walking") + var fresh_path := PathType.new() + fresh_path.rebuild_graph(cells) + fresh_path.set_vertical_walk_requires_connector(true) + var fresh := LegacyType.new() + fresh.configure(fresh_path) + _expect(fresh.migrate_existing_vertical_walks() and fresh.create_snapshot()[&"stairs"].is_empty(), "new strict game never gains migration stairs") + _expect(fresh.restore_snapshot({&"schema_version": 1, &"stairs": []}), "new game empty legacy snapshot is valid") + var out_of_bounds_path := PathType.new() + out_of_bounds_path.rebuild_graph([Vector3i(100_001, 0, 0), Vector3i(100_001, 1, 0)]) + var out_of_bounds := LegacyType.new() + out_of_bounds.configure(out_of_bounds_path) + var out_of_bounds_before := out_of_bounds_path.create_snapshot() + _expect(not out_of_bounds.migrate_existing_vertical_walks() and out_of_bounds_path.create_snapshot() == out_of_bounds_before, "unsupported migration coordinates cannot create unrestorable records") + +func _test_atomic_failure_and_reentry() -> void: + var path := ControlledPath.new() + var cells: Array[Vector3i] = [Vector3i(0, 0, 0), Vector3i(0, 1, 0), Vector3i(0, 2, 0)] + path.rebuild_graph(cells) + var legacy := LegacyType.new() + legacy.configure(path) + var before := path.create_snapshot() + var metadata := legacy.create_snapshot() + path.fail_restore_once = true + _expect(not legacy.migrate_existing_vertical_walks(), "injected partial Path apply failure rejects migration") + _expect(path.create_snapshot() == before and legacy.create_snapshot() == metadata, "failed migration restores Path and owned metadata exactly") + path.reenter = legacy + _expect(legacy.migrate_existing_vertical_walks() and path.nested_rejected, "outer migration succeeds while nested migration is rejected") + cells.erase(Vector3i(0, 1, 0)) + path.rebuild_graph(cells) + before = path.create_snapshot() + metadata = legacy.create_snapshot() + path.fail_restore_once = true + _expect(not legacy.prune_invalid(), "injected partial prune failure rejects ownership removal") + _expect(path.create_snapshot() == before and legacy.create_snapshot() == metadata, "failed prune restores both dangling records and original Path for caller rollback") + _expect(legacy.prune_invalid(), "prune can retry after exact rollback") + var broken_path := ControlledPath.new() + broken_path.rebuild_graph([Vector3i(0, 0, 0), Vector3i(0, 1, 0)]) + var broken := LegacyType.new() + broken.configure(broken_path) + broken_path.fail_restore_always = true + _expect(not broken.migrate_existing_vertical_walks(), "repeated rollback failure rejects migration") + _expect(broken.validate_invariants()[&"quarantined"] and not broken.migrate_existing_vertical_walks(), "uncertain rollback quarantines future mutation") + +func _connector(snapshot: Dictionary, connector_id: StringName) -> Dictionary: + for connector: Dictionary in snapshot[&"connectors"]: + if StringName(connector[&"id"]) == connector_id: + return connector + return {} + +func _expect(ok: bool, label: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(label) diff --git a/tests/world/test_legacy_stairs.gd.uid b/tests/world/test_legacy_stairs.gd.uid new file mode 100644 index 0000000..1af1391 --- /dev/null +++ b/tests/world/test_legacy_stairs.gd.uid @@ -0,0 +1 @@ +uid://c6pvi4t0vy41s diff --git a/tests/world/test_path_transit_density.gd b/tests/world/test_path_transit_density.gd new file mode 100644 index 0000000..c05db7d --- /dev/null +++ b/tests/world/test_path_transit_density.gd @@ -0,0 +1,120 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +const ORIGIN := Vector3i.ZERO +const DESTINATION := Vector3i(0, 1, 0) + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + _test_occupancy_lifecycle() + _test_projection_and_despawn() + _test_save_and_migration() + print("Path transit density: %d checks, %d failures" % [_checks, _failures]) + quit(_failures) + +func _path() -> PathService: + var path := PathService.new() + path.rebuild_graph([ORIGIN, DESTINATION, Vector3i(1, 0, 0)]) + path.configure_elevator(&"lift", ORIGIN, DESTINATION, {&"cabin_capacity": 4}) + path.set_cell_base_capacity(ORIGIN, 1) + return path + +func _queue(path: PathService, actor: StringName, id: StringName) -> void: + _expect(path.enqueue_elevator_request(&"lift", {&"request_id": id, &"unit_id": actor, &"floor_from": 0, &"floor_to": 1, &"enqueued_at": 10})[&"ok"], "elevator request registers: %s" % actor) + +func _populate(path: PathService) -> void: + for id: StringName in [&"guest:a", &"guest:b", &"staff:c"]: + path.project_actor_position(id, ORIGIN, 10) + +func _test_occupancy_lifecycle() -> void: + var path := _path() + _populate(path) + _expect(path.get_current_density(ORIGIN) == 3 and is_equal_approx(path.get_speed_multiplier(ORIGIN), 1.0 / 3.0), "all three floor actors initially contribute to congestion") + _queue(path, &"guest:a", &"ride.a") + _expect(path.get_current_density(ORIGIN) == 3 and path.get_transit_actor_ids().is_empty(), "queued passengers still occupy the floor") + var before := path.create_snapshot() + var graph_before := path.graph_revision + var boarded := path.dequeue_elevator_boarding(&"lift", 0, 11, 1) + _expect(boarded.size() == 1 and path.get_transit_actor_ids() == [&"guest:a"], "boarding derives transit occupancy from the existing Path owner") + _expect(path.get_current_density(ORIGIN) == 2 and is_equal_approx(path.get_speed_multiplier(ORIGIN), 0.5), "a boarded passenger no longer slows the remaining floor actors") + _expect(path.get_actor_projected_cell(&"guest:a") == ORIGIN and path.create_snapshot()[&"actor_progress"] == before[&"actor_progress"], "boarding retains logical origin and progress records") + _expect(path.graph_revision == graph_before and path.traffic_revision > before[&"traffic_revision"], "boarding changes traffic costs without changing structural topology") + var stable := path.create_snapshot() + _expect(path.dequeue_elevator_boarding(&"lift", 0, 11, 1).is_empty() and path.get_current_density(ORIGIN) == 2 \ + and path.get_transit_actor_ids() == [&"guest:a"] and path.traffic_revision == stable[&"traffic_revision"], "repeated empty boarding does not change occupancy or traffic revision") + _expect(path.complete_elevator_request(&"lift", &"ride.a"), "completion releases the existing boarded owner") + _expect(path.get_current_density(ORIGIN) == 3 and not path.is_actor_in_transit(&"guest:a"), "completed trip restores the anchor's floor contribution before Runtime relocates it") + _expect(path.project_actor_position(&"guest:a", DESTINATION, 12), "Runtime arrival projects the actor at the real destination") + _expect(path.get_current_density(ORIGIN) == 2 and path.get_current_density(DESTINATION) == 1, "arrival moves exactly one floor occupant from origin to destination") + stable = path.create_snapshot() + _expect(not path.complete_elevator_request(&"lift", &"ride.a") and path.create_snapshot() == stable, "duplicate completion cannot release density twice") + _queue(path, &"guest:b", &"ride.b") + _expect(path.cancel_elevator_request(&"lift", &"ride.b") and path.get_current_density(ORIGIN) == 2, "cancelling a queued ride keeps its waiting actor on the floor") + +func _test_projection_and_despawn() -> void: + var path := _path() + _populate(path) + _queue(path, &"staff:c", &"ride.c") + path.dequeue_elevator_boarding(&"lift", 0, 11, 1) + _expect(path.project_occupancy([{&"actor_id": &"guest:a", &"cell": ORIGIN}, {&"actor_id": &"staff:c", &"cell": ORIGIN}]), "batch projection retains existing boarded ownership") + _expect(path.get_current_density(ORIGIN) == 1 and path.get_transit_actor_ids() == [&"staff:c"], "batch occupancy excludes the passenger exactly once") + _expect(path.rebuild_graph([ORIGIN, DESTINATION, Vector3i(1, 0, 0)]) and path.get_current_density(ORIGIN) == 1, "topology refresh does not reintroduce cabin passengers into density") + var revision := path.traffic_revision + _expect(path.project_actor_position(&"staff:c", DESTINATION, 12), "transit projection can update its logical anchor without materializing an occupant") + _expect(path.get_current_density(ORIGIN) == 1 and path.get_current_density(DESTINATION) == 0 and path.traffic_revision == revision, "moving an excluded anchor changes no floor density or route costs") + _expect(path.despawn_actor(&"staff:c") and path.get_current_density(DESTINATION) == 0 and path.get_transit_actor_ids().is_empty(), "despawning a passenger does not decrement a zero-density floor") + _expect(path.complete_elevator_request(&"lift", &"ride.c") and path.get_current_density(DESTINATION) == 0, "later owner cleanup cannot resurrect a despawned passenger") + _queue(path, &"unprojected_unit", &"ride.unit") + path.dequeue_elevator_boarding(&"lift", 0, 13, 1) + _expect(path.get_transit_actor_ids().is_empty() and path.get_current_density(ORIGIN) == 1, "standalone elevator units without actor projections do not affect floor density") + _expect(path.project_actor_position(&"unprojected_unit", ORIGIN, 14) and path.is_actor_in_transit(&"unprojected_unit") and path.get_current_density(ORIGIN) == 1, "a projection added after boarding inherits its existing transit ownership") + _expect(path.actor_leave_cell(&"unprojected_unit", false) and not path.is_actor_in_transit(&"unprojected_unit"), "leaving the actor graph clears transit membership even when retaining progress") + +func _test_save_and_migration() -> void: + var path := _path() + _populate(path) + _queue(path, &"guest:a", &"ride.a") + _queue(path, &"staff:c", &"ride.c") + path.dequeue_elevator_boarding(&"lift", 0, 11, 2) + var saved := path.create_snapshot() + _expect(saved[&"transit_actor_ids"] == [&"guest:a", &"staff:c"] and path.get_current_density(ORIGIN) == 1, "v5 saves canonical excluded actors and physical floor density") + var restored := _path() + _expect(restored.restore_snapshot(saved) and restored.create_snapshot() == saved, "boarded Path v5 snapshot restores exactly") + for raw: Variant in [null, [], ["missing"], ["guest:a", "guest:a"], ["staff:c", "guest:a"], [17, "staff:c"]]: + var bad := saved.duplicate(true) + bad[&"transit_actor_ids"] = raw + _expect(not restored.restore_snapshot(bad) and restored.create_snapshot() == saved, "invalid transit ownership rejects atomically: %s" % str(raw)) + var wrong_density := saved.duplicate(true) + for row: Dictionary in wrong_density[&"cell_traffic"]: + if row[&"cell"] == ORIGIN: row[&"current_density"] += 1 + _expect(not restored.restore_snapshot(wrong_density) and restored.create_snapshot() == saved, "v5 cannot count an excluded passenger back into origin density") + var legacy := saved.duplicate(true) + legacy[&"schema_version"] = 4 + legacy.erase(&"transit_actor_ids") + legacy.erase(&"vertical_walk_requires_connector") + _expect(not restored.restore_snapshot(legacy) and restored.create_snapshot() == saved, "legacy snapshots must first satisfy their original full-occupancy density rule") + for row: Dictionary in legacy[&"cell_traffic"]: + if row[&"cell"] == ORIGIN: row[&"current_density"] = 3 + _expect(restored.restore_snapshot(legacy), "genuine v4 boarded save migrates") + var migrated := restored.create_snapshot() + _expect(restored.get_current_density(ORIGIN) == 1 and migrated[&"transit_actor_ids"] == saved[&"transit_actor_ids"], "legacy migration removes only projected boarded actors from density") + _expect(migrated[&"actor_cells"] == legacy[&"actor_cells"] and migrated[&"actor_progress"] == legacy[&"actor_progress"], "migration preserves every logical actor location and progress timestamp") + _expect(migrated[&"traffic_revision"] == legacy[&"traffic_revision"] + 1 and migrated[&"graph_revision"] == legacy[&"graph_revision"], "migration advances traffic revision once without rewriting topology") + _expect(restored.restore_snapshot(migrated) and restored.create_snapshot() == migrated, "canonical reload does not repeat legacy density subtraction") + var replay := _path() + _expect(replay.restore_snapshot(migrated), "a second runtime starts from the same boarded snapshot") + for service: PathService in [restored, replay]: + service.complete_elevator_request(&"lift", &"ride.a") + service.project_actor_position(&"guest:a", DESTINATION, 12) + service.complete_elevator_request(&"lift", &"ride.c") + service.project_actor_position(&"staff:c", DESTINATION, 12) + _expect(restored.create_snapshot() == replay.create_snapshot() and restored.get_current_density(DESTINATION) == 2, "arrival replay preserves exact ownership, density and timestamps after migration") + +func _expect(ok: bool, label: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(label) diff --git a/tests/world/test_path_transit_density.gd.uid b/tests/world/test_path_transit_density.gd.uid new file mode 100644 index 0000000..8def9ed --- /dev/null +++ b/tests/world/test_path_transit_density.gd.uid @@ -0,0 +1 @@ +uid://bwopfir6rudpp diff --git a/tests/world/test_path_vertical_policy.gd b/tests/world/test_path_vertical_policy.gd new file mode 100644 index 0000000..c57eed6 --- /dev/null +++ b/tests/world/test_path_vertical_policy.gd @@ -0,0 +1,105 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + _test_policy_and_connectors() + _test_recovery_and_diagnostics() + _test_snapshots() + print("Path vertical policy: %d checks, %d failures" % [_checks, _failures]) + quit(_failures) + +func _path() -> PathService: + var path := PathService.new() + path.rebuild_graph([Vector3i.ZERO, Vector3i(1, 0, 0), Vector3i(0, 1, 0), Vector3i(1, 1, 0), Vector3i(0, 0, 1), Vector3i(0, 1, 1)]) + return path + +func _test_policy_and_connectors() -> void: + var path := _path() + var bottom := Vector3i.ZERO + var top := Vector3i(0, 1, 0) + _expect(not path.get_vertical_walk_requires_connector(), "standalone Path retains legacy cardinal movement by default") + var old_route := path.find_path(bottom, top) + _expect(old_route == [bottom, top] and path.cached_path_count() == 1, "legacy upper-floor route can be cached") + var graph_before := path.graph_revision + _expect(path.set_vertical_walk_requires_connector(true), "strict vertical policy enables") + _expect(path.graph_revision == graph_before + 1 and path.cached_path_count() == 0, "policy change invalidates previously cached floor climbing") + _expect(path.find_path(bottom, top).is_empty() and path.last_no_route_reason == PathService.ROUTE_GOAL_UNREACHABLE, "adjacent floors require an explicit vertical connection") + _expect(path.calculate_path_cost(old_route) == -1 and path.get_edge_report(bottom, top).is_empty(), "cost and movement edge resolution cannot bypass strict routing") + _expect(path.find_path(bottom, Vector3i(1, 0, 0)).size() == 2, "horizontal walking remains available") + var raw_vertical := false + for edge: Dictionary in path.get_neighbors(bottom): + if edge[&"to"] == top and edge[&"movement_type"] == PathService.MOVEMENT_WALK: raw_vertical = true + _expect(raw_vertical, "raw structural neighbors remain available to legacy-stair migration") + var snapshot := path.create_snapshot() + _expect(path.set_vertical_walk_requires_connector(true) and path.create_snapshot() == snapshot, "reapplying the same policy is an exact no-op") + _expect(path.configure_connector(&"stairs", bottom, top, 150), "explicit public stairs register") + _expect(path.find_path(bottom, top) == [bottom, top] and path.get_step_connector(bottom, top) == &"stairs", "explicit stairs work through strict policy") + _expect(path.calculate_path_cost([bottom, top]) == 150, "stairs keep their defined movement cost") + path.set_connector_enabled(&"stairs", false) + _expect(path.find_path(bottom, top).is_empty() and path.last_no_route_reason == PathService.ROUTE_CONNECTOR_UNAVAILABLE, "closed stairs cannot fall back to implicit floor climbing") + path.configure_connector(&"service_stairs", Vector3i(0, 0, 1), Vector3i(0, 1, 1), 150, true, &"staff") + _expect(path.find_path(Vector3i(0, 0, 1), Vector3i(0, 1, 1), [1], []).is_empty(), "service stairs retain staff-only access") + _expect(path.find_path(Vector3i(0, 0, 1), Vector3i(0, 1, 1), [1], [&"staff"]).size() == 2, "authorized staff use explicit BACK stairs") + path.configure_elevator(&"lift", bottom, top) + _expect(path.get_step_connector(bottom, top) == &"lift", "an operational elevator remains an explicit vertical route") + path.set_connector_enabled(&"lift", false) + _expect(path.find_path(bottom, top).is_empty(), "a stopped lift never exposes an ordinary vertical edge") + path.set_vertical_walk_blockers(&"shaft", [bottom, top]) + path.set_vertical_walk_requires_connector(false) + _expect(path.get_edge_report(bottom, top).is_empty(), "physical shaft blockers survive opting out of the global floor policy") + +func _test_recovery_and_diagnostics() -> void: + var path := PathService.new() + var bottom := Vector3i.ZERO + var top := Vector3i(0, 1, 0) + path.rebuild_graph([bottom, top]) + path.set_vertical_walk_requires_connector(true) + path.set_cell_temporary_block(bottom, true) + _expect(not path.find_nearest_walkable_recovery(bottom).get(&"found", false), "recovery cannot choose an unreachable floor solely because it is nearby") + path.configure_connector(&"stairs", bottom, top, 150) + _expect(path.find_nearest_walkable_recovery(bottom).get(&"cell") == top, "a real public stair makes the upper recovery proposal structurally reachable") + path.set_connector_enabled(&"stairs", false) + _expect(not path.find_nearest_walkable_recovery(bottom).get(&"found", false), "recovery respects an unavailable vertical connector") + path.set_cell_temporary_block(bottom, false) + path.remove_connector(&"stairs") + path.rebuild_graph([bottom, top, Vector3i(1, 0, 0), Vector3i(1, 1, 0)]) + path.set_cell_temporary_block(Vector3i(1, 0, 0), true) + _expect(path.find_path(bottom, top).is_empty() and path.last_no_route_reason == PathService.ROUTE_GOAL_UNREACHABLE, "diagnostics do not invent a blocked walking route between floors") + path.set_vertical_walk_requires_connector(false) + _expect(path.find_path(bottom, top) == [bottom, top], "explicit legacy opt-out restores standalone vertical walking") + +func _test_snapshots() -> void: + var path := _path() + path.set_vertical_walk_requires_connector(true) + path.set_vertical_walk_blockers(&"shaft", [Vector3i.ZERO, Vector3i(0, 1, 0)]) + var saved := path.create_snapshot() + _expect(saved[&"schema_version"] == 5 and saved[&"vertical_walk_requires_connector"] == true and saved[&"transit_actor_ids"] == [], "Path v5 saves explicit policy and transit occupancy") + var restored := _path() + _expect(restored.restore_snapshot(saved) and restored.create_snapshot() == saved, "strict policy and shaft ownership restore exactly") + for value: Variant in [0, 1, "true", null]: + var bad := saved.duplicate(true) + bad[&"vertical_walk_requires_connector"] = value + _expect(not restored.restore_snapshot(bad) and restored.create_snapshot() == saved, "non-bool vertical policy rejects atomically: %s" % str(value)) + var missing := saved.duplicate(true) + missing.erase(&"vertical_walk_requires_connector") + _expect(not restored.restore_snapshot(missing), "v5 cannot silently default a missing policy") + var legacy := saved.duplicate(true) + legacy[&"schema_version"] = 4 + _expect(not restored.restore_snapshot(legacy), "outer-only legacy downgrade cannot hide new policy fields") + legacy.erase(&"vertical_walk_requires_connector") + legacy.erase(&"transit_actor_ids") + _expect(restored.restore_snapshot(legacy) and not restored.get_vertical_walk_requires_connector(), "v4 defaults to its original vertical rules for Runtime-managed migration") + _expect(restored.get_vertical_walk_blockers() == path.get_vertical_walk_blockers(), "v4 keeps existing shaft ownership independently of v5 policy") + var migrated := restored.create_snapshot() + _expect(restored.restore_snapshot(migrated) and restored.create_snapshot() == migrated, "legacy migration is canonical and does not repeat on second restore") + +func _expect(ok: bool, label: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(label) diff --git a/tests/world/test_path_vertical_policy.gd.uid b/tests/world/test_path_vertical_policy.gd.uid new file mode 100644 index 0000000..9544764 --- /dev/null +++ b/tests/world/test_path_vertical_policy.gd.uid @@ -0,0 +1 @@ +uid://bttq5agd5o82p diff --git a/tests/world/test_stair_infrastructure.gd b/tests/world/test_stair_infrastructure.gd new file mode 100644 index 0000000..ae4649c --- /dev/null +++ b/tests/world/test_stair_infrastructure.gd @@ -0,0 +1,203 @@ +extends SceneTree + +const PathType = preload("res://scripts/world/path_service.gd") +const ProjectorType = preload("res://scripts/world/facility_infrastructure_projector.gd") +const AdapterType = preload("res://scripts/world/route_subslot_path_adapter.gd") +const SpatialType = preload("res://scripts/world/spatial_subslot_service.gd") +const CatalogType = preload("res://scripts/data/facility_catalog.gd") +const RegistryType = preload("res://scripts/core/definition_registry.gd") +const GUEST = preload("res://data/facilities/infrastructure/guest_stairs.tres") +const SERVICE = preload("res://data/facilities/infrastructure/service_stairs.tres") +const CORRIDOR = preload("res://data/facilities/guest_corridor.tres") + +class ControlledPath: + extends PathService + var fail_configure := false + var fail_remove := false + func configure_connector(connector_id: StringName, from: Vector3i, to: Vector3i, cost: int = 150, bidirectional: bool = true, access_id: StringName = &"", enabled: bool = true, options: Dictionary = {}) -> bool: + var result := super.configure_connector(connector_id, from, to, cost, bidirectional, access_id, enabled, options) + if fail_configure: + fail_configure = false + return false + return result + func remove_connector(connector_id: StringName) -> bool: + var result := super.remove_connector(connector_id) + if fail_remove: + fail_remove = false + return false + return result + +var _checks := 0 +var _failures := 0 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + _test_catalog_compatibility() + _test_guest_stairs_and_stacking() + _test_service_permissions() + _test_lower_landing_and_atomic_demolition() + _test_snapshot_and_schema_validation() + _test_injected_connector_failures() + print("Stair infrastructure: %d checks, %d failures" % [_checks, _failures]) + quit(_failures) + +func _test_catalog_compatibility() -> void: + _expect(CatalogType.load_all().size() == 16 and CatalogType.FACILITY_PATHS.size() == 16, "baseline sixteen remains unchanged") + _expect(CatalogType.load_expansion().size() == 16 and CatalogType.EXPANSION_FACILITY_PATHS.size() == 16, "optional expansion sixteen remains unchanged") + _expect(CatalogType.load_available().size() == 16 and CatalogType.load_available(true).size() == 32, "existing catalog loading contract remains unchanged") + var infrastructure := CatalogType.load_infrastructure() + _expect(infrastructure.size() == 2 and CatalogType.INFRASTRUCTURE_FACILITY_IDS == [&"guest_stairs", &"service_stairs"], "required stair infrastructure is explicitly listed") + for definition: FacilityDefinition in infrastructure: + _expect(definition.is_valid(), "stair definition validates") + _expect(CatalogType.by_id(definition.id) == definition, "global stable ID lookup resolves stairs") + _expect(definition.footprint == Vector2i.ONE and definition.allowed_rotations == [0], "stairs use an unrotated upper landing") + _expect(definition.build_minutes == 10 and definition.maintenance_mana == 0 and definition.weight_per_cell == 1, "stairs preserve low-cost unpowered construction") + _expect(int(definition.construction_cost[&"materials"]) == 0 and definition.pass_through, "stairs require no materials and provide floor access") + _expect(GUEST.construction_cost[&"gold"] == 40 and SERVICE.construction_cost[&"gold"] == 50, "guest and service stair prices match approved plan") + var registry := RegistryType.new() + registry.rebuild() + _expect(registry.is_valid() and registry.facility_count() == 16, "default nonrecursive registry remains baseline compatible") + var required_paths: Array[String] = CatalogType.FACILITY_PATHS.duplicate() + required_paths.append_array(CatalogType.INFRASTRUCTURE_FACILITY_PATHS) + registry.rebuild_from_paths(required_paths) + _expect(registry.is_valid() and registry.facility_count() == 18, "Runtime can explicitly load all eighteen required facilities") + +func _test_guest_stairs_and_stacking() -> void: + var bottom := Vector3i(0, 0, 0) + var middle := Vector3i(0, 1, 0) + var upper := Vector3i(0, 2, 0) + var context := _context([bottom, Vector3i(-1, 1, 0), Vector3i(1, 2, 0)]) + _expect(context.projector.project_facility(&"build.first", GUEST, _instance(GUEST, &"first", middle)).is_success(), "guest stairs project onto existing lower landing") + _expect(context.source.get_entity_cells(&"infra.route:first") == [middle], "only the upper landing consumes ROUTE subslot") + var connector: Dictionary = context.path.get_connector(&"infra.connector:first") + _expect(connector[&"from"] == middle and connector[&"to"] == bottom and connector[&"cost"] == 150, "stair connector joins one floor at approved route cost") + _expect(connector[&"movement_type"] == PathType.MOVEMENT_CONNECTOR and connector[&"bidirectional"] and connector[&"access_id"] == &"", "guest stairs work in both directions without elevator dispatch") + _expect(context.path.find_path(bottom, Vector3i(-1, 1, 0), [0], []) == [bottom, middle, Vector3i(-1, 1, 0)], "strict floor policy still permits explicit guest stairs and floor exit") + _expect(context.path.find_path(middle, bottom, [0], []) == [middle, bottom], "descending stairs is valid") + _expect(context.projector.project_facility(&"build.second", GUEST, _instance(GUEST, &"second", upper)).is_success(), "stacked upper landings connect consecutive floors") + _expect(context.path.find_path(bottom, Vector3i(1, 2, 0)) == [bottom, middle, upper, Vector3i(1, 2, 0)], "two modules reach second floor without overlapping footprints") + _expect(context.path.get_vertical_walk_blockers().is_empty(), "stairs do not create elevator shaft blockers") + var queried_cells: Array[Vector3i] = [middle, bottom, bottom] + var dependents: Array[StringName] = context.projector.get_stair_dependents(queried_cells) + _expect(dependents == [&"first", &"second"], "lower-landing dependency query is unique and deterministic") + dependents.clear() + queried_cells.assign([bottom]) + _expect(context.projector.get_stair_dependents(queried_cells) == [&"first"], "dependency report cannot mutate owned records") + _expect(context.projector.validate_invariants()[&"ok"], "stacked stair topology validates") + var before := _snapshots(context) + _expect(not context.projector.unproject_facility(&"remove.first", &"first").is_success(), "removing lower stair cannot strand upper stair") + _expect(_snapshots(context) == before, "dependent stair rejection restores all authoritative state") + _expect(context.projector.unproject_facility(&"remove.second", &"second").is_success(), "remove top stair first") + _expect(context.projector.unproject_facility(&"remove.first", &"first").is_success(), "lower stair demolition remains retryable after dependent removed") + _expect(context.path.get_connector(&"infra.connector:first").is_empty() and not context.path.has_cell(middle), "demolition removes connector and landing together") + +func _test_service_permissions() -> void: + var bottom := Vector3i(0, 0, 1) + var upper := Vector3i(0, 1, 1) + var context := _context([bottom, Vector3i(0, 0, 0), Vector3i(0, 1, 0), Vector3i(1, 1, 1)]) + _expect(context.projector.project_facility(&"build.service", SERVICE, _instance(SERVICE, &"service", upper)).is_success(), "service stairs project on BACK landings") + _expect(context.path.find_path(bottom, upper, [0, 1], []).is_empty(), "guest without staff permission cannot climb service stairs") + _expect(context.path.find_path(bottom, upper, [0, 1], [&"staff"]) == [bottom, upper], "staff can climb service stairs") + _expect(context.path.find_path(upper, bottom, [1], [&"staff"]) == [upper, bottom], "staff can descend service stairs") + _expect(context.path.find_path(Vector3i(0, 0, 0), upper, [0, 1], [&"staff"]).is_empty(), "service stairs do not invent a FRONT to BACK transfer") + _expect(context.path.find_path(upper, Vector3i(1, 1, 1), [1], []) == [upper, Vector3i(1, 1, 1)], "stair landing preserves existing same-floor geometry") + _expect(context.path.get_connector(&"infra.connector:service")[&"access_id"] == &"staff", "service access is stored in authoritative connector") + +func _test_lower_landing_and_atomic_demolition() -> void: + var bottom := Vector3i(0, 0, 0) + var upper := Vector3i(0, 1, 0) + var context := _context([]) + var before := _snapshots(context) + var stairs := _instance(GUEST, &"stairs", upper) + _expect(not context.projector.project_facility(&"build.stairs", GUEST, stairs).is_success(), "missing lower landing rejects construction projection") + _expect(_snapshots(context) == before, "missing landing failure does not consume request or create route") + _expect(context.projector.project_facility(&"build.lower", CORRIDOR, _instance(CORRIDOR, &"lower", bottom)).is_success(), "create lower corridor") + _expect(context.projector.project_facility(&"build.stairs", GUEST, stairs).is_success(), "same failed request can succeed after lower landing exists") + before = _snapshots(context) + _expect(not context.projector.unproject_facility(&"remove.lower", &"lower").is_success(), "direct projector demolition protects dependent stair landing") + _expect(_snapshots(context) == before, "lower corridor rejection has exact rollback") + _expect(context.adapter.remove_route(&"external.remove", &"infra.route:lower").is_success(), "external source mutation can remove landing outside projector boundary") + _expect(context.projector.is_projection_stale(), "externally missing lower landing invalidates stair crosslinks") + _expect(not context.projector.restore_snapshot(before.projector), "metadata restore cannot mask a missing lower landing") + _expect(context.source.restore_snapshot(before.source), "repair lower landing source") + _expect(context.path.rebuild_graph([bottom, upper]), "materialize repaired landing graph before snapshot restore") + _expect(context.path.restore_snapshot(before.path), "repair connector and floor policy") + _expect(context.adapter.restore_snapshot(before.adapter), "repair exact route projection") + _expect(context.projector.validate_invariants()[&"ok"], "landing repair restores valid crosslinks") + _expect(context.projector.unproject_facility(&"remove.stairs", &"stairs").is_success(), "remove dependent staircase") + _expect(context.projector.unproject_facility(&"remove.lower", &"lower").is_success(), "previously rejected lower demolition succeeds once safe") + var overlap := _context([bottom]) + _expect(overlap.projector.project_facility(&"overlap.lower", CORRIDOR, _instance(CORRIDOR, &"lower", bottom)).is_success(), "project lower corridor over existing base landing") + _expect(overlap.projector.project_facility(&"overlap.stairs", GUEST, stairs).is_success(), "project stairs over overlapping lower source") + _expect(overlap.projector.unproject_facility(&"overlap.remove", &"lower").is_success(), "removing route is safe when base keeps lower landing") + _expect(overlap.projector.validate_invariants()[&"ok"] and overlap.path.find_path(bottom, upper) == [bottom, upper], "remaining base landing preserves usable stairs") + +func _test_snapshot_and_schema_validation() -> void: + var bottom := Vector3i(0, 0, 0) + var context := _context([bottom]) + var stairs := _instance(GUEST, &"stairs", Vector3i(0, 1, 0)) + _expect(context.projector.project_facility(&"build", GUEST, stairs).is_success(), "create snapshot fixture") + var saved := _snapshots(context) + _expect(context.projector.restore_snapshot(saved.projector) and _snapshots(context) == saved, "snapshot roundtrip preserves exact connector and history") + for key: StringName in [&"connector_id", &"kind", &"definition_tags", &"rotation", &"footprint"]: + var malformed: Dictionary = saved.projector.duplicate(true) + match key: + &"connector_id": malformed[&"projections"][0][key] = "" + &"kind": malformed[&"projections"][0][key] = "guest_corridor" + &"definition_tags": malformed[&"projections"][0][key] = ["movement"] + &"rotation": malformed[&"projections"][0][key] = 90 + &"footprint": malformed[&"projections"][0][key] = Vector2i(1, 2) + _expect(not context.projector.restore_snapshot(malformed), "reject malformed stair record %s" % key) + _expect(_snapshots(context) == saved, "malformed stair restore is atomic") + var changed_definition := GUEST.duplicate(true) as FacilityDefinition + changed_definition.allowed_rotations = [0, 90] + _expect(not context.projector.project_facility(&"bad.rotation", changed_definition, _instance(changed_definition, &"other", Vector3i(0, 1, 0))).is_success(), "definition cannot widen fixed stair orientation") + var rotated := _instance(GUEST, &"rotated", Vector3i(0, 1, 0)) + rotated[&"rotation"] = 90 + _expect(not context.projector.project_facility(&"bad.instance.rotation", GUEST, rotated).is_success(), "completed stair cannot use rotation with unchanged footprint") + _expect(context.path.configure_connector(&"infra.connector:orphan", bottom, Vector3i(0, 1, 0)), "create untracked managed connector fixture") + _expect(context.projector.is_projection_stale() and not context.projector.restore_snapshot(saved.projector), "managed orphan connector cannot hide behind valid stair metadata") + _expect(context.path.remove_connector(&"infra.connector:orphan"), "remove orphan") + _expect(context.path.configure_connector(&"legacy.stairs:external", bottom, Vector3i(0, 1, 0), 100, true, &"permit"), "create separately owned legacy connector") + _expect(context.projector.validate_invariants()[&"ok"], "external connector ownership and permissions remain independent") + _expect(context.projector.unproject_facility(&"remove", &"stairs").is_success(), "remove owned stairs without removing external connector") + _expect(context.path.has_connector(&"legacy.stairs:external") and context.path.get_connector(&"legacy.stairs:external")[&"access_id"] == &"permit", "demolition preserves external connector details") + +func _test_injected_connector_failures() -> void: + var controlled := ControlledPath.new() + var context := _context([Vector3i(0, 0, 0)], controlled) + var stairs := _instance(GUEST, &"stairs", Vector3i(0, 1, 0)) + var before := _snapshots(context) + controlled.fail_configure = true + _expect(not context.projector.project_facility(&"build", GUEST, stairs).is_success(), "failed connector write rejects whole projection") + _expect(_snapshots(context) == before, "connector write failure rolls back source, route, path and metadata") + _expect(context.projector.project_facility(&"build", GUEST, stairs).is_success(), "failed projection request remains retryable") + before = _snapshots(context) + controlled.fail_remove = true + _expect(not context.projector.unproject_facility(&"remove", &"stairs").is_success(), "failed connector removal rejects whole demolition") + _expect(_snapshots(context) == before, "connector removal failure restores complete staircase") + _expect(context.projector.unproject_facility(&"remove", &"stairs").is_success(), "failed demolition request remains retryable") + +func _context(base_cells: Array[Vector3i], path_value: PathService = null) -> Dictionary: + var source := SpatialType.new() + var path := path_value if path_value != null else PathType.new() + _expect(path.set_vertical_walk_requires_connector(true), "enable explicit vertical connectors") + var adapter := AdapterType.new() + _expect(adapter.configure(source, path, base_cells), "configure route adapter") + var projector := ProjectorType.new() + _expect(projector.configure(adapter, source, path), "configure infrastructure projector") + return {&"source": source, &"path": path, &"adapter": adapter, &"projector": projector} + +func _instance(definition: FacilityDefinition, instance_id: StringName, origin: Vector3i) -> Dictionary: + return {&"instance_id": instance_id, &"definition_id": definition.id, &"origin": origin, &"rotation": 0, &"layer": definition.layer, &"occupied_cells": [origin], &"construction_cost": definition.construction_cost.duplicate(true), &"source_job_id": StringName("build." + String(instance_id)), &"state": &"ACTIVE", &"completed_at": 300} + +func _snapshots(context: Dictionary) -> Dictionary: + return {&"source": context.source.create_snapshot(), &"path": context.path.create_snapshot(), &"adapter": context.adapter.create_snapshot(), &"projector": context.projector.create_snapshot()} + +func _expect(ok: bool, label: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(label) diff --git a/tests/world/test_stair_infrastructure.gd.uid b/tests/world/test_stair_infrastructure.gd.uid new file mode 100644 index 0000000..058d319 --- /dev/null +++ b/tests/world/test_stair_infrastructure.gd.uid @@ -0,0 +1 @@ +uid://cw0y2pyvn04ob