Add explicit stairs and measure elevator congestion with real actors

This commit is contained in:
Takeshi
2026-09-09 14:26:38 +09:00
parent e246ce382b
commit fb4ad23190
57 changed files with 3030 additions and 162 deletions
+195 -24
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 := 8
const RUNTIME_SNAPSHOT_VERSION := 9
const TRANSPORT_RUNTIME_SNAPSHOT_VERSION := 8
const SUPPLY_RUNTIME_SNAPSHOT_VERSION := 7
const INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION := 6
const LEGACY_RUNTIME_SNAPSHOT_VERSION := 1
@@ -41,6 +42,7 @@ const DEFAULT_OPERATIONS_DEMAND_PROFILES := [
{&"facility_tag": &"underground", &"guest_class": &"rogue", &"time_band": &"NIGHT"},
]
const MOVEMENT_CREDIT_SCALE := 1_000
const STAIR_MOVEMENT_CREDIT := 1_500
const MIN_MOVEMENT_CREDIT_PER_MINUTE := 250
const DEFAULT_AUTOSAVE_PATH := "user://saves/autosave"
const SAVE_LIFECYCLE_VERSION := 1
@@ -92,6 +94,7 @@ var kitchen_production := KitchenProductionService.new()
var supply_management := SupplyManagementService.new()
var supply_logistics := SupplyLogisticsBridge.new()
var elevators := ElevatorService.new()
var legacy_stairs := LegacyStairService.new()
var staff_commute := StaffCommuteService.new()
var facility_service := FacilityService.new()
var guest_service := GuestService.new()
@@ -190,6 +193,9 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S
domain_events = DomainEventStream.new()
world_service = WorldService.new()
path_service = PathService.new()
path_service.set_vertical_walk_requires_connector(true)
legacy_stairs = LegacyStairService.new()
legacy_stairs.configure(path_service)
_movement_credits.clear()
_connect_path_performance_signal()
mana_network_service = ManaNetworkService.new()
@@ -376,7 +382,8 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S
Callable(self, "_process_save_checkpoints")
)
_initialized = true
var report := definition_registry.rebuild()
var report := definition_registry.rebuild_from_paths(
FacilityCatalog.FACILITY_PATHS + FacilityCatalog.INFRASTRUCTURE_FACILITY_PATHS)
var catalog_registration_ok := _catalog_definitions_are_registered(guest_service)
report[&"content_catalog"] = mvp_content_report.duplicate(true)
report[&"progression_initialized"] = initial_progression_ok
@@ -471,6 +478,7 @@ func create_snapshot() -> Dictionary:
&"inventory": inventory_service.create_snapshot(),
&"kitchen_production": kitchen_production.create_snapshot(),
&"elevators": elevators.create_snapshot(),
&"legacy_stairs": legacy_stairs.create_snapshot(),
&"logistics_dispatcher": logistics_dispatcher.create_snapshot(),
&"logistics_requests": _serialize_logistics_requests(),
&"facilities": facility_service.create_snapshot(),
@@ -536,6 +544,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
OPERATIONS_RUNTIME_SNAPSHOT_VERSION,
INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION,
SUPPLY_RUNTIME_SNAPSHOT_VERSION,
TRANSPORT_RUNTIME_SNAPSHOT_VERSION,
RUNTIME_SNAPSHOT_VERSION,
]:
return false
@@ -565,8 +574,10 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
])
if runtime_schema_version >= SUPPLY_RUNTIME_SNAPSHOT_VERSION:
runtime_current_keys.append(&"kitchen_production")
if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION:
if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION:
runtime_current_keys.append(&"elevators")
if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION:
runtime_current_keys.append(&"legacy_stairs")
if not _dictionary_has_exact_canonical_keys(runtime_data, runtime_current_keys) \
or typeof(runtime_data.get(&"runtime_schema_version", null)) != TYPE_INT \
or typeof(runtime_data.get(&"world_seed", null)) != TYPE_INT \
@@ -598,9 +609,13 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
return false
if runtime_schema_version >= SUPPLY_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"kitchen_production", null) is Dictionary:
return false
if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"elevators"):
if runtime_schema_version < TRANSPORT_RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"elevators"):
return false
if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"elevators", null) is Dictionary:
if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"elevators", null) is Dictionary:
return false
if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"legacy_stairs"):
return false
if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"legacy_stairs", null) is Dictionary:
return false
var restored_catalog := MvpContentCatalog.new()
var restored_catalog_report := restored_catalog.rebuild()
@@ -719,6 +734,20 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
restored_route_adapter, restored_spatial, restored_path
):
return false
if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION:
var legacy_path_data: Dictionary = runtime_data[&"path"]
if legacy_path_data.has(&"vertical_walk_requires_connector") or legacy_path_data.has(&"transit_actor_ids"):
return false
var restored_legacy_stairs := LegacyStairService.new()
restored_legacy_stairs.configure(restored_path)
if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION:
if runtime_data[&"path"].get(&"schema_version", -1) != PathService.SNAPSHOT_VERSION \
or not restored_path.get_vertical_walk_requires_connector() \
or not restored_legacy_stairs.restore_snapshot(runtime_data[&"legacy_stairs"]):
return false
else:
if restored_path.get_vertical_walk_requires_connector() or not restored_legacy_stairs.migrate_existing_vertical_walks():
return false
var restored_mana := ManaNetworkService.new()
if not restored_mana.restore_snapshot(runtime_data[&"mana"]):
return false
@@ -1042,7 +1071,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
for staff_id: StringName in facility.active_staff_ids:
var worker := restored_staff.get_staff(staff_id)
if not StringName(restored_staff.get_work_assignment(staff_id).get(&"facility_id", &"")).is_empty() and not StaffCommuteService.is_at_facility(worker, facility):
if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION:
if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION:
return false
if worker.role_id != &"porter" and worker.current_task_id.is_empty():
worker.state = StaffRuntime.State.COMMUTING
@@ -1053,7 +1082,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
{&"active_staff_ids": present_ids}, restored_runtime_domain_minute)
var restored_elevators := ElevatorService.new()
restored_elevators.configure(restored_path)
if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION:
if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION:
if not restored_elevators.restore_snapshot(runtime_data[&"elevators"]):
return false
else:
@@ -1124,6 +1153,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
guest_service = restored_guests
staff_service = restored_staff
elevators = restored_elevators
legacy_stairs = restored_legacy_stairs
staff_commute.configure(self)
supply_logistics.configure(self)
mvp_content_catalog = restored_catalog
@@ -1739,6 +1769,7 @@ func _build_operations_external_state() -> Dictionary:
&"world": world_service.create_snapshot(),
&"path": path_service.create_snapshot(),
&"elevators": elevators.create_snapshot(),
&"legacy_stairs": legacy_stairs.create_snapshot(),
&"excavation": excavation_service.create_snapshot(),
&"spatial_subslots": spatial_subslot_service.create_snapshot(),
&"route_projection": route_subslot_path_adapter.create_snapshot(),
@@ -1785,7 +1816,7 @@ func _restore_operations_external_state(token: Variant) -> bool:
var saved: Dictionary = token
var required_dictionary_keys: Array[StringName] = [
&"game_state", &"command_queue", &"economy", &"inventory", &"kitchen_production", &"facilities", &"guests",
&"staff", &"relationships", &"events", &"authority", &"world", &"path", &"elevators",
&"staff", &"relationships", &"events", &"authority", &"world", &"path", &"elevators", &"legacy_stairs",
&"excavation", &"spatial_subslots", &"route_projection",
&"facility_infrastructure",
&"movement_credits", &"domain_events", &"id_generator", &"rng_streams",
@@ -1838,6 +1869,7 @@ func _restore_operations_external_state(token: Variant) -> bool:
and path_service.rebuild_graph(projected_walkable)
var path_ok := topology_ok and path_service.restore_snapshot(saved[&"path"])
path_ok = elevators.restore_snapshot(saved[&"elevators"]) and path_ok
path_ok = legacy_stairs.restore_snapshot(saved[&"legacy_stairs"]) and path_ok
var route_ok := path_ok and route_subslot_path_adapter.restore_snapshot(route_snapshot)
var infrastructure_ok := route_ok and facility_infrastructure_projector.restore_snapshot(
saved[&"facility_infrastructure"]
@@ -1938,6 +1970,7 @@ func _operations_external_state_matches(saved: Dictionary) -> bool:
or not _strict_variants_equal(world_service.create_snapshot(), saved[&"world"]) \
or not _strict_variants_equal(path_service.create_snapshot(), saved[&"path"]) \
or not _strict_variants_equal(elevators.create_snapshot(), saved[&"elevators"]) \
or not _strict_variants_equal(legacy_stairs.create_snapshot(), saved[&"legacy_stairs"]) \
or not _strict_variants_equal(excavation_service.create_snapshot(), saved[&"excavation"]) \
or not _strict_variants_equal(spatial_subslot_service.create_snapshot(), saved[&"spatial_subslots"]) \
or not _strict_variants_equal(route_subslot_path_adapter.create_snapshot(), saved[&"route_projection"]) \
@@ -4681,8 +4714,8 @@ func rebuild_path_graph_from_world() -> bool:
),
walkable_cells
)
return synchronized.is_success()
return path_service.rebuild_graph(walkable_cells)
return synchronized.is_success() and _prune_legacy_stairs_and_movement()
return path_service.rebuild_graph(walkable_cells) and _prune_legacy_stairs_and_movement()
func _initialize_world(rank: int) -> void:
var bounds := _bounds_for_rank(rank)
@@ -5069,6 +5102,7 @@ func _capture_rank_external_state() -> Dictionary:
&"world": world_service.create_snapshot(),
&"path": path_service.create_snapshot(),
&"elevators": elevators.create_snapshot(),
&"legacy_stairs": legacy_stairs.create_snapshot(),
&"excavation": excavation_service.create_snapshot(),
&"spatial_subslots": spatial_subslot_service.create_snapshot(),
&"route_projection": route_subslot_path_adapter.create_snapshot(),
@@ -5132,6 +5166,7 @@ func _rollback_rank_external_state(snapshot: Dictionary) -> bool:
restored = path_service.rebuild_graph(projected_walkable) and restored
restored = path_service.restore_snapshot(snapshot[&"path"]) and restored
restored = elevators.restore_snapshot(snapshot[&"elevators"]) and restored
restored = legacy_stairs.restore_snapshot(snapshot[&"legacy_stairs"]) and restored
restored = route_subslot_path_adapter.restore_snapshot(route_snapshot) and restored
restored = facility_infrastructure_projector.restore_snapshot(
snapshot[&"facility_infrastructure"]
@@ -5146,6 +5181,8 @@ func _rollback_rank_external_state(snapshot: Dictionary) -> bool:
return (
_strict_variants_equal(world_service.create_snapshot(), snapshot[&"world"])
and _strict_variants_equal(path_service.create_snapshot(), snapshot[&"path"])
and _strict_variants_equal(elevators.create_snapshot(), snapshot[&"elevators"])
and _strict_variants_equal(legacy_stairs.create_snapshot(), snapshot[&"legacy_stairs"])
and _strict_variants_equal(
excavation_service.create_snapshot(), snapshot[&"excavation"]
)
@@ -5893,6 +5930,7 @@ func _execute_hire_staff_candidate(command: SimulationCommand) -> CommandResult:
&"staff": staff_service.create_snapshot(),
&"path": path_service.create_snapshot(),
&"elevators": elevators.create_snapshot(),
&"legacy_stairs": legacy_stairs.create_snapshot(),
&"movement_credits": _movement_credits.duplicate(true),
&"domain_events": domain_events.create_snapshot(),
&"id_generator": id_generator.create_snapshot(),
@@ -6021,6 +6059,7 @@ func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool:
restored = staff_service.restore_snapshot(snapshot[&"staff"]) and restored
restored = path_service.restore_snapshot(snapshot[&"path"]) and restored
restored = elevators.restore_snapshot(snapshot[&"elevators"]) and restored
restored = legacy_stairs.restore_snapshot(snapshot[&"legacy_stairs"]) and restored
_movement_credits = (snapshot[&"movement_credits"] as Dictionary).duplicate(true)
restored = domain_events.restore_snapshot(snapshot[&"domain_events"]) and restored
restored = id_generator.restore_snapshot(snapshot[&"id_generator"]) and restored
@@ -6865,6 +6904,8 @@ func _execute_build_facility(command: SimulationCommand) -> CommandResult:
if origin_value == null:
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "origin must be Vector3i or [x, y, layer].")
var origin: Vector3i = origin_value
if definition.id in [&"guest_stairs", &"service_stairs"] and not path_service.has_cell(origin + Vector3i(0, -1, 0)):
return _staff_assignment_failure(command.command_id, &"STAIR_LANDING_MISSING", "先に階段の一つ下の階に通路を作ってください。")
for shaft: Dictionary in elevators.get_report()[&"shafts"]:
if _elevator_touches_module(shaft, definition, origin) and _elevator_shaft_busy(shaft):
return _staff_assignment_failure(command.command_id, &"ELEVATOR_BUSY", "Wait until the shaft is idle before extending it.")
@@ -6947,6 +6988,13 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult:
for shaft: Dictionary in elevators.get_report()[&"shafts"]:
if instance_id in shaft[&"module_ids"] and _elevator_shaft_busy(shaft):
return _staff_assignment_failure(command.command_id, &"ELEVATOR_BUSY", "Wait until passengers have left before demolition.")
var lost_landing_cells := _demolition_lost_route_cells(instance_id)
for actor: Dictionary in path_service.create_snapshot()[&"actor_cells"]:
if actor[&"cell"] in lost_landing_cells:
return _staff_assignment_failure(command.command_id, &"FACILITY_OCCUPIED", "この通路・階段に人がいます。移動し終わってから撤去してください。")
var stair_dependents := _stair_demolition_dependents(instance_id)
if not stair_dependents.is_empty():
return _staff_assignment_failure(command.command_id, &"STAIR_LANDING_IN_USE", "この通路につながる上の階段を先に撤去するか、建設を中止してください。")
var defense_owner: StringName = _defense_lock_owner(instance_id, &"ASSET")
if not defense_owner.is_empty():
return CommandResult.failed(
@@ -6993,8 +7041,9 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult:
demolished.command_id = command.command_id
var transport_ok := false
if demolished.is_success():
transport_ok = bool(_synchronize_elevators(_runtime_event_minute()).get(&"ok", false))
transport_ok = bool(_synchronize_elevators(_runtime_event_minute()).get(&"ok", false)) and _prune_legacy_stairs_and_movement()
if not transport_ok or not demolished.is_success() \
or not _validate_runtime_path_crosslinks(path_service, guest_service, staff_service, _movement_credits, _runtime_event_minute()) \
or facility_infrastructure_projector.has_projection(instance_id) \
or not construction_service.get_facility(instance_id).is_empty() \
or not world_service.get_entity_cells(instance_id).is_empty() \
@@ -7034,6 +7083,7 @@ func _capture_infrastructure_demolition_state() -> Dictionary:
&"world": world_service.create_snapshot(),
&"path": path_service.create_snapshot(),
&"elevators": elevators.create_snapshot(),
&"legacy_stairs": legacy_stairs.create_snapshot(),
&"construction": construction_service.create_snapshot(),
&"excavation": excavation_service.create_snapshot(),
&"spatial_subslots": spatial_subslot_service.create_snapshot(),
@@ -7049,7 +7099,7 @@ func _capture_infrastructure_demolition_state() -> Dictionary:
func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool:
for key: StringName in [
&"game_state", &"economy", &"world", &"path", &"elevators", &"construction",
&"game_state", &"economy", &"world", &"path", &"elevators", &"legacy_stairs", &"construction",
&"excavation", &"spatial_subslots", &"route_projection",
&"facility_infrastructure", &"facilities", &"mana", &"movement_credits",
&"domain_events", &"id_generator",
@@ -7067,6 +7117,7 @@ func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool:
restored = path_service.rebuild_graph(projected_walkable) and restored
restored = path_service.restore_snapshot(snapshot[&"path"]) and restored
restored = elevators.restore_snapshot(snapshot[&"elevators"]) and restored
restored = legacy_stairs.restore_snapshot(snapshot[&"legacy_stairs"]) and restored
restored = route_subslot_path_adapter.restore_snapshot(route_snapshot) and restored
restored = facility_infrastructure_projector.restore_snapshot(
snapshot[&"facility_infrastructure"]
@@ -7097,6 +7148,7 @@ func _infrastructure_demolition_state_matches(snapshot: Dictionary) -> bool:
and _strict_variants_equal(world_service.create_snapshot(), snapshot.get(&"world", {})) \
and _strict_variants_equal(path_service.create_snapshot(), snapshot.get(&"path", {})) \
and _strict_variants_equal(elevators.create_snapshot(), snapshot.get(&"elevators", {})) \
and _strict_variants_equal(legacy_stairs.create_snapshot(), snapshot.get(&"legacy_stairs", {})) \
and _strict_variants_equal(construction_service.create_snapshot(), snapshot.get(&"construction", {})) \
and _strict_variants_equal(excavation_service.create_snapshot(), snapshot.get(&"excavation", {})) \
and _strict_variants_equal(spatial_subslot_service.create_snapshot(), snapshot.get(&"spatial_subslots", {})) \
@@ -7491,6 +7543,8 @@ func _apply_completed_facility_internal(instance: Dictionary, absolute_minute: i
var transport := _synchronize_elevators(absolute_minute)
if not bool(transport.get(&"ok", false)):
return {&"ok": false, &"code": &"ELEVATOR_SYNC_FAILED", &"reason": elevators.last_error, &"details": transport}
if not _prune_legacy_stairs_and_movement():
return {&"ok": false, &"code": &"LEGACY_STAIR_PRUNE_FAILED", &"reason": "Stair topology could not be updated."}
if not _publish_domain_event(SimulationDomainEvent.new(
id_generator.next_id(&"domain_event"),
&"FACILITY_COMPLETED",
@@ -7965,7 +8019,7 @@ func _record_blocked_actor_wait(
_report_path_integration_failure(actor_id, entity_id, kind, cell)
return
_movement_credits[actor_id] = {
&"credit": clampi(credit, 0, MOVEMENT_CREDIT_SCALE - 1),
&"credit": clampi(credit, 0, _cell_movement_credit_limit(path_service, cell) - 1),
&"updated_at": absolute_minute,
}
if int(recorded.get(&"minutes_without_progress", 0)) >= PathService.STUCK_THRESHOLD_MINUTES:
@@ -8010,10 +8064,12 @@ func _advance_guest_path_travel(
return
if _request_elevator_step(actor_id, path[0], path[1], absolute_minute, []):
return
var required_credit := _step_movement_credit(path_service, path[0], path[1], [])
var accumulated := _movement_credit_after_one_minute(
actor_id, guest.current_cell, absolute_minute
)
if accumulated < MOVEMENT_CREDIT_SCALE:
accumulated = mini(accumulated, required_credit + MOVEMENT_CREDIT_SCALE - 1)
if accumulated < required_credit:
_record_blocked_actor_wait(
actor_id,
guest.guest_id,
@@ -8037,7 +8093,7 @@ func _advance_guest_path_travel(
guest.recovery_kind = previous_recovery_kind
_report_path_integration_failure(actor_id, guest.guest_id, &"guest", previous_cell)
return
var remaining_credit := accumulated - MOVEMENT_CREDIT_SCALE
var remaining_credit := accumulated - required_credit
_movement_credits[actor_id] = {
&"credit": remaining_credit,
&"updated_at": absolute_minute,
@@ -8116,10 +8172,12 @@ func _advance_staff_task_travel(
return
if _request_elevator_step(actor_id, path[0], path[1], absolute_minute, [&"staff"]):
return
var required_credit := _step_movement_credit(path_service, path[0], path[1], [&"staff"])
var accumulated := _movement_credit_after_one_minute(
actor_id, staff.current_cell, absolute_minute
)
if accumulated < MOVEMENT_CREDIT_SCALE:
accumulated = mini(accumulated, required_credit + MOVEMENT_CREDIT_SCALE - 1)
if accumulated < required_credit:
_record_blocked_actor_wait(
actor_id,
staff.staff_id,
@@ -8135,7 +8193,7 @@ func _advance_staff_task_travel(
staff.current_cell = previous_cell
_report_path_integration_failure(actor_id, staff.staff_id, &"staff", previous_cell)
return
var remaining_credit := accumulated - MOVEMENT_CREDIT_SCALE
var remaining_credit := accumulated - required_credit
_movement_credits[actor_id] = {
&"credit": remaining_credit,
&"updated_at": absolute_minute,
@@ -9107,6 +9165,7 @@ func _capture_event_action_token() -> Dictionary:
&"guests": guest_service.create_snapshot(),
&"path": path_service.create_snapshot(),
&"elevators": elevators.create_snapshot(),
&"legacy_stairs": legacy_stairs.create_snapshot(),
&"movement_credits": _movement_credits.duplicate(true),
&"domain_events": domain_events.create_snapshot(),
&"id_generator": id_generator.create_snapshot(),
@@ -9124,6 +9183,8 @@ func _restore_event_action_token(token: Variant) -> Dictionary:
var facilities_ok: bool = facility_service.restore_snapshot(token.get(&"facilities", {}))
var guests_ok: bool = guest_service.restore_snapshot(token.get(&"guests", {}))
var path_ok: bool = path_service.restore_snapshot(token.get(&"path", {}))
path_ok = elevators.restore_snapshot(token.get(&"elevators", {})) and path_ok
path_ok = legacy_stairs.restore_snapshot(token.get(&"legacy_stairs", {})) and path_ok
var credits_value: Variant = token.get(&"movement_credits", null)
var credits_ok := credits_value is Dictionary
if credits_ok:
@@ -10202,7 +10263,7 @@ func _deserialize_movement_credits(raw_entries: Array, absolute_minute: int) ->
var credit := int(credit_value)
var updated_at := int(updated_at_value)
if actor_id.is_empty() or restored.has(actor_id) \
or credit < 0 or credit >= MOVEMENT_CREDIT_SCALE \
or credit < 0 or credit >= STAIR_MOVEMENT_CREDIT \
or updated_at < 0 or updated_at > absolute_minute:
return null
restored[actor_id] = {
@@ -10311,7 +10372,7 @@ func _validate_runtime_path_crosslinks(
var last_progress_at := int(last_progress_value)
var last_observed_at := int(last_observed_value)
var stuck_reported_at := int(stuck_reported_value)
if credit < 0 or credit >= MOVEMENT_CREDIT_SCALE \
if credit < 0 or credit >= _cell_movement_credit_limit(path, cell) \
or updated_at < 0 or updated_at > absolute_minute \
or progress_entry.get(&"last_cell", null) != cell \
or last_progress_at < 0 or last_progress_at > last_observed_at \
@@ -10321,7 +10382,8 @@ func _validate_runtime_path_crosslinks(
stuck_reported_at < last_progress_at or stuck_reported_at > last_observed_at
)):
return false
expected_density[cell] = int(expected_density.get(cell, 0)) + 1
if not path.is_actor_in_transit(actor_id):
expected_density[cell] = int(expected_density.get(cell, 0)) + 1
for raw_cell: Variant in raw_traffic:
if not raw_cell is Dictionary:
return false
@@ -10341,7 +10403,7 @@ func _path_snapshot_cells_match_world(
&"schema_version",
path_snapshot.get("schema_version", null)
)
if not schema_value is int or int(schema_value) not in [PathService.ELEVATOR_INDEX_SNAPSHOT_VERSION, PathService.SNAPSHOT_VERSION]:
if not schema_value is int or int(schema_value) not in [PathService.ELEVATOR_INDEX_SNAPSHOT_VERSION, PathService.SHAFT_SNAPSHOT_VERSION, PathService.SNAPSHOT_VERSION]:
return false
var raw_traffic: Variant = path_snapshot.get(
&"cell_traffic",
@@ -11430,8 +11492,10 @@ func advance_staff_commute(staff_id: StringName, target_cell: Vector3i, minute:
return {&"ok": true, &"moved": false, &"reason": &"COMMUTE_ROUTE_BLOCKED"}
if _request_elevator_step(actor_id, path[0], path[1], minute, [&"staff"]):
return {&"ok": true, &"moved": false, &"reason": &"ELEVATOR_WAIT"}
var required_credit := _step_movement_credit(path_service, path[0], path[1], [&"staff"])
var credit := _movement_credit_after_one_minute(actor_id, staff.current_cell, minute)
if credit < MOVEMENT_CREDIT_SCALE:
credit = mini(credit, required_credit + MOVEMENT_CREDIT_SCALE - 1)
if credit < required_credit:
_record_expected_actor_wait(actor_id, staff.current_cell, minute)
_movement_credits[actor_id] = {&"credit": credit, &"updated_at": minute}
return {&"ok": true, &"moved": false}
@@ -11440,7 +11504,7 @@ func advance_staff_commute(staff_id: StringName, target_cell: Vector3i, minute:
_report_path_integration_failure(actor_id, staff_id, &"staff", previous)
return {&"ok": false}
staff.current_cell = path[1]
_movement_credits[actor_id] = {&"credit": credit - MOVEMENT_CREDIT_SCALE, &"updated_at": minute}
_movement_credits[actor_id] = {&"credit": credit - required_credit, &"updated_at": minute}
_publish_actor_event(&"STAFF_MOVED", &"staff_commute", [staff_id, actor_id], {
&"path_actor_id": actor_id, &"from_cell": previous, &"cell": staff.current_cell,
&"target_cell": target_cell, &"reason": &"COMMUTE"})
@@ -11464,6 +11528,10 @@ func _request_elevator_step(actor_id: StringName, from: Vector3i, to: Vector3i,
func _advance_elevators(minute: int) -> void:
var queued_before: Dictionary = {}
for trip: Dictionary in elevators.get_report()[&"trips"]:
if trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
queued_before[trip[&"request_id"]] = true
for shaft: Dictionary in elevators.get_report()[&"shafts"]:
var powered := true
for module_id: StringName in shaft[&"module_ids"]:
@@ -11472,6 +11540,12 @@ func _advance_elevators(minute: int) -> void:
powered = false
_apply_elevator_arrivals(elevators.set_powered(shaft[&"shaft_id"], powered, minute), minute)
_apply_elevator_arrivals(elevators.advance_one_minute(minute), minute)
for trip: Dictionary in elevators.get_report()[&"trips"]:
if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED and queued_before.has(trip[&"request_id"]):
var payload := trip.duplicate(true)
payload[&"boarded_at"] = minute
payload[&"wait_minutes"] = minute - int(trip[&"enqueued_at"])
_publish_actor_event(&"ELEVATOR_BOARDED", &"elevators", [trip[&"actor_id"], trip[&"shaft_id"]], payload)
func _apply_elevator_arrivals(arrivals: Array[Dictionary], minute: int) -> void:
@@ -11575,7 +11649,8 @@ func is_staff_in_transit(staff_id: StringName) -> bool:
func _estimated_actor_path_minutes(path: Array, access: Array[StringName]) -> int:
for index: int in range(1, path.size()):
if elevators.owns_connector(path_service.get_step_connector(path[index - 1], path[index], access)):
if elevators.owns_connector(path_service.get_step_connector(path[index - 1], path[index], access)) \
or _step_movement_credit(path_service, path[index - 1], path[index], access) > MOVEMENT_CREDIT_SCALE:
return ceili(float(path_service.calculate_path_cost(path, access)) / PathService.ORTHOGONAL_MOVE_COST)
return maxi(path.size() - 1, 0)
@@ -11598,3 +11673,99 @@ func _prune_expired_staff_elevator_waits(minute: int) -> void:
if not staff.can_take_shift() or shift == null or (not shift.contains_minute(minute) and not shift.is_commute_window(minute)):
elevators.cancel_actor(actor_id)
_publish_actor_event(&"ELEVATOR_COMMUTE_CANCELLED", &"staff_commute", [staff.staff_id, actor_id], {&"reason": &"SHIFT_ENDED_OR_UNAVAILABLE"})
## Stairs carry half-minute progress between ticks. Doors keep their established
## one-step walking behavior, and elevator time is owned by the cabin service.
static func _step_movement_credit(path: PathService, from: Vector3i, to: Vector3i,
access: Array[StringName]) -> int:
var connector_id := path.get_step_connector(from, to, access)
if String(connector_id).begins_with("infra.connector:") and from.x == to.x \
and from.z == to.z and absi(from.y - to.y) == 1:
var connector := path.get_connector(connector_id)
if connector.get(&"movement_type", &"") == PathService.MOVEMENT_CONNECTOR \
and int(connector.get(&"cost", 0)) == 150:
return STAIR_MOVEMENT_CREDIT
return MOVEMENT_CREDIT_SCALE
static func _cell_movement_credit_limit(path: PathService, cell: Vector3i) -> int:
# Temporary blockades must not invalidate progress saved on a staircase.
# Inspect structural edges here instead of querying currently usable routes.
for edge: Dictionary in path.get_neighbors(cell):
var next: Vector3i = edge[&"to"]
if String(edge[&"connector_id"]).begins_with("infra.connector:") \
and edge[&"movement_type"] == PathService.MOVEMENT_CONNECTOR \
and int(edge[&"base_cost"]) == 150 and cell.x == next.x \
and cell.z == next.z and absi(cell.y - next.y) == 1:
return STAIR_MOVEMENT_CREDIT
return MOVEMENT_CREDIT_SCALE
func _prune_legacy_stairs_and_movement() -> bool:
if not legacy_stairs.prune_invalid():
return false
# A removed staircase discards only progress that can no longer be used.
for actor_id: StringName in _movement_credits:
if path_service.has_actor_projection(actor_id):
var cell: Vector3i = path_service.get_actor_projected_cell(actor_id)
_movement_credits[actor_id][&"credit"] = mini(int(_movement_credits[actor_id][&"credit"]),
_cell_movement_credit_limit(path_service, cell) - 1)
return true
func get_elevator_wait_statistics(shaft_id: StringName) -> Dictionary:
var samples: Array[int] = []
var oldest_wait := 0
var minute := _runtime_event_minute()
for event: SimulationDomainEvent in domain_events.recent_events():
if event.event_type != &"ELEVATOR_BOARDED" or event.absolute_minute < minute - 60 \
or event.absolute_minute > minute or event.payload.get(&"shaft_id", &"") != shaft_id:
continue
var wait: int = int(event.payload.get(&"wait_minutes", -1))
if wait >= 0:
samples.append(wait)
for trip: Dictionary in elevators.get_report()[&"trips"]:
if trip[&"shaft_id"] == shaft_id and trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED:
oldest_wait = maxi(oldest_wait, minute - int(trip[&"enqueued_at"]))
samples.sort()
var total := 0
for wait: int in samples:
total += wait
return {&"sample_count": samples.size(), &"window_minutes": 60,
&"event_history_limit": DomainEventStream.RECENT_EVENT_LIMIT,
&"mean_wait_minutes": float(total) / samples.size() if not samples.is_empty() else 0.0,
&"p95_wait_minutes": samples[ceili(samples.size() * 0.95) - 1] if not samples.is_empty() else 0,
&"oldest_wait_minutes": oldest_wait}
func _demolition_lost_route_cells(instance_id: StringName) -> Array[Vector3i]:
var lost_cells: Array[Vector3i] = []
var projection := facility_infrastructure_projector.get_projection(instance_id)
var base := route_subslot_path_adapter.get_base_walkable_cells()
for cell: Vector3i in projection.get(&"cells", []):
if cell not in base:
lost_cells.append(cell)
return lost_cells
func _stair_demolition_dependents(instance_id: StringName) -> Array[StringName]:
var lost_cells := _demolition_lost_route_cells(instance_id)
var dependents := facility_infrastructure_projector.get_stair_dependents(lost_cells)
dependents.erase(instance_id)
# A staircase under construction already relies on its lower landing too.
for job: ConstructionJob in construction_service.get_jobs():
if not job.is_terminal() and job.definition_id in [&"guest_stairs", &"service_stairs"] \
and job.origin + Vector3i(0, -1, 0) in lost_cells:
dependents.append(job.job_id)
return dependents
## Use the same entrance and horizontal access cells as ordinary Guest AI.
## Facility access_available is an operating override, not a topology cache.
func get_facility_access_report(facility_id: StringName) -> Dictionary:
var facility := facility_service.get_instance(facility_id)
var entrance: Variant = _operations_entrance_cell()
if facility == null or entrance == null or not facility.access_available:
return {&"reachable": false, &"path": []}
return _find_facility_access_path(entrance, facility)