Connect physical elevators and staff commutes to construction, saves, and cutaway UI

This commit is contained in:
Takeshi
2026-09-09 12:58:40 +09:00
parent 4d72de8973
commit 0f12c748e9
46 changed files with 3267 additions and 165 deletions
+303 -17
View File
@@ -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"})