From 0f12c748e99fa86ee96b0151fa8dafedf6ace90d Mon Sep 17 00:00:00 2001 From: Takeshi Date: Wed, 9 Sep 2026 12:58:40 +0900 Subject: [PATCH] Connect physical elevators and staff commutes to construction, saves, and cutaway UI --- Makefile | 17 + autoload/simulation_runtime.gd | 320 ++++++++- docs/DESIGN_DECISIONS.md | 13 + docs/DEVELOPMENT_BACKLOG.md | 16 +- docs/DEVELOPMENT_PLAN.md | 4 +- docs/IMPLEMENTATION_STATUS.md | 8 + docs/PLAYTEST_GUIDE.md | 6 + docs/qa/P2_TRANSPORT.md | 47 ++ scenes/main/main.gd | 140 ++++ scenes/main/world_preview.gd | 106 ++- scripts/construction/construction_service.gd | 6 + scripts/staff/staff_commute_service.gd | 168 +++++ scripts/staff/staff_commute_service.gd.uid | 1 + scripts/staff/staff_service.gd | 47 +- scripts/supply/supply_logistics_bridge.gd | 9 +- scripts/transport/elevator_service.gd | 678 ++++++++++++++++++ scripts/transport/elevator_service.gd.uid | 1 + scripts/ui/pixel_tower_art.gd | 44 +- .../facility_infrastructure_projector.gd | 38 +- scripts/world/path_service.gd | 165 ++++- tests/mvp/test_runtime_content_progression.gd | 9 +- .../mvp/test_runtime_operations_acceptance.gd | 53 +- .../test_runtime_operations_integration.gd | 5 +- .../test_facility_infrastructure_projector.gd | 18 +- tests/s1/test_path_congestion.gd | 7 +- ...test_runtime_infrastructure_integration.gd | 74 +- tests/s3/test_runtime_path_traffic.gd | 15 +- tests/s3/test_runtime_staff_assignment.gd | 19 +- tests/s5/test_runtime_defense_cycle.gd | 3 + tests/staff/test_staff_commute.gd | 317 ++++++++ tests/staff/test_staff_commute.gd.uid | 1 + tests/supply/test_supply_logistics_bridge.gd | 19 +- tests/supply/test_supply_save_migration.gd | 20 +- tests/transport/test_elevator_service.gd | 258 +++++++ tests/transport/test_elevator_service.gd.uid | 1 + tests/transport/test_runtime_transport.gd | 147 ++++ tests/transport/test_runtime_transport.gd.uid | 1 + tests/transport/test_transport_boundaries.gd | 175 +++++ .../test_transport_boundaries.gd.uid | 1 + tests/transport/test_transport_save.gd | 143 ++++ tests/transport/test_transport_save.gd.uid | 1 + tests/ui/test_elevator_management.gd | 101 +++ tests/ui/test_elevator_management.gd.uid | 1 + tests/ui/test_staff_management.gd | 4 + tests/world/test_elevator_topology.gd | 204 ++++++ tests/world/test_elevator_topology.gd.uid | 1 + 46 files changed, 3267 insertions(+), 165 deletions(-) create mode 100644 docs/qa/P2_TRANSPORT.md create mode 100644 scripts/staff/staff_commute_service.gd create mode 100644 scripts/staff/staff_commute_service.gd.uid create mode 100644 scripts/transport/elevator_service.gd create mode 100644 scripts/transport/elevator_service.gd.uid create mode 100644 tests/staff/test_staff_commute.gd create mode 100644 tests/staff/test_staff_commute.gd.uid create mode 100644 tests/transport/test_elevator_service.gd create mode 100644 tests/transport/test_elevator_service.gd.uid create mode 100644 tests/transport/test_runtime_transport.gd create mode 100644 tests/transport/test_runtime_transport.gd.uid create mode 100644 tests/transport/test_transport_boundaries.gd create mode 100644 tests/transport/test_transport_boundaries.gd.uid create mode 100644 tests/transport/test_transport_save.gd create mode 100644 tests/transport/test_transport_save.gd.uid create mode 100644 tests/ui/test_elevator_management.gd create mode 100644 tests/ui/test_elevator_management.gd.uid create mode 100644 tests/world/test_elevator_topology.gd create mode 100644 tests/world/test_elevator_topology.gd.uid diff --git a/Makefile b/Makefile index 62fae34..3eec3b2 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,7 @@ test: $(MAKE) test-mvp $(MAKE) test-acceptance $(MAKE) test-ui + $(MAKE) test-transport $(MAKE) test-perf test-s0: @@ -93,6 +94,7 @@ test-ui: $(GODOT) --headless --path . --script res://tests/ui/test_staff_management.gd $(GODOT) --headless --path . --script res://tests/ui/test_supply_management.gd $(GODOT) --headless --path . --script res://tests/ui/test_business_management.gd + $(GODOT) --headless --path . --script res://tests/ui/test_elevator_management.gd $(MAKE) test-opening test-perf: @@ -155,3 +157,18 @@ preview-business: capture-business: THE_TREE_BUSINESS_UI_CAPTURE=1 $(GODOT) --path . --script res://tests/ui/test_business_management.gd + +.PHONY: test-transport capture-transport preview-transport +test-transport: + $(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_boundaries.gd + $(GODOT) --headless --path . --script res://tests/transport/test_runtime_transport.gd + +capture-transport: + THE_TREE_TRANSPORT_CAPTURE=1 $(GODOT) --path . --script res://tests/transport/test_runtime_transport.gd + +preview-transport: + THE_TREE_TRANSPORT_PREVIEW=1 $(GODOT) --path . --script res://tests/transport/test_runtime_transport.gd diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index 3deab3c..b61b523 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 := 7 +const RUNTIME_SNAPSHOT_VERSION := 8 +const SUPPLY_RUNTIME_SNAPSHOT_VERSION := 7 const INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION := 6 const LEGACY_RUNTIME_SNAPSHOT_VERSION := 1 const DEFENSE_RUNTIME_SNAPSHOT_VERSION := 2 @@ -90,6 +91,8 @@ var logistics_requests: Dictionary = {} var kitchen_production := KitchenProductionService.new() var supply_management := SupplyManagementService.new() var supply_logistics := SupplyLogisticsBridge.new() +var elevators := ElevatorService.new() +var staff_commute := StaffCommuteService.new() var facility_service := FacilityService.new() var guest_service := GuestService.new() var staff_service := StaffService.new() @@ -233,12 +236,18 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S _initialize_rng_streams() supply_logistics = SupplyLogisticsBridge.new() supply_logistics.configure(self) + elevators = ElevatorService.new() + elevators.configure(path_service) + elevators.synchronize([], _clock.absolute_minute) + staff_commute = StaffCommuteService.new() + staff_commute.configure(self) _executors.clear() register_command_executor(&"change_resources", Callable(self, "_execute_change_resources")) register_command_executor(&"spend_resources", Callable(self, "_execute_spend_resources")) register_command_executor(&"build_facility", Callable(self, "_execute_build_facility")) register_command_executor(&"cancel_construction", Callable(self, "_execute_cancel_construction")) register_command_executor(&"demolish_facility", Callable(self, "_execute_demolish_facility")) + register_command_executor(&"set_elevator_operation", Callable(self, "_execute_set_elevator_operation")) register_command_executor(&"start_excavation", Callable(self, "_execute_start_excavation")) register_command_executor(&"pause_excavation", Callable(self, "_execute_pause_excavation")) register_command_executor(&"resume_excavation", Callable(self, "_execute_resume_excavation")) @@ -461,6 +470,7 @@ func create_snapshot() -> Dictionary: &"economy": economy_service.create_snapshot(), &"inventory": inventory_service.create_snapshot(), &"kitchen_production": kitchen_production.create_snapshot(), + &"elevators": elevators.create_snapshot(), &"logistics_dispatcher": logistics_dispatcher.create_snapshot(), &"logistics_requests": _serialize_logistics_requests(), &"facilities": facility_service.create_snapshot(), @@ -525,6 +535,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo CONTENT_RUNTIME_SNAPSHOT_VERSION, OPERATIONS_RUNTIME_SNAPSHOT_VERSION, INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION, + SUPPLY_RUNTIME_SNAPSHOT_VERSION, RUNTIME_SNAPSHOT_VERSION, ]: return false @@ -552,8 +563,10 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo &"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure", ]) - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version >= SUPPLY_RUNTIME_SNAPSHOT_VERSION: runtime_current_keys.append(&"kitchen_production") + if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + runtime_current_keys.append(&"elevators") 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 \ @@ -581,9 +594,13 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo ]: if not runtime_data.get(infrastructure_key, null) is Dictionary: return false - if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"kitchen_production"): + if runtime_schema_version < SUPPLY_RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"kitchen_production"): return false - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"kitchen_production", null) is Dictionary: + 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"): + return false + if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"elevators", null) is Dictionary: return false var restored_catalog := MvpContentCatalog.new() var restored_catalog_report := restored_catalog.rebuild() @@ -1018,9 +1035,40 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo restored_infrastructure_projector ): return false + # Older saves used logical attendance. Migrate their staff capacity once; + # new saves must already agree with physical workplace positions. + for facility: FacilityInstance in restored_facilities.get_instances(): + var present_ids: Array[StringName] = [] + 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: + return false + if worker.role_id != &"porter" and worker.current_task_id.is_empty(): + worker.state = StaffRuntime.State.COMMUTING + else: + present_ids.append(staff_id) + if present_ids != facility.active_staff_ids: + restored_facilities.set_operational_status(facility.instance_id, + {&"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 not restored_elevators.restore_snapshot(runtime_data[&"elevators"]): + return false + else: + if not bool(restored_elevators.synchronize([], restored_runtime_domain_minute).get(&"ok", false)): + return false + if not _validate_elevator_crosslinks(restored_elevators, restored_path, restored_construction, + restored_guests, restored_staff, restored_runtime_domain_minute): + return false + for facility: FacilityInstance in restored_facilities.get_instances(): + for staff_id: StringName in facility.active_staff_ids: + if not restored_elevators.get_actor_trip(_staff_path_actor_id(staff_id)).is_empty(): + return false var restored_kitchen := KitchenProductionService.new() restored_kitchen.configure(restored_inventory, restored_facilities, definition_registry, restored_runtime_domain_minute) - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version >= SUPPLY_RUNTIME_SNAPSHOT_VERSION: if not restored_kitchen.restore_snapshot(runtime_data[&"kitchen_production"]): return false if restored_kitchen.current_minute != restored_runtime_domain_minute or not restored_kitchen.validate_crosslinks(): @@ -1075,6 +1123,8 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo facility_service = restored_facilities guest_service = restored_guests staff_service = restored_staff + elevators = restored_elevators + staff_commute.configure(self) supply_logistics.configure(self) mvp_content_catalog = restored_catalog mvp_content_report = restored_catalog_report.duplicate(true) @@ -1688,6 +1738,7 @@ func _build_operations_external_state() -> Dictionary: &"authority": authority_service.create_snapshot(), &"world": world_service.create_snapshot(), &"path": path_service.create_snapshot(), + &"elevators": elevators.create_snapshot(), &"excavation": excavation_service.create_snapshot(), &"spatial_subslots": spatial_subslot_service.create_snapshot(), &"route_projection": route_subslot_path_adapter.create_snapshot(), @@ -1721,7 +1772,8 @@ func _runtime_aggregate_committed_minute() -> int: if minute < 0 or guest_service.current_minute != minute \ or inventory_service.current_minute != minute \ or staff_service.current_minute != minute \ - or kitchen_production.current_minute != minute: + or kitchen_production.current_minute != minute \ + or elevators.current_minute != minute: return -1 return minute @@ -1733,7 +1785,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", + &"staff", &"relationships", &"events", &"authority", &"world", &"path", &"elevators", &"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure", &"movement_credits", &"domain_events", &"id_generator", &"rng_streams", @@ -1785,6 +1837,7 @@ func _restore_operations_external_state(token: Variant) -> bool: var topology_ok := projected_walkable is Array \ 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 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"] @@ -1884,6 +1937,7 @@ func _operations_external_state_matches(saved: Dictionary) -> bool: or not _strict_variants_equal(authority_service.create_snapshot(), saved[&"authority"]) \ 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(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"]) \ @@ -5014,6 +5068,7 @@ func _capture_rank_external_state() -> Dictionary: return { &"world": world_service.create_snapshot(), &"path": path_service.create_snapshot(), + &"elevators": elevators.create_snapshot(), &"excavation": excavation_service.create_snapshot(), &"spatial_subslots": spatial_subslot_service.create_snapshot(), &"route_projection": route_subslot_path_adapter.create_snapshot(), @@ -5076,6 +5131,7 @@ func _rollback_rank_external_state(snapshot: Dictionary) -> bool: return false 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 = route_subslot_path_adapter.restore_snapshot(route_snapshot) and restored restored = facility_infrastructure_projector.restore_snapshot( snapshot[&"facility_infrastructure"] @@ -5836,6 +5892,7 @@ func _execute_hire_staff_candidate(command: SimulationCommand) -> CommandResult: &"game_state": _state.create_snapshot(), &"staff": staff_service.create_snapshot(), &"path": path_service.create_snapshot(), + &"elevators": elevators.create_snapshot(), &"movement_credits": _movement_credits.duplicate(true), &"domain_events": domain_events.create_snapshot(), &"id_generator": id_generator.create_snapshot(), @@ -5963,6 +6020,7 @@ func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool: restored = _state.restore_snapshot(snapshot[&"game_state"]) and restored 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 _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 @@ -6012,6 +6070,10 @@ func _execute_set_staff_work_assignment(command: SimulationCommand) -> CommandRe var result := staff_service.set_work_assignment(staff_id, facility_id, template_id, _runtime_event_minute()) result.command_id = command.command_id if result.is_success(): + var actor_id := _staff_path_actor_id(staff_id) + var trip := elevators.get_actor_trip(actor_id) + if result.details.get(&"reason", &"") == &"ASSIGNMENT_UPDATED" and trip.get(&"state", &"") == PathService.ELEVATOR_REQUEST_QUEUED: + elevators.cancel_actor(actor_id) _staffing_projection_enabled = true _sync_facility_staffing(_runtime_event_minute()) if result.details.get(&"reason", &"") == &"ASSIGNMENT_UPDATED": @@ -6045,7 +6107,7 @@ func _staff_can_reach_facility(staff: StaffRuntime, facility: FacilityInstance) occupied.append(facility.origin) var candidates: Dictionary = {} for cell: Vector3i in occupied: - for offset: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]: + for offset: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]: var candidate := cell + offset if path_service.has_cell(candidate): candidates[candidate] = true @@ -6803,6 +6865,9 @@ 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 + 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.") var rotation := int(command.payload.get(&"rotation", command.payload.get("rotation", 0))) if rotation not in definition.allowed_rotations: return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "The selected rotation is not allowed.") @@ -6879,6 +6944,9 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult: var instance_id := StringName(command.payload.get(&"instance_id", command.payload.get("instance_id", ""))) if instance_id.is_empty(): return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "instance_id is required.") + 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 defense_owner: StringName = _defense_lock_owner(instance_id, &"ASSET") if not defense_owner.is_empty(): return CommandResult.failed( @@ -6923,7 +6991,10 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult: ) var demolished := construction_service.demolish_facility(instance_id) demolished.command_id = command.command_id - if not demolished.is_success() \ + var transport_ok := false + if demolished.is_success(): + transport_ok = bool(_synchronize_elevators(_runtime_event_minute()).get(&"ok", false)) + if not transport_ok or not demolished.is_success() \ 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() \ @@ -6962,6 +7033,7 @@ func _capture_infrastructure_demolition_state() -> Dictionary: &"economy": economy_service.create_snapshot(), &"world": world_service.create_snapshot(), &"path": path_service.create_snapshot(), + &"elevators": elevators.create_snapshot(), &"construction": construction_service.create_snapshot(), &"excavation": excavation_service.create_snapshot(), &"spatial_subslots": spatial_subslot_service.create_snapshot(), @@ -6977,7 +7049,7 @@ func _capture_infrastructure_demolition_state() -> Dictionary: func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool: for key: StringName in [ - &"game_state", &"economy", &"world", &"path", &"construction", + &"game_state", &"economy", &"world", &"path", &"elevators", &"construction", &"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure", &"facilities", &"mana", &"movement_credits", &"domain_events", &"id_generator", @@ -6994,6 +7066,7 @@ func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool: return false 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 = route_subslot_path_adapter.restore_snapshot(route_snapshot) and restored restored = facility_infrastructure_projector.restore_snapshot( snapshot[&"facility_infrastructure"] @@ -7023,6 +7096,7 @@ func _infrastructure_demolition_state_matches(snapshot: Dictionary) -> bool: and _strict_variants_equal(economy_service.create_snapshot(), snapshot.get(&"economy", {})) \ 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(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", {})) \ @@ -7412,6 +7486,11 @@ func _apply_completed_facility_internal(instance: Dictionary, absolute_minute: i &"reason": "The completed Facility Mana consumer could not be registered.", &"details": {&"instance_id": instance_id}, } + if definition.id in [&"guest_elevator", &"service_elevator"]: + _register_facility_mana_consumer(instance) + 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 _publish_domain_event(SimulationDomainEvent.new( id_generator.next_id(&"domain_event"), &"FACILITY_COMPLETED", @@ -7731,7 +7810,11 @@ func _build_event_context(absolute_minute: int) -> Dictionary: func _advance_staff_ai(absolute_minute: int) -> void: if _operations_tick_blocked: return + _prune_expired_staff_elevator_waits(absolute_minute) + _advance_elevators(absolute_minute) staff_service.advance_one_minute(absolute_minute) + _prune_expired_staff_elevator_waits(absolute_minute) + staff_commute.tick(absolute_minute) for task: StaffTask in staff_service.get_tasks(): if task.state == StaffTask.State.RESERVED: if task.state_changed_at < absolute_minute: @@ -7795,6 +7878,9 @@ func _advance_runtime_path_traffic(absolute_minute: int) -> void: ) for entry: Dictionary in actors: var actor_id: StringName = entry[&"actor_id"] + if not elevators.get_actor_trip(actor_id).is_empty(): + _record_expected_actor_wait(actor_id, path_service.get_actor_projected_cell(actor_id), absolute_minute) + continue var kind: StringName = entry[&"kind"] var entity_id: StringName = entry[&"entity_id"] if kind == &"guest": @@ -7816,6 +7902,10 @@ func _advance_runtime_path_traffic(absolute_minute: int) -> void: if not _path_projection_matches(actor_id, staff.current_cell): _report_path_integration_failure(actor_id, entity_id, kind, staff.current_cell) continue + if staff.state == StaffRuntime.State.COMMUTING and staff_service.requires_attendance(staff.staff_id): + # The commute driver already consumed this tick, including fractional + # walking credit under congestion. Do not reset it as an idle task. + continue var task := staff_service.get_task(staff.current_task_id) if task != null and task.state == StaffTask.State.MOVING \ and task.assigned_staff_id == staff.staff_id \ @@ -7918,6 +8008,8 @@ func _advance_guest_path_travel( actor_id, guest.guest_id, &"guest", guest.current_cell, absolute_minute, 0 ) return + if _request_elevator_step(actor_id, path[0], path[1], absolute_minute, []): + return var accumulated := _movement_credit_after_one_minute( actor_id, guest.current_cell, absolute_minute ) @@ -8022,6 +8114,8 @@ func _advance_staff_task_travel( actor_id, staff.staff_id, &"staff", staff.current_cell, absolute_minute, 0 ) return + if _request_elevator_step(actor_id, path[0], path[1], absolute_minute, [&"staff"]): + return var accumulated := _movement_credit_after_one_minute( actor_id, staff.current_cell, absolute_minute ) @@ -8219,7 +8313,7 @@ func _estimate_staff_task_travel(staff: StaffRuntime, task: StaffTask, _absolute [&"staff"] ) if not path.is_empty(): - return maxi(path.size() - 1, 0) + return _estimated_actor_path_minutes(path, [&"staff"]) return -1 func _sync_facility_staffing(absolute_minute: int) -> void: @@ -8229,8 +8323,7 @@ func _sync_facility_staffing(absolute_minute: int) -> void: for facility: FacilityInstance in facility_service.get_instances(): var active_ids: Array[StringName] = [] for staff: StaffRuntime in staff_members: - if staff.assigned_facility_id == facility.instance_id \ - and staff.state == StaffRuntime.State.AVAILABLE: + if staff_commute.is_present(staff, facility): active_ids.append(staff.staff_id) facility_service.set_operational_status( facility.instance_id, @@ -8264,7 +8357,7 @@ func _project_guest_candidates(guest_id: StringName) -> Array: &"facility_tags": definition.tags.duplicate(), &"is_open": facility.state in [FacilityInstance.State.OPEN, FacilityInstance.State.DEGRADED], &"reachable": bool(access.get(&"reachable", false)), - &"travel_minutes": maxi(path.size() - 1, 0), + &"travel_minutes": _estimated_actor_path_minutes(path, []), &"expected_wait": facility.queue_entries.size() * facility.base_service_minutes / maxi(facility.capacity, 1), &"estimated_total_gold": FacilityPricing.quote(_business_base_price(facility), facility.policy_current), &"expected_quality": clampi(facility.base_quality, 0, 100), @@ -8286,7 +8379,7 @@ func _find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> if occupied.is_empty(): occupied.append(facility.origin) for cell: Vector3i in occupied: - for direction: Vector3i in [Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]: + for direction: Vector3i in [Vector3i.LEFT, Vector3i.RIGHT]: var candidate := Vector3i(cell.x + direction.x, cell.y + direction.y, cell.z) if path_service.has_cell(candidate): candidate_cells[candidate] = true @@ -8304,7 +8397,7 @@ func _find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> var path := path_service.find_path(start, candidate) if path.is_empty(): continue - if best_path.is_empty() or path.size() < best_path.size(): + if best_path.is_empty() or path_service.calculate_path_cost(path) < path_service.calculate_path_cost(best_path): best_path.assign(path) best_cell = candidate return {&"reachable": not best_path.is_empty(), &"path": best_path, &"target_cell": best_cell} @@ -9013,6 +9106,7 @@ func _capture_event_action_token() -> Dictionary: &"facilities": facility_service.create_snapshot(), &"guests": guest_service.create_snapshot(), &"path": path_service.create_snapshot(), + &"elevators": elevators.create_snapshot(), &"movement_credits": _movement_credits.duplicate(true), &"domain_events": domain_events.create_snapshot(), &"id_generator": id_generator.create_snapshot(), @@ -10247,7 +10341,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) != PathService.SNAPSHOT_VERSION: + if not schema_value is int or int(schema_value) not in [PathService.ELEVATOR_INDEX_SNAPSHOT_VERSION, PathService.SNAPSHOT_VERSION]: return false var raw_traffic: Variant = path_snapshot.get( &"cell_traffic", @@ -10735,6 +10829,7 @@ func _on_guest_recovery_started( func _on_guest_despawned(guest_id: StringName, persistent_record: Dictionary) -> void: var actor_id := _guest_path_actor_id(guest_id) + elevators.cancel_actor(actor_id) var path_removed := path_service.despawn_actor(actor_id) _movement_credits.erase(actor_id) _synchronize_operations_population() @@ -10764,6 +10859,7 @@ func _on_staff_registered(staff: StaffRuntime) -> void: func _on_staff_removed(staff_id: StringName, reason: StringName) -> void: var actor_id := _staff_path_actor_id(staff_id) + elevators.cancel_actor(actor_id) var path_removed := path_service.despawn_actor(actor_id) _movement_credits.erase(actor_id) _publish_actor_event(&"STAFF_REMOVED", &"staff_service", [staff_id], { @@ -11312,3 +11408,193 @@ func _release_closed_business_request(facility: FacilityInstance, request: Servi guest_service.restore_snapshot(before_guest) domain_events.restore_snapshot(before_events) id_generator.restore_snapshot(before_ids) + + +## Commute and elevator movement share the authoritative Path projection and +## fixed-point walking credit used by errands and guests. +func advance_staff_commute(staff_id: StringName, target_cell: Vector3i, minute: int, reason: StringName = &"") -> Dictionary: + var staff := staff_service.get_staff(staff_id) + if staff == null: + return {&"ok": false, &"reason": &"STAFF_NOT_FOUND"} + var actor_id := _staff_path_actor_id(staff_id) + if not _path_projection_matches(actor_id, staff.current_cell): + _report_path_integration_failure(actor_id, staff_id, &"staff", staff.current_cell) + return {&"ok": false} + if not reason.is_empty() or staff.current_cell == target_cell or not elevators.get_actor_trip(actor_id).is_empty(): + _record_expected_actor_wait(actor_id, staff.current_cell, minute) + return {&"ok": true, &"moved": false, &"reason": reason} + var path := path_service.find_path(staff.current_cell, target_cell, + [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"]) + if path.size() < 2: + _record_expected_actor_wait(actor_id, staff.current_cell, 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 credit := _movement_credit_after_one_minute(actor_id, staff.current_cell, minute) + if credit < MOVEMENT_CREDIT_SCALE: + _record_expected_actor_wait(actor_id, staff.current_cell, minute) + _movement_credits[actor_id] = {&"credit": credit, &"updated_at": minute} + return {&"ok": true, &"moved": false} + var previous := staff.current_cell + if not path_service.project_actor_position(actor_id, path[1], 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} + _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"}) + return {&"ok": true, &"moved": true, &"cell": staff.current_cell} + + +func _request_elevator_step(actor_id: StringName, from: Vector3i, to: Vector3i, + minute: int, access: Array[StringName]) -> bool: + var connector_id := path_service.get_step_connector(from, to, access) + if not elevators.owns_connector(connector_id): + return false + var shaft_id := elevators.get_shaft_for_connector(connector_id) + if not _elevator_extension_pending(shaft_id): + var result := elevators.request_trip(actor_id, from, to, minute, access) + if bool(result.get(&"ok", false)) and not bool(result.get(&"replay", false)): + _publish_actor_event(&"ELEVATOR_TRIP_REQUESTED", &"elevators", [actor_id, shaft_id], result[&"trip"]) + # Waiting for a cabin or a construction extension is intentional, never a + # walk across floors and never a stuck-actor timeout. + _record_expected_actor_wait(actor_id, from, minute) + return true + + +func _advance_elevators(minute: int) -> void: + for shaft: Dictionary in elevators.get_report()[&"shafts"]: + var powered := true + for module_id: StringName in shaft[&"module_ids"]: + var state := mana_network_service.get_consumer_state(module_id) + if state not in [ManaNetworkService.ConsumerState.ACTIVE, ManaNetworkService.ConsumerState.DEGRADED]: + powered = false + _apply_elevator_arrivals(elevators.set_powered(shaft[&"shaft_id"], powered, minute), minute) + _apply_elevator_arrivals(elevators.advance_one_minute(minute), minute) + + +func _apply_elevator_arrivals(arrivals: Array[Dictionary], minute: int) -> void: + for arrival: Dictionary in arrivals: + var actor_id := StringName(arrival[&"actor_id"]) + if not path_service.has_actor_projection(actor_id): + continue + var previous: Vector3i = path_service.get_actor_projected_cell(actor_id) + var cell: Vector3i = arrival[&"cell"] + var entity_id := StringName(String(actor_id).trim_prefix("guest:").trim_prefix("staff:")) + var guest := guest_service.get_guest(entity_id) if String(actor_id).begins_with("guest:") else null + var staff := staff_service.get_staff(entity_id) if String(actor_id).begins_with("staff:") else null + if guest == null and staff == null: + _report_path_integration_failure(actor_id, entity_id, &"elevator", previous) + continue + if not path_service.project_actor_position(actor_id, cell, minute): + _report_path_integration_failure(actor_id, entity_id, &"elevator", previous) + continue + if guest != null: + guest_service.record_path_success(guest.guest_id, cell, minute) + else: + staff.current_cell = cell + _movement_credits[actor_id] = {&"credit": 0, &"updated_at": minute} + _publish_actor_event(&"ELEVATOR_ARRIVED", &"elevators", [actor_id, entity_id], arrival) + + +func _execute_set_elevator_operation(command: SimulationCommand) -> CommandResult: + var shaft_id := StringName(command.payload.get(&"shaft_id", &"")) + var open_value: Variant = command.payload.get(&"operator_open", null) + if typeof(open_value) != TYPE_BOOL: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "operator_open must be a bool.") + var found := false + for shaft: Dictionary in elevators.get_report()[&"shafts"]: + if shaft[&"shaft_id"] == shaft_id: found = true + if not found: + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Elevator shaft was not found.") + _apply_elevator_arrivals(elevators.set_operator_open(shaft_id, open_value, _runtime_event_minute()), _runtime_event_minute()) + _publish_actor_event(&"ELEVATOR_OPERATION_CHANGED", &"elevators", [shaft_id], {&"operator_open": open_value}) + return CommandResult.succeeded(command.command_id, {&"shaft_id": shaft_id, &"operator_open": open_value}) + + +func _synchronize_elevators(minute: int) -> Dictionary: + return elevators.synchronize(ElevatorService.configurations_from_facilities( + construction_service.get_facilities(), definition_registry), minute) + + +func _elevator_shaft_busy(shaft: Dictionary) -> bool: + return shaft.get(&"phase", &"IDLE") != &"IDLE" or int(shaft.get(&"waiting_count", 0)) > 0 \ + or int(shaft.get(&"passenger_count", 0)) > 0 + + +func _elevator_touches_module(shaft: Dictionary, definition: FacilityDefinition, origin: Vector3i) -> bool: + if definition == null or definition.id not in [&"guest_elevator", &"service_elevator"]: + return false + var stops: Array = shaft[&"stops"] + var bottom: Vector3i = stops.front() + var top: Vector3i = stops.back() + return origin.x == bottom.x and origin.z == bottom.z and definition.footprint.x == int(shaft[&"width"]) \ + and origin.y <= top.y + 1 and origin.y + definition.footprint.y >= bottom.y + + +func _elevator_extension_pending(shaft_id: StringName) -> bool: + for shaft: Dictionary in elevators.get_report()[&"shafts"]: + if shaft[&"shaft_id"] != shaft_id: continue + for job: ConstructionJob in construction_service.get_jobs(): + if job.state in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]: continue + if _elevator_touches_module(shaft, definition_registry.get_facility(job.definition_id), job.origin): + return true + return false + + +func _validate_elevator_crosslinks(service: ElevatorService, paths: PathService, + construction: ConstructionService, guests: GuestService, staff: StaffService, minute: int) -> bool: + if service.current_minute != minute: return false + var expected := ElevatorService.configurations_from_facilities(construction.get_facilities(), definition_registry) + var saved_configs: Array = [] + for car: Dictionary in service.create_snapshot()[&"shafts"]: + saved_configs.append(car[&"config"]) + # Construction uses numeric spatial order while snapshots use lexical IDs. + # Compare canonical shaft identity order (x=-2/10, floors=2/10 otherwise differ). + var by_id := func(a: Dictionary, b: Dictionary) -> bool: + return String(a[&"shaft_id"]) < String(b[&"shaft_id"]) + expected.sort_custom(by_id) + saved_configs.sort_custom(by_id) + if expected != saved_configs: return false + for trip: Dictionary in service.get_report()[&"trips"]: + var actor_id := StringName(trip[&"actor_id"]) + var entity_id := StringName(String(actor_id).trim_prefix("guest:").trim_prefix("staff:")) + var guest := guests.get_guest(entity_id) if String(actor_id).begins_with("guest:") else null + var worker := staff.get_staff(entity_id) if String(actor_id).begins_with("staff:") else null + if guest == null and worker == null: return false + if guest != null and (guest.current_cell != trip[&"from"] or trip[&"from"].z != WorldService.LAYER_FRONT): return false + if worker != null and worker.current_cell != trip[&"from"]: return false + if not paths.has_actor_projection(actor_id) or paths.get_actor_projected_cell(actor_id) != trip[&"from"]: return false + return true + + +func is_staff_in_transit(staff_id: StringName) -> bool: + return not elevators.get_actor_trip(_staff_path_actor_id(staff_id)).is_empty() + + +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)): + return ceili(float(path_service.calculate_path_cost(path, access)) / PathService.ORTHOGONAL_MOVE_COST) + return maxi(path.size() - 1, 0) + + +func _prune_expired_staff_elevator_waits(minute: int) -> void: + for trip: Dictionary in elevators.get_report()[&"trips"]: + var actor_id := StringName(trip[&"actor_id"]) + if trip[&"state"] != PathService.ELEVATOR_REQUEST_QUEUED or not String(actor_id).begins_with("staff:"): + continue + var staff := staff_service.get_staff(StringName(String(actor_id).trim_prefix("staff:"))) + if staff == null: + elevators.cancel_actor(actor_id) + continue + if not staff_service.requires_attendance(staff.staff_id): + continue + var task := staff_service.get_task(staff.current_task_id) + if task != null and task.state == StaffTask.State.MOVING and task.assigned_staff_id == staff.staff_id: + continue + var shift := staff_service.get_shift(staff.shift_id) + 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"}) diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index b05d16f..be9ce39 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -260,3 +260,16 @@ - 表示: 営業パネルの当日費用は当該施設をsourceとする運営費の記帳で、給与配賦を含む確定利益ではない。給与配賦・施設別利益は06:00の帳簿で確認する。需要は客の予算とUtilityへ実反映するが、予測人数を算出していない段階で架空の人数予測を表示しない。 - 性能: Snapshotの履歴・項目・検証頻度を減らさず、Digest呼出しの中だけで同じ辞書キー構成の符号化と順序を再利用する。String/StringNameを分け、上限を設ける。値や可変コンテナの出力をキャッシュしない。旧v1実装をテスト参照として残し、全対応型・衝突・変更・深さ・拒否条件のバイト一致を検証する。測定の約2倍はDigest単体であり、実ゲームのfps改善率ではない。 - 検証記録: [P1営業設定](qa/P1_BUSINESS_MANAGEMENT.md)。P1全体・初見30分・昇降機・Windows性能の完成判定とは分ける。 + +## D-035 昇降機を建設・乗客・通勤へ接続する + +- 状態: 採用(2026-09-09。P2の基本運行。改善前後比較と全階間経路規則は継続) +- 運行: 同じ幅・x座標・レイヤーで縦に連結した完成モジュールを1本の昇降路とし、1台のかごを共有する。客用は4人・1階10分、業務用は8人・1階5分。扉の処理は各端で固定2分と乗降人数分。古い要求を優先し、同じ出発階・目的階の客をまとめる基本方式とする。途中階の相乗りを行うSCAN方式は未実装。 +- 経路: Path v4に所有者付きの縦徒歩抑制領域を保存する。昇降路の全占有マスでは上下の徒歩Edgeを作らず、同じ階の横アクセスと運行Connectorを使う。既存の昇降路以外の上下通路は階段相当として維持する。全世界の階間徒歩抑制と明示的な階段施設への移行はMOVE-01の残課題とする。客用と業務用の権限を分け、BACKはstaff権限を必須にする。 +- 位置: 乗車中はPathとActorの論理位置を乗車元の停留所に保持し、実際の移動時間と降車処理が終わってから到着階へ更新する。画面では乗車中の人物を地上から隠してかごに描く。この段階では乗車中の人数が乗車元の混雑密度に残る。車内を別空間として扱う密度改善は残課題。 +- 工事と停止: 人が乗車・待機している、またはかごが運行中の昇降路は延伸・撤去を拒否する。空の昇降路の延伸開始後は新規乗車を完了まで保留する。運行停止や電力喪失は待機要求を解除し、乗客を最寄りの有効停留所へ避難させる。有効停留所がなければかご内で保持して復旧を待つ。プレイヤーの運行許可と魔力供給は別に保存し、通電だけで停止設定を解除しない。 +- 通勤: 通常UIの継続配属は到着までCOMMUTINGとする。同じ階の勤務施設内または横に接する経路に到着してから稼働人員と勤務実績へ反映する。階の上下隣接だけでは到着・荷物の受渡しとしない。混雑による歩行距離の端数は持ち越す。運搬員は勤務ごとに倉庫へ出勤後、配送先から次便を受けられるが、不在の倉庫には人員を計上しない。勤務終了時は位置を保持し、帰宅の瞬間移動は行わない。 +- 保存: Runtime v8にElevatorServiceの状態、要求、かご、扉、残り時間、運行設定を保存する。Path、Actor、建設モジュールの所有関係を検証し、Operationsと建設の巻き戻しにも含める。旧v7以前の昇降機のない保存は移行し、厨房の導入境界は引き続きv7。旧配属の論理人員は物理位置へ合わせて移行し、新しい保存の幽霊人員は拒否する。継続計画のない旧単日Shiftには従来の論理出勤を保持する。 +- 素材: 既存のKenney人物を車内でも使い、レール・扉・かごは既存のコード描画によるドット表現へ追加する。外部画像の新規導入はない。 + +- 撤去の保存修正: 施設撤去の成功時は対応する完成済み工事を現役の工事一覧と進捗時刻表から取り除く。これまで完成工事だけが残り「完成施設がない」と保存検証で拒否されていた。IDの最大値、台帳、DomainEventの履歴は維持し、工事IDを再利用しない。延伸後の部分撤去・全撤去と、失敗時の巻き戻しを検証する。 diff --git a/docs/DEVELOPMENT_BACKLOG.md b/docs/DEVELOPMENT_BACKLOG.md index 09119be..9fe97e7 100644 --- a/docs/DEVELOPMENT_BACKLOG.md +++ b/docs/DEVELOPMENT_BACKLOG.md @@ -32,7 +32,7 @@ P0の保存物はGit管理対象の制作ファイル。`.godot/`、`build/`、` | ID | 実装済みの根拠 | 未接続・仕様差・検証残と次の確認 | |---|---|---| | PLAY-01 | **最小ループ実装済み・単日確認**。初期Gold 5,000/資材50、通常UIの建築経路で受付1・一般客室2・通路7、アカネ雇用・配属、自然来客の宿泊320G、翌06:00決算まで確認。`test_first_operating_day.gd` | 受入は見学Fixture・無料在庫・客挿入なし。**供給込み3営業日も自動確認/バランス調整残**。受付売上6,020G等の数値を正本と照合し、受付の直接収入と価格・営業Policyを調整する | -| PLAY-02 | **通常UI接続済み・個別API103項目/単日確認**。雇用済み一覧、配属・再配属・未配属、DAY/NIGHT継続計画、変更不可・経路・定員等の理由、予約Commandと保存復元。StaffService v4、D-032 | 実通勤と到着後の稼働はMOVE-07へ分離。現在は経路存在を検証して論理配属し、人物位置は変えない。5職員・複数施設・3営業日の継続配属は供給受入で確認。実通勤と人手不足への人手試遊は残る | +| PLAY-02 | **通常UI接続済み・個別API103項目/単日確認**。雇用済み一覧、配属・再配属・未配属、DAY/NIGHT継続計画、変更不可・経路・定員等の理由、予約Commandと保存復元。StaffService v4、D-032 | 実通勤と到着後の稼働はMOVE-07へ接続済み。5職員・複数施設・3営業日の継続配属は供給受入で確認。実通勤と人手不足への人手試遊は残る | | PLAY-03 | **通常UI接続済み**。完成倉庫への有料購入、容量・期限・予約、実porter TaskとPathによる倉庫→厨房→食堂。物流81項目、管理処理84項目、商品UI37項目の回帰 | 仕入れは倉庫への即納。外部業者の納期、継続自動購入、複数品目、正本の食器・消耗品は後続。経路断・満杯・腐敗・担当不能での保持/再試行/返送を検証 | | PLAY-04 | **自律生産接続済み・140項目確認**。明示設定した厨房で原料4→料理4、15稼働分、目標在庫、入力予約・出力容量・期限・中断復帰を扱う。見学用の無料在庫に依存しない | **仕様差をD-033で明記**。今回の原料/基本料理を維持し、料理ポイント+食器は残す。食堂価格・所要時間はD-034で新設25G/45分に統一 | | PLAY-05 | **日次帳簿UI接続済み・単日確認**。06:00営業日、入出金と建築、確定売上・運営費・給与・利益・前日差・未払・施設別損益を表示。翌朝給与120G、未払0、照合一致 | **価格・営業UI接続済み**。5段階の見積・客の選択と満足・新規受付停止・忍耐を減らさない退列・途中保存。新設受付0G/20分、客室120G、食堂25G/45分へ修正。宿泊前払い・維持費・初見の改善試遊は残る。D-034と営業設定QA参照 | @@ -48,13 +48,13 @@ P0の保存物はGit管理対象の制作ファイル。`.godot/`、`build/`、` | ID | 実装済みの根拠 | 未接続・仕様差・検証残と次の確認 | |---|---|---| -| MOVE-01 | `path_service.gd` の隣接経路、Connector、アクセス権、`facility_infrastructure_projector.gd` の通路・職員ドア投影 | **仕様との相違/未統合**。同一階の徒歩と設備による垂直移動を分け、通常隣接Edgeで階間を迂回する経路を抑制。旧通路・Saveからの移行を決める | -| MOVE-02 | PathにElevator状態、有限Queue、要求重複防止、停止時迂回、停滞検知の部品 | **未統合**。昇降路、停止階、車両、定員、待機要求を1方式の運行モデルへつなぐ。部品のQueue試験だけで昇降機完成としない | -| MOVE-03 | 昇降機Definitionがパレットにある。現在のRuntimeは安全なTopology未統合として建設を拒否 | **画面へ未接続/未検証**。建設・延伸・停止階変更・撤去・客用/業務用権限を運行へ統合する | -| MOVE-04 | `world_preview.gd` の徒歩補間と人物の小型描画 | **未実装**。待つ→乗る→昇降→降りるの運行状態を表示し、車内と徒歩の重複を防ぐ。見学の上階通路移動は乗降試験ではない | -| MOVE-05 | Path単体の停止・Queue・停滞検知とSnapshot試験 | **未検証**。統合した車両で満員、撤去、経路変更、車内Save復元を試験し、永久行列・二重乗車・閉じ込めを解消 | +| MOVE-01 | **部分統合**。Path v4の所有者付き縦徒歩抑制を昇降路の全マスへ投影。旧Path3保存は移行 | 昇降路以外の上下通路は階段相当を維持。全階間の徒歩禁止と明示的階段への移行が残る。D-035 | +| 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-07 | **計画と経路検証のみ接続済み**。通常UIの配属は継続計画を保存し、経路が存在するかを検証。人物のcurrent_cellは変更しない | **未統合**。勤務開始→実通勤→職場到着→人員稼働をつなぎ、移動中や不到達の人物を到着済み人員として扱わない。再配属・夜勤・Save復元を含める | +| MOVE-07 | **実通勤接続済み**。同じ階の職場へ到着してから稼働。混雑端数、再配属、夜勤、運搬員出勤、Saveと旧論理配属の移行 | 帰宅経路と出退勤演出、移動設備改善による遅刻削減の対照試遊は未実施 | ## P3:営みと初見の操作 @@ -110,3 +110,5 @@ P0の保存物はGit管理対象の制作ファイル。`.godot/`、`build/`、` 営業設定と価格調整、旧v1とバイト一致するDigest軽量化を追加した。最新結果は [営業設定QA](qa/P1_BUSINESS_MANAGEMENT.md)。過去の受付収入を含む数字は履歴であり、現行の収益バランスを示さない。 営業設定追加後の通常供給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全体を合格とはしない。 diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index 2242c7f..9778e70 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -20,7 +20,7 @@ |---|---|---| | 外観・操作 | ドット断面、家具、Kenney人物の簡易歩行、状態表示、コンパクトUI、ズーム・パン | 専用動作、施設ごとの視認性、混雑・稼働の見せ方、実描画性能 | | シミュレーション | 時間、建設、在庫・物流、経済、客AI、従業員、イベント、監査、保存の基盤 | 供給と営業Policyは通常操作へ接続。宿泊前払い・維持費・改善対照試遊を継続 | -| 縦移動 | Path側にConnector・Queue等の部品が存在 | 階間の通常隣接経路抑制、建設との接続、昇降・乗降、保存復元の統合 | +| 縦移動 | 建設から共有かご、待機・乗降、実移動、停止、保存へ接続。昇降路の上下徒歩を抑制 | 全階間の規則、途中階相乗り、車内混雑密度の分離、同需要での改善比較 | | 見学版 | 実施設7件と通路、従業員4人、自然来客が動く | 見学は食材32個を用意した保存なしの再現場面。通常ゲームの完成とは区別 | | 運営UI | 建設・掘削・雇用候補・継続配属/勤務変更・施設情報・開業の手引き・日次帳簿・イベント選択・手動保存。通常初期予算で最小宿の単日成立と保存復元を確認 | 実通勤・到着後の稼働、保存枠、宿泊精算、初見での継続運営 | | 長期検証 | 24時間の統合検証、14日の運営境界比較等は実装記録あり | 全AIを含む20,160分の実Tick、各A/B比較、実描画を伴う負荷確認 | @@ -152,3 +152,5 @@ MVP後、試遊で残った課題と既存の製品仕様を照合して後半 最初のレビューは「通常操作だけで初売上と翌朝の決算を迎える宿」。次のレビューは「3階の宿を客が昇降機で往復し、混雑を改善できる場面」とする。機能・見た目・操作を一緒に確かめ、以後の日程と素材制作量を更新する。 2026-09-09 営業設定追加後の通常3営業日は49項目合格。受付無料、食堂3,000G・宿泊1,200G、未払0、残金2,895Gで継続。価格操作・保存は接続済みとし、次はP2の実通勤と基本昇降機、P1の宿泊精算・改善比較・初見試遊を進める。 + +2026-09-09 P2の基本昇降機と実通勤を接続。通常初期予算で4停止階の建設と自然客の乗降を検証する。MOVE-01は昇降路内の抑制まで、MOVE-06の改善対照試験は未実施。詳細は [昇降機QA](qa/P2_TRANSPORT.md) とD-035を参照。 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 2c7dbe7..2e73d4b 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -214,3 +214,11 @@ - 合格範囲、通常運営の再検証、残る精算・性能・試遊課題は [営業設定QA](qa/P1_BUSINESS_MANAGEMENT.md)。この追加をP1全体や昇降機の完成とはしない。 新料金の供給込み3営業日は49項目合格。受付収入0G、食堂3,000G・宿泊1,200G、給与未払0・照合差0、残金2,895G。営業・休業・配送中の保存と再開、既存回帰も成功終了。実装コミット `b3b82ef`。 + +## P2 基本昇降機と実通勤(2026-09-09) + +- 客用/業務用モジュールから共有かごと停止階を構築。待機、定員、乗降、階移動、停止/再開、魔力不足、保存再開をRuntimeへ接続した。 +- 断面に実際のかごとKenney人物を表示する。完成した昇降機を選ぶと現在階・目的階・人数・待機・魔力と運行操作が出る。 +- 継続配属は歩いて職場へ到着してから稼働する。運搬も同じ階のアクセス点で受渡しし、上下階越しの到着や受取を防ぐ。 +- Path v4/Runtime v8。旧保存移行、乗車途中の復元、変更拒否の原子性を検証する。 +- 残課題は全階間の徒歩規則、途中階相乗り、車内を分離した混雑密度、同じ需要での改善比較。P2全体の完成とは扱わない。詳細と実測は [昇降機QA](qa/P2_TRANSPORT.md)。 diff --git a/docs/PLAYTEST_GUIDE.md b/docs/PLAYTEST_GUIDE.md index f8935e6..afd49d7 100644 --- a/docs/PLAYTEST_GUIDE.md +++ b/docs/PLAYTEST_GUIDE.md @@ -144,3 +144,9 @@ Windows書き出しコマンドの存在はWindows実機合格を意味しませ `make preview-business` で、通常予算から建てた供給可能な宿を開く。Spaceで再開し、左「営業」から食堂の料金を変える。値上げ前に受付済みの客は元の見積額を払い、新しく検討する客には新料金が反映される。休業すると新規受付が止まり、待機客は別の施設を探す。すでに利用中の客はサービスを完了する。給与は休業中も発生する。 商品で仕入れ・厨房・配送、帳簿で翌06:00の給与と施設別利益を確認する。この試遊は保存なし。新設の標準料金は食堂25G・客室120G・受付無料。旧保存の既存施設は保存された基準料金を引き継ぐ。検証結果は [営業設定QA](qa/P1_BUSINESS_MANAGEMENT.md)。 + +## 昇降機と通勤の試遊 + +`make preview-transport` で、通常初期資源から建設した4停止階の宿を開く。検証と保存再開の確認後に一時停止して表示する。Spaceで再開し、客が扉の前で待ち、かごで上階へ移る様子を見る。完成した昇降機をクリックすると、現在階・人数・待機列と停止/再開の操作が出る。保存先は試験専用、通常の自動保存は無効。 + +新規ゲームでは、客用昇降機を同じx座標で上へ積み重ねると停止階を延ばせる。運行・待機中の延伸と撤去は拒否するため、空になってから着工する。工事中の新規乗車は完成まで待機する。通常の配属後は、従業員が同じ階の職場まで歩いてから施設が稼働する。 diff --git a/docs/qa/P2_TRANSPORT.md b/docs/qa/P2_TRANSPORT.md new file mode 100644 index 0000000..23f6707 --- /dev/null +++ b/docs/qa/P2_TRANSPORT.md @@ -0,0 +1,47 @@ +# P2 基本昇降機と実通勤の検証 + +日付: 2026-09-09。ローカル正本、Godot 4.7.2。D-035に従う基本運行の検証であり、P2全体の完了ではない。 + +## 通常初期予算の試遊 + +`make preview-transport` は、初期5,000G・資材50から客用昇降機2モジュール、受付1、客室2を通常建設し、アカネを有料雇用・配属する。昇降機は停止階0~3を持つ1台のかごになる。建設4,200G、雇用180Gで残金620G・資材2。客を手動挿入せず、通常の来客が受付後に昇降機へ並び、乗車し、上階へ到着する。 + +乗車中の専用ディスクセーブ/ロード、45分進行と復元後の同一区間の全Snapshot一致、占有中の撤去拒否、停止・最寄り階への降車、停止状態の保存を確認。描画でも実際の乗客IDが車内へ移り、床に重複しない。最終試遊起動は **35項目合格**、`TRANSPORT_PREVIEW_READY` を確認した。最初の乗車場面へ戻して一時停止しており、Spaceで再開できる。通常の自動保存は無効、手動検証は試験専用の保存先を使う。 + +- ログ: `artifacts/transport/final-preview.log` +- 画像: `artifacts/transport/elevator-cabin.png` +- 起動: `make preview-transport` +- 静止画だけの検証: `make capture-transport` + +## 合格した個別検証 + +| 対象 | 合格数 | 主な確認 | +|---|---:|---| +| 昇降機の経路投影 | 93 | モジュールの経路、縦徒歩の抑制、BACK権限、所有者と保存 | +| ElevatorService | 103 | 共有かご、定員、乗降時間、運行時間、先着順、停電、全停留所閉塞、復旧、破損拒否 | +| StaffCommuteService | 85 | 到着前の非稼働、早着、遅刻、混雑、再配属、夜勤、運搬員、車内の誤計上防止 | +| Runtime保存 | 29 | v7→v8移行、Path3→4、幽霊人員、架空乗客、座席・経路・工事の不整合、停止設定 | +| 工事・勤務境界 | 57 | 空車の延伸・撤去、運行中拒否、後段失敗の巻き戻し、同一配属で順番保持、勤務終了時の待機取消と乗車済みの降車 | +| 昇降機UI | 20 | 通常の選択、停止/再開、予約Commandと保存再開、表示領域 | +| Pixel UI | 238 | 既存の断面表示、カメラ、クリック、Simulation不変 | + +新しい一括ターゲットは `make test-transport`。UIは `make test-ui` に含めた。撤去の保存不具合修正後は、既存Construction105項目・Runtime construction37項目も再実行して合格した。 + +## 既存機能の回帰 + +- `make check`: Editor読込と通常Headless起動が成功。SCRIPT ERRORなし。 +- S0: 438項目合格。 +- S2・S3: 983項目合格。Path trafficの旧v7固定期待をv8へ更新し、正式な旧形式を作る試験から新設フィールドを除去した後、82項目の同スクリプトを再実行して合格。 +- S1・S4・S5・S6・MVP・受入・性能: 合計3,107項目合格。旧形式の固定テストデータを更新して再実行した。14日受入は既存の運営境界を加速する方式であり、全Actorの20,160回の実Tickや実描画の14日負荷試験ではない。加速用fixtureは空の昇降機だけ時計を揃え、乗車中の時間を飛ばすことは拒否する。 +- 通常UI: Dashboard121、従業員46、商品37、営業55、開業35項目合格。 +- 実通勤を含む供給3営業日: **49項目合格**。食堂売上3,000G、宿泊1,200G、受付0G、原材料配送30便、食事配送73便、残金2,895G。全決算で照合差0・給与未払0。途中配送のディスク保存と再生も一致した。 + +集約ログは `artifacts/transport/`、3営業日の数値は `normal-supply-3days.json`。初回に古い期待値で失敗した回帰ログも残してあるが、該当スクリプトの修正後の成功ログを確認している。新しい運行・描画に由来するエラーを合格扱いにしていない。 + +## 残る課題 + +- MOVE-01は昇降路内の上下徒歩抑制まで。既存の上下通路は階段相当で残る。全階間の規則と明示的な階段施設への移行が必要。 +- 配車は先着順、同じ発着階のまとめ乗り。途中階の相乗り・複数かごは未実装。 +- 車内の論理位置とPath密度は乗車元に残る。描画は車内へ移るが、混雑密度を車内と分離する改善が必要。 +- 同じSeed・需要・人数を用いた設備改善前後のP95移動時間・離脱比較(MOVE-06)は未実施。 +- 帰宅経路・専用ドット動作・音・初見試遊、宿泊の精算方式と全AIを含む長期描画性能は後続。 diff --git a/scenes/main/main.gd b/scenes/main/main.gd index 4dda1b3..8f8baf5 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -35,6 +35,7 @@ const FACILITY_STATE_NAMES := [ "低下運転", "営業中", ] +const ELEVATOR_PHASE_NAMES := {&"IDLE": "待機", &"PICKUP": "迎車中", &"BOARDING": "乗車中", &"TRAVEL": "運行中", &"ALIGHTING": "降車中"} const FACILITY_REASON_NAMES := { &"BROKEN": "故障", &"NO_ACCESS": "経路なし", @@ -125,6 +126,9 @@ var staff_candidate_buttons: Dictionary = {} var selected_heading_label: Label var selected_detail_label: Label var selected_entity_icon: TextureRect +var selected_elevator_controls: VBoxContainer +var elevator_operation_button: Button +var elevator_operation_hint: Label var build_status_label: Label var build_buttons: Dictionary = {} var navigation_buttons: Dictionary = {} @@ -152,6 +156,8 @@ var _build_rotation := 0 var _build_sequence := 1 var _excavation_sequence := 1 var _hire_sequence := 1 +var _elevator_sequence := 1 +var _pending_elevator_commands: Dictionary = {} var _pending_build_commands: Dictionary = {} var _excavation_mode := false var _pending_excavation_commands: Dictionary = {} @@ -533,6 +539,18 @@ func _create_side_panel() -> Control: selected_detail_label.add_theme_color_override("font_color", Color("4d341f")) selected_detail_label.add_theme_font_size_override("font_size", 13) selected_row.add_child(selected_detail_label) + selected_elevator_controls = VBoxContainer.new() + selected_elevator_controls.name = "SelectedElevatorControls" + selected_elevator_controls.visible = false + column.add_child(selected_elevator_controls) + elevator_operation_button = _button("昇降機を停止", _on_elevator_operation_pressed, 160) + elevator_operation_button.name = "ElevatorOperationButton" + selected_elevator_controls.add_child(elevator_operation_button) + elevator_operation_hint = Label.new() + elevator_operation_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + elevator_operation_hint.add_theme_font_size_override("font_size", 12) + elevator_operation_hint.add_theme_color_override("font_color", Color("654d37")) + selected_elevator_controls.add_child(elevator_operation_hint) operating_ledger_panel = OperatingLedgerPanelScript.new() root_column.add_child(operating_ledger_panel) @@ -1138,6 +1156,8 @@ func _on_map_cell_clicked(cell: Vector3i) -> void: _selected_entity_id = &"" _selected_entity_kind = &"" world_preview.selected_entity_id = &"" + selected_elevator_controls.visible = false + selected_entity_icon.texture = null selected_heading_label.text = "選択中のマス" selected_detail_label.text = "座標 (%d, %d)\nレイヤー %s\n\n施設のないマスです。" % [cell.x, cell.y, "FRONT / 表層" if cell.z == 0 else "BACK / 裏層"] return @@ -1499,7 +1519,9 @@ func _operations_dashboard_text() -> String: func _refresh_selected_entity() -> void: + selected_elevator_controls.visible = false if _selected_entity_id.is_empty(): + _move_selected_detail_first(false) selected_entity_icon.texture = null selected_heading_label.text = "掘削モード" if _excavation_mode else "選択中の施設" selected_detail_label.text = ( @@ -1508,11 +1530,18 @@ func _refresh_selected_entity() -> void: ) return if _selected_entity_kind == &"construction": + _move_selected_detail_first(false) _refresh_selected_construction() return if _selected_entity_kind == &"excavation": + _move_selected_detail_first(false) _refresh_selected_excavation() return + var shaft := _selected_elevator_report() + if not shaft.is_empty(): + _refresh_selected_elevator(shaft) + return + _move_selected_detail_first(false) var facility_service: Variant = _service(&"facility_service") if facility_service == null or not facility_service.has_method(&"get_instance"): selected_detail_label.text = "施設サービスを利用できません。" @@ -1552,6 +1581,106 @@ func _refresh_selected_entity() -> void: ) +func _selected_elevator_report() -> Dictionary: + if _selected_entity_kind != &"facility" or _selected_entity_id.is_empty(): + return {} + var transport: Variant = _service(&"elevators") + if transport == null or not transport.has_method(&"get_report"): + return {} + for shaft: Dictionary in transport.call(&"get_report").get(&"shafts", []): + if (shaft.get(&"module_ids", []) as Array).has(_selected_entity_id): + return shaft + return {} + + +func _refresh_selected_elevator(shaft: Dictionary) -> void: + _move_selected_detail_first(true) + var construction_facility := _construction_facility(_selected_entity_id) + var definition_id := StringName(construction_facility.get(&"definition_id", &"guest_elevator")) + var definition: Variant = _facility_definition(definition_id) + selected_heading_label.text = String(_definition_value(definition, &"display_name", "昇降機")) + selected_entity_icon.texture = visual_theme.facility_icon(definition_id) if visual_theme != null else null + var powered := bool(shaft.get(&"powered", true)) + var operator_open := bool(shaft.get(&"operator_open", true)) + var state_text := String(ELEVATOR_PHASE_NAMES.get(StringName(shaft.get(&"phase", &"IDLE")), "待機")) + if not operator_open: + state_text = "手動停止" + elif not powered: + state_text = "魔力供給なし" + var floor_index := int(shaft.get(&"current_floor", 0)) + var target_index := int(shaft.get(&"boarding_floor", floor_index) if StringName(shaft.get(&"phase", &"IDLE")) == &"PICKUP" else shaft.get(&"target_floor", floor_index)) + var stops: Array = shaft.get(&"stops", []) + var stop_text := "—" + if not stops.is_empty(): + stop_text = "%s 〜 %s" % [_elevator_floor_name(stops.front().y), _elevator_floor_name(stops.back().y)] + selected_detail_label.text = ( + "状態 %s\n" % state_text + + "現在階 %s  目的階 %s\n" % [_elevator_floor_name(floor_index), _elevator_floor_name(target_index)] + + "乗客 %d / %d人  待機 %d人\n" % [int(shaft.get(&"passenger_count", 0)), int(shaft.get(&"capacity", 0)), int(shaft.get(&"waiting_count", 0))] + + "停止階 %s\n" % stop_text + + "魔力 %s  %s" % ["供給中" if powered else "不足", "客・従業員用" if definition_id == &"guest_elevator" else "従業員・物資用"] + ) + 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 _move_selected_detail_first(first: bool) -> void: + var column := selected_heading_label.get_parent() + var selected_row := selected_detail_label.get_parent() + # The transport action fits without scrolling; other management views keep + # their existing staff-first layout. + var desired_index := 0 if first else column.get_child_count()-3 + if selected_heading_label.get_index() == desired_index: + return + column.move_child(selected_heading_label, 0 if first else -1) + column.move_child(selected_row, 1 if first else -1) + column.move_child(selected_elevator_controls, 2 if first else -1) + + +func _elevator_floor_name(floor_index: int) -> String: + return "%dF" % (floor_index+1) if floor_index >= 0 else "B%d" % -floor_index + + +func _on_elevator_operation_pressed() -> void: + var shaft := _selected_elevator_report() + if shaft.is_empty() or _runtime == null: + return + var shaft_id := StringName(shaft[&"shaft_id"]) + if _pending_elevator_commands.values().has(shaft_id): + return + var key := StringName("ui.elevator.%08d" % _elevator_sequence) + _elevator_sequence += 1 + var open_value := not bool(shaft.get(&"operator_open", true)) + var result: Variant = _runtime.call(&"submit_command", &"set_elevator_operation", {&"shaft_id": shaft_id, &"operator_open": open_value}, &"player", &"", 50, -1, key) + if result == null or not result.is_success(): + _set_status("昇降機の指示を反映できませんでした。", false) + return + if not result.details.has(&"shaft_id"): + _pending_elevator_commands[int(result.command_id)] = shaft_id + _set_status("昇降機の運行指示を受理しました。", true) + else: + var message := "昇降機を停止しました。" + if open_value: + message = "昇降機の運行を再開しました。" if bool(shaft.get(&"powered", true)) else "運行を許可しました。魔力の供給待ちです。" + _set_status(message, true) + _refresh_selected_entity() + + +func _synchronize_elevator_pending() -> void: + var queue: Variant = _service(&"command_queue") + if queue == null: + return + if queue.pending_count() == 0: + _pending_elevator_commands.clear() + return + _pending_elevator_commands.clear() + for command: Dictionary in queue.create_snapshot().get("pending", []): + if StringName(command.get("command_type", &"")) == &"set_elevator_operation": + _pending_elevator_commands[int(command.get("command_id", 0))] = StringName((command.get("payload", {}) as Dictionary).get(&"shaft_id", &"")) + + func _refresh_selected_construction() -> void: var construction: Variant = _service(&"construction_service") if construction == null or not construction.has_method(&"get_job"): @@ -1764,6 +1893,7 @@ func _load_manual() -> void: _pending_build_commands.clear() _pending_excavation_commands.clear() _pending_hire_commands.clear() + _pending_elevator_commands.clear() _synchronize_ui_command_state() if staff_management_panel != null: staff_management_panel.refresh() @@ -1820,6 +1950,8 @@ func _synchronize_ui_command_state() -> void: if command_id <= 0: continue match command_type: + &"set_elevator_operation": + _pending_elevator_commands[command_id] = StringName(payload.get(&"shaft_id", &"")) &"build_facility": var definition_id := StringName(payload.get(&"definition_id", payload.get("definition_id", &""))) if not definition_id.is_empty(): @@ -1850,9 +1982,12 @@ func _advance_ui_sequence_from_key(key: String) -> void: _excavation_sequence = maxi(_excavation_sequence, next_sequence) elif key.begins_with("ui.hire."): _hire_sequence = maxi(_hire_sequence, next_sequence) + elif key.begins_with("ui.elevator."): + _elevator_sequence = maxi(_elevator_sequence, next_sequence) func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: + _synchronize_elevator_pending() _refresh_dashboard() _update_pause_and_speed() @@ -1860,6 +1995,11 @@ func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: func _on_command_completed(result: Variant) -> void: if result == null: return + if _pending_elevator_commands.has(int(result.command_id)): + _pending_elevator_commands.erase(int(result.command_id)) + _set_status("昇降機の運行設定を反映しました。" if result.is_success() else "昇降機の運行設定を反映できませんでした。", result.is_success()) + _refresh_selected_entity() + return if _pending_hire_commands.has(int(result.command_id)): var candidate_id := StringName(_pending_hire_commands[int(result.command_id)]) _pending_hire_commands.erase(int(result.command_id)) diff --git a/scenes/main/world_preview.gd b/scenes/main/world_preview.gd index 4cddb2f..c1497af 100644 --- a/scenes/main/world_preview.gd +++ b/scenes/main/world_preview.gd @@ -49,6 +49,8 @@ var show_grid := false var _animation_time := 0.0 var _animation_frame := -1 var _actor_tracks: Dictionary = {} +var _elevator_tracks: Dictionary = {} +var _boarded_actor_ids: Dictionary = {} var _dragging := false var _runtime: Variant = null @@ -74,6 +76,8 @@ func set_runtime(value: Variant) -> void: _disconnect_runtime() _runtime = value _actor_tracks.clear() + _elevator_tracks.clear() + _boarded_actor_ids.clear() _connect_runtime() queue_redraw() _update_build_preview() @@ -349,6 +353,7 @@ func _draw() -> void: if show_grid: _draw_congestion_overlay(bounds) _draw_world_entity_icons() + _draw_elevators() _draw_construction_labels() _draw_facility_labels() _draw_queue_badges() @@ -611,6 +616,93 @@ func _draw_facility_labels() -> void: draw_string(ThemeDB.fallback_font,at+Vector2(4,12),label,HORIZONTAL_ALIGNMENT_CENTER,label_width-8,font_size,Color("f0e2b9")) +func _draw_elevators() -> void: + _boarded_actor_ids.clear() + var transport: Variant = _service(&"elevators") + if transport == null or not transport.has_method(&"get_report"): + _elevator_tracks.clear() + return + var report_value: Variant = transport.call(&"get_report") + if not report_value is Dictionary: + return + var alive: Dictionary = {} + var p := _cell_dimensions().x/16.0 + for value: Variant in report_value.get(&"shafts", []): + if not value is Dictionary: + continue + var shaft: Dictionary = value + var stops := _cells_from_serialized(shaft.get(&"stops", [])) + if stops.is_empty(): + continue + var shaft_id := StringName(shaft.get(&"shaft_id", &"")) + alive[shaft_id] = true + var passengers: Array = shaft.get(&"boarded", []) + for actor_id: Variant in passengers: + _boarded_actor_ids[_elevator_actor_key(String(actor_id))] = true + var floor_value := float(shaft.get(&"visual_floor", shaft.get(&"current_floor", stops[0].y))) + var visual_floor := _tracked_elevator_floor(shaft_id, floor_value) + if stops[0].z != layer_mode: + continue + var origin := get_cell_rect(stops[0]) + var width := maxi(int(shaft.get(&"width", 2)), 1) + var baseline := origin.end.y-float(visual_floor-stops[0].y)*_cell_dimensions().y-3*p + var cabin := Rect2(Vector2(origin.position.x+4*p,baseline-19*p).round(),Vector2(width*_cell_dimensions().x-8*p,19*p)) + if not cabin.intersects(Rect2(Vector2.ZERO,size)): + continue + var operational := bool(shaft.get(&"operational", true)) + var phase := StringName(shaft.get(&"phase", &"IDLE")) + var doors_open := operational and phase in [&"IDLE", &"BOARDING", &"ALIGHTING"] + PixelArt.elevator_cabin(self,cabin,p,layer_mode==LayerMode.BACK,operational) + var visible_count := mini(passengers.size(), maxi(width*2-1,1)) + for index: int in range(visible_count): + var actor_key := _elevator_actor_key(String(passengers[index])) + var is_staff := actor_key.begins_with("staff/") + var person: Variant = null + var people: Variant = _service(&"staff_service" if is_staff else &"guest_service") + var getter := &"get_staff" if is_staff else &"get_guest" + if people != null and people.has_method(getter): + person = people.call(getter,StringName(actor_key.get_slice("/",1))) + var carrying := is_staff and person != null and not String(person.carried_inventory_id).is_empty() + var state := int(person.state) if person != null else 0 + var x := cabin.position.x+5*p+float(index)*(cabin.size.x-10*p)/maxf(float(visible_count-1),1.0) + if visible_count == 1: + x = cabin.get_center().x + var center := Vector2(x,cabin.end.y-_cell_dimensions().y*0.5) + _draw_pixel_actor(center,actor_key,is_staff,state,false,carrying,&"",true) + PixelArt.elevator_gate(self,cabin,p,doors_open) + var waiting := int(shaft.get(&"waiting_count",0)) + if waiting > 0: + var badge := Vector2(cabin.end.x+2*p,cabin.position.y+2*p) + draw_rect(Rect2(badge,Vector2(20,12)*p),Color("76593f")) + draw_string(ThemeDB.fallback_font,badge+Vector2(2,9)*p,"待%d" % waiting,HORIZONTAL_ALIGNMENT_LEFT,-1,clampi(int(7*p),8,13),Color("f6e8bc")) + for shaft_id: Variant in _elevator_tracks.keys(): + if not alive.has(shaft_id): + _elevator_tracks.erase(shaft_id) + + +func _elevator_actor_key(actor_id: String) -> String: + if actor_id.begins_with("guest:"): + return "guest/" + actor_id.trim_prefix("guest:") + if actor_id.begins_with("staff:"): + return "staff/" + actor_id.trim_prefix("staff:") + return actor_id + + +func _tracked_elevator_floor(shaft_id: StringName, current_floor: float) -> float: + if not _elevator_tracks.has(shaft_id): + _elevator_tracks[shaft_id] = {&"floor":current_floor,&"from":current_floor,&"at":_animation_time} + var track: Dictionary = _elevator_tracks[shaft_id] + if not is_equal_approx(float(track[&"floor"]),current_floor): + track[&"from"] = float(track[&"floor"]) + track[&"floor"] = current_floor + track[&"at"] = _animation_time + var clock := get_node_or_null("/root/GameClock") + if clock != null and bool(clock.get("paused")): + return current_floor + var t := clampf((_animation_time-float(track[&"at"]))/0.42,0.0,1.0) + return lerpf(float(track[&"from"]),current_floor,t) + + func _draw_actors() -> void: var counts: Dictionary = {} var alive: Dictionary = {} @@ -622,6 +714,9 @@ func _draw_actors() -> void: continue var key := "guest/"+String(guest.guest_id) alive[key] = true + if _boarded_actor_ids.has(key): + _actor_tracks.erase(key) + continue var position := _tracked_actor_position(key,guest.current_cell) if guest.current_cell.z==layer_mode: var receiving := not String(guest.current_service_id).is_empty() @@ -637,6 +732,9 @@ func _draw_actors() -> void: continue var key := "staff/"+String(staff.staff_id) alive[key] = true + if _boarded_actor_ids.has(key): + _actor_tracks.erase(key) + continue var position := _tracked_actor_position(key,staff.current_cell) if staff.current_cell.z==layer_mode: _draw_pixel_actor(position+_actor_offset(staff.current_cell,counts),key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),staff.role_id) @@ -690,7 +788,7 @@ func _guest_activity(guest: Variant) -> StringName: return StringName(facility.definition_id) if facility != null else &"" -func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, receiving: bool, carrying: bool, activity: StringName = &"") -> void: +func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, receiving: bool, carrying: bool, activity: StringName = &"", riding := false) -> void: var texture := visual_theme.pixel_dungeon_tiles if visual_theme != null else null if texture==null: return @@ -700,7 +798,7 @@ func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, re var moving: bool = track.get(&"cell",Vector3i.ZERO)!=track.get(&"from",Vector3i.ZERO) and _animation_time-float(track.get(&"at",0.0))<0.42 var clock := get_node_or_null("/root/GameClock") var paused := clock != null and bool(clock.get("paused")) - moving = moving and not paused + moving = moving and not paused and not riding var variant := int(absi(key.hash()))%5 var tile_id := (96 if staff else 84)+variant var frame := int(_animation_time*9.0+variant)%4 @@ -729,7 +827,7 @@ func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, re draw_rect(Rect2(at+Vector2(7,8)*p,Vector2(2,3)*p),Color("81c6ba")) if carrying: PixelArt.tile(self,texture,89,Rect2(at+Vector2(9,6)*p,Vector2(9,9)*p)) - var working := staff and state==StaffRuntime.State.WORKING + var working := staff and state==StaffRuntime.State.WORKING and not riding var resting := receiving and activity==&"standard_room" if resting: var lift := float(int(_animation_time*2)%3) if not paused else 0.0 @@ -741,7 +839,7 @@ func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, re if receiving and activity in [&"cafeteria",&"tavern"]: var lift := p if not paused and int(_animation_time*3)%2==0 else 0.0 draw_rect(Rect2(at+Vector2(11*p,8*p-lift),Vector2(3*p,2*p)),Color("e6d7b5")) - var waiting := not staff and state==GuestRuntime.State.QUEUING + var waiting := not staff and state==GuestRuntime.State.QUEUING and not riding if (receiving and not resting) or working or waiting: var badge := Rect2(at+Vector2(10,-4)*p,Vector2(6,5)*p) draw_rect(badge,Color("f2e9cb")) diff --git a/scripts/construction/construction_service.gd b/scripts/construction/construction_service.gd index 9da9a02..0c00acf 100644 --- a/scripts/construction/construction_service.gd +++ b/scripts/construction/construction_service.gd @@ -468,6 +468,12 @@ func demolish_facility( }) _facilities.erase(instance_id) + # A completed construction job owns its live facility. Once demolition has + # committed, retire both records so snapshots cannot retain an orphaned + # COMPLETED job. Sequence counters and emitted domain events retain history. + var source_job_id := StringName(instance.get(&"source_job_id", &"")) + _jobs.erase(source_job_id) + _last_progress_minutes.erase(source_job_id) _mutation_in_progress = false facility_demolished.emit(instance_id, refund) return CommandResultType.succeeded(0, { diff --git a/scripts/staff/staff_commute_service.gd b/scripts/staff/staff_commute_service.gd new file mode 100644 index 0000000..e5191a5 --- /dev/null +++ b/scripts/staff/staff_commute_service.gd @@ -0,0 +1,168 @@ +class_name StaffCommuteService +extends RefCounted + +## Commutes derive from the saved work plan, Shift and current actor position. +## Runtime owns movement credit, Path projection, elevator rides and events. +## This adapter deliberately owns no additional snapshot payload. +const ROUTE_BLOCKED := &"COMMUTE_ROUTE_BLOCKED" +const WORKPLACE_MISSING := &"COMMUTE_WORKPLACE_MISSING" +const WAITING_SHIFT := &"WAITING_FOR_SHIFT" +const MOVING_TO_WORK := &"MOVING_TO_WORK" + +var _runtime: Variant + + +func configure(runtime: Variant) -> StaffCommuteService: + _runtime = runtime + _runtime.staff_service.configure_attendance_driver(Callable(self, "can_attend_shift")) + return self + + +## A porter checks in once at the warehouse, then remains dispatchable at the +## end of a delivery. Fixed-post workers return before resuming desk service. +func can_attend_shift(staff: StaffRuntime, shift: ShiftInstance, _minute: int) -> bool: + if _runtime == null or staff == null or shift == null: + return false + if _in_transit(staff.staff_id) and (staff.current_task_id.is_empty() or shift.actual_started_at < 0): + return false + if shift.actual_started_at >= 0 and (staff.role_id == &"porter" or not staff.current_task_id.is_empty()): + return true + var facility: FacilityInstance = _runtime.facility_service.get_instance(shift.assigned_facility_id) + return is_at_facility(staff, facility) + + +## Physical attendance is separate from dispatch availability. A porter away +## with goods does not provide staff capacity to an empty warehouse. +func is_present(staff: StaffRuntime, facility: FacilityInstance) -> bool: + if _runtime == null or staff == null or facility == null \ + or staff.assigned_facility_id != facility.instance_id \ + or staff.state != StaffRuntime.State.AVAILABLE or not staff.can_take_shift(): + return false + if _in_transit(staff.staff_id): + return false + if not _runtime.staff_service.requires_attendance(staff.staff_id): + return true + return is_at_facility(staff, facility) + + +func tick(absolute_minute: int) -> Dictionary: + if _runtime == null: + return {&"ok": false, &"reason": &"NOT_CONFIGURED"} + var moved: Array[StringName] = [] + var waiting: Array[StringName] = [] + for staff: StaffRuntime in _runtime.staff_service.get_staff_members(): + if not _runtime.staff_service.requires_attendance(staff.staff_id) \ + or staff.state != StaffRuntime.State.COMMUTING \ + or not staff.can_take_shift() or not staff.current_task_id.is_empty(): + continue + var shift: ShiftInstance = _runtime.staff_service.get_shift(staff.shift_id) + if shift == null or (not shift.contains_minute(absolute_minute) and not shift.is_commute_window(absolute_minute)): + continue + if _in_transit(staff.staff_id): + _wait(staff, absolute_minute, &"ELEVATOR_WAIT") + waiting.append(staff.staff_id) + continue + var facility: FacilityInstance = _runtime.facility_service.get_instance(shift.assigned_facility_id) + if facility == null: + _wait(staff, absolute_minute, WORKPLACE_MISSING) + waiting.append(staff.staff_id) + continue + if is_at_facility(staff, facility): + staff.absence_reason = &"" + _wait(staff, absolute_minute, WAITING_SHIFT if shift.is_commute_window(absolute_minute) else &"COMMUTE_ARRIVED") + waiting.append(staff.staff_id) + continue + var access := find_facility_access_path(staff.current_cell, facility) + if not bool(access.get(&"reachable", false)): + _wait(staff, absolute_minute, ROUTE_BLOCKED) + waiting.append(staff.staff_id) + continue + staff.absence_reason = &"" + var result: Dictionary = _runtime.advance_staff_commute(staff.staff_id, access[&"cell"], absolute_minute) + if bool(result.get(&"moved", false)): + moved.append(staff.staff_id) + else: + waiting.append(staff.staff_id) + return {&"ok": true, &"moved_staff_ids": moved, &"waiting_staff_ids": waiting} + + +func get_report() -> Dictionary: + var rows: Array[Dictionary] = [] + if _runtime == null: + return {&"staff": rows} + for staff: StaffRuntime in _runtime.staff_service.get_staff_members(): + if not _runtime.staff_service.requires_attendance(staff.staff_id): + continue + var shift: ShiftInstance = _runtime.staff_service.get_shift(staff.shift_id) + var facility: FacilityInstance = _runtime.facility_service.get_instance(staff.assigned_facility_id) + var reason := staff.absence_reason + var target := staff.current_cell + if _in_transit(staff.staff_id): + reason = &"ELEVATOR_WAIT" + if facility != null: + var access := find_facility_access_path(staff.current_cell, facility) + target = access.get(&"cell", facility.origin) + if staff.state == StaffRuntime.State.COMMUTING and reason.is_empty(): + reason = WAITING_SHIFT if is_at_facility(staff, facility) else MOVING_TO_WORK + rows.append({ + &"staff_id": staff.staff_id, &"state": StaffRuntime.State.keys()[staff.state], + &"reason": reason, &"facility_id": staff.assigned_facility_id, + &"current_cell": staff.current_cell, &"target_cell": target, + &"present": is_present(staff, facility), + &"late_minutes": shift.late_minutes if shift != null else 0, + }) + return {&"staff": rows} + + +func find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary: + if _runtime == null or facility == null: + return {&"reachable": false} + var candidates: Dictionary = {} + var occupied := facility.occupied_cells.duplicate() + if occupied.is_empty(): + occupied.append(facility.origin) + for cell: Vector3i in occupied: + for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]: + if _runtime.path_service.has_cell(cell + delta): + candidates[cell + delta] = true + var ordered: Array = candidates.keys() + ordered.sort_custom(func(a: Vector3i, b: Vector3i) -> bool: + if a.z != b.z: return a.z < b.z + if a.y != b.y: return a.y < b.y + return a.x < b.x) + var best: Array[Vector3i] = [] + var best_cost := 9223372036854775807 + for cell: Vector3i in ordered: + var path: Array[Vector3i] = _runtime.path_service.find_path(start, cell, + [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"]) + var cost: int = _runtime.path_service.calculate_path_cost(path, [&"staff"]) if not path.is_empty() else -1 + if cost >= 0 and (best.is_empty() or cost < best_cost): + best = path + best_cost = cost + return {&"reachable": not best.is_empty(), &"path": best, + &"cell": best[-1] if not best.is_empty() else facility.origin, + &"steps": maxi(0, best.size() - 1)} + + +static func is_at_facility(staff: StaffRuntime, facility: FacilityInstance) -> bool: + if staff == null or facility == null: + return false + var occupied := facility.occupied_cells.duplicate() + if occupied.is_empty(): + occupied.append(facility.origin) + for cell: Vector3i in occupied: + if cell.z == staff.current_cell.z and cell.y == staff.current_cell.y \ + and absi(cell.x - staff.current_cell.x) <= 1: + return true + return false + + +func _wait(staff: StaffRuntime, minute: int, reason: StringName) -> void: + if reason in [ROUTE_BLOCKED, WORKPLACE_MISSING]: + staff.absence_reason = reason + _runtime.advance_staff_commute(staff.staff_id, staff.current_cell, minute, reason) + + +func _in_transit(staff_id: StringName) -> bool: + return _runtime != null and _runtime.has_method("is_staff_in_transit") \ + and bool(_runtime.is_staff_in_transit(staff_id)) diff --git a/scripts/staff/staff_commute_service.gd.uid b/scripts/staff/staff_commute_service.gd.uid new file mode 100644 index 0000000..b9adf75 --- /dev/null +++ b/scripts/staff/staff_commute_service.gd.uid @@ -0,0 +1 @@ +uid://ddcf3c4qyyysc diff --git a/scripts/staff/staff_service.gd b/scripts/staff/staff_service.gd index 50e6dbe..ccd794b 100644 --- a/scripts/staff/staff_service.gd +++ b/scripts/staff/staff_service.gd @@ -49,6 +49,7 @@ var _item_reserver := Callable() var _item_releaser := Callable() var _travel_estimator := Callable() var _external_task_driver := Callable() +var _attendance_driver := Callable() func configure_callbacks( @@ -69,6 +70,17 @@ func configure_external_task_driver(driver: Callable = Callable()) -> void: _external_task_driver = driver +## Runtime attendance uses the employee's real position. Component consumers +## and legacy one-off shifts retain their existing scheduling contract. +func configure_attendance_driver(driver: Callable = Callable()) -> void: + _attendance_driver = driver + + +func requires_attendance(staff_id: StringName) -> bool: + return _attendance_driver.is_valid() and _work_assignments.has(staff_id) \ + and not String(_work_assignments[staff_id][&"facility_id"]).is_empty() + + func is_externally_driven_task(task_id: StringName) -> bool: return _external_task_driver.is_valid() and bool(_external_task_driver.call(task_id)) @@ -461,6 +473,17 @@ func set_work_assignment(staff_id: StringName, facility_id: StringName, template if shift.cancelled or shift.end_at <= minute: continue var replacement := ShiftInstanceType.from_snapshot(shift.create_snapshot()) + # Attendance can contain gaps, so clamping by scheduled duration alone + # cannot remove an already recorded paused-edit boundary minute. The + # current AVAILABLE employee recorded that minute; its replacement plan + # records it only if the employee is physically at the new workplace. + if requires_attendance(staff_id) and minute == current_minute \ + and staff.state == StaffRuntimeType.State.AVAILABLE \ + and staff.shift_id == shift.shift_instance_id \ + and shift.contains_minute(minute) and shift.worked_minutes > 0: + replacement.worked_minutes -= 1 + if replacement.worked_minutes == 0 and replacement.overtime_minutes == 0: + replacement.actual_started_at = -1 if replacement.start_at >= minute: replacement.cancelled = true else: @@ -496,6 +519,8 @@ func set_work_assignment(staff_id: StringName, facility_id: StringName, template staff.shift_id = &"" staff.assigned_facility_id = &"" staff.is_overtime = false + if String(staff.absence_reason).begins_with("COMMUTE_"): + staff.absence_reason = &"" staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"WORK_ASSIGNMENT_CHANGED", minute) if new_shift != null: _shifts[new_shift.shift_instance_id] = new_shift @@ -1379,6 +1404,8 @@ func _update_shift_states(absolute_minute: int, only_staff: StringName = &"", re task_reopened.emit(current_task.task_id, &"SHIFT_ENDED") staff.current_task_id = &"" staff.is_overtime = false + if String(staff.absence_reason).begins_with("COMMUTE_"): + staff.absence_reason = &"" staff.shift_id = &"" if staff.assigned_facility_id == shift.assigned_facility_id: staff.assigned_facility_id = &"" @@ -1410,16 +1437,26 @@ func _update_shift_states(absolute_minute: int, only_staff: StringName = &"", re continue if shift.contains_minute(absolute_minute): staff.is_overtime = false + var attendance_required := requires_attendance(staff.staff_id) + var attended := not attendance_required or bool(_attendance_driver.call(staff, shift, absolute_minute)) + if attendance_required and attended and String(staff.absence_reason).begins_with("COMMUTE_"): + staff.absence_reason = &"" if staff.current_task_id.is_empty() and staff.state in [ StaffRuntimeType.State.OFF_DUTY, StaffRuntimeType.State.COMMUTING, + StaffRuntimeType.State.AVAILABLE, ]: - staff.transition_to(StaffRuntimeType.State.AVAILABLE, &"SHIFT_STARTED", absolute_minute) - shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state) - if record_duty: + var next_state := StaffRuntimeType.State.AVAILABLE if attended else StaffRuntimeType.State.COMMUTING + if staff.state != next_state: + var reason := &"SHIFT_STARTED" if attended else (&"RETURN_TO_POST" if shift.actual_started_at >= 0 else &"SHIFT_COMMUTE") + staff.transition_to(next_state, reason, absolute_minute) + shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state) + if record_duty and attended: shift.record_duty_minute(absolute_minute) - if absolute_minute >= shift.start_at + 15 and staff.state == StaffRuntimeType.State.COMMUTING: - shift.mark_late(absolute_minute, DEFAULT_LATE_REASON) + if absolute_minute >= shift.start_at + 15 and staff.state == StaffRuntimeType.State.COMMUTING \ + and (not attendance_required or shift.actual_started_at < 0): + var late_reason := staff.absence_reason if attendance_required and not staff.absence_reason.is_empty() else DEFAULT_LATE_REASON + shift.mark_late(absolute_minute, late_reason) func _find_relevant_shift(staff_id: StringName, absolute_minute: int) -> ShiftInstance: diff --git a/scripts/supply/supply_logistics_bridge.gd b/scripts/supply/supply_logistics_bridge.gd index 8dabe57..0b1bdd5 100644 --- a/scripts/supply/supply_logistics_bridge.gd +++ b/scripts/supply/supply_logistics_bridge.gd @@ -403,7 +403,7 @@ func _access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary: return {&"reachable": false} var cells: Dictionary = {} for cell: Vector3i in facility.occupied_cells: - for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]: + for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]: if _runtime.path_service.has_cell(cell + delta): cells[cell + delta] = true if cells.is_empty() and _runtime.path_service.has_cell(facility.origin): @@ -414,10 +414,13 @@ func _access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary: if a.y != b.y: return a.y < b.y return a.x < b.x) var best: Array[Vector3i] = [] + var best_cost := 9223372036854775807 for cell: Vector3i in ordered: var path: Array[Vector3i] = _runtime.path_service.find_path(start, cell, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"]) - if not path.is_empty() and (best.is_empty() or path.size() < best.size()): + var cost: int = _runtime.path_service.calculate_path_cost(path, [&"staff"]) if not path.is_empty() else -1 + if cost >= 0 and (best.is_empty() or cost < best_cost): best = path + best_cost = cost return {&"reachable": not best.is_empty(), &"cell": best[-1] if not best.is_empty() else facility.origin, &"steps": maxi(0, best.size() - 1)} @@ -557,6 +560,6 @@ static func validate_crosslinks(requests: Dictionary, staff: StaffService, inven static func _cell_touches_facility(cell: Vector3i, facility: FacilityInstance) -> bool: for occupied: Vector3i in facility.occupied_cells: - if cell.z == occupied.z and absi(cell.x - occupied.x) + absi(cell.y - occupied.y) <= 1: + if cell.z == occupied.z and cell.y == occupied.y and absi(cell.x - occupied.x) <= 1: return true return cell == facility.origin diff --git a/scripts/transport/elevator_service.gd b/scripts/transport/elevator_service.gd new file mode 100644 index 0000000..7eff76c --- /dev/null +++ b/scripts/transport/elevator_service.gd @@ -0,0 +1,678 @@ +class_name ElevatorService +extends RefCounted + +## One physical cabin per contiguous shaft. Path connectors expose reachability; +## boarding and arrival here remain authoritative for actual movement. +## Dispatch is oldest request first, batching one origin/destination at a time. +const SNAPSHOT_VERSION := 1 +const PREFIX := "transport." +const REQUEST_PREFIX := "transport.trip." +const DOOR_MINUTES := 2 +const QUEUE_CAPACITY := 256 + +var last_error := "" +var _path: PathService +var _shafts: Dictionary = {} +var _trips: Dictionary = {} +var _minute := -1 +var _sequence := 0 +var current_minute: int: + get: return _minute + + +func configure(path: PathService) -> void: + _path = path + + +static func configurations_from_facilities(facilities: Array, registry: DefinitionRegistry) -> Array: + var modules: Array[Dictionary] = [] + for value: Variant in facilities: + if not value is Dictionary: + continue + var facility: Dictionary = value + var definition_id := StringName(str(facility.get(&"definition_id", ""))) + if definition_id not in [&"guest_elevator", &"service_elevator"]: + continue + var definition := registry.get_facility(definition_id) + var origin: Variant = facility.get(&"origin") + if definition == null or not origin is Vector3i: + continue + var width := definition.footprint.x + var height := definition.footprint.y + var cells: Array = facility.get(&"occupied_cells", []) + if not cells.is_empty(): + width = 1 + height = 1 + for cell: Vector3i in cells: + width = maxi(width, cell.x - origin.x + 1) + height = maxi(height, cell.y - origin.y + 1) + modules.append({&"origin": origin, &"width": width, &"height": height, + &"module_id": StringName(str(facility.get(&"instance_id", "")))}) + modules.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var ac: Vector3i = a[&"origin"] + var bc: Vector3i = b[&"origin"] + if ac.z != bc.z: return ac.z < bc.z + if ac.x != bc.x: return ac.x < bc.x + if a[&"width"] != b[&"width"]: return a[&"width"] < b[&"width"] + return ac.y < bc.y) + var result: Array = [] + for module: Dictionary in modules: + var origin: Vector3i = module[&"origin"] + var width: int = module[&"width"] + var config: Dictionary = {} + if not result.is_empty(): + var previous: Dictionary = result.back() + var stops: Array = previous[&"stops"] + var top: Vector3i = stops.back() + if top.x == origin.x and top.z == origin.z and top.y + 1 == origin.y and previous[&"width"] == width: + config = previous + if config.is_empty(): + config = {&"shaft_id": StringName("transport.shaft.%d.%d.%d.%d" % [origin.z, origin.x, origin.y, width]), + &"module_ids": [], &"stops": [], &"width": width, + &"capacity": 4 if origin.z == 0 else 8, + &"travel_minutes_per_floor": 10 if origin.z == 0 else 5, + &"access_id": &"" if origin.z == 0 else &"staff"} + result.append(config) + (config[&"module_ids"] as Array).append(module[&"module_id"]) + for y: int in range(origin.y, origin.y + int(module[&"height"])): + (config[&"stops"] as Array).append(Vector3i(origin.x, y, origin.z)) + return result + + +func synchronize(configurations: Array, minute: int) -> Dictionary: + if _path == null or minute < 0 or (_minute >= 0 and minute < _minute): + return _failure(&"INVALID_CLOCK", "Elevator synchronization requires a path and current time.") + var normalized := _normalize_configs(configurations) + if not normalized[&"ok"]: + return normalized + var configs: Dictionary = normalized[&"configs"] + for shaft_id: StringName in _names(_shafts): + var car: Dictionary = _shafts[shaft_id] + if configs.has(shaft_id) and configs[shaft_id] == car[&"config"]: + continue + if _shaft_busy(shaft_id): + return _failure(&"ELEVATOR_BUSY", "An occupied or moving shaft cannot be modified.") + # All new IDs must belong to us and must not overwrite an unrelated connector. + var old_ids: Dictionary = {} + for value: Variant in _shafts.values(): + for connector: Dictionary in _connector_configs(value[&"config"]): + old_ids[connector[&"id"]] = true + for value: Variant in configs.values(): + for connector: Dictionary in _connector_configs(value): + if _path.has_connector(connector[&"id"]) and not old_ids.has(connector[&"id"]): + return _failure(&"CONNECTOR_CONFLICT", "Elevator connector is already owned.") + var path_before := _path.create_snapshot() + var next: Dictionary = {} + for shaft_id: StringName in _names(_shafts): + var car: Dictionary = _shafts[shaft_id] + if configs.has(shaft_id) and configs[shaft_id] == car[&"config"]: + next[shaft_id] = car + continue + for connector: Dictionary in _connector_configs(car[&"config"]): + if not _path.remove_connector(connector[&"id"]): + _path.restore_snapshot(path_before) + return _failure(&"CONNECTOR_BUSY", _path.last_error) + for shaft_id: StringName in _names(configs): + if next.has(shaft_id): + continue + var config: Dictionary = configs[shaft_id] + var car := _new_car(config) + if _shafts.has(shaft_id): + car[&"operational"] = _shafts[shaft_id][&"operational"] + car[&"operator_open"] = _shafts[shaft_id][&"operator_open"] + car[&"powered"] = _shafts[shaft_id][&"powered"] + var previous_floor: int = _shafts[shaft_id][&"current_floor"] + if _has_floor(config, previous_floor): car[&"current_floor"] = previous_floor + for connector: Dictionary in _connector_configs(config): + if not _path.configure_connector(connector[&"id"], connector[&"from"], connector[&"to"], + PathService.ORTHOGONAL_MOVE_COST, true, config[&"access_id"], true, connector): + _path.restore_snapshot(path_before) + return _failure(&"CONNECTOR_CONFIGURATION_FAILED", _path.last_error) + next[shaft_id] = car + _shafts = next + if _minute < 0: _minute = minute + for shaft_id: StringName in _names(_shafts): _publish_cabin(_shafts[shaft_id]) + last_error = "" + return {&"ok": true, &"shaft_count": _shafts.size()} + + +func request_trip(actor_id: StringName, from: Vector3i, to: Vector3i, minute: int, + access: Array[StringName] = []) -> Dictionary: + if actor_id.is_empty() or minute < _minute or minute > _minute + 1: + return _failure(&"INVALID_REQUEST", "Elevator request has invalid actor or time.") + if _trips.has(actor_id): + var existing: Dictionary = _trips[actor_id] + if existing[&"from"] == from and existing[&"to"] == to: + return {&"ok": true, &"replay": true, &"trip": existing.duplicate(true)} + return _failure(&"ACTOR_ALREADY_TRAVELLING", "Actor already owns an elevator trip.") + var selected: Dictionary = {} + for shaft_id: StringName in _names(_shafts): + var car: Dictionary = _shafts[shaft_id] + var config: Dictionary = car[&"config"] + if from != to and (config[&"stops"] as Array).has(from) and (config[&"stops"] as Array).has(to): + selected = car + break + if selected.is_empty(): return _failure(&"NO_ELEVATOR", "No shaft joins those stops.") + var config: Dictionary = selected[&"config"] + if not selected[&"operational"]: return _failure(&"ELEVATOR_STOPPED", "Elevator is stopped.") + if not StringName(config[&"access_id"]).is_empty() and not access.has(config[&"access_id"]): + return _failure(&"ACCESS_DENIED", "Elevator access is not permitted.") + if not _valid_stop(from) or not _valid_stop(to): + return _failure(&"STOP_UNAVAILABLE", "Elevator landing is unavailable.") + var connector_id := _connector_id(config[&"shaft_id"], from.y, to.y) + _sequence += 1 + var request_id := StringName("transport.trip.%012d" % _sequence) + var queued := _path.enqueue_elevator_request(connector_id, {&"request_id": request_id, + &"unit_id": actor_id, &"floor_from": from.y, &"floor_to": to.y, &"enqueued_at": minute}) + if not queued[&"ok"]: + return _failure(queued.get(&"reason", &"QUEUE_REJECTED"), _path.last_error) + var trip := {&"actor_id": actor_id, &"request_id": request_id, &"shaft_id": config[&"shaft_id"], + &"connector_id": connector_id, &"from": from, &"to": to, + &"enqueued_at": minute, &"state": PathService.ELEVATOR_REQUEST_QUEUED} + _trips[actor_id] = trip + last_error = "" + return {&"ok": true, &"replay": false, &"trip": trip.duplicate(true)} + + +func advance_one_minute(minute: int) -> Array[Dictionary]: + var arrivals: Array[Dictionary] = [] + if minute == _minute: return arrivals + if minute != _minute + 1: + last_error = "Elevators must advance in consecutive minutes." + return arrivals + _minute = minute + for shaft_id: StringName in _names(_shafts): + var car: Dictionary = _shafts[shaft_id] + if not car[&"operational"]: continue + var unavailable := false + for actor_id: StringName in car[&"boarded"]: + if not _valid_stop(_trips[actor_id][&"to"]): unavailable = true + if unavailable: + arrivals.append_array(set_operational(shaft_id, false, minute)) + continue + if car[&"phase"] == &"IDLE": + _dispatch(car) + elif int(car[&"remaining_minutes"]) > 0: + car[&"remaining_minutes"] -= 1 + if int(car[&"remaining_minutes"]) == 0: + _finish_phase(car, arrivals) + _publish_cabin(car) + last_error = "" + return arrivals + + +func get_actor_trip(actor_id: StringName) -> Dictionary: + return (_trips.get(actor_id, {}) as Dictionary).duplicate(true) + + +func cancel_actor(actor_id: StringName) -> bool: + if not _trips.has(actor_id): return true + var trip: Dictionary = _trips[actor_id] + if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED: + if not _path.complete_elevator_request(trip[&"connector_id"], trip[&"request_id"]): + last_error = _path.last_error + return false + (_shafts[trip[&"shaft_id"]][&"boarded"] as Array).erase(actor_id) + _trips.erase(actor_id) + last_error = "" + return true + if not _path.cancel_elevator_request(trip[&"connector_id"], trip[&"request_id"]): + last_error = _path.last_error + return false + _trips.erase(actor_id) + last_error = "" + return true + + +func set_operator_open(shaft_id: StringName, enabled: bool, minute: int) -> Array[Dictionary]: + if not _shafts.has(shaft_id) or minute < _minute or minute > _minute + 1: + last_error = "Unknown shaft or invalid operator change time." + return [] + _shafts[shaft_id][&"operator_open"] = enabled + return _apply_operational(shaft_id, enabled and _shafts[shaft_id][&"powered"], minute) + + +func set_powered(shaft_id: StringName, powered: bool, minute: int) -> Array[Dictionary]: + if not _shafts.has(shaft_id) or minute < _minute or minute > _minute + 1: + last_error = "Unknown shaft or invalid power change time." + return [] + _shafts[shaft_id][&"powered"] = powered + return _apply_operational(shaft_id, powered and _shafts[shaft_id][&"operator_open"], minute) + + +func set_operational(shaft_id: StringName, operational: bool, minute: int) -> Array[Dictionary]: + return set_powered(shaft_id, operational, minute) + + +func _apply_operational(shaft_id: StringName, operational: bool, minute: int) -> Array[Dictionary]: + var arrivals: Array[Dictionary] = [] + if not _shafts.has(shaft_id) or minute < _minute or minute > _minute + 1: + last_error = "Unknown shaft or invalid operational change time." + return arrivals + var car: Dictionary = _shafts[shaft_id] + if car[&"operational"] == operational: return arrivals + if not operational: + var landing: Variant = _nearest_valid_stop(car) + if landing == null and not (car[&"boarded"] as Array).is_empty(): + last_error = "Occupied elevator has no safe landing; cabin remains held." + # No legal teleportation destination exists. The caller must repair a + # landing; movement remains held and requests remain owned. + car[&"operational"] = false + for actor_id: StringName in _names(_trips): + var waiting: Dictionary = _trips[actor_id] + if waiting[&"shaft_id"] == shaft_id and waiting[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED and cancel_actor(actor_id): + arrivals.append({&"actor_id": actor_id, &"request_id": waiting[&"request_id"], &"cell": waiting[&"from"], &"emergency": false, &"cancelled": true}) + _publish_cabin(car) + return arrivals + for actor_id: StringName in _names(_trips): + var trip: Dictionary = _trips[actor_id] + if trip[&"shaft_id"] != shaft_id: continue + var boarded: bool = trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED + if boarded: + if not _path.complete_elevator_request(trip[&"connector_id"], trip[&"request_id"]): continue + else: + if not _path.cancel_elevator_request(trip[&"connector_id"], trip[&"request_id"]): continue + arrivals.append({&"actor_id": actor_id, &"request_id": trip[&"request_id"], + &"cell": landing if boarded else trip[&"from"], &"emergency": boarded, &"cancelled": not boarded}) + _trips.erase(actor_id) + car[&"boarded"] = [] + if landing != null: car[&"current_floor"] = (landing as Vector3i).y + _idle(car) + if operational and car[&"phase"] != &"IDLE": + car[&"phase_started_at"] = _minute - (int(car[&"phase_total_minutes"]) - int(car[&"remaining_minutes"])) + car[&"operational"] = operational + _publish_cabin(car) + last_error = "" + return arrivals + + +func owns_connector(connector_id: StringName) -> bool: + return not get_shaft_for_connector(connector_id).is_empty() + + +func get_shaft_for_connector(connector_id: StringName) -> StringName: + for shaft_id: StringName in _names(_shafts): + for connector: Dictionary in _connector_configs(_shafts[shaft_id][&"config"]): + if connector[&"id"] == connector_id: return shaft_id + return &"" + + +func get_report() -> Dictionary: + var rows: Array[Dictionary] = [] + for shaft_id: StringName in _names(_shafts): + var car: Dictionary = _shafts[shaft_id] + var row: Dictionary = car.duplicate(true) + row.merge(car[&"config"], true) + row[&"visual_floor"] = _visual_floor(car) + row[&"waiting_count"] = 0 + row[&"passenger_count"] = (car[&"boarded"] as Array).size() + row[&"passenger_ids"] = car[&"boarded"].duplicate() + for trip: Dictionary in _trips.values(): + if trip[&"shaft_id"] == shaft_id and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED: + row[&"waiting_count"] += 1 + rows.append(row) + return {&"current_minute": _minute, &"shafts": rows, &"trips": _ordered_trips()} + + +func create_snapshot() -> Dictionary: + var cars: Array[Dictionary] = [] + for shaft_id: StringName in _names(_shafts): cars.append(_shafts[shaft_id].duplicate(true)) + return {&"version": SNAPSHOT_VERSION, &"current_minute": _minute, + &"request_sequence": _sequence, &"shafts": cars, &"trips": _ordered_trips()} + + +func restore_snapshot(snapshot: Dictionary) -> bool: + if _path == null or not _exact_keys(snapshot, [&"version", &"current_minute", &"request_sequence", &"shafts", &"trips"]): + return _invalid("Elevator snapshot shape is invalid.") + if snapshot[&"version"] != SNAPSHOT_VERSION or not snapshot[&"current_minute"] is int or snapshot[&"current_minute"] < 0 \ + or not snapshot[&"request_sequence"] is int or snapshot[&"request_sequence"] < 0 \ + or not snapshot[&"shafts"] is Array or not snapshot[&"trips"] is Array: + return _invalid("Elevator snapshot header is invalid.") + var next_shafts: Dictionary = {} + var next_trips: Dictionary = {} + var seen_modules: Dictionary = {} + var expected_connectors: Dictionary = {} + var boarded_owners: Dictionary = {} + for value: Variant in snapshot[&"shafts"]: + if not value is Dictionary: return _invalid("Elevator cabin must be a Dictionary.") + var car: Dictionary = value + if not _exact_keys(car, _new_car({&"stops": [Vector3i.ZERO]}).keys()) or not car[&"config"] is Dictionary: + return _invalid("Elevator cabin fields are invalid.") + var normalized := _normalize_configs([car[&"config"]]) + if not normalized[&"ok"]: return false + var config: Dictionary = car[&"config"] + var shaft_id := StringName(config[&"shaft_id"]) + if next_shafts.has(shaft_id): return _invalid("Duplicate elevator shaft.") + for module_id: StringName in config[&"module_ids"]: + if seen_modules.has(module_id): return _invalid("Module belongs to multiple shafts.") + seen_modules[module_id] = true + if not _validate_car(car, int(snapshot[&"current_minute"])): return false + for actor: Variant in car[&"boarded"]: + if not (actor is String or actor is StringName) or str(actor).is_empty() or boarded_owners.has(StringName(actor)): + return _invalid("Elevator cabin actor is invalid or duplicated.") + boarded_owners[StringName(actor)] = shaft_id + for expected: Dictionary in _connector_configs(config): + var connector_id: StringName = expected[&"id"] + var live := _path.get_connector(connector_id) + for key: StringName in expected: + if not live.has(key) or live[key] != expected[key]: return _invalid("Path elevator configuration disagrees with cabin.") + if live[&"cabin_state"] != _path_cabin_state(car) \ + or live[&"operational_state"] != (PathService.CONNECTOR_OPERATIONAL if car[&"operational"] else PathService.CONNECTOR_STOPPED): + return _invalid("Path elevator operation disagrees with cabin.") + expected_connectors[connector_id] = true + next_shafts[shaft_id] = car.duplicate(true) + for value: Variant in snapshot[&"trips"]: + if not value is Dictionary: return _invalid("Elevator trip must be a Dictionary.") + var trip: Dictionary = value + if not _exact_keys(trip, [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"from", &"to", &"enqueued_at", &"state"]): + return _invalid("Elevator trip fields are invalid.") + for key: StringName in [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"state"]: + if not (trip[key] is String or trip[key] is StringName) or str(trip[key]).is_empty(): return _invalid("Elevator trip names are invalid.") + var actor_id := StringName(trip[&"actor_id"]) + var shaft_id := StringName(trip[&"shaft_id"]) + if next_trips.has(actor_id) or not next_shafts.has(shaft_id): return _invalid("Elevator trip actor or shaft is duplicated/missing.") + var config: Dictionary = next_shafts[shaft_id][&"config"] + if not trip[&"from"] is Vector3i or not trip[&"to"] is Vector3i \ + or trip[&"from"] == trip[&"to"] or not (config[&"stops"] as Array).has(trip[&"from"]) or not (config[&"stops"] as Array).has(trip[&"to"]): + return _invalid("Elevator trip stops are invalid.") + if trip[&"connector_id"] != _connector_id(shaft_id, trip[&"from"].y, trip[&"to"].y) \ + or not trip[&"enqueued_at"] is int or trip[&"enqueued_at"] < 0 or trip[&"enqueued_at"] > snapshot[&"current_minute"]: + return _invalid("Elevator trip connector/time is invalid.") + var active := _path.get_active_elevator_request(actor_id) + if active.get(&"request_id") != trip[&"request_id"] or active.get(&"connector_id") != trip[&"connector_id"] or active.get(&"state") != trip[&"state"]: + return _invalid("Elevator trip and Path active owner disagree.") + if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED: + if boarded_owners.get(actor_id) != shaft_id: return _invalid("Boarded trip has no cabin seat.") + var car: Dictionary = next_shafts[shaft_id] + if trip[&"to"].y != car[&"target_floor"] or trip[&"from"].y != car[&"boarding_floor"]: + return _invalid("Boarded trip and cabin destination disagree.") + elif trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED: + if boarded_owners.has(actor_id): return _invalid("Queued trip also owns a cabin seat.") + var found := false + for queued: Dictionary in _path.get_elevator_queue(trip[&"connector_id"]): + if queued[&"request_id"] == trip[&"request_id"]: + found = queued[&"unit_id"] == actor_id and queued[&"floor_from"] == trip[&"from"].y and queued[&"floor_to"] == trip[&"to"].y and queued[&"enqueued_at"] == trip[&"enqueued_at"] and queued[&"priority"] == PathService.ELEVATOR_PRIORITY_NORMAL + if not found: return _invalid("Queued trip details disagree with Path queue.") + else: return _invalid("Unknown elevator trip state.") + next_trips[actor_id] = trip.duplicate(true) + for actor_id: StringName in boarded_owners: + if not next_trips.has(actor_id): return _invalid("Cabin seat has no matching trip.") + var path_snapshot := _path.create_snapshot() + for connector: Dictionary in path_snapshot[&"connectors"]: + if str(connector[&"id"]).begins_with(PREFIX) and not expected_connectors.has(connector[&"id"]): + return _invalid("Path contains an orphan transport connector.") + for active: Dictionary in path_snapshot[&"active_elevator_units"]: + if expected_connectors.has(active[&"connector_id"]) and not next_trips.has(active[&"unit_id"]): + return _invalid("Path contains an orphan transport passenger.") + for request_id: Variant in path_snapshot[&"known_elevator_request_ids"]: + var name := str(request_id) + if not name.begins_with(REQUEST_PREFIX): continue + var suffix := name.trim_prefix(REQUEST_PREFIX) + if not suffix.is_valid_int() or int(suffix) <= 0 or int(suffix) > snapshot[&"request_sequence"]: + return _invalid("Elevator sequence would reuse an active or retired request ID.") + var all_configs: Array = [] + for car: Dictionary in next_shafts.values(): all_configs.append(car[&"config"]) + if not _normalize_configs(all_configs)[&"ok"]: return false + _shafts = next_shafts + _trips = next_trips + _minute = snapshot[&"current_minute"] + _sequence = snapshot[&"request_sequence"] + last_error = "" + return true + + +func _dispatch(car: Dictionary) -> void: + var candidates: Array[Dictionary] = [] + for trip: Dictionary in _trips.values(): + if trip[&"shaft_id"] == car[&"config"][&"shaft_id"] and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED and trip[&"enqueued_at"] <= _minute: + candidates.append(trip) + candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + if a[&"enqueued_at"] != b[&"enqueued_at"]: return a[&"enqueued_at"] < b[&"enqueued_at"] + return str(a[&"actor_id"]) < str(b[&"actor_id"])) + if candidates.is_empty(): + _idle(car) + return + var trip: Dictionary = candidates.front() + car[&"boarding_floor"] = trip[&"from"].y + car[&"target_floor"] = trip[&"to"].y + if car[&"current_floor"] != car[&"boarding_floor"]: + _start_phase(car, &"PICKUP", absi(int(car[&"current_floor"]) - int(car[&"boarding_floor"])) * int(car[&"config"][&"travel_minutes_per_floor"])) + else: + _board(car) + + +func _board(car: Dictionary) -> void: + var config: Dictionary = car[&"config"] + var connector_id := _connector_id(config[&"shaft_id"], car[&"boarding_floor"], car[&"target_floor"]) + var boarded := _path.dequeue_elevator_boarding(connector_id, car[&"current_floor"], _minute, config[&"capacity"]) + car[&"boarded"] = [] + for request: Dictionary in boarded: + var actor_id: StringName = request[&"unit_id"] + if not _trips.has(actor_id): continue + _trips[actor_id][&"state"] = PathService.ELEVATOR_REQUEST_BOARDED + (car[&"boarded"] as Array).append(actor_id) + if boarded.is_empty(): + _idle(car) + return + _start_phase(car, &"BOARDING", DOOR_MINUTES + boarded.size()) + + +func _finish_phase(car: Dictionary, arrivals: Array[Dictionary]) -> void: + match car[&"phase"]: + &"PICKUP": + car[&"current_floor"] = car[&"boarding_floor"] + _board(car) + &"BOARDING": + _start_phase(car, &"TRAVEL", absi(int(car[&"target_floor"]) - int(car[&"current_floor"])) * int(car[&"config"][&"travel_minutes_per_floor"])) + &"TRAVEL": + car[&"current_floor"] = car[&"target_floor"] + _start_phase(car, &"ALIGHTING", DOOR_MINUTES + (car[&"boarded"] as Array).size()) + &"ALIGHTING": + for actor_id: StringName in car[&"boarded"]: + var trip: Dictionary = _trips[actor_id] + if _path.complete_elevator_request(trip[&"connector_id"], trip[&"request_id"]): + arrivals.append({&"actor_id": actor_id, &"request_id": trip[&"request_id"], &"cell": trip[&"to"], &"emergency": false, &"cancelled": false}) + _trips.erase(actor_id) + car[&"boarded"] = [] + _idle(car) + _dispatch(car) + + +func _new_car(config: Dictionary) -> Dictionary: + var floor: int = config[&"stops"][0].y + return {&"config": config.duplicate(true), &"operational": true, &"operator_open": true, &"powered": true, &"current_floor": floor, + &"target_floor": floor, &"boarding_floor": floor, &"phase": &"IDLE", + &"remaining_minutes": 0, &"phase_total_minutes": 0, &"phase_started_at": -1, + &"phase_origin_floor": floor, &"phase_passenger_count": 0, &"boarded": []} + + +func _start_phase(car: Dictionary, phase: StringName, duration: int) -> void: + car[&"phase"] = phase + car[&"remaining_minutes"] = duration + car[&"phase_total_minutes"] = duration + car[&"phase_started_at"] = _minute + car[&"phase_origin_floor"] = car[&"current_floor"] + car[&"phase_passenger_count"] = car[&"boarded"].size() + + +func _idle(car: Dictionary) -> void: + car[&"phase"] = &"IDLE" + car[&"target_floor"] = car[&"current_floor"] + car[&"boarding_floor"] = car[&"current_floor"] + car[&"phase_origin_floor"] = car[&"current_floor"] + car[&"phase_started_at"] = -1 + car[&"remaining_minutes"] = 0 + car[&"phase_total_minutes"] = 0 + car[&"phase_passenger_count"] = 0 + + +func _visual_floor(car: Dictionary) -> float: + if car[&"phase"] not in [&"PICKUP", &"TRAVEL"]: return float(car[&"current_floor"]) + var destination: int = car[&"boarding_floor"] if car[&"phase"] == &"PICKUP" else car[&"target_floor"] + return lerpf(float(car[&"phase_origin_floor"]), float(destination), + 1.0 - float(car[&"remaining_minutes"]) / maxf(float(car[&"phase_total_minutes"]), 1.0)) + + +func _nearest_valid_stop(car: Dictionary) -> Variant: + var floor := _visual_floor(car) + var nearest: Variant = null + var distance := INF + for cell: Vector3i in car[&"config"][&"stops"]: + if not _valid_stop(cell): continue + var candidate := absf(float(cell.y) - floor) + if candidate < distance: + nearest = cell + distance = candidate + return nearest + + +func _valid_stop(cell: Vector3i) -> bool: + var traffic := _path.get_cell_traffic(cell) + return not traffic.is_empty() and not bool(traffic.get(&"temporary_block", false)) + + +func _path_cabin_state(car: Dictionary) -> StringName: + if not car[&"operational"]: return PathService.CABIN_OUTAGE + if car[&"phase"] in [&"BOARDING", &"ALIGHTING"]: return PathService.CABIN_DOOR_OPEN + if car[&"phase"] in [&"PICKUP", &"TRAVEL"]: + var target: int = car[&"boarding_floor"] if car[&"phase"] == &"PICKUP" else car[&"target_floor"] + return PathService.CABIN_MOVING_UP if target > int(car[&"current_floor"]) else PathService.CABIN_MOVING_DOWN + return PathService.CABIN_IDLE + + +func _publish_cabin(car: Dictionary) -> void: + for connector: Dictionary in _connector_configs(car[&"config"]): + _path.set_connector_operational_state(connector[&"id"], PathService.CONNECTOR_OPERATIONAL if car[&"operational"] else PathService.CONNECTOR_STOPPED) + _path.set_elevator_cabin_state(connector[&"id"], _path_cabin_state(car)) + + +func _shaft_busy(shaft_id: StringName) -> bool: + if _shafts[shaft_id][&"phase"] != &"IDLE": return true + for trip: Dictionary in _trips.values(): + if trip[&"shaft_id"] == shaft_id: return true + return false + + +static func _connector_id(shaft_id: StringName, from: int, to: int) -> StringName: + return StringName("%s.link.%d.%d" % [shaft_id, mini(from, to), maxi(from, to)]) + + +func _connector_configs(config: Dictionary) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + var stops: Array = config[&"stops"] + for i: int in range(stops.size()): + for j: int in range(i + 1, stops.size()): + result.append({&"id": _connector_id(config[&"shaft_id"], stops[i].y, stops[j].y), + &"from": stops[i], &"to": stops[j], &"movement_type": PathService.MOVEMENT_ELEVATOR, + &"cost": PathService.ORTHOGONAL_MOVE_COST, &"bidirectional": true, &"access_id": config[&"access_id"], + &"enabled": true, &"cabin_capacity": config[&"capacity"], &"queue_capacity": QUEUE_CAPACITY, + &"travel_minutes_per_floor": config[&"travel_minutes_per_floor"], &"dynamic_cost": 0}) + return result + + +func _normalize_configs(configurations: Array) -> Dictionary: + var configs: Dictionary = {} + var modules: Dictionary = {} + var landing_owners: Dictionary = {} + for value: Variant in configurations: + if not value is Dictionary: return _failure(&"INVALID_CONFIG", "Shaft configuration must be a Dictionary.") + var config: Dictionary = value + if not _exact_keys(config, [&"shaft_id", &"module_ids", &"stops", &"width", &"capacity", &"travel_minutes_per_floor", &"access_id"]): + return _failure(&"INVALID_CONFIG", "Shaft configuration fields are invalid.") + for key: StringName in [&"shaft_id", &"access_id"]: + if not (config[key] is String or config[key] is StringName): return _failure(&"INVALID_CONFIG", "Shaft names are invalid.") + var shaft_id := StringName(config[&"shaft_id"]) + if not str(shaft_id).begins_with("transport.shaft.") or configs.has(shaft_id): return _failure(&"INVALID_CONFIG", "Shaft ID is invalid or duplicated.") + if not config[&"stops"] is Array or config[&"stops"].size() < 2 or not config[&"module_ids"] is Array or config[&"module_ids"].is_empty(): + return _failure(&"INVALID_CONFIG", "Shaft must have modules and at least two stops.") + for key: StringName in [&"capacity", &"travel_minutes_per_floor", &"width"]: + if not config[key] is int or config[key] <= 0: return _failure(&"INVALID_CONFIG", "Shaft dimensions/timing are invalid.") + var first: Variant = config[&"stops"][0] + if not first is Vector3i or first.z not in [0, 1]: return _failure(&"INVALID_CONFIG", "Shaft stop layer is invalid.") + if config[&"capacity"] != (4 if first.z == 0 else 8) or config[&"travel_minutes_per_floor"] != (10 if first.z == 0 else 5): + return _failure(&"INVALID_CONFIG", "Shaft capacity/speed differs from its layer.") + for i: int in range(config[&"stops"].size()): + var cell: Variant = config[&"stops"][i] + if not cell is Vector3i or cell != Vector3i(first.x, first.y + i, first.z) or landing_owners.has(cell): + return _failure(&"INVALID_CONFIG", "Shaft stops must form a unique contiguous column.") + landing_owners[cell] = true + for module: Variant in config[&"module_ids"]: + if not (module is String or module is StringName) or str(module).is_empty() or modules.has(StringName(module)): + return _failure(&"INVALID_CONFIG", "Shaft module is empty or duplicated.") + modules[StringName(module)] = true + configs[shaft_id] = config.duplicate(true) + return {&"ok": true, &"configs": configs} + + +func _validate_car(car: Dictionary, minute: int) -> bool: + if not car[&"operational"] is bool or not car[&"operator_open"] is bool or not car[&"powered"] is bool or car[&"operational"] != (car[&"operator_open"] and car[&"powered"]) or not car[&"boarded"] is Array \ + or car[&"boarded"].size() > car[&"config"][&"capacity"]: + return _invalid("Elevator cabin operation/capacity is invalid.") + for key: StringName in [&"current_floor", &"target_floor", &"boarding_floor", &"phase_origin_floor", &"remaining_minutes", &"phase_total_minutes", &"phase_started_at", &"phase_passenger_count"]: + if not car[key] is int: return _invalid("Elevator cabin clocks and floors must be integers.") + for key: StringName in [&"current_floor", &"target_floor", &"boarding_floor", &"phase_origin_floor"]: + if not _has_floor(car[&"config"], car[key]): return _invalid("Elevator cabin floor is absent from its shaft.") + if car[&"phase_passenger_count"] < car[&"boarded"].size() or car[&"phase_passenger_count"] > car[&"config"][&"capacity"]: + return _invalid("Elevator phase passenger count is invalid.") + var phase := StringName(str(car[&"phase"])) + if phase not in [&"IDLE", &"PICKUP", &"BOARDING", &"TRAVEL", &"ALIGHTING"]: return _invalid("Unknown elevator phase.") + if phase == &"IDLE": + return _valid_or_error(car[&"boarded"].is_empty() and car[&"phase_passenger_count"] == 0 and car[&"remaining_minutes"] == 0 and car[&"phase_total_minutes"] == 0 and car[&"phase_started_at"] == -1 and car[&"target_floor"] == car[&"current_floor"] and car[&"boarding_floor"] == car[&"current_floor"] and car[&"phase_origin_floor"] == car[&"current_floor"], "Idle cabin contains active phase data.") + if car[&"remaining_minutes"] <= 0 or car[&"remaining_minutes"] > car[&"phase_total_minutes"] or car[&"phase_started_at"] < 0 or car[&"phase_started_at"] > minute: + return _invalid("Elevator phase countdown is invalid.") + # Operational time is exact; an outage with no valid floor may hold a phase. + if car[&"operational"] and car[&"phase_total_minutes"] - car[&"remaining_minutes"] != minute - car[&"phase_started_at"]: + return _invalid("Elevator phase countdown disagrees with simulation time.") + var expected := 0 + if phase in [&"BOARDING", &"ALIGHTING"]: + expected = DOOR_MINUTES + int(car[&"phase_passenger_count"]) + else: + var destination: int = car[&"boarding_floor"] if phase == &"PICKUP" else car[&"target_floor"] + expected = absi(destination - int(car[&"phase_origin_floor"])) * int(car[&"config"][&"travel_minutes_per_floor"]) + if car[&"phase_total_minutes"] != expected or expected <= 0: return _invalid("Elevator phase duration does not match distance/passengers.") + if phase == &"PICKUP" and not car[&"boarded"].is_empty(): return _invalid("Empty pickup cabin contains passengers.") + if car[&"phase_origin_floor"] != car[&"current_floor"]: return _invalid("Elevator phase origin disagrees with logical floor.") + if phase == &"ALIGHTING" and car[&"current_floor"] != car[&"target_floor"]: return _invalid("Alighting cabin is at the wrong floor.") + if phase in [&"BOARDING", &"TRAVEL"] and car[&"current_floor"] != car[&"boarding_floor"]: return _invalid("Boarding/travel origin disagrees with pickup floor.") + return true + + +func _has_floor(config: Dictionary, floor: int) -> bool: + for cell: Vector3i in config[&"stops"]: + if cell.y == floor: return true + return false + + +func _ordered_trips() -> Array[Dictionary]: + var trips: Array[Dictionary] = [] + for actor_id: StringName in _names(_trips): trips.append(_trips[actor_id].duplicate(true)) + return trips + + +func _names(values: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for value: Variant in values: result.append(StringName(value)) + result.sort_custom(func(a: StringName, b: StringName) -> bool: return str(a) < str(b)) + return result + + +func _exact_keys(value: Dictionary, keys: Array) -> bool: + if value.size() != keys.size(): return false + for key: Variant in keys: + if not value.has(key): return false + return true + + +func _failure(reason: StringName, message: String) -> Dictionary: + last_error = message + return {&"ok": false, &"reason": reason, &"error": message} + + +func _invalid(message: String) -> bool: + last_error = message + return false + + +func _valid_or_error(valid: bool, message: String) -> bool: + if not valid: last_error = message + return valid diff --git a/scripts/transport/elevator_service.gd.uid b/scripts/transport/elevator_service.gd.uid new file mode 100644 index 0000000..20c5284 --- /dev/null +++ b/scripts/transport/elevator_service.gd.uid @@ -0,0 +1 @@ +uid://cckw41o08w2xx diff --git a/scripts/ui/pixel_tower_art.gd b/scripts/ui/pixel_tower_art.gd index 40c9e5b..fdb73d5 100644 --- a/scripts/ui/pixel_tower_art.gd +++ b/scripts/ui/pixel_tower_art.gd @@ -44,11 +44,7 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, var w := rect.size.x var top := rect.position.y if id in [&"guest_elevator", &"service_elevator"]: - canvas.draw_rect(inner, Color("455855") if not back else Color("506277")) - canvas.draw_rect(Rect2(mid - p, top + p, 2 * p, rect.size.y - 2 * p), Color("243c3c")) - canvas.draw_rect(Rect2(inner.position.x + 2*p, top + 4*p, inner.size.x - 4*p, inner.size.y - 6*p), Color("84978b")) - canvas.draw_line(Vector2(mid, top+4*p), Vector2(mid, rect.end.y-3*p), INK, p) - canvas.draw_rect(Rect2(mid-2*p, top+2*p, 4*p, p), Color("f1c774")) + elevator_shaft(canvas, rect, p, back) return if id in [&"guest_corridor", &"service_corridor", &"staff_door"]: if id == &"staff_door": @@ -105,6 +101,44 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, table(canvas, Vector2(mid-6*p, floor_y-7*p), p) +## 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: + canvas.draw_rect(rect, INK) + canvas.draw_rect(rect.grow(-p), Color("3e514e") if not back else Color("404f63")) + canvas.draw_rect(Rect2(rect.position.x+2*p, rect.position.y, 2*p, rect.size.y), Color("83958a")) + canvas.draw_rect(Rect2(rect.end.x-4*p, rect.position.y, 2*p, rect.size.y), Color("83958a")) + canvas.draw_rect(Rect2(rect.get_center().x-p, rect.position.y, 2*p, rect.size.y), Color("283a39")) + for row: int in range(2): + var floor_y := rect.position.y + float(row+1)*rect.size.y*0.5 + canvas.draw_rect(Rect2(rect.position.x, floor_y-4*p, rect.size.x, 3*p), Color("586c62") if not back else Color("526177")) + canvas.draw_rect(Rect2(rect.position.x, floor_y-p, rect.size.x, p), INK) + canvas.draw_rect(Rect2(rect.position.x+p, floor_y-7*p, p, 2*p), Color("dcc08b")) + canvas.draw_rect(Rect2(rect.end.x-2*p, floor_y-7*p, p, 2*p), Color("dcc08b")) + + +static func elevator_cabin(canvas: CanvasItem, rect: Rect2, p: float, back: bool, operational: bool) -> void: + canvas.draw_rect(rect, INK) + canvas.draw_rect(rect.grow(-p), Color("9daa8d") if not back else Color("879eaa")) + canvas.draw_rect(Rect2(rect.position+Vector2(2,3)*p,rect.size-Vector2(4,5)*p),Color("556e62") if not back else Color("4d6472")) + canvas.draw_rect(Rect2(rect.position.x+p,rect.end.y-3*p,rect.size.x-2*p,2*p),WOOD_LIGHT) + canvas.draw_rect(Rect2(rect.position.x+2*p,rect.position.y+p,rect.size.x-4*p,p),CREAM) + canvas.draw_rect(Rect2(rect.get_center().x-p,rect.position.y-p,2*p,p),Color("e2bd6c") if operational else Color("ba7157")) + + +static func elevator_gate(canvas: CanvasItem, rect: Rect2, p: float, doors_open: bool) -> void: + # The cutaway keeps passengers visible through the iron gate during travel. + var rail := Color("bfbb9a") + canvas.draw_rect(Rect2(rect.position.x,rect.position.y,2*p,rect.size.y),rail) + canvas.draw_rect(Rect2(rect.end.x-2*p,rect.position.y,2*p,rect.size.y),rail) + if doors_open: + return + var gate_y := rect.end.y-7*p + canvas.draw_rect(Rect2(rect.position.x+2*p,gate_y,rect.size.x-4*p,p),rail) + for x: int in range(int(rect.position.x+5*p),int(rect.end.x-3*p),maxi(int(6*p),1)): + canvas.draw_rect(Rect2(x,rect.position.y+4*p,p,rect.size.y-7*p),Color("a9aa91",0.8)) + + static func window(canvas: CanvasItem, at: Vector2, p: float) -> void: canvas.draw_rect(Rect2(at, Vector2(7,9)*p), WOOD) canvas.draw_rect(Rect2(at+Vector2.ONE*p, Vector2(5,6)*p), Color("9ab9b3")) diff --git a/scripts/world/facility_infrastructure_projector.gd b/scripts/world/facility_infrastructure_projector.gd index cce07c0..c6689bf 100644 --- a/scripts/world/facility_infrastructure_projector.gd +++ b/scripts/world/facility_infrastructure_projector.gd @@ -37,8 +37,6 @@ const SUPPORTED_KINDS: Array[StringName] = [ KIND_GUEST_CORRIDOR, KIND_SERVICE_CORRIDOR, KIND_STAFF_DOOR, -] -const UNSUPPORTED_VERTICAL_KINDS: Array[StringName] = [ KIND_GUEST_ELEVATOR, KIND_SERVICE_ELEVATOR, ] @@ -50,6 +48,8 @@ const KNOWN_KINDS: Array[StringName] = [ KIND_SERVICE_ELEVATOR, ] +const UNSUPPORTED_VERTICAL_KINDS: Array[StringName] = [] +const VERTICAL_KINDS: Array[StringName] = [KIND_GUEST_ELEVATOR, KIND_SERVICE_ELEVATOR] 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" @@ -60,6 +60,7 @@ const QUARANTINE_REASONS: Array[StringName] = [ const ROUTE_ENTITY_PREFIX := "infra.route:" const CONNECTOR_PREFIX := "infra.connector:" +const SHAFT_OWNER_PREFIX := "infra.shaft:" const ADAPTER_PROJECT_REQUEST_PREFIX := "infra.project:" const ADAPTER_UNPROJECT_REQUEST_PREFIX := "infra.unproject:" const STAFF_ACCESS_ID: StringName = &"staff" @@ -168,16 +169,6 @@ func project_facility( var canonical_input: Dictionary = input_result[&"input"] var definition_input: Dictionary = canonical_input[&"definition"] var kind: StringName = definition_input[&"id"] - if kind in UNSUPPORTED_VERTICAL_KINDS: - return _failure( - CommandResultType.Code.VALIDATION_FAILED, - "Vertical facility projection is unsupported until ordinary adjacent walk edges can be suppressed safely.", - { - &"reason": REASON_UNSUPPORTED_VERTICAL_TOPOLOGY, - &"definition_id": kind, - &"unchanged": true, - } - ) if kind not in SUPPORTED_KINDS: return _failure(CommandResultType.Code.INVALID_PAYLOAD, "Facility definition is not an infrastructure proxy.") var request_id := StringName(request_id_value) @@ -197,6 +188,7 @@ func project_facility( return _failure(CommandResultType.Code.DUPLICATE_COMMAND_ID, "Facility instance is already projected.") var record := _projection_from_project_input(canonical_input) if _source.has_entity(record[&"route_entity_id"]) \ + or _path.get_vertical_walk_blockers().has(StringName(SHAFT_OWNER_PREFIX + String(instance_id))) \ or (not StringName(record[&"connector_id"]).is_empty() and _path.has_connector(record[&"connector_id"])): return _failure(CommandResultType.Code.DUPLICATE_COMMAND_ID, "Stable infrastructure ID collides with existing authoritative state.") if not _dependencies_match_current_state(): @@ -244,6 +236,11 @@ func project_facility( dependency_capture, "Staff-door connector projection failed or callback tampering was detected." ) + if kind in VERTICAL_KINDS: + var shaft_cells: Array[Vector3i] = [] + shaft_cells.assign(record[&"cells"]) + if not _path.set_vertical_walk_blockers(StringName(SHAFT_OWNER_PREFIX + String(instance_id)), shaft_cells) or not _owned_state_matches(guard_state): + return _rollback_failure(owned_before, dependency_capture, "Elevator shaft projection failed or callback tampering was detected.") var candidate_projections := _projections.duplicate(true) candidate_projections[instance_id] = record.duplicate(true) if not _dependencies_match_projection_map(candidate_projections) \ @@ -332,6 +329,9 @@ func unproject_facility(request_id_value: Variant, instance_id_value: Variant) - dependency_capture, "Staff-door 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): + return _rollback_failure(owned_before, dependency_capture, "Elevator shaft unprojection failed or callback tampering was detected.") var route_result := _adapter.remove_route( StringName(ADAPTER_UNPROJECT_REQUEST_PREFIX + String(request_id)), StringName(record[&"route_entity_id"]) @@ -977,6 +977,14 @@ func _projection_live_matches(record: Dictionary) -> bool: if connector_id.is_empty() or not _path.has_connector(connector_id): return false return _path.get_connector(connector_id) == _expected_staff_connector(record) + if record[&"kind"] in VERTICAL_KINDS: + var blockers := _path.get_vertical_walk_blockers() + var owner_id := StringName(SHAFT_OWNER_PREFIX + String(record[&"instance_id"])) + if not blockers.has(owner_id): + return false + var cells: Array = (blockers[owner_id] as Array).duplicate() + cells.sort_custom(_cell_less) + return connector_id.is_empty() and cells == record[&"cells"] return connector_id.is_empty() @@ -1009,16 +1017,22 @@ static func _has_managed_namespace_collision( ) -> bool: var expected_routes: Dictionary = {} var expected_connectors: Dictionary = {} + var expected_shafts: Dictionary = {} for record_value: Variant in projections.values(): if not record_value is Dictionary: return true var record: Dictionary = record_value expected_routes[record[&"route_entity_id"]] = true + if record[&"kind"] in VERTICAL_KINDS: + expected_shafts[StringName(SHAFT_OWNER_PREFIX + String(record[&"instance_id"]))] = true if not StringName(record[&"connector_id"]).is_empty(): expected_connectors[record[&"connector_id"]] = true for entity_id: StringName in source_service.get_entity_ids(source_service.CATEGORY_ROUTE): if String(entity_id).begins_with(ROUTE_ENTITY_PREFIX) and not expected_routes.has(entity_id): return true + for owner_id: Variant in path_service.get_vertical_walk_blockers(): + if String(owner_id).begins_with(SHAFT_OWNER_PREFIX) and not expected_shafts.has(owner_id): + return true var path_snapshot := path_service.create_snapshot() var connector_rows: Variant = path_snapshot.get(&"connectors", null) if not connector_rows is Array: diff --git a/scripts/world/path_service.gd b/scripts/world/path_service.gd index 78fdd8d..e41df1d 100644 --- a/scripts/world/path_service.gd +++ b/scripts/world/path_service.gd @@ -17,7 +17,10 @@ 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 := 3 +const SNAPSHOT_VERSION := 4 +const ELEVATOR_INDEX_SNAPSHOT_VERSION := 3 +const MAX_VERTICAL_BLOCKER_OWNERS := 4_096 +const MAX_VERTICAL_BLOCKER_CELLS := 262_144 const LEGACY_SNAPSHOT_VERSION := 1 const TRAFFIC_SNAPSHOT_VERSION := 2 @@ -74,6 +77,8 @@ var last_path_cost := 0 var _passable_cells: Dictionary = {} var _connectors: Dictionary = {} var _adjacency: Dictionary = {} +var _vertical_walk_blockers: Dictionary = {} +var _vertical_walk_blocked_cells: Dictionary = {} var _path_cache: Dictionary = {} var _cell_traffic: Dictionary = {} var _actor_cells: Dictionary = {} @@ -124,6 +129,79 @@ func rebuild_graph(passable_cells: Variant) -> bool: return true +## A shaft is always physical topology, independent of cabin availability. +## Owners may overlap; removing one owner does not clear another owner's cells. +func set_vertical_walk_blockers(owner_id: StringName, cells: Array[Vector3i]) -> bool: + if not _vertical_blocker_owner_is_valid(owner_id): + last_error = "Vertical walk blockers require a bounded, nonempty owner id." + return false + if cells.is_empty() or cells.size() > MAX_VERTICAL_BLOCKER_CELLS: + last_error = "Vertical walk blockers require a bounded, nonempty cell set." + return false + var normalization := _normalize_cell_set(cells) + if not bool(normalization[&"ok"]) or (normalization[&"cells"] as Dictionary).size() != cells.size(): + last_error = "Vertical walk blocker cells must be valid and unique." + return false + for cell: Vector3i in cells: + if not _passable_cells.has(cell): + last_error = "Vertical walk blocker cells must belong to the current graph." + return false + var canonical: Array[Vector3i] = cells.duplicate() + canonical.sort_custom(Callable(self, "_coordinate_less")) + if _vertical_walk_blockers.get(owner_id, []) == canonical: + last_error = "" + return true + var candidate := _vertical_walk_blockers.duplicate(true) + candidate[owner_id] = canonical + var count := 0 + for owned_cells: Array in candidate.values(): + count += owned_cells.size() + if candidate.size() > MAX_VERTICAL_BLOCKER_OWNERS or count > MAX_VERTICAL_BLOCKER_CELLS: + last_error = "Vertical walk blocker ownership capacity exceeded." + return false + _vertical_walk_blockers = candidate + _rebuild_vertical_walk_blocked_cells() + _rebuild_derived_graph() + _mark_graph_changed() + last_error = "" + return true + + +func remove_vertical_walk_blockers(owner_id: StringName) -> bool: + if not _vertical_walk_blockers.has(owner_id): + last_error = "Unknown vertical walk blocker owner." + return false + _vertical_walk_blockers.erase(owner_id) + _rebuild_vertical_walk_blocked_cells() + _rebuild_derived_graph() + _mark_graph_changed() + last_error = "" + return true + + +func get_vertical_walk_blockers() -> Dictionary: + return _vertical_walk_blockers.duplicate(true) + + +func _rebuild_vertical_walk_blocked_cells() -> void: + _vertical_walk_blocked_cells.clear() + for cells: Array in _vertical_walk_blockers.values(): + for cell: Vector3i in cells: + _vertical_walk_blocked_cells[cell] = true + + +func _ordinary_walk_edge_allowed(from_cell: Vector3i, to_cell: Vector3i) -> bool: + return from_cell.y == to_cell.y or ( + not _vertical_walk_blocked_cells.has(from_cell) + and not _vertical_walk_blocked_cells.has(to_cell) + ) + + +func _vertical_blocker_owner_is_valid(owner_id: StringName) -> bool: + var value := String(owner_id) + return not value.is_empty() and value.length() <= 128 and value == value.strip_edges() + + func configure_connector( connector_id: StringName, from_cell: Vector3i, @@ -995,6 +1073,28 @@ func get_connector(connector_id: StringName) -> Dictionary: return (_connectors[connector_id] as Dictionary).duplicate(true) +## Resolves the same cheapest accessible edge that find_path/calculate_path_cost +## use, so a movement driver can distinguish riding from an ordinary step. +func get_edge_report(from_cell: Vector3i, to_cell: Vector3i, connector_access: Variant = null) -> Dictionary: + var normalized := _normalize_access_ids(connector_access) + if not bool(normalized[&"ok"]): + last_error = String(normalized[&"error"]) + return {} + if _cell_is_temporarily_blocked(from_cell) or _cell_is_temporarily_blocked(to_cell): + return {} + var edge := _find_edge(from_cell, to_cell, normalized[&"values"]) + if edge.is_empty(): + return {} + var report := edge.duplicate(true) + report[&"from"] = from_cell + report[&"total_cost"] = _edge_total_cost(edge) + return report + + +func get_step_connector(from_cell: Vector3i, to_cell: Vector3i, connector_access: Variant = null) -> StringName: + return StringName(get_edge_report(from_cell, to_cell, connector_access).get(&"connector_id", &"")) + + func get_neighbors(cell: Vector3i) -> Array: if not _adjacency.has(cell): return [] @@ -1179,6 +1279,9 @@ func create_snapshot() -> Dictionary: var saved_active_units: Array = [] for unit_id: Variant in active_unit_ids: saved_active_units.append((_active_elevator_units[unit_id] as Dictionary).duplicate(true)) + var saved_vertical_blockers: Array[Dictionary] = [] + for owner_id: StringName in _sorted_name_keys(_vertical_walk_blockers): + saved_vertical_blockers.append({&"owner_id": owner_id, &"cells": (_vertical_walk_blockers[owner_id] as Array).duplicate()}) return { &"schema_version": SNAPSHOT_VERSION, &"graph_revision": graph_revision, @@ -1189,14 +1292,18 @@ func create_snapshot() -> Dictionary: &"actor_progress": saved_progress, &"known_elevator_request_ids": saved_known_requests, &"active_elevator_units": saved_active_units, + &"vertical_walk_blockers": saved_vertical_blockers, } 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, SNAPSHOT_VERSION]: + if version not in [LEGACY_SNAPSHOT_VERSION, TRAFFIC_SNAPSHOT_VERSION, ELEVATOR_INDEX_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")): + last_error = "Legacy PathService snapshots cannot contain shaft ownership fields." + return false var raw_connectors: Variant = _dictionary_value(snapshot, &"connectors", null) if not raw_connectors is Array: last_error = "PathService snapshot connectors must be an Array." @@ -1210,7 +1317,9 @@ 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 + var changed := next_connectors != _connectors or not _vertical_walk_blockers.is_empty() + _vertical_walk_blockers.clear() + _rebuild_vertical_walk_blocked_cells() _connectors = next_connectors _cell_traffic.clear() _actor_cells.clear() @@ -1256,7 +1365,7 @@ func restore_snapshot(snapshot: Dictionary) -> bool: last_error = progress_result[&"error"] return false var next_progress: Dictionary = progress_result[&"progress"] - if version == SNAPSHOT_VERSION: + if version >= ELEVATOR_INDEX_SNAPSHOT_VERSION: var raw_known_requests: Variant = _dictionary_value( snapshot, &"known_elevator_request_ids", @@ -1277,6 +1386,13 @@ func restore_snapshot(snapshot: Dictionary) -> bool: return false next_known_requests = index_result[&"known_request_ids"] next_active_units = index_result[&"active_units"] + var next_vertical_blockers: Dictionary = {} + if version >= 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): last_error = "PathService snapshot density does not match actor projections." return false @@ -1290,6 +1406,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool: return false _connectors = next_connectors + _vertical_walk_blockers = next_vertical_blockers + _rebuild_vertical_walk_blocked_cells() _cell_traffic = next_traffic _actor_cells = next_actor_cells _actor_progress = next_progress @@ -1320,7 +1438,7 @@ func _rebuild_derived_graph() -> void: var cell: Vector3i = cell_value for direction in _CARDINAL_DIRECTIONS: var neighbor := cell + direction - if _passable_cells.has(neighbor): + if _passable_cells.has(neighbor) and _ordinary_walk_edge_allowed(cell, neighbor): _append_edge( cell, neighbor, @@ -1587,7 +1705,7 @@ func _route_exists_with_overrides( var neighbor_set: Dictionary = {} for direction: Vector3i in _CARDINAL_DIRECTIONS: var neighbor := current + direction - if _passable_cells.has(neighbor): + if _passable_cells.has(neighbor) and _ordinary_walk_edge_allowed(current, neighbor): neighbor_set[neighbor] = true for connector_id: StringName in connector_ids: var connector: Dictionary = _connectors[connector_id] @@ -2080,6 +2198,41 @@ func _normalize_snapshot_elevator_indexes( } +func _normalize_snapshot_vertical_blockers(raw: Variant, traffic: Dictionary) -> Dictionary: + var failure := {&"ok": false, &"error": "Path shaft ownership rows are invalid, unordered, or crosslink unknown cells."} + if not raw is Array or raw.size() > MAX_VERTICAL_BLOCKER_OWNERS: + return failure + var blockers: Dictionary = {} + var previous_owner := "" + var total_cells := 0 + for row: Variant in raw: + if not row is Dictionary or row.size() != 2: + return failure + var owner_value: Variant = _dictionary_value(row, &"owner_id", null) + var cells_value: Variant = _dictionary_value(row, &"cells", null) + if typeof(owner_value) not in [TYPE_STRING, TYPE_STRING_NAME] or not cells_value is Array: + return failure + var owner_id := StringName(owner_value) + if not _vertical_blocker_owner_is_valid(owner_id) or String(owner_id) <= previous_owner or cells_value.is_empty(): + return failure + previous_owner = String(owner_id) + total_cells += cells_value.size() + if total_cells > MAX_VERTICAL_BLOCKER_CELLS: + return failure + var cells: Array[Vector3i] = [] + var seen: Dictionary = {} + for cell: Variant in cells_value: + if not cell is Vector3i or not traffic.has(cell) or seen.has(cell): + return failure + seen[cell] = true + cells.append(cell) + cells.sort_custom(Callable(self, "_coordinate_less")) + if cells != cells_value: + return failure + blockers[owner_id] = cells + return {&"ok": true, &"blockers": blockers} + + func _normalize_snapshot_cell_traffic(raw_entries: Array) -> Dictionary: var traffic: Dictionary = {} for raw_entry: Variant in raw_entries: diff --git a/tests/mvp/test_runtime_content_progression.gd b/tests/mvp/test_runtime_content_progression.gd index b525193..6f541e7 100644 --- a/tests/mvp/test_runtime_content_progression.gd +++ b/tests/mvp/test_runtime_content_progression.gd @@ -380,6 +380,9 @@ func _test_v3_migration_and_v4_tamper_guards() -> void: var v3_runtime: Dictionary = v3[&"systems"][&"runtime"] v3_runtime[&"runtime_schema_version"] = 3 v3_runtime.erase(&"kitchen_production") + v3_runtime.erase(&"elevators") + v3_runtime[&"path"][&"schema_version"] = 3 + v3_runtime[&"path"].erase(&"vertical_walk_blockers") v3_runtime.erase(&"operations") v3_runtime.erase(&"catalog_manifest") v3_runtime.erase(&"progression") @@ -396,10 +399,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"]) == 7, "v3 migration emits canonical Runtime schema v7") + _expect(int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 8, "v3 migration emits canonical Runtime schema v8") var digest := SimulationDigest.compute(migrated) - _expect(_runtime.restore_snapshot(migrated), "canonical v7 snapshot restores") - _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v7 restore is exact") + _expect(_runtime.restore_snapshot(migrated), "canonical v8 snapshot restores") + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v8 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 5ae1b8c..ab6154b 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 := 7 +const RUNTIME_SNAPSHOT_VERSION := 8 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_v7_tamper() + _test_full_partial_payroll_bankruptcy_and_v8_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 v7" + "fixture emits Runtime snapshot schema v8" ) if enable_operations: var enabled: Dictionary = runtime.enable_production_operations() @@ -107,6 +107,15 @@ func _align_operations_fixture_clocks(runtime: Variant, minute: int) -> void: runtime.defense_service.current_minute = minute runtime.construction_service.current_minute = minute runtime.excavation_service.current_minute = minute + # This accelerated oracle owns no cars or rides; update its empty transport + # aggregate through the public snapshot validator instead of a private clock. + var transport_snapshot: Dictionary = runtime.elevators.create_snapshot() + if not (transport_snapshot[&"shafts"] as Array).is_empty() or not (transport_snapshot[&"trips"] as Array).is_empty(): + _expect(false, "accelerated Operations oracle cannot skip live elevator movement") + return + transport_snapshot[&"current_minute"] = minute + if not runtime.elevators.restore_snapshot(transport_snapshot): + _expect(false, "empty transport clock aligns through validated snapshot restore") func _operations_slice_is_healthy(fixture: Dictionary) -> bool: @@ -208,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 v7 snapshot round trip" + "command history survives an exact v8 snapshot round trip" ) var restored_retry: CommandResult = runtime.submit_command( &"change_resources", @@ -248,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 v7 snapshot round trip is exact" + "24-hour actual-tick v8 snapshot round trip is exact" ) _free_fixture(fixture) -func _test_full_partial_payroll_bankruptcy_and_v7_tamper() -> void: +func _test_full_partial_payroll_bankruptcy_and_v8_tamper() -> void: var fixture := _new_fixture(73_002, false) var runtime: Variant = fixture[&"runtime"] var clock: Variant = fixture[&"clock"] @@ -340,7 +349,7 @@ func _test_full_partial_payroll_bankruptcy_and_v7_tamper() -> void: var terminal_digest := SimulationDigest.compute(terminal_snapshot) _expect( runtime.restore_snapshot(terminal_snapshot), - "BANKRUPT terminal v7 snapshot restores without clearing terminal state" + "BANKRUPT terminal v8 snapshot restores without clearing terminal state" ) clock.advance_minutes(180) _expect( @@ -353,32 +362,32 @@ func _test_full_partial_payroll_bankruptcy_and_v7_tamper() -> void: "BANKRUPT cannot be acknowledged away" ) - _assert_v7_tamper_rejected( + _assert_v8_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: tampered[&"systems"][&"runtime"][&"operations"][&"personal_action_points"] = 99, - "v7 rejects an out-of-range Operations action-point total" + "v8 rejects an out-of-range Operations action-point total" ) - _assert_v7_tamper_rejected( + _assert_v8_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: tampered[&"systems"][&"runtime"][&"next_command_id"] = int( tampered[&"systems"][&"runtime"][&"next_command_id"] ) + 1, - "v7 rejects command high-water gaps" + "v8 rejects command high-water gaps" ) - _assert_v7_tamper_rejected( + _assert_v8_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", - "v7 rejects accepted-command/idempotency cross-link tampering" + "v8 rejects accepted-command/idempotency cross-link tampering" ) - _assert_v7_tamper_rejected( + _assert_v8_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: @@ -386,26 +395,26 @@ func _test_full_partial_payroll_bankruptcy_and_v7_tamper() -> void: summaries[0][&"category_totals"][&"wages"] = int( summaries[0][&"category_totals"][&"wages"] ) + 1, - "v7 rejects DaySummary/payroll wage reconciliation tampering" + "v8 rejects DaySummary/payroll wage reconciliation tampering" ) - _assert_v7_tamper_rejected( + _assert_v8_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: - tampered[&"systems"][&"runtime"][&"unexpected_v7_field"] = true, - "v7 rejects unknown Runtime fields" + tampered[&"systems"][&"runtime"][&"unexpected_v8_field"] = true, + "v8 rejects unknown Runtime fields" ) - _assert_v7_tamper_rejected( + _assert_v8_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: tampered[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"unexpected_field"] = true, - "v7 rejects unknown Coordinator fields" + "v8 rejects unknown Coordinator fields" ) _free_fixture(fixture) -func _assert_v7_tamper_rejected( +func _assert_v8_tamper_rejected( runtime: Variant, canonical: Dictionary, mutator: Callable, @@ -454,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 v7 midpoint snapshot") + _expect(restored, "fresh Runtime restores the enabled v8 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 993a5db..58b574f 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"]) == 7, "Runtime snapshot advances to schema v7") + _expect(int(disabled_runtime[&"runtime_schema_version"]) == 8, "Runtime snapshot advances to schema v8") _expect( disabled_runtime[&"operations"] == { &"wrapper_version": 1, @@ -361,6 +361,9 @@ func _test_daily_payroll_financials_and_crosslink_tamper() -> void: var downgraded: Dictionary = canonical.duplicate(true) downgraded[&"systems"][&"runtime"][&"runtime_schema_version"] = 4 downgraded[&"systems"][&"runtime"].erase(&"kitchen_production") + downgraded[&"systems"][&"runtime"].erase(&"elevators") + downgraded[&"systems"][&"runtime"][&"path"][&"schema_version"] = 3 + downgraded[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_blockers") _expect( _rejects_atomically(downgraded), "relabelled enabled v5 cannot discard Operations history through v4 migration" diff --git a/tests/s1/test_facility_infrastructure_projector.gd b/tests/s1/test_facility_infrastructure_projector.gd index 6ed72d8..3a21e91 100644 --- a/tests/s1/test_facility_infrastructure_projector.gd +++ b/tests/s1/test_facility_infrastructure_projector.gd @@ -93,7 +93,7 @@ func _run() -> void: _test_corridors_and_primary_world_coexistence() _test_staff_door_access_and_stable_ids() _test_project_unproject_replay_and_defensive_queries() - _test_vertical_topology_rejects_unchanged() + _test_vertical_topology_projects_without_walk_bypass() _test_definition_and_instance_crosslink_validation() _test_project_and_unproject_failures_roll_back_exactly() _test_callback_reentry_tamper_and_quarantine_recovery() @@ -202,21 +202,17 @@ func _test_project_unproject_replay_and_defensive_queries() -> void: _expect(context.projector.projection_revision == 2, "project and unproject each advance exactly once") -func _test_vertical_topology_rejects_unchanged() -> void: +func _test_vertical_topology_projects_without_walk_bypass() -> void: var context := _context([Vector3i(0, 0, PathType.FRONT)]) - var before_projector: Dictionary = context.projector.create_snapshot() - var before_source: Dictionary = context.source.create_snapshot() - var before_adapter: Dictionary = context.adapter.create_snapshot() - var before_path: Dictionary = context.path.create_snapshot() var guest_instance := _instance(GUEST_ELEVATOR, &"facility_00000005", Vector3i(4, 4, PathType.FRONT), 40, &"construction_00000005") var guest = context.projector.project_facility(&"guest_elevator", GUEST_ELEVATOR, guest_instance) - _expect(guest.code == CommandResultType.Code.VALIDATION_FAILED and guest.details[&"reason"] == context.projector.REASON_UNSUPPORTED_VERTICAL_TOPOLOGY, "guest elevator explicitly reports unsupported vertical topology") + _expect(guest.is_success(), "guest elevator projects completed shaft topology") var service_instance := _instance(SERVICE_ELEVATOR, &"facility_00000006", Vector3i(8, 4, PathType.BACK), 41, &"construction_00000006") var service = context.projector.project_facility(&"service_elevator", SERVICE_ELEVATOR, service_instance) - _expect(service.code == CommandResultType.Code.VALIDATION_FAILED and service.details[&"reason"] == context.projector.REASON_UNSUPPORTED_VERTICAL_TOPOLOGY, "service elevator explicitly reports unsupported vertical topology") - _expect(context.projector.create_snapshot() == before_projector, "unsupported vertical facilities leave projector state unchanged") - _expect(context.source.create_snapshot() == before_source and context.adapter.create_snapshot() == before_adapter and context.path.create_snapshot() == before_path, "unsupported vertical facilities leave all authoritative dependencies unchanged") - _expect(context.projector.get_processed_request_ids().is_empty(), "unsupported vertical rejection does not consume request IDs") + _expect(service.is_success(), "service elevator projects completed shaft topology") + _expect(context.path.find_path(Vector3i(4, 4, 0), Vector3i(4, 5, 0)).is_empty(), "guest shaft cannot be climbed without its separately owned cabin connector") + _expect(context.path.find_path(Vector3i(8, 4, 1), Vector3i(8, 5, 1)).is_empty(), "service shaft cannot be climbed by ordinary walk edges") + _expect(context.path.get_vertical_walk_blockers().size() == 2 and context.projector.validate_invariants()[&"ok"], "both completed elevator footprints retain authoritative owned shaft restrictions") func _test_definition_and_instance_crosslink_validation() -> void: diff --git a/tests/s1/test_path_congestion.gd b/tests/s1/test_path_congestion.gd index 3795d00..479b686 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"]) == 3, "request-safe traffic snapshot uses schema version 3") + _expect(int(snapshot[&"schema_version"]) == 4, "shaft-safe traffic snapshot uses schema version 4") _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), "v3 snapshot restores before authoritative world graph") + _expect(restored.restore_snapshot(snapshot), "v4 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, "v3 snapshot roundtrip is exact") + _expect(restored.create_snapshot() == snapshot, "v4 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") @@ -407,6 +407,7 @@ func _test_snapshot_roundtrip_and_atomic_rejection() -> void: v2_snapshot[&"schema_version"] = 2 v2_snapshot.erase(&"known_elevator_request_ids") v2_snapshot.erase(&"active_elevator_units") + v2_snapshot.erase(&"vertical_walk_blockers") 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 a469d04..cfee3cc 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 := 7 +const RUNTIME_SNAPSHOT_VERSION := 8 const INFRASTRUCTURE_SNAPSHOT_KEYS: Array[StringName] = [ &"excavation", &"spatial_subslots", @@ -59,7 +59,7 @@ func _run() -> void: _test_completed_proxy_projection() _test_proxy_completion_raw_signal_commit_order() _test_proxy_completion_projection_failure_is_atomic() - _test_vertical_preflight_rejection() + _test_completed_elevator_topology() _test_demolition_projection_atomicity() _test_current_snapshot_strictness() _test_v5_proxy_migration_determinism() @@ -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 v7 with infrastructure state") + _expect(current_version, "Runtime emits schema v8 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 v6 infrastructure surface is not connected yet; behavioral cases skipped.") + print(" PENDING: Runtime v8 infrastructure surface is not connected yet; behavioral cases skipped.") return ready @@ -593,45 +593,23 @@ func _test_proxy_completion_projection_failure_is_atomic() -> void: _free_fixture(fixture) -func _test_vertical_preflight_rejection() -> void: +func _test_completed_elevator_topology() -> void: var fixture := _new_fixture(81_040) var runtime: Variant = fixture[&"runtime"] _expect(_promote_rank_two(fixture, &"infra.vertical.rank"), "vertical fixture reaches Rank 2") - var before := _infrastructure_state(fixture) - var guest = _submit( - runtime, - &"build_facility", - { - &"definition_id": &"guest_elevator", - &"origin": Vector3i(-6, 1, PathType.FRONT), - &"rotation": 0, - }, - &"infra.vertical.guest" - ) - var service = _submit( - runtime, - &"build_facility", - { - &"definition_id": &"service_elevator", - &"origin": Vector3i(5, 1, PathType.BACK), - &"rotation": 0, - }, - &"infra.vertical.service" - ) - _expect( - not guest.is_success() - and guest.details.get(&"reason", &"") == &"UNSUPPORTED_VERTICAL_TOPOLOGY", - "guest elevator is rejected at build-command preflight" - ) - _expect( - not service.is_success() - and service.details.get(&"reason", &"") == &"UNSUPPORTED_VERTICAL_TOPOLOGY", - "service elevator is rejected with the same stable reason" - ) - _expect( - _infrastructure_state(fixture) == before, - "unsupported vertical preflight spends nothing and mutates no service" - ) + var cash_before: int = runtime.economy_service.get_balance(&"PLAYER_CASH") + for request: Array in [[&"guest_elevator", Vector3i(-6, 0, PathType.FRONT)], [&"service_elevator", Vector3i(5, 0, PathType.BACK)]]: + var result = _submit(runtime, &"build_facility", {&"definition_id": request[0], &"origin": request[1], &"rotation": 0}, StringName("infra.vertical." + String(request[0]))) + _expect(result.is_success(), "%s is accepted through paid build-command preflight" % request[0]) + _expect(runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before - 3700, "accepted lift construction spends actual definition costs") + _expect(runtime.facility_infrastructure_projector.get_projection_ids().is_empty(), "unfinished lift construction does not create usable shaft topology") + fixture[&"clock"].advance_minutes(360) + _expect(runtime.facility_infrastructure_projector.get_projection_ids().size() == 2 and runtime.elevators.get_report()[&"shafts"].size() == 2, "completed guest and service modules project separate cabins") + for shaft: Dictionary in runtime.elevators.get_report()[&"shafts"]: + var result = _submit(runtime, &"set_elevator_operation", {&"shaft_id": shaft[&"shaft_id"], &"operator_open": false}, StringName("infra.vertical.stop." + String(shaft[&"shaft_id"]))) + _expect(result.is_success(), "completed lift can be stopped through Runtime command") + _expect(runtime.path_service.find_path(shaft[&"stops"][0], shaft[&"stops"][1]).is_empty(), "stopped lift shaft never permits an ordinary vertical walk bypass") + _expect(runtime.facility_infrastructure_projector.validate_invariants()[&"ok"], "completed elevator ROUTE and owned shaft restrictions crosslink exactly") _free_fixture(fixture) @@ -750,10 +728,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 v7 snapshot restores") + _expect(runtime.restore_snapshot(snapshot), "valid Runtime v8 snapshot restores") _expect( runtime.create_snapshot() == snapshot and runtime.latest_digest == expected_digest, - "Runtime v7 round trip is exact" + "Runtime v8 round trip is exact" ) _assert_runtime_restore_rejected( @@ -761,7 +739,7 @@ func _test_current_snapshot_strictness() -> void: snapshot, func(candidate: Dictionary) -> void: candidate[&"systems"][&"runtime"][&"unknown_infrastructure"] = {}, - "unknown v7 Runtime field" + "unknown v8 Runtime field" ) for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: _assert_runtime_restore_rejected( @@ -838,9 +816,13 @@ func _test_v5_proxy_migration_determinism() -> void: var stripped_v6_events := _strip_v6_infrastructure_domain_history(legacy_runtime) legacy_runtime[&"runtime_schema_version"] = 5 legacy_runtime.erase(&"kitchen_production") + legacy_runtime.erase(&"elevators") + 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") _free_fixture(baseline) _expect( stripped_v6_events > 0 and _legacy_v5_infrastructure_shape_is_clean(legacy_runtime), @@ -855,8 +837,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"]) == 7, - "v5 migration is immediately re-emitted as v7" + int(first_migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 8, + "v5 migration is immediately re-emitted as v8" ) _expect( first_runtime.facility_infrastructure_projector.has_projection(instance_id) @@ -1057,7 +1039,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"): + if runtime_data.has(&"kitchen_production") or runtime_data.has(&"elevators") 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 f474450..46f5ac0 100644 --- a/tests/s3/test_runtime_path_traffic.gd +++ b/tests/s3/test_runtime_path_traffic.gd @@ -2,6 +2,7 @@ extends SceneTree const POST_V2_RUNTIME_KEYS := [ &"kitchen_production", + &"elevators", &"movement_credits", &"catalog_manifest", &"progression", @@ -302,11 +303,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"]) == 7, "Runtime snapshot advertises schema v7") + _expect(int(runtime_data[&"runtime_schema_version"]) == 8, "Runtime snapshot advertises schema v8") _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 v7 snapshot restores") - _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "mid-move v7 save/load is exact") + _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") var corruptions: Array[Dictionary] = [] var bad_actor := snapshot.duplicate(true) @@ -342,6 +343,8 @@ func _test_current_snapshot_crosslinks_and_legacy_v2() -> void: var legacy_data: Dictionary = legacy_v2[&"systems"][&"runtime"] legacy_data[&"runtime_schema_version"] = 2 _strip_post_v2_runtime_state(legacy_data) + legacy_data[&"path"][&"schema_version"] = 3 + legacy_data[&"path"].erase(&"vertical_walk_blockers") legacy_data[&"path"][&"actor_cells"] = [] legacy_data[&"path"][&"actor_progress"] = [] for traffic: Dictionary in legacy_data[&"path"][&"cell_traffic"]: @@ -350,16 +353,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-v7 aggregate and dedicated history instead of relabelling v7" + "legacy fixture removes every v3-v8 aggregate and dedicated history instead of relabelling v8" ) _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"]) == 7 + int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 8 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 v7 ownership while preserving blocked traffic state" + "legacy migration emits canonical v8 ownership while preserving blocked traffic state" ) diff --git a/tests/s3/test_runtime_staff_assignment.gd b/tests/s3/test_runtime_staff_assignment.gd index 30e7c41..1610211 100644 --- a/tests/s3/test_runtime_staff_assignment.gd +++ b/tests/s3/test_runtime_staff_assignment.gd @@ -156,7 +156,7 @@ func _test_runtime_assignment_boundary() -> void: _expect(_runtime.submit_command(&"hire_staff_candidate", {&"candidate_id": id}).is_success(), "catalog employee is hired: %s" % id) var money_before: int = _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) _expect(_assign(&"staff_akane", west, &"DAY").is_success(), "new command assigns hired receptionist") - _expect(buildings[0].active_staff_ids == [&"staff_akane"] and buildings[0].state == FacilityInstance.State.OPEN, "paused assignment immediately opens the facility") + _expect(buildings[0].active_staff_ids.is_empty() and _runtime.staff_service.get_staff(&"staff_akane").state == StaffRuntime.State.COMMUTING, "paused assignment waits for the receptionist to physically arrive") var old_cell: Vector3i = _runtime.staff_service.get_staff(&"staff_akane").current_cell var before: Dictionary = _runtime.staff_service.create_snapshot() for row: Array in [ @@ -174,8 +174,10 @@ func _test_runtime_assignment_boundary() -> void: _expect(_runtime.staff_service.create_snapshot() == before, "route failure preserves the old assignment") _runtime.path_service.set_cell_temporary_block(old_cell, false) _clock.advance_minutes(20) + _expect(buildings[0].active_staff_ids == [&"staff_akane"] and buildings[0].state == FacilityInstance.State.OPEN, "real commute arrival opens the first reception") + old_cell = _runtime.staff_service.get_staff(&"staff_akane").current_cell _expect(_assign(&"staff_akane", east, &"DAY").is_success(), "employee can move to another compatible facility") - _expect(buildings[0].active_staff_ids.is_empty() and buildings[1].active_staff_ids == [&"staff_akane"], "reassignment removes the old service staffing and activates the new facility") + _expect(buildings[0].active_staff_ids.is_empty() and buildings[1].active_staff_ids.is_empty(), "reassignment removes old staffing while the new commute is in progress") _expect(_runtime.staff_service.get_staff(&"staff_akane").current_cell == old_cell, "Runtime assignment leaves Path position intact") _expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == money_before, "assignment and shift changes charge no second signing fee") var report: Dictionary = _runtime.get_staff_management_report() @@ -206,7 +208,7 @@ func _test_runtime_assignment_boundary() -> void: var applied: Dictionary = _runtime.staff_service.get_work_assignment(&"staff_akane") var applied_shift: ShiftInstance = _runtime.staff_service.get_shift(_runtime.staff_service.get_staff(&"staff_akane").shift_id) _expect(int(applied[&"changed_at"]) == queued_at + 1 and int(applied[&"effective_from"]) == queued_at + 1, "queued assignment uses the execution minute, not the previous Staff tick") - _expect(applied_shift.worked_minutes == 1, "command and AI phases record the execution minute only once") + _expect(applied_shift.worked_minutes == 0, "queued reassignment cannot record work before arriving at its new workplace") _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "queued assignment leaves a coherent save at the end of its tick") _test_active_service_reassignment(west, east) _test_busy_staff_and_capacity(west) @@ -214,6 +216,7 @@ func _test_runtime_assignment_boundary() -> void: func _test_active_service_reassignment(west: StringName, east: StringName) -> void: _expect(_assign(&"staff_akane", east, &"DAY").is_success(), "service test keeps receptionist on DAY") + _wait_for_attendance(&"staff_akane", east) var spawned = _runtime.submit_command(&"spawn_guest", { &"guest_id": &"reassignment_guest", &"guest_type_id": &"traveler", &"gold": 100, &"options": {&"state": GuestRuntime.State.DECIDING, &"current_cell": Vector3i.ZERO}, @@ -236,6 +239,7 @@ func _test_active_service_reassignment(west: StringName, east: StringName) -> vo _expect(service.state == ServiceInstance.State.WAITING_FOR_STAFF, "existing service enters the normal staff-replacement grace period") _expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == balance_before, "waiting service posts no phantom revenue") _expect(_assign(&"staff_akane", east, &"DAY").is_success(), "replacement can return during grace period") + _wait_for_attendance(&"staff_akane", east) _clock.advance_one_minute() _expect(service.state == ServiceInstance.State.ACTIVE, "staff return resumes the same service instead of duplicating it") _expect(_assign(&"staff_akane", &"", &"DAY").is_success(), "employee can be unassigned during service") @@ -247,6 +251,7 @@ func _test_active_service_reassignment(west: StringName, east: StringName) -> vo func _test_busy_staff_and_capacity(west: StringName) -> void: _expect(_assign(&"staff_akane", west, &"DAY").is_success(), "busy-task test starts with a working employee") + _wait_for_attendance(&"staff_akane", west) var task = _runtime.submit_command(&"create_staff_task", { &"task_id": &"assignment_guard_task", &"task_type": &"reception", &"priority": 99, &"options": {&"target_cell": Vector3i.ZERO, &"required_role_tags": [&"receptionist"], @@ -270,6 +275,14 @@ func _test_busy_staff_and_capacity(west: StringName) -> void: _expect(_assign(&"capacity_overflow", west, &"NIGHT").is_success(), "opposite shift has its own staffing capacity") +func _wait_for_attendance(staff_id: StringName, facility_id: StringName) -> void: + for minute in range(40): + if _runtime.facility_service.get_instance(facility_id).active_staff_ids.has(staff_id): + return + _clock.advance_one_minute() + _expect(false, "employee reaches the assigned workplace through the real route") + + func _assign(staff_id: StringName, facility_id: StringName, template_id: StringName) -> CommandResult: return _runtime.submit_command(&"set_staff_work_assignment", {&"staff_id": staff_id, &"facility_id": facility_id, &"template_id": template_id}) diff --git a/tests/s5/test_runtime_defense_cycle.gd b/tests/s5/test_runtime_defense_cycle.gd index a73197e..128068f 100644 --- a/tests/s5/test_runtime_defense_cycle.gd +++ b/tests/s5/test_runtime_defense_cycle.gd @@ -794,6 +794,9 @@ func _test_snapshot_replay_corruption_and_legacy_migration() -> void: var legacy: Dictionary = _runtime.create_snapshot() legacy[&"systems"][&"runtime"].erase(&"runtime_schema_version") legacy[&"systems"][&"runtime"].erase(&"kitchen_production") + legacy[&"systems"][&"runtime"].erase(&"elevators") + legacy[&"systems"][&"runtime"][&"path"][&"schema_version"] = 3 + legacy[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_blockers") legacy[&"systems"][&"runtime"].erase(&"operations") legacy[&"systems"][&"runtime"].erase(&"defense") legacy[&"systems"][&"runtime"][&"rng_streams"].erase("defense") diff --git a/tests/staff/test_staff_commute.gd b/tests/staff/test_staff_commute.gd new file mode 100644 index 0000000..ddfa608 --- /dev/null +++ b/tests/staff/test_staff_commute.gd @@ -0,0 +1,317 @@ +extends SceneTree + +const CommuteType = preload("res://scripts/staff/staff_commute_service.gd") +var _checks := 0 +var _failures := 0 +var _fixtures: Array = [] + + +## Movement is injected at Runtime's narrow boundary. Real Staff, Shift, Task, +## Facility and staff-only Path rights run unchanged in this focused suite. +class CommuteRuntime extends RefCounted: + var staff_service := StaffService.new() + var facility_service := FacilityService.new() + var path_service := PathService.new() + var commute = CommuteType.new() + var movements: Array[Dictionary] = [] + var last_moved: Dictionary = {} + var fail_movement := false + var in_transit := false + + func configure() -> void: + var cells: Array[Vector3i] = [] + for layer: int in [0, 1]: + for x in range(13): cells.append(Vector3i(x, 0, layer)) + path_service.rebuild_graph(cells) + path_service.configure_connector(&"staff_door", Vector3i.ZERO, Vector3i(0, 0, 1), 100, true, &"staff") + for row: Array in [[&"west", Vector3i(5, 0, 0)], [&"east", Vector3i(10, 0, 0)], [&"back", Vector3i(5, 0, 1)]]: + facility_service.create_instance({&"instance_id": row[0], &"definition_id": &"office", &"origin": row[1], &"occupied_cells": [row[1]], &"staff_min": 1, &"staff_max": 2}) + commute.configure(self) + + func advance_staff_commute(staff_id: StringName, target: Vector3i, minute: int, reason: StringName = &"") -> Dictionary: + var worker := staff_service.get_staff(staff_id) + if not reason.is_empty() or int(last_moved.get(staff_id, -1)) >= minute or fail_movement: + return {&"moved": false} + var path := path_service.find_path(worker.current_cell, target, [0, 1], [&"staff"]) + if path.size() <= 1: return {&"moved": false} + var previous := worker.current_cell + worker.current_cell = path[1] + last_moved[staff_id] = minute + movements.append({&"minute": minute, &"staff_id": staff_id, &"from": previous, &"to": worker.current_cell}) + return {&"moved": true} + + func is_staff_in_transit(_staff_id: StringName) -> bool: + return in_transit + + func advance(minute: int) -> void: + staff_service.advance_one_minute(minute) + commute.tick(minute) + for facility: FacilityInstance in facility_service.get_instances(): + var active: Array[StringName] = [] + for worker: StaffRuntime in staff_service.get_staff_members(): + if commute.is_present(worker, facility): active.append(worker.staff_id) + facility_service.set_operational_status(facility.instance_id, {&"active_staff_ids": active}, minute) + + func dispose() -> void: + staff_service.configure_attendance_driver() + commute._runtime = null + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P2: physical attendance, recurring commutes and saved position replay") + _test_legacy_contract() + _test_walk_and_arrival_staffing() + _test_early_arrival_and_shift_end() + _test_late_route_repair_and_replay() + _test_reassignment_and_return_to_post() + _test_reassignment_after_late_arrival_counts_boundary_once() + _test_porter_dispatch_after_check_in() + _test_back_layer_permissions_and_unavailable() + _test_night_shift_and_missing_workplace() + _test_boarded_reassignment_does_not_staff_origin() + for fixture: CommuteRuntime in _fixtures: fixture.dispose() + print("Staff commute: %d checks, %d failures" % [_checks, _failures]) + quit(_failures) + + +func _fixture(role: StringName = &"receptionist", minute := 360) -> CommuteRuntime: + var fixture := CommuteRuntime.new() + fixture.configure() + fixture.staff_service.create_staff(&"worker", role) + fixture.staff_service.advance_one_minute(minute) + _fixtures.append(fixture) + return fixture + + +func _plan(fixture: CommuteRuntime, facility: StringName = &"west", template: StringName = &"DAY") -> StaffRuntime: + _expect(fixture.staff_service.set_work_assignment(&"worker", facility, template).is_success(), "repeating work plan is accepted") + return fixture.staff_service.get_staff(&"worker") + + +func _test_legacy_contract() -> void: + var legacy := StaffService.new() + legacy.create_staff(&"legacy", &"receptionist") + legacy.advance_one_minute(360) + legacy.set_work_assignment(&"legacy", &"west", &"DAY") + _expect(legacy.get_staff(&"legacy").state == StaffRuntime.State.AVAILABLE, "component-only recurring plans keep their original shift contract") + var fixture := _fixture() + fixture.staff_service.create_standard_shift(&"worker", &"DAY", 0, &"west", &"manual") + var worker := fixture.staff_service.get_staff(&"worker") + _expect(worker.state == StaffRuntime.State.AVAILABLE and not fixture.staff_service.requires_attendance(&"worker"), "manual legacy shifts remain available without a persistent plan") + _expect(fixture.commute.is_present(worker, fixture.facility_service.get_instance(&"west")), "legacy manual-shift staffing keeps compatibility") + + +func _test_walk_and_arrival_staffing() -> void: + var fixture := _fixture() + var worker := _plan(fixture) + var shift := fixture.staff_service.get_shift(worker.shift_id) + var west := fixture.facility_service.get_instance(&"west") + _expect(worker.state == StaffRuntime.State.COMMUTING and worker.current_cell == Vector3i.ZERO, "paused assignment starts a real commute without teleporting") + _expect(shift.worked_minutes == 0 and shift.actual_started_at == -1, "assignment time is not falsely counted as work") + _expect(not fixture.commute.is_present(worker, west), "a remote assignment cannot staff a facility") + for minute in range(361, 365): + var previous := worker.current_cell + fixture.advance(minute) + _expect(worker.current_cell.distance_to(previous) <= 1.0, "commute advances at most one route edge per minute") + _expect(worker.current_cell == Vector3i(4, 0, 0), "worker reaches the actual facility access cell") + _expect(worker.state == StaffRuntime.State.COMMUTING and west.active_staff_ids.is_empty(), "movement arrival activates at the next Staff AI boundary") + fixture.advance(365) + _expect(worker.state == StaffRuntime.State.AVAILABLE and west.active_staff_ids == [&"worker"], "only physical arrival contributes the facility staff capacity") + _expect(shift.actual_started_at == 365 and shift.worked_minutes == 1, "first recorded work minute is the arrival activation minute") + var snapshot := fixture.staff_service.create_snapshot() + fixture.commute.get_report() + _expect(fixture.staff_service.create_snapshot() == snapshot, "attendance report reads do not mutate saved Staff state") + _expect(fixture.staff_service.validate_invariants()[&"ok"], "physical commute preserves Staff invariants") + + +func _test_early_arrival_and_shift_end() -> void: + var fixture := _fixture(&"receptionist", 330) + var worker := _plan(fixture) + var shift := fixture.staff_service.get_shift(worker.shift_id) + for minute in range(331, 360): fixture.advance(minute) + _expect(worker.current_cell == Vector3i(4, 0, 0) and worker.state == StaffRuntime.State.COMMUTING, "early arrival waits at the workplace through the commute lead window") + _expect(shift.actual_started_at == -1 and shift.worked_minutes == 0, "early arrival does not start paid duty before 06:00") + fixture.advance(360) + _expect(worker.state == StaffRuntime.State.AVAILABLE and shift.actual_started_at == 360, "an employee already at the workplace starts at exactly 06:00") + fixture.advance(1080) + _expect(worker.state == StaffRuntime.State.OFF_DUTY and worker.shift_id.is_empty() and shift.actual_ended_at == 1080, "18:00 closes the work shift and removes attendance") + _expect(worker.current_cell == Vector3i(4, 0, 0), "shift end keeps the real position rather than teleporting home") + fixture.advance(1770) + _expect(worker.state == StaffRuntime.State.COMMUTING, "next day's recurring commute begins automatically at 05:30") + fixture.advance(1800) + _expect(worker.state == StaffRuntime.State.AVAILABLE, "next day's saved plan activates only at its new shift boundary") + + +func _test_late_route_repair_and_replay() -> void: + var fixture := _fixture() + fixture.path_service.set_cell_temporary_block(Vector3i(2, 0, 0), true) + var worker := _plan(fixture) + for minute in range(361, 379): fixture.advance(minute) + var shift := fixture.staff_service.get_shift(worker.shift_id) + _expect(worker.current_cell == Vector3i.ZERO and worker.state == StaffRuntime.State.COMMUTING, "a blocked route never teleports or invents attendance") + _expect(shift.late_minutes == 18 and shift.late_reason == CommuteType.ROUTE_BLOCKED, "lateness records the real route blockage after the grace threshold") + _expect(shift.worked_minutes == 0 and shift.actual_started_at == -1, "unreachable staff do not accumulate fictional worked minutes") + var checkpoint := fixture.staff_service.create_snapshot() + var restored := _fixture() + _expect(restored.staff_service.restore_snapshot(checkpoint), "a blocked commute snapshot restores using existing Staff and Shift data") + restored.path_service.set_cell_temporary_block(Vector3i(2, 0, 0), true) + _expect(restored.staff_service.create_snapshot() == checkpoint, "restored blocked attendance is exact without new service state") + fixture.path_service.set_cell_temporary_block(Vector3i(2, 0, 0), false) + restored.path_service.set_cell_temporary_block(Vector3i(2, 0, 0), false) + for minute in range(379, 390): + fixture.advance(minute) + restored.advance(minute) + _expect(fixture.staff_service.create_snapshot() == restored.staff_service.create_snapshot(), "route repair replays to identical work, lateness and positions after restore") + _expect(worker.state == StaffRuntime.State.AVAILABLE and worker.absence_reason.is_empty(), "repairing the route allows physical arrival and clears the active absence") + _expect(shift.actual_started_at == 383 and shift.late_minutes == 22, "arrival keeps historical late minutes while beginning real duty") + var midwalk := _fixture() + var walker := _plan(midwalk) + midwalk.advance(361) + midwalk.advance(362) + var moving := midwalk.staff_service.create_snapshot() + var replay := _fixture() + _expect(replay.staff_service.restore_snapshot(moving), "mid-walk save restores the exact current cell") + for minute in range(363, 368): + midwalk.advance(minute) + replay.advance(minute) + _expect(midwalk.staff_service.create_snapshot() == replay.staff_service.create_snapshot() and walker.current_cell == Vector3i(4, 0, 0), "mid-walk replay neither skips nor repeats route cells") + + +func _test_reassignment_and_return_to_post() -> void: + var fixture := _fixture() + var worker := _plan(fixture) + for minute in range(361, 367): fixture.advance(minute) + var first := fixture.staff_service.get_shift(worker.shift_id) + _expect(fixture.staff_service.set_work_assignment(&"worker", &"east", &"DAY").is_success(), "fixed staff can be reassigned after arriving") + first = fixture.staff_service.get_shift(first.shift_instance_id) + _expect(worker.state == StaffRuntime.State.COMMUTING and worker.current_cell == Vector3i(4, 0, 0), "reassignment walks from the current location") + _expect(first.assigned_facility_id == &"west" and first.actual_ended_at == 366, "historical duty remains attached to the old workplace") + for minute in range(367, 374): fixture.advance(minute) + var shift := fixture.staff_service.get_shift(worker.shift_id) + _expect(worker.current_cell == Vector3i(9, 0, 0) and worker.state == StaffRuntime.State.AVAILABLE, "reassignment opens the new workplace after arrival") + # Completion of a portable task leaves its employee at the work target. + worker.current_cell = Vector3i(6, 0, 0) + worker.transition_to(StaffRuntime.State.AVAILABLE, &"TASK_COMPLETED", 374) + fixture.advance(374) + _expect(worker.state == StaffRuntime.State.COMMUTING and worker.current_cell == Vector3i(7, 0, 0), "fixed roles return to their post after work elsewhere") + fixture.advance(375) + fixture.advance(376) + fixture.advance(377) + _expect(worker.state == StaffRuntime.State.AVAILABLE and worker.current_cell == Vector3i(9, 0, 0), "desk service resumes after returning physically") + _expect(shift.late_minutes == 0, "returning from a completed task does not rewrite arrival lateness") + _expect(fixture.staff_service.set_work_assignment(&"worker", &"", &"DAY").is_success(), "unassignment cancels the repeating commute") + var cell := worker.current_cell + fixture.advance(378) + _expect(worker.state == StaffRuntime.State.OFF_DUTY and worker.current_cell == cell, "unassigned staff keep position and stop commuting") + + +func _test_reassignment_after_late_arrival_counts_boundary_once() -> void: + var fixture := _fixture() + var worker := _plan(fixture) + for minute in range(361, 366): fixture.advance(minute) + var old_id := worker.shift_id + fixture.facility_service.create_instance({&"instance_id": &"nearby", &"definition_id": &"office", &"origin": Vector3i(3, 0, 0), &"occupied_cells": [Vector3i(3, 0, 0)]}) + _expect(fixture.staff_service.get_shift(old_id).worked_minutes == 1, "late arrival has only one actual duty minute before reassignment") + _expect(fixture.staff_service.set_work_assignment(&"worker", &"nearby", &"DAY").is_success(), "paused reassignment may choose another workplace sharing this access cell") + var old := fixture.staff_service.get_shift(old_id) + var replacement := fixture.staff_service.get_shift(worker.shift_id) + _expect(old.worked_minutes == 0 and old.actual_started_at == -1 and replacement.worked_minutes == 1, "reassignment transfers the current duty minute exactly once despite earlier commute gaps") + _expect(fixture.staff_service.restore_snapshot(fixture.staff_service.create_snapshot()), "gap-aware historical shift split restores without extra saved attendance state") + + +func _test_porter_dispatch_after_check_in() -> void: + var fixture := _fixture(&"porter") + var worker := _plan(fixture) + _expect(not worker.is_available_for_dispatch(), "new porter cannot take a delivery before checking in") + for minute in range(361, 367): fixture.advance(minute) + worker.current_cell = Vector3i(10, 0, 0) + worker.transition_to(StaffRuntime.State.AVAILABLE, &"TASK_COMPLETED", 367) + fixture.advance(367) + _expect(worker.state == StaffRuntime.State.AVAILABLE and worker.current_cell == Vector3i(10, 0, 0), "checked-in porter can accept another job from a delivery destination") + _expect(not fixture.commute.is_present(worker, fixture.facility_service.get_instance(&"west")), "porter away from the warehouse does not falsely staff the warehouse") + var restored := _fixture(&"porter") + _expect(restored.staff_service.restore_snapshot(fixture.staff_service.create_snapshot()), "the porter's once-per-shift check-in survives save restoration") + restored.advance(368) + _expect(restored.staff_service.get_staff(&"worker").state == StaffRuntime.State.AVAILABLE, "loaded porter duty does not force an unnecessary return commute") + fixture.advance(1080) + fixture.advance(1770) + _expect(worker.state == StaffRuntime.State.COMMUTING and worker.current_cell == Vector3i(9, 0, 0), "the following shift requires a fresh real warehouse check-in") + + +func _test_back_layer_permissions_and_unavailable() -> void: + var fixture := _fixture() + var worker := _plan(fixture, &"back") + var access: Dictionary = fixture.commute.find_facility_access_path(Vector3i.ZERO, fixture.facility_service.get_instance(&"back")) + _expect(access[&"reachable"] and access[&"cell"].z == 1, "commute can reach BACK facilities through an authorized staff door") + _expect(fixture.path_service.find_path(Vector3i.ZERO, Vector3i(4, 0, 1), [0, 1], []).is_empty(), "the same staff door remains inaccessible without staff rights") + fixture.advance(361) + _expect(worker.current_cell == Vector3i(0, 0, 1), "cross-layer commute uses the actual connector endpoint") + worker.health = 0 + worker.transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", 362) + fixture.advance(362) + _expect(worker.current_cell == Vector3i(0, 0, 1) and worker.state == StaffRuntime.State.INCAPACITATED, "unavailable workers do not walk or get promoted to available") + worker.health = 100 + worker.transition_to(StaffRuntime.State.COMMUTING, &"RECOVERED", 363) + fixture.fail_movement = true + fixture.advance(363) + _expect(worker.current_cell == Vector3i(0, 0, 1), "a refused Runtime movement leaves the commute position unchanged") + fixture.fail_movement = false + for minute in range(364, 371): fixture.advance(minute) + _expect(fixture.commute.is_present(worker, fixture.facility_service.get_instance(&"back")), "recovered staff finish the real BACK commute") + _expect(not CommuteType.is_at_facility(worker, fixture.facility_service.get_instance(&"west")), "identical screen coordinates on different layers never count as attendance") + var upper := FacilityInstance.new().configure(&"upper", &"office", {&"origin": Vector3i(4, 1, 1), &"occupied_cells": [Vector3i(4, 1, 1)]}) + _expect(not CommuteType.is_at_facility(worker, upper), "standing below an upper room cannot bypass the route to its floor") + _expect(not fixture.commute.find_facility_access_path(worker.current_cell, upper)[&"reachable"], "a corridor underneath a room is not a valid workplace entrance") + + +func _test_night_shift_and_missing_workplace() -> void: + var fixture := _fixture(&"receptionist", 1410) + var worker := _plan(fixture, &"west", &"NIGHT") + for minute in range(1411, 1417): fixture.advance(minute) + var shift := fixture.staff_service.get_shift(worker.shift_id) + fixture.advance(1440) + _expect(worker.state == StaffRuntime.State.AVAILABLE and worker.shift_id == shift.shift_instance_id, "midnight retains the same physically attended NIGHT shift") + fixture.advance(1800) + _expect(worker.state == StaffRuntime.State.OFF_DUTY and shift.actual_ended_at == 1800, "NIGHT attendance ends at next-day 06:00") + var missing := _fixture() + var absent := _plan(missing, &"missing") + missing.advance(361) + _expect(absent.state == StaffRuntime.State.COMMUTING and absent.absence_reason == CommuteType.WORKPLACE_MISSING, "missing workplace remains a visible waiting condition") + _expect(missing.staff_service.get_shift(absent.shift_id).worked_minutes == 0, "a missing workplace creates no phantom duty") + + +func _test_boarded_reassignment_does_not_staff_origin() -> void: + var fixture := _fixture() + var worker := _plan(fixture) + fixture.facility_service.create_instance({&"instance_id": &"boarding_post", &"definition_id": &"office", &"origin": Vector3i.ZERO, &"occupied_cells": [Vector3i.ZERO]}) + fixture.in_transit = true + _expect(fixture.staff_service.set_work_assignment(&"worker", &"boarding_post", &"DAY").is_success(), "a boarded commute can accept a new destination plan") + var shift := fixture.staff_service.get_shift(worker.shift_id) + fixture.advance(361) + _expect(worker.state == StaffRuntime.State.COMMUTING and shift.worked_minutes == 0, "the cabin's projected boarding cell cannot turn into attendance at the origin") + _expect(not fixture.commute.is_present(worker, fixture.facility_service.get_instance(&"boarding_post")), "a passenger never supplies staff capacity while still in transit") + _expect(fixture.commute.get_report()[&"staff"][0][&"reason"] == &"ELEVATOR_WAIT", "commute report distinguishes the owned ride from arrival at its projected origin") + _expect(worker.current_cell == Vector3i.ZERO and fixture.movements.is_empty(), "the commute driver waits for the owned ride rather than moving its projected anchor") + fixture.in_transit = false + worker.current_cell = Vector3i(3, 0, 0) + fixture.advance(362) + _expect(worker.state == StaffRuntime.State.COMMUTING and worker.current_cell == Vector3i(2, 0, 0), "after safe disembarkation the employee walks toward the latest assignment") + fixture.advance(363) + fixture.advance(364) + _expect(worker.state == StaffRuntime.State.AVAILABLE and shift.actual_started_at == 364, "new-post duty begins only after the completed ride and actual return") + fixture.in_transit = true + worker.current_task_id = &"owned_work" + _expect(fixture.commute.can_attend_shift(worker, shift, 365), "an already checked-in employee's owned portable work remains duty while riding") + worker.current_task_id = &"" + _expect(not fixture.commute.can_attend_shift(worker, shift, 365), "an idle passenger cannot become dispatchable based on its old checked-in position") + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error("FAIL: %s" % message) diff --git a/tests/staff/test_staff_commute.gd.uid b/tests/staff/test_staff_commute.gd.uid new file mode 100644 index 0000000..f6dc49c --- /dev/null +++ b/tests/staff/test_staff_commute.gd.uid @@ -0,0 +1 @@ +uid://cpp5niwsp4ayo diff --git a/tests/supply/test_supply_logistics_bridge.gd b/tests/supply/test_supply_logistics_bridge.gd index 9e3bf48..6212f59 100644 --- a/tests/supply/test_supply_logistics_bridge.gd +++ b/tests/supply/test_supply_logistics_bridge.gd @@ -52,16 +52,21 @@ func _run() -> void: return _expect(_command(&"hire_staff_candidate", {&"candidate_id": PORTER}), "hire the catalogue porter") _expect(_command(&"set_staff_work_assignment", {&"staff_id": PORTER, &"facility_id": _source, &"template_id": &"DAY"}), "assign porter to a repeating real shift") + _expect(_worker().state == StaffRuntime.State.COMMUTING, "porter assignment begins a real warehouse commute") + for minute in range(40): + if _worker().state == StaffRuntime.State.AVAILABLE: break + _clock.advance_minutes(1) + _expect(_worker().state == StaffRuntime.State.AVAILABLE and StaffCommuteService.is_at_facility(_worker(), _runtime.facility_service.get_instance(_source)), "porter checks in physically before taking a supply job") _expect(_command(&"purchase_supplies", {&"warehouse_id": _source, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 8}), "purchase the source stock through the ledger") _expect(_command(&"create_logistics_request", {&"request_id": REQUEST, &"source_inventory_id": _inventory(_source), &"destination_inventory_id": _inventory(_destination), &"item_requirements": {SupplyCatalog.RAW_FOOD: 8}}), "create the reserved supply request") _expect(_quantity(_inventory(_destination)) == 0, "request creation does not deliver anything") _test_manual_dispatch_boundary() _reserved = _runtime.create_snapshot() + var check_in_cell := _worker().current_cell _clock.advance_minutes(1) - _expect(_request().assigned_carrier_id == PORTER and _worker().current_cell.distance_to(Vector3i.ZERO) <= 1.0, "dispatcher selects the actual employee without teleporting") - var start := _worker().current_cell + _expect(_request().assigned_carrier_id == PORTER and _worker().current_cell.distance_to(check_in_cell) <= 1.0, "dispatcher selects the checked-in employee without teleporting") _wait_for(LogisticsRequest.State.DELIVERING, 40) - _expect(_request().state == LogisticsRequest.State.DELIVERING and _worker().current_cell != start, "porter walks to the source before pickup") + _expect(_request().state == LogisticsRequest.State.DELIVERING and _worker().current_cell.distance_to(check_in_cell) <= 1.0, "physical pickup starts delivery from the checked-in warehouse access without teleporting") if _request().state != LogisticsRequest.State.DELIVERING: _finish() return @@ -69,6 +74,7 @@ func _run() -> void: _expect(_crosslinks(), "loaded Request, Task, reservations and cargo agree") _loaded = _runtime.create_snapshot() _expect(_runtime.restore_snapshot(_loaded), "mid-delivery Runtime snapshot restores") + _test_same_floor_access() _test_ownership_and_replay() _test_capacity_wait() _test_route_recovery() @@ -84,6 +90,13 @@ func _run() -> void: _finish() +func _test_same_floor_access() -> void: + var upper := FacilityInstance.new().configure(&"upper", &"warehouse", {&"origin": Vector3i(2, 1, 1), &"occupied_cells": [Vector3i(2, 1, 1)]}) + _expect(not SupplyLogisticsBridge._cell_touches_facility(Vector3i(2, 0, 1), upper), "goods cannot be picked up from the floor below a warehouse") + _expect(SupplyLogisticsBridge._cell_touches_facility(Vector3i(1, 1, 1), upper), "same-floor doorway remains a valid transfer boundary") + _expect(not _runtime.supply_logistics.find_facility_access_path(Vector3i(2, 0, 1), upper).get(&"reachable", false), "source access routing cannot substitute a corridor below the destination floor") + + func _test_ownership_and_replay() -> void: var owned := _owned_snapshot() var manual: CommandResult = _runtime.submit_command(&"advance_logistics_request", {&"request_id": REQUEST}) diff --git a/tests/supply/test_supply_save_migration.gd b/tests/supply/test_supply_save_migration.gd index 725b03a..81ea8bd 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 v7 supply save migration") + print("P1: Runtime v8 supply save migration") var source := _fixture(7301) var first := _fixture(7302) var second := _fixture(7303) @@ -19,28 +19,31 @@ 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"] == 7, "current Runtime emits schema v7") + _expect(canonical_data[&"runtime_schema_version"] == 8, "current Runtime emits schema v8") _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[&"path"][&"schema_version"] = 3 + legacy_data[&"path"].erase(&"vertical_walk_blockers") _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 v7") + _expect(first_runtime.restore_snapshot(legacy), "genuine v6 state restores into v8") var migrated: Dictionary = first_runtime.create_snapshot() - _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 7, "v6 migration re-emits current schema") + _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 8, "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") _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 v7 round trip is exact") + _expect(first_runtime.restore_snapshot(migrated) and first_runtime.create_snapshot() == migrated, "canonical v8 round trip is exact") var missing_supply := canonical.duplicate(true) missing_supply[&"systems"][&"runtime"].erase(&"kitchen_production") - _expect(_rejects_atomically(first_runtime, missing_supply), "v7 cannot omit its supply aggregate") + _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 v7 with retained kitchen state cannot masquerade as v6") + _expect(_rejects_atomically(first_runtime, relabelled), "relabelled v8 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") @@ -57,6 +60,9 @@ func _run() -> void: var orphaned: Dictionary = source_runtime.create_snapshot() orphaned[&"systems"][&"runtime"][&"runtime_schema_version"] = 6 orphaned[&"systems"][&"runtime"].erase(&"kitchen_production") + orphaned[&"systems"][&"runtime"].erase(&"elevators") + orphaned[&"systems"][&"runtime"][&"path"][&"schema_version"] = 3 + orphaned[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_blockers") _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_elevator_service.gd b/tests/transport/test_elevator_service.gd new file mode 100644 index 0000000..7ddfba4 --- /dev/null +++ b/tests/transport/test_elevator_service.gd @@ -0,0 +1,258 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _path: PathService +var _elevators: ElevatorService +var _config: Dictionary +var _minute := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var clock := root.get_node_or_null("GameClock") + if clock != null: clock.set_paused(true) + _test_grouping() + _test_capacity_and_time() + _test_access_and_shared_cabin() + _test_restore_and_emergency() + _test_mutations_and_cancellation() + _test_snapshot_rejections() + print("Elevator service: %d checks, %d failures" % [_checks, _failures]) + quit(0 if _failures == 0 else 1) + + +func _setup(layer := 0, floors := 4) -> void: + _path = PathService.new() + var cells: Array[Vector3i] = [] + for y: int in range(floors + 2): + cells.append(Vector3i(0, y, layer)) + _path.rebuild_graph(cells) + _elevators = ElevatorService.new() + _elevators.configure(_path) + _config = {&"shaft_id": StringName("transport.shaft.%d.0.0.%d" % [layer, 2 if layer == 0 else 3]), + &"module_ids": [&"module.0", &"module.1"], &"stops": [], &"width": 2 if layer == 0 else 3, + &"capacity": 4 if layer == 0 else 8, &"travel_minutes_per_floor": 10 if layer == 0 else 5, + &"access_id": &"" if layer == 0 else &"staff"} + for y: int in range(floors): (_config[&"stops"] as Array).append(Vector3i(0, y, layer)) + _expect(_elevators.synchronize([_config], 0)[&"ok"], "shaft initializes") + _minute = 0 + + +func _request(actor: StringName, from: int, to: int, layer := 0) -> Dictionary: + var access: Array[StringName] = [] + if layer == 1: access.append(&"staff") + return _elevators.request_trip(actor, Vector3i(0, from, layer), Vector3i(0, to, layer), _minute, access) + + +func _tick(count := 1) -> Array[Dictionary]: + var arrivals: Array[Dictionary] = [] + for i: int in range(count): + _minute += 1 + arrivals.append_array(_elevators.advance_one_minute(_minute)) + return arrivals + + +func _car() -> Dictionary: + return _elevators.get_report()[&"shafts"][0] + + +func _test_grouping() -> void: + var registry := DefinitionRegistry.new() + registry.rebuild() + var records: Array = [] + for row: Array in [[&"g2", &"guest_elevator", Vector3i(0, 2, 0)], [&"g0", &"guest_elevator", Vector3i(0, 0, 0)], [&"g6", &"guest_elevator", Vector3i(0, 6, 0)], [&"b0", &"service_elevator", Vector3i(0, 0, 1)], [&"other", &"reception", Vector3i(6, 0, 0)]]: + records.append({&"instance_id": row[0], &"definition_id": row[1], &"origin": row[2]}) + var configs := ElevatorService.configurations_from_facilities(records, registry) + _expect(configs.size() == 3, "adjacent modules form one shaft, gaps and layers form separate shafts") + _expect(configs[0][&"stops"] == [Vector3i(0, 0, 0), Vector3i(0, 1, 0), Vector3i(0, 2, 0), Vector3i(0, 3, 0)], "each occupied integer floor gets a landing") + _expect(configs[0][&"module_ids"] == [&"g0", &"g2"], "construction ordering does not change module/stop ordering") + _expect(configs[2][&"capacity"] == 8 and configs[2][&"travel_minutes_per_floor"] == 5 and configs[2][&"access_id"] == &"staff", "BACK shaft has eight seats, five minutes per floor and staff access") + records.reverse() + _expect(ElevatorService.configurations_from_facilities(records, registry) == configs, "configuration generation is deterministic") + + +func _test_capacity_and_time() -> void: + _setup() + _expect(_path.create_snapshot()[&"connectors"].size() == 6, "four stops expose six bidirectional direct links") + for i: int in range(5): _expect(_request(StringName("guest/%d" % i), 0, 3)[&"ok"], "guest queues without moving") + _expect(_car()[&"passenger_count"] == 0 and _car()[&"waiting_count"] == 5, "requests do not board before simulation advances") + _expect(_tick().is_empty() and _car()[&"passenger_count"] == 4 and _car()[&"waiting_count"] == 1, "shared cabin boards at most four FRONT actors") + _expect(_car()[&"phase"] == &"BOARDING" and _car()[&"remaining_minutes"] == 6, "doors take two minutes plus one per boarding actor") + _expect(_tick(6).is_empty() and _car()[&"phase"] == &"TRAVEL", "boarding duration elapses before cabin travels") + _expect(_tick(15).is_empty() and is_equal_approx(_car()[&"visual_floor"], 1.5), "cabin interpolates actual half journey without arriving") + _expect(_tick(15).is_empty() and _car()[&"phase"] == &"ALIGHTING", "three FRONT floors take thirty minutes before alighting") + _expect(_tick(5).is_empty(), "passengers remain owned while doors are unloading") + var arrivals := _tick() + _expect(arrivals.size() == 4 and _minute == 43, "first four arrivals happen only after both door intervals and travel") + for arrival: Dictionary in arrivals: + _expect(arrival[&"cell"] == Vector3i(0, 3, 0) and not arrival[&"emergency"] and _path.get_active_elevator_request(arrival[&"actor_id"]).is_empty(), "arrival transfers actor once and releases Path ownership") + _expect(_car()[&"phase"] == &"PICKUP" and _car()[&"waiting_count"] == 1, "remaining guest waits for same cabin to return") + arrivals = _tick(66) + _expect(arrivals.size() == 1 and arrivals[0][&"actor_id"] == &"guest/4" and _car()[&"phase"] == &"IDLE", "fifth guest waits for real return and completes without starvation") + _expect(_tick(10).is_empty(), "completed request never arrives twice") + var read := _elevators.get_report() + read[&"shafts"][0][&"boarded"].append(&"tamper") + _expect(_car()[&"boarded"].is_empty(), "report returns defensive copies") + + +func _test_access_and_shared_cabin() -> void: + _setup(1) + _expect(not _elevators.request_trip(&"guest/no_access", Vector3i(0, 0, 1), Vector3i(0, 3, 1), 0)[&"ok"], "BACK elevator rejects actor without staff access") + for i: int in range(9): _expect(_request(StringName("staff/%d" % i), 0, 3, 1)[&"ok"], "authorized staff queues") + _tick() + _expect(_car()[&"passenger_count"] == 8 and _car()[&"waiting_count"] == 1, "BACK cabin shares eight seats across requests") + _tick(10) + _expect(_car()[&"phase"] == &"TRAVEL" and _car()[&"remaining_minutes"] == 15, "BACK shaft uses five minutes per floor") + _tick(15) + _expect(_car()[&"phase"] == &"ALIGHTING", "BACK travel duration is physical") + _expect(_tick(10).size() == 8, "BACK eight passengers alight together after paid time") + _setup() + _request(&"a", 0, 3) + _request(&"b", 1, 2) + _tick() + _expect(_car()[&"passenger_count"] == 1 and _car()[&"waiting_count"] == 1, "different floor pair cannot create a second cabin") + _expect(_elevators.get_actor_trip(&"b")[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED, "other connector actor remains waiting while shared car moves") + _expect(_elevators.owns_connector(_elevators.get_actor_trip(&"a")[&"connector_id"]), "transport identifies its owned path step") + _expect(not _elevators.owns_connector(&"door.other"), "unrelated door connector is not transport-owned") + var all := _tick(100) + _expect(all.size() == 2 and all[0][&"actor_id"] == &"a" and all[1][&"actor_id"] == &"b", "FIFO serves different floor pair after first journey") + + +func _test_restore_and_emergency() -> void: + _setup() + _request(&"rider", 0, 3) + _tick(20) + _expect(_car()[&"phase"] == &"TRAVEL" and _car()[&"visual_floor"] > 1.0, "midride fixture is physically between floors") + var state := _elevators.create_snapshot() + var path_state := _path.create_snapshot() + var future := _tick(40) + var final_state := _elevators.create_snapshot() + _expect(_path.restore_snapshot(path_state), "Path queue/boarded state restores") + _expect(_elevators.restore_snapshot(state), "midride save restores against matching Path ownership: %s" % _elevators.last_error) + _minute = state[&"current_minute"] + _expect(_tick(40) == future and _elevators.create_snapshot() == final_state, "restored trip produces identical arrivals and final snapshot") + _setup() + _request(&"aboard", 0, 3) + _request(&"waiting", 2, 3) + _tick(20) + var released := _elevators.set_powered(_config[&"shaft_id"], false, _minute) + _expect(released.size() == 2, "power failure releases both waiting and boarded owners") + _expect(released[0][&"actor_id"] == &"aboard" and released[0][&"cell"] == Vector3i(0, 2, 0) and released[0][&"emergency"], "power failure unloads boarded actor at nearest valid floor") + _expect(released[1][&"actor_id"] == &"waiting" and released[1][&"cell"] == Vector3i(0, 2, 0) and released[1][&"cancelled"], "waiting cancellation stays on original landing for replan") + _expect(_car()[&"operator_open"] and not _car()[&"powered"] and not _car()[&"operational"], "power loss does not alter player operation policy") + _expect(_path.get_active_elevator_request(&"aboard").is_empty() and _elevators.get_actor_trip(&"waiting").is_empty(), "emergency leaves no orphan owner") + _expect(not _request(&"stopped", 0, 3)[&"ok"], "stopped elevator rejects new boarding") + _expect(_elevators.restore_snapshot(_elevators.create_snapshot()), "stopped elevator restores with Path outage state") + _elevators.set_operator_open(_config[&"shaft_id"], false, _minute) + _elevators.set_powered(_config[&"shaft_id"], true, _minute) + _expect(not _car()[&"operator_open"] and _car()[&"powered"] and not _car()[&"operational"], "power restoration preserves player-requested stop") + _expect(_elevators.restore_snapshot(_elevators.create_snapshot()), "player-stopped powered elevator state restores") + _elevators.set_operator_open(_config[&"shaft_id"], true, _minute) + _expect(_car()[&"operational"] and _request(&"resumed", 2, 0)[&"ok"], "player reopens restored elevator") + _expect(_tick(27).size() == 1, "reopened cabin resumes from emergency landing with real travel") + _setup() + _request(&"blocked_destination", 0, 3) + _tick(20) + _path.configure_cell_traffic(Vector3i(0, 3, 0), 4, 0, true) + released = _tick() + _expect(released.size() == 1 and released[0][&"emergency"] and released[0][&"cell"] != Vector3i(0, 3, 0), "blocked destination causes emergency at a valid landing") + _setup() + _request(&"held", 0, 3) + _request(&"waiting_held", 2, 3) + _tick(15) + for cell: Vector3i in _config[&"stops"]: _path.configure_cell_traffic(cell, 4, 0, true) + released = _elevators.set_operator_open(_config[&"shaft_id"], false, _minute) + _expect(released.size() == 1 and released[0][&"actor_id"] == &"waiting_held" and released[0][&"cancelled"], "all-landings closure still cancels waiting actors at their original position") + _expect(_car()[&"passenger_count"] == 1 and not _car()[&"operational"] and not _path.get_active_elevator_request(&"held").is_empty(), "no valid landing holds boarded actor safely with ownership") + var held_remaining: int = _car()[&"remaining_minutes"] + _tick(20) + _expect(_car()[&"remaining_minutes"] == held_remaining and _elevators.restore_snapshot(_elevators.create_snapshot()), "held cabin survives elapsed time and save restore without advancing or losing its rider") + for cell: Vector3i in _config[&"stops"]: _path.configure_cell_traffic(cell) + _elevators.set_operator_open(_config[&"shaft_id"], true, _minute) + _expect(_elevators.restore_snapshot(_elevators.create_snapshot()), "repaired cabin resumes with an adjusted physical phase clock") + _expect(_tick(40).size() == 1, "held actor arrives exactly once after landing repair") + + +func _test_mutations_and_cancellation() -> void: + _setup() + _request(&"waiter", 0, 3) + var before := _elevators.create_snapshot() + _expect(not _elevators.synchronize([], _minute)[&"ok"] and _elevators.create_snapshot() == before, "queued rider prevents destructive shaft modification atomically") + var larger := _config.duplicate(true) + larger[&"module_ids"].append(&"module.2") + larger[&"stops"].append(Vector3i(0, 4, 0)) + larger[&"stops"].append(Vector3i(0, 5, 0)) + _expect(not _elevators.synchronize([larger], _minute)[&"ok"], "extension waits until cabin and queues are idle") + _expect(_elevators.cancel_actor(&"waiter") and _path.get_active_elevator_request(&"waiter").is_empty(), "waiting actor removal cancels Path queue ownership") + _expect(_elevators.synchronize([larger], _minute)[&"ok"] and _car()[&"stops"].size() == 6, "idle upward extension joins existing physical cabin") + _expect(_request(&"despawn", 0, 3)[&"ok"], "new request uses fresh request ID after cancellation") + _tick(2) + _expect(_elevators.cancel_actor(&"despawn") and _path.get_active_elevator_request(&"despawn").is_empty() and _car()[&"passenger_count"] == 0, "boarded actor despawn removes only seat and ownership") + _expect(_elevators.restore_snapshot(_elevators.create_snapshot()), "saved doors remain valid after only passenger despawns") + _expect(_tick(50).is_empty() and _car()[&"phase"] == &"IDLE", "empty cabin safely finishes its motion without resurrecting despawned actor") + _expect(_elevators.synchronize([], _minute)[&"ok"] and _path.create_snapshot()[&"connectors"].is_empty(), "idle demolition removes all owned connectors") + _setup() + _request(&"duplicate", 0, 3) + _expect(_elevators.restore_snapshot(_elevators.create_snapshot()), "queued snapshot restores with matching floor, request, and Path unit") + var replay := _request(&"duplicate", 0, 3) + _expect(replay[&"ok"] and replay[&"replay"] and _car()[&"waiting_count"] == 1, "same actor route request replays without duplicate queue") + _expect(not _request(&"duplicate", 0, 2)[&"ok"], "actor cannot own another simultaneous destination") + _expect(_elevators.advance_one_minute(8).is_empty() and _elevators.current_minute == 0, "skipped simulation minutes are rejected") + _expect(_tick().is_empty() and _elevators.advance_one_minute(1).is_empty() and _car()[&"remaining_minutes"] == 3, "duplicate minute cannot advance cabin twice") + + +func _test_snapshot_rejections() -> void: + _setup() + _request(&"saved", 0, 3) + _tick(10) + var saved := _elevators.create_snapshot() + _expect(_elevators.restore_snapshot(saved), "valid active snapshot restores") + var bad := saved.duplicate(true) + bad[&"shafts"][0][&"config"][&"capacity"] = 5 + _reject(bad, saved, "tampered FRONT capacity") + bad = saved.duplicate(true) + bad[&"shafts"][0][&"config"][&"travel_minutes_per_floor"] = 1 + _reject(bad, saved, "tampered floor speed") + bad = saved.duplicate(true) + bad[&"shafts"][0][&"remaining_minutes"] -= 1 + _reject(bad, saved, "tampered cabin countdown") + bad = saved.duplicate(true) + bad[&"trips"][0][&"to"] = Vector3i(0, 2, 0) + _reject(bad, saved, "tampered destination") + bad = saved.duplicate(true) + bad[&"shafts"][0][&"boarded"] = [] + _reject(bad, saved, "missing passenger seat") + bad = saved.duplicate(true) + bad[&"trips"] = [] + _reject(bad, saved, "missing active trip") + bad = saved.duplicate(true) + bad[&"request_sequence"] = 0 + _reject(bad, saved, "active request sequence reuse") + bad = saved.duplicate(true) + bad[&"shafts"][0][&"powered"] = false + _reject(bad, saved, "inconsistent power and operation") + bad = saved.duplicate(true) + bad[&"unexpected"] = true + _reject(bad, saved, "unknown snapshot field") + _tick(50) + saved = _elevators.create_snapshot() + bad = saved.duplicate(true) + bad[&"request_sequence"] = 0 + _reject(bad, saved, "retired request sequence reuse") + _expect(_elevators.restore_snapshot(saved), "completed trip history permits valid restore") + + +func _reject(bad: Dictionary, before: Dictionary, label: String) -> void: + _expect(not _elevators.restore_snapshot(bad) and _elevators.create_snapshot() == before, "%s is rejected atomically" % label) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if condition: print("PASS: %s" % label) + else: + _failures += 1 + push_error("FAIL: %s" % label) diff --git a/tests/transport/test_elevator_service.gd.uid b/tests/transport/test_elevator_service.gd.uid new file mode 100644 index 0000000..38ce77f --- /dev/null +++ b/tests/transport/test_elevator_service.gd.uid @@ -0,0 +1 @@ +uid://pe3usnvol833 diff --git a/tests/transport/test_runtime_transport.gd b/tests/transport/test_runtime_transport.gd new file mode 100644 index 0000000..ab67657 --- /dev/null +++ b/tests/transport/test_runtime_transport.gd @@ -0,0 +1,147 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _runtime: Variant +var _clock: Variant +var _state: Variant +var _serial := 0 +var _main: Control +var _saw_trip := false +var _saw_boarded := false +var _saw_arrival := false +var _saved := false +var _preview_snapshot: Dictionary = {} + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + _runtime = root.get_node("SimulationRuntime") + _clock = root.get_node("GameClock") + _state = root.get_node("GameState") + _clock.reset(1, 6, 0) + _state.reset() + _runtime.initialize(_clock, _state, 424242) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _clock.set_paused(true) + root.size = Vector2i(1440, 810) + _main = load("res://scenes/main/main.tscn").instantiate() as Control + _main.set("manual_save_path", "user://tests/transport_%d/manual" % OS.get_process_id()) + root.add_child(_main) + await process_frame + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _expect(_state.get_resource(&"gold") == 5000 and _state.get_resource(&"materials") == 50, "ordinary starting budget") + if not _build(&"guest_elevator", Vector3i(0, 0, 0)) or not _build(&"reception", Vector3i(2, 0, 0)): + _finish(); return + _clock.advance_minutes(300) + _expect(_runtime.elevators.get_report()[&"shafts"].size() == 1, "completed construction registers physical cabin") + if _runtime.elevators.get_report()[&"shafts"].is_empty(): _finish(); return + if not _build(&"guest_elevator", Vector3i(0, 2, 0)) or not _build(&"standard_room", Vector3i(2, 2, 0)): + _finish(); return + _clock.advance_minutes(300) + if not _build(&"standard_room", Vector3i(2, 3, 0)): + _finish(); return + _clock.advance_minutes(60) + var report: Dictionary = _runtime.elevators.get_report() + _expect(report[&"shafts"].size() == 1 and report[&"shafts"][0][&"stops"].size() == 4, "stacked modules share one cabin across four floors") + _expect(_state.get_resource(&"materials") == 2, "buildings fit within 50 materials") + var reception: FacilityInstance + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"reception": reception = facility + _expect(_cmd(&"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success(), "hire receptionist with earned starting funds") + _expect(_cmd(&"set_staff_work_assignment", {&"staff_id": &"staff_akane", &"facility_id": reception.instance_id, &"template_id": &"DAY"}).is_success(), "assign real commute") + _expect(reception.active_staff_ids.is_empty(), "assignment alone does not open reception") + for index: int in range(250): + _clock.advance_minutes(1) + var current: Dictionary = _runtime.elevators.get_report() + if not current[&"trips"].is_empty(): _saw_trip = true + for trip: Dictionary in current[&"trips"]: + if trip[&"state"] != PathService.ELEVATOR_REQUEST_BOARDED: continue + _saw_boarded = true + if not _saved: + _saved = _verify_mid_ride() + _preview_snapshot = _runtime.create_snapshot() + for guest: GuestRuntime in _runtime.guest_service.get_guests(): + if guest.current_cell.y >= 2: _saw_arrival = true + if _saw_boarded and not _main.get_meta(&"captured", false) and OS.get_environment("THE_TREE_TRANSPORT_CAPTURE") == "1": + _main.set_meta(&"captured", true) + await _capture() + if index % 30 == 0: await process_frame + if not _saw_trip: + print("TRANSPORT_DIAGNOSTIC ", _runtime.elevators.get_report(), " STAFF ", _runtime.staff_commute.get_report()) + for f: FacilityInstance in _runtime.facility_service.get_instances(): print("FAC ", f.definition_id, " ", f.state, " ", f.active_staff_ids) + for g: GuestRuntime in _runtime.guest_service.get_guests(): print("GUEST ", g.guest_id, " ", g.state, " ", g.current_cell, " ", _runtime._project_guest_candidates(g.guest_id)) + _expect(_saw_trip, "natural guest requests an elevator") + _expect(_saw_boarded, "natural guest boards the shared cabin") + _expect(_saw_arrival, "natural guest physically reaches an upper floor") + _expect(_saved, "mid-ride replay tested") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "current completed runtime restores") + if OS.get_environment("THE_TREE_TRANSPORT_PREVIEW") == "1" and _failures == 0: + _expect(_runtime.restore_snapshot(_preview_snapshot), "preview returns to the verified first boarding") + if _failures > 0: _finish(); return + await _capture() + DisplayServer.window_set_title("The Tree · 昇降機と通勤の試遊(保存なし)") + _clock.set_paused(true) + print("Runtime transport: %d checks, %d failures" % [_checks, _failures]) + print("TRANSPORT_PREVIEW_READY Space resumes; test saves disabled") + return + _finish() + +func _verify_mid_ride() -> bool: + var save_path := "user://tests/transport_%d/ride" % OS.get_process_id() + _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(_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]}) + _expect(not busy.is_success() and busy.details.get(&"reason") == &"ELEVATOR_BUSY", "occupied shaft demolition rejected before spending or topology changes") + _expect(_runtime.restore_snapshot(snapshot), "snapshot resets rejected command metadata") + _clock.advance_minutes(45) + var expected: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(snapshot), "rewind boarded save for deterministic replay") + _clock.advance_minutes(45) + _expect(_runtime._strict_variants_equal(expected, _runtime.create_snapshot()), "mid-ride save replays all Runtime state identically") + _expect(_runtime.restore_snapshot(snapshot), "restore original ride position after replay test") + var stopped := _cmd(&"set_elevator_operation", {&"shaft_id": shaft[&"shaft_id"], &"operator_open": false}) + _expect(stopped.is_success(), "player stops elevator through public command") + _expect(_runtime.elevators.get_report()[&"trips"].is_empty(), "stop releases waiting and disembarks passengers at safe floor") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "stopped elevator save restores") + _expect(_runtime.restore_snapshot(snapshot), "resume original operating snapshot") + return true + +func _capture() -> void: + var preview: Control = _main.get("world_preview") + _main.call("_on_facility_selected", _runtime.elevators.get_report()[&"shafts"][0][&"module_ids"][0]) + preview.call("set_zoom", 2.0) + preview.set("pan_offset", preview.get("pan_offset") + preview.size * Vector2(0.5, 0.55) - preview.call("cell_to_canvas", Vector3i(2, 1, 0))) + preview.queue_redraw() + await process_frame + await RenderingServer.frame_post_draw + for trip: Dictionary in _runtime.elevators.get_report()[&"trips"]: + if trip[&"state"] != PathService.ELEVATOR_REQUEST_BOARDED: continue + var key := String(trip[&"actor_id"]).replace("guest:", "guest/").replace("staff:", "staff/") + _expect((preview.get("_boarded_actor_ids") as Dictionary).has(key), "live rider identified in cabin rendering") + _expect(not (preview.get("_actor_tracks") as Dictionary).has(key), "live rider is not duplicated on floor") + var path := ProjectSettings.globalize_path("res://artifacts/transport/elevator-cabin.png") + root.get_texture().get_image().save_png(path) + +func _build(id: StringName, origin: Vector3i) -> bool: + var result := _cmd(&"build_facility", {&"definition_id": id, &"origin": origin}) + _expect(result.is_success(), "build %s at %s %s" % [id, origin, result.message]) + return result.is_success() + +func _cmd(kind: StringName, payload: Dictionary) -> CommandResult: + _serial += 1 + return _runtime.submit_command(kind, payload, &"player", &"", 0, _clock.absolute_minute, StringName("transport.test.%d" % _serial)) + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: _failures += 1 + print("%s %s" % ["PASS" if condition else "FAIL", message]) + +func _finish() -> void: + print("Runtime transport: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures else 0) diff --git a/tests/transport/test_runtime_transport.gd.uid b/tests/transport/test_runtime_transport.gd.uid new file mode 100644 index 0000000..a51a25a --- /dev/null +++ b/tests/transport/test_runtime_transport.gd.uid @@ -0,0 +1 @@ +uid://cityh7pucrssv diff --git a/tests/transport/test_transport_boundaries.gd b/tests/transport/test_transport_boundaries.gd new file mode 100644 index 0000000..e99c7fb --- /dev/null +++ b/tests/transport/test_transport_boundaries.gd @@ -0,0 +1,175 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _runtime: Variant +var _clock: Variant +var _state: Variant +var _serial := 0 +const STAFF := &"staff_akane" +const ACTOR := &"staff:staff_akane" + + +class LateFailElevators extends ElevatorService: + var fail_next_sync := false + + func synchronize(configurations: Array, minute: int) -> Dictionary: + var result := super.synchronize(configurations, minute) + if fail_next_sync and result[&"ok"]: + fail_next_sync = false + last_error = "Injected failure after actual transport mutation." + return {&"ok": false, &"reason": &"INJECTED_LATE_FAILURE"} + return result + + +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() + _clock.reset(1, 6, 0) + _state.reset() + _expect(_runtime.initialize(_clock, _state, 81662)[&"valid"], "boundary fixture initializes") + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _clock.set_paused(true) + _expect(_command(&"build_facility", {&"definition_id": &"guest_elevator", &"origin": Vector3i.ZERO}).is_success(), "build lower lift from real starting budget") + _clock.advance_minutes(310) + _expect(_car().get(&"stops", []).size() == 2, "completed lower lift has two real stops") + _expect(_command(&"hire_staff_candidate", {&"candidate_id": STAFF}).is_success(), "hire actual ground-floor actor") + var idle: Dictionary = _runtime.create_snapshot() + var lower_id: StringName = _car()[&"module_ids"][0] + var extension := _command(&"build_facility", {&"definition_id": &"guest_elevator", &"origin": Vector3i(0, 2, 0)}) + _expect(extension.is_success(), "idle shaft accepts contiguous extension") + var extension_id: StringName + for job: ConstructionJob in _runtime.construction_service.get_jobs(): + if job.origin == Vector3i(0, 2, 0): extension_id = job.job_id + var freeze: Dictionary = _runtime.advance_staff_commute(STAFF, Vector3i(0, 1, 0), _clock.absolute_minute) + _expect(freeze.get(&"reason") == &"ELEVATOR_WAIT" and _runtime.elevators.get_report()[&"trips"].is_empty(), "in-progress extension freezes new boarding at Runtime movement boundary") + _expect(_runtime.staff_service.get_staff(STAFF).current_cell == Vector3i.ZERO, "extension freeze never walks actor vertically") + _clock.advance_minutes(5) + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "in-progress extension and frozen empty car survive save restore") + _expect(_command(&"cancel_construction", {&"job_id": extension_id}).is_success(), "public cancellation releases extension freeze") + var resumed: Dictionary = _runtime.advance_staff_commute(STAFF, Vector3i(0, 1, 0), _clock.absolute_minute) + _expect(resumed.get(&"reason") == &"ELEVATOR_WAIT" and _runtime.elevators.get_actor_trip(ACTOR).get(&"state") == PathService.ELEVATOR_REQUEST_QUEUED, "after cancellation the same real actor queues for a cabin") + _check_busy_commands(lower_id, "queued") + _check_operations_rollback("queued") + _clock.advance_minutes(1) + _expect(_runtime.elevators.get_actor_trip(ACTOR).get(&"state") == PathService.ELEVATOR_REQUEST_BOARDED, "queued actor boards through next real minute") + _check_busy_commands(lower_id, "boarded") + _check_operations_rollback("boarded") + var reassigned := _command(&"set_staff_work_assignment", {&"staff_id": STAFF, &"facility_id": &"", &"template_id": &"DAY"}) + _expect(reassigned.is_success() and _runtime.elevators.get_actor_trip(ACTOR).get(&"state") == PathService.ELEVATOR_REQUEST_BOARDED, "unassigning boarded worker preserves cabin ride until safe arrival") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "reassigned boarded worker remains a valid Runtime save") + _clock.advance_minutes(20) + _expect(_runtime.elevators.get_actor_trip(ACTOR).is_empty() and _runtime.staff_service.get_staff(STAFF).current_cell == Vector3i(0, 1, 0), "unassigned worker still physically finishes the existing ride") + _expect(_runtime.restore_snapshot(idle), "restore original empty shaft for topology completion cases") + var shaft_id: StringName = _car()[&"shaft_id"] + _expect(_command(&"set_elevator_operation", {&"shaft_id": shaft_id, &"operator_open": false}).is_success(), "player stops idle shaft before extension") + _expect(_command(&"build_facility", {&"definition_id": &"guest_elevator", &"origin": Vector3i(0, 2, 0)}).is_success(), "stopped idle shaft can extend") + _clock.advance_minutes(310) + _expect(_car()[&"stops"].size() == 4 and _car()[&"module_ids"].size() == 2 and not _car()[&"operator_open"], "successful extension keeps one cabin and persists player-stopped policy") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "completed extended shaft has exact save crosslinks") + var upper_id: StringName = _car()[&"module_ids"][1] + _expect(_command(&"demolish_facility", {&"instance_id": upper_id}).is_success(), "empty upper module demolition succeeds") + _expect(_car()[&"stops"].size() == 2 and _car()[&"module_ids"] == [lower_id] and not _car()[&"operator_open"], "upper demolition removes its stops without re-enabling lower car") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "partially demolished shaft remains saveable") + var injected := LateFailElevators.new() + injected.configure(_runtime.path_service) + _expect(injected.restore_snapshot(_runtime.elevators.create_snapshot()), "late-failure injector starts from exact live cabin state") + _runtime.elevators = injected + injected.fail_next_sync = true + var before := _participants() + var failure := _command(&"demolish_facility", {&"instance_id": lower_id}) + _expect(not failure.is_success() and failure.details.get(&"rolled_back", false), "post-demolition transport failure rolls back the public transaction") + _expect(_participants() == before, "late rollback restores world, money, projections, construction, power and physical car exactly") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "late demolition rollback produces a valid canonical save") + _expect(_command(&"demolish_facility", {&"instance_id": lower_id}).is_success(), "empty final shaft demolition succeeds on retry") + _expect(_runtime.elevators.get_report()[&"shafts"].is_empty() and _runtime.path_service.get_vertical_walk_blockers().is_empty(), "final demolition clears cabin and all shaft vertical blockers") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "fully demolished world remains exactly saveable") + _test_shift_end(idle) + _runtime.free() + _clock.free() + _state.free() + print("Transport boundaries: %d checks, %d failures" % [_checks, _failures]) + quit(0 if _failures == 0 else 1) + + +func _test_shift_end(idle: Dictionary) -> void: + _expect(_runtime.restore_snapshot(idle), "shift-boundary fixture restores ordinary lower elevator") + _expect(_command(&"build_facility", {&"definition_id": &"cafeteria", &"origin": Vector3i(2, 1, 0)}).is_success(), "real upper-floor cafeteria accepts commuting staff") + _clock.advance_minutes(190) + _clock.advance_minutes(1078 - _clock.absolute_minute) + var cafeteria: StringName + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"cafeteria": cafeteria = facility.instance_id + _expect(not cafeteria.is_empty() and _command(&"hire_staff_candidate", {&"candidate_id": &"staff_chika"}).is_success(), "hire real cook just before DAY shift ends") + var assignment := {&"staff_id": &"staff_chika", &"facility_id": cafeteria, &"template_id": &"DAY"} + _expect(_command(&"set_staff_work_assignment", assignment).is_success(), "cook receives real persistent DAY work plan") + var before_commute: Dictionary = _runtime.create_snapshot() + _clock.advance_minutes(1) + var actor_id := &"staff:staff_chika" + var queued: Dictionary = _runtime.elevators.get_actor_trip(actor_id) + _expect(queued.get(&"state") == PathService.ELEVATOR_REQUEST_QUEUED, "17:59 cook naturally queues during actual commute") + var unchanged := _command(&"set_staff_work_assignment", assignment) + _expect(unchanged.is_success() and unchanged.details.get(&"reason") == &"ASSIGNMENT_UNCHANGED" and _runtime.elevators.get_actor_trip(actor_id) == queued, "unchanged work plan preserves the same FIFO request") + _clock.advance_minutes(1) + var worker: StaffRuntime = _runtime.staff_service.get_staff(&"staff_chika") + _expect(worker.state == StaffRuntime.State.OFF_DUTY and _runtime.elevators.get_actor_trip(actor_id).is_empty(), "18:00 shift end cancels stale commute before the available cabin can board") + _expect(worker.current_cell == Vector3i.ZERO and _runtime.path_service.get_active_elevator_request(actor_id).is_empty(), "expired commute leaves cook at original landing without orphaned Path ownership") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "cancelled shift-boundary wait is a valid Runtime save") + _expect(_runtime.restore_snapshot(before_commute), "restore 17:58 for already-boarded shift-boundary case") + _runtime.advance_staff_commute(&"staff_chika", Vector3i(0, 1, 0), _clock.absolute_minute) + _clock.advance_minutes(1) + _expect(_runtime.elevators.get_actor_trip(actor_id).get(&"state") == PathService.ELEVATOR_REQUEST_BOARDED, "cook queued a minute earlier is already aboard before shift end") + _clock.advance_minutes(1) + worker = _runtime.staff_service.get_staff(&"staff_chika") + _expect(worker.state == StaffRuntime.State.OFF_DUTY and _runtime.elevators.get_actor_trip(actor_id).get(&"state") == PathService.ELEVATOR_REQUEST_BOARDED, "shift end retains already-boarded rider until physical alighting") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "off-duty boarded employee remains a valid save") + _clock.advance_minutes(20) + worker = _runtime.staff_service.get_staff(&"staff_chika") + _expect(worker.current_cell == Vector3i(0, 1, 0) and _runtime.elevators.get_actor_trip(actor_id).is_empty(), "off-duty boarded cook completes exactly one physical arrival") + + +func _check_busy_commands(module_id: StringName, label: String) -> void: + var before := _participants() + var build := _command(&"build_facility", {&"definition_id": &"guest_elevator", &"origin": Vector3i(0, 2, 0)}) + _expect(not build.is_success() and build.details.get(&"reason") == &"ELEVATOR_BUSY" and _participants() == before, "%s passengers reject extension before any participant changes" % label) + var demolition := _command(&"demolish_facility", {&"instance_id": module_id}) + _expect(not demolition.is_success() and demolition.details.get(&"reason") == &"ELEVATOR_BUSY" and _participants() == before, "%s passengers reject demolition before any participant changes" % label) + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "%s rejected topology commands preserve a valid save" % label) + + +func _check_operations_rollback(label: String) -> void: + var before: Dictionary = _runtime.create_snapshot() + var token: Dictionary = _runtime._capture_operations_external_state() + _expect(not token.is_empty(), "%s ride can participate in Operations snapshot boundary" % label) + var stopped := _command(&"set_elevator_operation", {&"shaft_id": _car()[&"shaft_id"], &"operator_open": false}) + _expect(stopped.is_success() and _runtime.elevators.get_report()[&"trips"].is_empty(), "%s rollback fixture actually mutates car and Path ownership through public command" % label) + _expect(_runtime._restore_operations_external_state(token), "%s Operations rollback restores external participants" % label) + _expect(_runtime.create_snapshot() == before, "%s Operations rollback restores full Runtime including command IDs and event state exactly" % label) + + +func _participants() -> Dictionary: + var snapshot: Dictionary = _runtime._capture_infrastructure_demolition_state() + snapshot.erase(&"domain_events") + snapshot.erase(&"id_generator") + return snapshot + + +func _car() -> Dictionary: + var shafts: Array = _runtime.elevators.get_report()[&"shafts"] + return shafts[0] if not shafts.is_empty() else {} + + +func _command(kind: StringName, payload: Dictionary) -> CommandResult: + _serial += 1 + return _runtime.submit_command(kind, payload, &"player", &"", 0, _clock.absolute_minute, StringName("transport.boundary.%d" % _serial)) + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if not condition: _failures += 1 + print("%s: %s" % ["PASS" if condition else "FAIL", label]) diff --git a/tests/transport/test_transport_boundaries.gd.uid b/tests/transport/test_transport_boundaries.gd.uid new file mode 100644 index 0000000..cbc8e24 --- /dev/null +++ b/tests/transport/test_transport_boundaries.gd.uid @@ -0,0 +1 @@ +uid://bjv4yxwqibp1n diff --git a/tests/transport/test_transport_save.gd b/tests/transport/test_transport_save.gd new file mode 100644 index 0000000..1ddf37a --- /dev/null +++ b/tests/transport/test_transport_save.gd @@ -0,0 +1,143 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _serial := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var fixture := _fixture(81423) + var runtime: Variant = fixture[&"runtime"] + 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") + var legacy := current.duplicate(true) + var data: Dictionary = legacy[&"systems"][&"runtime"] + data[&"runtime_schema_version"] = 7 + data.erase(&"elevators") + 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") + 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") + bad = current.duplicate(true) + bad[&"systems"][&"runtime"][&"elevators"][&"current_minute"] -= 1 + _expect(_rejects(runtime, bad), "elevator clock must match Runtime domain minute") + bad = current.duplicate(true) + bad[&"systems"][&"runtime"][&"path"][&"schema_version"] = 3 + _expect(_rejects(runtime, bad), "Path3 relabel with retained vertical blocker metadata is rejected") + _expect(_command(runtime, clock, &"build_facility", {&"definition_id": &"guest_elevator", &"origin": Vector3i(0, 0, 0)}).is_success(), "real construction starts elevator module") + clock.advance_minutes(310) + _expect(_command(runtime, clock, &"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success(), "hire actual actor at entrance for ownership fixture") + var worker: StaffRuntime = runtime.staff_service.get_staff(&"staff_akane") + var actor_id := &"staff:staff_akane" + _expect(worker != null and worker.current_cell == Vector3i.ZERO, "hired actor is physically at elevator's ground landing") + var access: Array[StringName] = [&"staff"] + var queued: Dictionary = runtime.elevators.request_trip(actor_id, Vector3i.ZERO, Vector3i(0, 1, 0), clock.absolute_minute, access) + _expect(queued.get(&"ok", false), "queue real actor through transport ownership API: %s" % queued) + if not queued.get(&"ok", false): + for key: StringName in [&"runtime", &"clock", &"state"]: fixture[key].free() + quit(1) + return + clock.advance_minutes(1) + 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") + 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" + for unit: Dictionary in bad[&"systems"][&"runtime"][&"path"][&"active_elevator_units"]: + if unit[&"unit_id"] == actor_id: unit[&"unit_id"] = &"staff:missing_person" + _expect(_rejects(runtime, bad), "internally consistent seat and Path owner cannot refer to nonexistent Runtime actor") + bad = current.duplicate(true) + bad[&"systems"][&"runtime"][&"elevators"][&"shafts"][0][&"config"][&"module_ids"][0] = &"missing_module" + _expect(_rejects(runtime, bad), "elevator cabin cannot substitute a module absent from real construction") + bad = current.duplicate(true) + bad[&"systems"][&"runtime"][&"elevators"][&"shafts"][0][&"boarded"] = [] + _expect(_rejects(runtime, bad), "active Path rider cannot lose its cabin seat") + bad = current.duplicate(true) + bad[&"systems"][&"runtime"][&"elevators"][&"trips"][0][&"from"] = Vector3i(0, 1, 0) + bad[&"systems"][&"runtime"][&"elevators"][&"trips"][0][&"to"] = Vector3i.ZERO + bad[&"systems"][&"runtime"][&"elevators"][&"shafts"][0][&"boarding_floor"] = 1 + bad[&"systems"][&"runtime"][&"elevators"][&"shafts"][0][&"target_floor"] = 0 + bad[&"systems"][&"runtime"][&"elevators"][&"shafts"][0][&"current_floor"] = 1 + bad[&"systems"][&"runtime"][&"elevators"][&"shafts"][0][&"phase_origin_floor"] = 1 + _expect(_rejects(runtime, bad), "valid reverse cabin route cannot move actor's saved logical origin") + var shaft_id: StringName = runtime.elevators.get_report()[&"shafts"][0][&"shaft_id"] + _expect(_command(runtime, clock, &"set_elevator_operation", {&"shaft_id": shaft_id, &"operator_open": false}).is_success(), "player closes running elevator through public command") + current = runtime.create_snapshot() + _expect(runtime.restore_snapshot(current) and not runtime.elevators.get_report()[&"shafts"][0][&"operator_open"], "closed operator policy survives Runtime restore") + for key: StringName in [&"runtime", &"clock", &"state"]: fixture[key].free() + _test_attendance_migration() + print("Transport save: %d checks, %d failures" % [_checks, _failures]) + quit(0 if _failures == 0 else 1) + + +func _test_attendance_migration() -> void: + var fixture := _fixture(81424) + var runtime: Variant = fixture[&"runtime"] + var clock: Variant = fixture[&"clock"] + _expect(_command(runtime, clock, &"build_facility", {&"definition_id": &"reception", &"origin": Vector3i(2, 0, 0)}).is_success(), "attendance fixture builds a real workplace") + clock.advance_minutes(180) + var facility: FacilityInstance = runtime.facility_service.get_instances()[0] + _expect(_command(runtime, clock, &"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success(), "attendance fixture hires actual receptionist") + _expect(_command(runtime, clock, &"set_staff_work_assignment", {&"staff_id": &"staff_akane", &"facility_id": facility.instance_id, &"template_id": &"DAY"}).is_success(), "attendance fixture assigns a real shift") + var worker: StaffRuntime = runtime.staff_service.get_staff(&"staff_akane") + _expect(not StaffCommuteService.is_at_facility(worker, facility), "employee has not yet physically walked to reception") + # Reproduce the old logical-attendance rule without changing position, route, + # income or inventory. Facility projection is rebuilt by its public service. + 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") + var legacy := logical.duplicate(true) + var data: Dictionary = legacy[&"systems"][&"runtime"] + data[&"runtime_schema_version"] = 7 + data.erase(&"elevators") + data[&"path"][&"schema_version"] = 3 + data[&"path"].erase(&"vertical_walk_blockers") + _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") + _expect(facility.active_staff_ids.is_empty() and worker.state == StaffRuntime.State.COMMUTING and worker.current_cell == Vector3i.ZERO, "migration removes phantom workplace capacity and starts commute without teleporting") + _expect(runtime.restore_snapshot(runtime.create_snapshot()), "migrated attendance is a valid current Runtime snapshot") + for key: StringName in [&"runtime", &"clock", &"state"]: fixture[key].free() + + +func _fixture(seed: int) -> Dictionary: + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1, 6, 0) + state.reset() + _expect(runtime.initialize(clock, state, seed)[&"valid"], "transport save fixture initializes") + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + clock.set_paused(true) + return {&"clock": clock, &"state": state, &"runtime": runtime} + + +func _command(runtime: Variant, clock: Variant, kind: StringName, payload: Dictionary) -> CommandResult: + _serial += 1 + return runtime.submit_command(kind, payload, &"player", &"", 0, clock.absolute_minute, StringName("transport.save.%d" % _serial)) + + +func _rejects(runtime: Variant, candidate: Dictionary) -> bool: + var before: Dictionary = runtime.create_snapshot() + return not runtime.restore_snapshot(candidate) and runtime.create_snapshot() == before + + +func _expect(condition: bool, label: String) -> void: + _checks += 1 + if not condition: _failures += 1 + print("%s: %s" % ["PASS" if condition else "FAIL", label]) diff --git a/tests/transport/test_transport_save.gd.uid b/tests/transport/test_transport_save.gd.uid new file mode 100644 index 0000000..3cb1a35 --- /dev/null +++ b/tests/transport/test_transport_save.gd.uid @@ -0,0 +1 @@ +uid://daygk8x6blorg diff --git a/tests/ui/test_elevator_management.gd b/tests/ui/test_elevator_management.gd new file mode 100644 index 0000000..eeda193 --- /dev/null +++ b/tests/ui/test_elevator_management.gd @@ -0,0 +1,101 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _main: Control +var _runtime: Variant +var _clock: Variant +var _module_id: StringName = &"" +var _shaft_id: 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() + _runtime.initialize(_clock, state, 924263) + _clock.set_paused(true) + root.size = Vector2i(1280, 720) + _main = load("res://scenes/main/main.tscn").instantiate() as Control + _main.set("manual_save_path", "user://tests/elevator_ui_%d/manual" % OS.get_process_id()) + root.add_child(_main) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + await process_frame + await process_frame + _expect(not _main.get("selected_elevator_controls").visible, "empty inspector hides lift controls") + _main.call("_select_build_definition", &"guest_elevator") + _main.call("_on_map_cell_clicked", Vector3i(0, 0, 0)) + _expect(_runtime.construction_service.get_jobs().size() == 1, "normal build UI pays for lift construction") + _clock.advance_minutes(300) + _main.call("_clear_build_selection") + var report: Dictionary = _runtime.elevators.get_report() + _expect(report[&"shafts"].size() == 1, "completed paid lift registers one real car") + if report[&"shafts"].size() != 1: + _finish() + return + _shaft_id = report[&"shafts"][0][&"shaft_id"] + _module_id = report[&"shafts"][0][&"module_ids"][0] + _main.call("_on_facility_selected", _module_id) + _expect(_main.get("selected_elevator_controls").visible and _main.get("management_panel").visible, "selecting finished lift opens existing inspector controls") + _expect(_main.get("selected_heading_label").text == "客用昇降機", "proxy lift gets localized title instead of syncing placeholder") + var detail: String = _main.get("selected_detail_label").text + _expect("現在階" in detail and "目的階" in detail and "乗客" in detail and "待機" in detail and "1F 〜 2F" in detail, "inspector displays real cabin floors, occupancy, queue and stops") + var button: Button = _main.get("elevator_operation_button") + _expect(not button.disabled and button.text == "昇降機を停止", "idle powered car exposes stop command") + button.pressed.emit() + _expect(not _shaft()[&"operator_open"] and button.text == "運行を再開", "paused stop applies real operator state and swaps button") + _expect("手動停止" in _main.get("selected_detail_label").text, "stopped inspector state is explicit") + _expect(_runtime.path_service.find_path(Vector3i(0, 0, 0), Vector3i(0, 1, 0)).is_empty(), "UI stop removes usable ride route while shaft blocks climbing") + button.pressed.emit() + _expect(_shaft()[&"operator_open"] and bool(_shaft()[&"operational"]) == bool(_shaft()[&"powered"]), "UI resume respects actual cabin power availability") + _expect(bool(_shaft()[&"powered"]) or "魔力供給なし" in _main.get("selected_detail_label").text, "unpowered cabin cannot appear as running after operator resume") + _clock.set_paused(false) + button.pressed.emit() + _expect(button.disabled and _runtime.command_queue.pending_count() == 1, "running-clock command locks further submission") + _main.call("_on_elevator_operation_pressed") + _expect(_runtime.command_queue.pending_count() == 1, "repeated stop press cannot enqueue duplicate command") + _main.call("_save_manual") + _clock.advance_minutes(1) + _expect(not _shaft()[&"operator_open"], "queued command applies at next simulation minute") + _main.call("_load_manual") + _main.call("_on_facility_selected", _module_id) + _expect(_runtime.command_queue.pending_count() == 1 and button.disabled, "manual load restores queued lift command and disables duplicate submission") + _clock.advance_minutes(1) + _clock.set_paused(true) + _expect(not _shaft()[&"operator_open"] and not button.disabled, "restored command applies exactly once and unlocks control") + button.pressed.emit() + _expect(_shaft()[&"operator_open"], "post-load UI uses fresh idempotency sequence and can resume") + _main.call("_on_map_cell_clicked", Vector3i(10, 0, 0)) + _expect(not _main.get("selected_elevator_controls").visible, "selecting empty ground removes obsolete operation control") + _main.call("_on_facility_selected", _module_id) + await process_frame + await process_frame + var panel_rect: Rect2 = _main.get("management_panel").get_global_rect() + _expect(panel_rect.encloses(button.get_global_rect()) and _main.get_global_rect().encloses(panel_rect), "operation button remains visible inside inspector at a 1280 by 720 window") + if OS.get_environment("THE_TREE_ELEVATOR_UI_CAPTURE") == "1": + await RenderingServer.frame_post_draw + var image_value := root.get_texture().get_image() + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("res://artifacts/transport")) + image_value.save_png("res://artifacts/transport/elevator-inspector.png") + _finish() + +func _shaft() -> Dictionary: + for row: Dictionary in _runtime.elevators.get_report()[&"shafts"]: + if row[&"shaft_id"] == _shaft_id: + return row + return {} + +func _finish() -> void: + _clock.set_paused(true) + print("Elevator management 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_elevator_management.gd.uid b/tests/ui/test_elevator_management.gd.uid new file mode 100644 index 0000000..5e94253 --- /dev/null +++ b/tests/ui/test_elevator_management.gd.uid @@ -0,0 +1 @@ +uid://cl4r811l5vs7i diff --git a/tests/ui/test_staff_management.gd b/tests/ui/test_staff_management.gd index 0d92f1d..31935b2 100644 --- a/tests/ui/test_staff_management.gd +++ b/tests/ui/test_staff_management.gd @@ -69,6 +69,10 @@ func _run() -> void: _panel.apply_button.pressed.emit() _expect(_assignment(&"staff_akane").get(&"facility_id") == _receptions[0], "paused Apply assigns the receptionist to the chosen completed facility") + _expect(_runtime.staff_service.get_staff(&"staff_akane").state == StaffRuntime.State.COMMUTING + and _runtime.facility_service.get_instance(_receptions[0]).active_staff_ids.is_empty(), + "paused Apply visibly starts a commute and does not invent facility attendance") + _expect("出勤中" in _panel.staff_detail_label.text, "staff panel distinguishes physical commuting from active duty") _expect("変更しました" in _panel.result_label.text and "毎日" in _panel.staff_detail_label.text, "assignment success and recurring schedule are visible in Japanese") _test_rejected_assignment() diff --git a/tests/world/test_elevator_topology.gd b/tests/world/test_elevator_topology.gd new file mode 100644 index 0000000..10a1a2f --- /dev/null +++ b/tests/world/test_elevator_topology.gd @@ -0,0 +1,204 @@ +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 GUEST = preload("res://data/facilities/guest_elevator.tres") +const SERVICE = preload("res://data/facilities/service_elevator.tres") + +class ControlledPath: + extends PathService + var fail_set := false + var fail_remove := false + func set_vertical_walk_blockers(owner_id: StringName, cells: Array[Vector3i]) -> bool: + var result := super.set_vertical_walk_blockers(owner_id, cells) + if fail_set: + fail_set = false + return false + return result + func remove_vertical_walk_blockers(owner_id: StringName) -> bool: + var result := super.remove_vertical_walk_blockers(owner_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_static_shaft_and_cabin_edges() + _test_owner_boundaries_and_cache() + _test_snapshot_validation_and_legacy() + _test_projection_and_rollback() + print("Elevator topology: %d checks, %d failures" % [_checks, _failures]) + quit(_failures) + +func _test_static_shaft_and_cabin_edges() -> void: + var path := PathType.new() + var cells: Array[Vector3i] = [Vector3i(-1, 0, 0), Vector3i(0, -1, 0), Vector3i(0, 0, 0), Vector3i(0, 1, 0), Vector3i(0, 2, 0), Vector3i(1, 0, 0), Vector3i(1, 1, 0), Vector3i(2, 1, 0)] + _expect(path.rebuild_graph(cells), "materialize ordinary graph") + _expect(not path.find_path(Vector3i(-1, 0, 0), Vector3i(2, 1, 0)).is_empty(), "legacy plain corridor graph still walks vertically") + _expect(path.set_vertical_walk_blockers(&"shaft.a", [Vector3i(0, 0, 0), Vector3i(0, 1, 0), Vector3i(1, 0, 0), Vector3i(1, 1, 0)]), "mark entire two-column shaft") + _expect(path.find_path(Vector3i(-1, 0, 0), Vector3i(2, 1, 0)).is_empty(), "neither shaft column admits ordinary climbing") + _expect(path.last_no_route_reason == path.ROUTE_GOAL_UNREACHABLE, "diagnostic graph cannot invent walk bypass") + _expect(path.find_path(Vector3i(0, -1, 0), Vector3i(0, 0, 0)).is_empty(), "edge touching bottom of shaft is suppressed") + _expect(path.find_path(Vector3i(0, 1, 0), Vector3i(0, 2, 0)).is_empty(), "edge touching top of shaft is suppressed") + _expect(path.find_path(Vector3i(-1, 0, 0), Vector3i(1, 0, 0)).size() == 3, "horizontal floor access through shaft remains") + _expect(path.configure_elevator(&"transport.a", Vector3i(0, 0, 0), Vector3i(0, 1, 0)), "separate cabin connector enables rides") + _expect(not path.find_path(Vector3i(-1, 0, 0), Vector3i(2, 1, 0)).is_empty(), "cabin restores reachable vertical route") + var edge := path.get_edge_report(Vector3i(0, 0, 0), Vector3i(0, 1, 0)) + _expect(edge.get(&"movement_type") == path.MOVEMENT_ELEVATOR and edge.get(&"connector_id") == &"transport.a", "movement driver sees elevator edge") + _expect(path.get_step_connector(Vector3i(0, 1, 0), Vector3i(0, 0, 0)) == &"transport.a", "reverse ride resolves same bidirectional connector") + _expect(path.get_step_connector(Vector3i(-1, 0, 0), Vector3i(0, 0, 0)).is_empty(), "floor step has no cabin owner") + edge[&"connector_id"] = &"forged" + _expect(path.get_step_connector(Vector3i(0, 0, 0), Vector3i(0, 1, 0)) == &"transport.a", "edge report is defensive") + _expect(path.set_connector_operational_state(&"transport.a", path.CONNECTOR_STOPPED), "stop car without removing shaft") + _expect(path.find_path(Vector3i(-1, 0, 0), Vector3i(2, 1, 0)).is_empty(), "stopped car cannot be bypassed") + _expect(path.last_no_route_reason == path.ROUTE_CONNECTOR_UNAVAILABLE, "diagnostic recognizes unavailable car") + _expect(path.get_edge_report(Vector3i(0, 0, 0), Vector3i(0, 1, 0)).is_empty(), "stopped car has no usable step") + _expect(path.remove_connector(&"transport.a"), "remove transport connector independently") + _expect(path.find_path(Vector3i(0, 0, 0), Vector3i(0, 1, 0)).is_empty(), "removing cabin retains shaft walk suppression") + _expect(path.configure_elevator(&"transport.staff", Vector3i(0, 0, 0), Vector3i(0, 1, 0), {}, &"staff"), "configure service-only cabin") + _expect(path.find_path(Vector3i(0, 0, 0), Vector3i(0, 1, 0), [0], []).is_empty(), "guest cannot use service cabin") + _expect(path.get_step_connector(Vector3i(0, 0, 0), Vector3i(0, 1, 0), [&"staff"]) == &"transport.staff", "staff access identifies service cabin") + +func _test_owner_boundaries_and_cache() -> void: + var path := PathType.new() + var bottom := Vector3i(0, 0, 0) + var top := Vector3i(0, 1, 0) + path.rebuild_graph([bottom, top]) + path.find_path(bottom, top) + var revision := path.graph_revision + _expect(path.cached_path_count() > 0, "seed walk path cache") + _expect(path.set_vertical_walk_blockers(&"owner.a", [bottom]), "first owner blocks one endpoint") + _expect(path.graph_revision > revision and path.cached_path_count() == 0, "blocker edit invalidates cached routes") + var snapshot := path.create_snapshot() + _expect(path.set_vertical_walk_blockers(&"owner.a", [bottom]) and path.create_snapshot() == snapshot, "same owner configuration is idempotent") + _expect(path.set_vertical_walk_blockers(&"owner.b", [bottom]), "independent owner can overlap") + _expect(path.remove_vertical_walk_blockers(&"owner.a") and path.find_path(bottom, top).is_empty(), "removing one owner retains other's restriction") + var defensive := path.get_vertical_walk_blockers() + defensive[&"owner.b"].clear() + _expect(path.find_path(bottom, top).is_empty(), "blocker read model cannot mutate graph") + _expect(path.remove_vertical_walk_blockers(&"owner.b") and path.find_path(bottom, top) == [bottom, top], "last owner removal restores ordinary topology") + snapshot = path.create_snapshot() + _expect(not path.set_vertical_walk_blockers(&"", [bottom]), "reject empty owner") + _expect(not path.set_vertical_walk_blockers(&" bad", [bottom]), "reject noncanonical owner") + _expect(not path.set_vertical_walk_blockers(&"owner", []), "empty replacement must use explicit removal") + _expect(not path.set_vertical_walk_blockers(&"owner", [bottom, bottom]), "reject duplicate owned cell") + _expect(not path.set_vertical_walk_blockers(&"owner", [Vector3i(4, 4, 0)]), "reject unknown graph cell") + _expect(not path.remove_vertical_walk_blockers(&"unknown"), "reject removing unknown owner") + _expect(path.create_snapshot() == snapshot, "invalid blocker mutations leave full state unchanged") + +func _test_snapshot_validation_and_legacy() -> void: + var path := PathType.new() + var cells: Array[Vector3i] = [Vector3i(0, 0, 0), Vector3i(0, 1, 0), Vector3i(1, 0, 0)] + path.rebuild_graph(cells) + 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") + 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") + var malformed_cases: Array[Dictionary] = [] + var missing := snapshot.duplicate(true) + missing.erase(&"vertical_walk_blockers") + malformed_cases.append(missing) + var owner_duplicate := snapshot.duplicate(true) + owner_duplicate[&"vertical_walk_blockers"].append(owner_duplicate[&"vertical_walk_blockers"][0].duplicate(true)) + malformed_cases.append(owner_duplicate) + var wrong_order := snapshot.duplicate(true) + wrong_order[&"vertical_walk_blockers"].reverse() + malformed_cases.append(wrong_order) + var wrong_cells := snapshot.duplicate(true) + wrong_cells[&"vertical_walk_blockers"][0][&"cells"].reverse() + malformed_cases.append(wrong_cells) + var unknown_cell := snapshot.duplicate(true) + unknown_cell[&"vertical_walk_blockers"][0][&"cells"] = [Vector3i(999, 999, 0)] + malformed_cases.append(unknown_cell) + var duplicate_cell := snapshot.duplicate(true) + duplicate_cell[&"vertical_walk_blockers"][0][&"cells"] = [cells[0], cells[0]] + malformed_cases.append(duplicate_cell) + var unknown_field := snapshot.duplicate(true) + unknown_field[&"vertical_walk_blockers"][0][&"extra"] = true + malformed_cases.append(unknown_field) + var empty_cells := snapshot.duplicate(true) + empty_cells[&"vertical_walk_blockers"][0][&"cells"] = [] + malformed_cases.append(empty_cells) + for malformed: Dictionary in malformed_cases: + _expect(not restored.restore_snapshot(malformed), "reject malformed shaft ownership snapshot") + _expect(restored.create_snapshot() == snapshot, "snapshot rejection is atomic") + for version: int in [1, 2, 3]: + var legacy := snapshot.duplicate(true) + legacy[&"schema_version"] = version + _expect(not restored.restore_snapshot(legacy), "old version with retained shaft field cannot disable safety") + legacy.erase(&"vertical_walk_blockers") + _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") + +func _test_projection_and_rollback() -> void: + var context := _context() + var guest := _instance(GUEST, &"guest", Vector3i(0, 0, 0)) + var service := _instance(SERVICE, &"service", Vector3i(4, 0, 1)) + _expect(context.projector.project_facility(&"project.guest", GUEST, guest).is_success(), "project guest elevator ROUTE footprint") + _expect(context.projector.project_facility(&"project.service", SERVICE, service).is_success(), "project service elevator ROUTE footprint") + _expect(context.source.get_route_cells().size() == 10, "all occupied 2x2 and 3x2 cells are floor-accessible") + _expect(context.projector.get_projection(&"guest")[&"connector_id"] == &"", "projector owns static shaft separately from transport connector") + _expect(context.projector.validate_invariants()[&"ok"], "shaft owner and record crosslinks match") + var saved := _snapshots(context) + _expect(context.projector.restore_snapshot(saved.projector) and context.projector.create_snapshot() == saved.projector, "current elevator projection snapshot roundtrip preserves history and static ownership") + context.path.remove_vertical_walk_blockers(&"infra.shaft:guest") + _expect(context.projector.is_projection_stale(), "missing shaft owner makes infrastructure stale") + _expect(not context.projector.restore_snapshot(saved.projector), "metadata restore cannot accept missing static shaft restriction") + _expect(context.path.restore_snapshot(saved.path), "repair exact shaft state") + _expect(context.projector.validate_invariants()[&"ok"], "restored shaft repairs infrastructure crosslink") + var orphan_cells: Array[Vector3i] = [Vector3i(0, 0, 0)] + context.path.set_vertical_walk_blockers(&"infra.shaft:orphan", orphan_cells) + _expect(context.projector.is_projection_stale(), "untracked managed shaft owner rejected") + context.path.remove_vertical_walk_blockers(&"infra.shaft:orphan") + _expect(context.projector.unproject_facility(&"remove.guest", &"guest").is_success(), "unproject guest shaft with its route") + _expect(not context.path.get_vertical_walk_blockers().has(&"infra.shaft:guest") and not context.path.has_cell(Vector3i(0, 0, 0)), "demolition removes static owner and route cells") + _expect(context.path.get_vertical_walk_blockers().has(&"infra.shaft:service") and context.projector.validate_invariants()[&"ok"], "independent service shaft remains intact") + var controlled := ControlledPath.new() + var failure_context := _context(controlled) + var before := _snapshots(failure_context) + controlled.fail_set = true + _expect(not failure_context.projector.project_facility(&"fail.project", GUEST, guest).is_success(), "injected shaft mutation failure rejects project") + _expect(_snapshots(failure_context) == before, "failed shaft project restores source, route adapter, Path, and metadata exactly") + _expect(failure_context.projector.project_facility(&"fail.project", GUEST, guest).is_success(), "failed transaction leaves request id available") + before = _snapshots(failure_context) + controlled.fail_remove = true + _expect(not failure_context.projector.unproject_facility(&"fail.remove", &"guest").is_success(), "injected shaft removal failure rejects demolition") + _expect(_snapshots(failure_context) == before, "failed shaft removal restores every dependency exactly") + _expect(failure_context.projector.unproject_facility(&"fail.remove", &"guest").is_success(), "failed demolition remains retryable") + +func _context(path_value: PathService = null) -> Dictionary: + var source := SpatialType.new() + var path := path_value if path_value != null else PathType.new() + var adapter := AdapterType.new() + _expect(adapter.configure(source, path, []), "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: + var cells: Array[Vector3i] = [] + for y: int in range(definition.footprint.y): + for x: int in range(definition.footprint.x): + cells.append(origin + Vector3i(x, y, 0)) + return {&"instance_id": instance_id, &"definition_id": definition.id, &"origin": origin, &"rotation": 0, &"layer": definition.layer, &"occupied_cells": cells, &"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_elevator_topology.gd.uid b/tests/world/test_elevator_topology.gd.uid new file mode 100644 index 0000000..afc9d9b --- /dev/null +++ b/tests/world/test_elevator_topology.gd.uid @@ -0,0 +1 @@ +uid://b54qfpjj4idli