13575 lines
599 KiB
GDScript
13575 lines
599 KiB
GDScript
class_name SimulationRuntimeService
|
|
extends Node
|
|
|
|
signal command_completed(result: CommandResult)
|
|
signal snapshot_published(snapshot: Dictionary, digest: String)
|
|
# UI subscribers only need notification; exact snapshots remain available.
|
|
signal state_updated(snapshot: Dictionary)
|
|
signal definition_validation_failed(report: Dictionary)
|
|
signal save_operation_completed(result: Dictionary)
|
|
# Optional, read-only UI contribution collected at the actual checkpoint.
|
|
signal checkpoint_presentation_requested(presentation: Dictionary)
|
|
|
|
const DEFAULT_WORLD_SEED := 20260827
|
|
const STARTER_MANA_OUTPUT := 100
|
|
const STARTER_MANA_SOURCE := &"world_tree.mana_source"
|
|
const STARTER_MANA_BUS := &"world_tree.mana_bus"
|
|
const PLAYER_CASH_ACCOUNT := &"PLAYER_CASH"
|
|
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 := 14
|
|
const AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION := 14
|
|
const EVENT_THREAT_RUNTIME_SNAPSHOT_VERSION := 13
|
|
const MIMI_PURCHASE_RUNTIME_SNAPSHOT_VERSION := 15
|
|
const PurchaseQuoteV2 := preload("res://scripts/supply/supply_purchase_quote_v2.gd")
|
|
const AutoPurchasePlansType := preload("res://scripts/supply/supply_auto_purchase_quote_plans.gd")
|
|
const CLEANING_RUNTIME_SNAPSHOT_VERSION := 12
|
|
const WEATHER_RUNTIME_SNAPSHOT_VERSION := 11
|
|
const FacilityCleanliness = preload("res://scripts/environment/facility_cleanliness_service.gd")
|
|
const CleaningBridge = preload("res://scripts/environment/facility_cleaning_bridge.gd")
|
|
const CleaningPolicy = preload("res://scripts/environment/facility_cleaning_policy.gd")
|
|
const UPKEEP_RUNTIME_SNAPSHOT_VERSION := 10
|
|
const WeatherTimeline = preload("res://scripts/environment/weather_timeline.gd")
|
|
const STAIRS_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
|
|
const DEFENSE_RUNTIME_SNAPSHOT_VERSION := 2
|
|
const PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION := 3
|
|
const CONTENT_RUNTIME_SNAPSHOT_VERSION := 4
|
|
const OPERATIONS_RUNTIME_SNAPSHOT_VERSION := 5
|
|
const MVP_CONTENT_MANIFEST_VERSION := 2
|
|
const OPERATIONS_RUNTIME_WRAPPER_VERSION := 1
|
|
const OPERATIONS_START_MINUTE := 360
|
|
const OPERATIONS_COORDINATOR_ORIGIN_MINUTE := 359
|
|
const MAX_PERSONAL_ACTION_POINTS := 4
|
|
const OPERATIONS_TEST_PREPARE_PHASE: StringName = &"PREPARE"
|
|
const OPERATIONS_TEST_AFTER_FIRST_MUTATION_PHASE: StringName = &"AFTER_FIRST_MUTATION"
|
|
const DEFAULT_OPERATIONS_DEMAND_PROFILES := [
|
|
{&"facility_tag": &"hygiene", &"guest_class": &"cleric", &"time_band": &"MORNING"},
|
|
{&"facility_tag": &"service", &"guest_class": &"diplomat", &"time_band": &"MORNING"},
|
|
{&"facility_tag": &"lodging", &"guest_class": &"mage", &"time_band": &"DAY"},
|
|
{&"facility_tag": &"service", &"guest_class": &"merchant", &"time_band": &"DAY"},
|
|
{&"facility_tag": &"lodging", &"guest_class": &"noble", &"time_band": &"EVENING"},
|
|
{&"facility_tag": &"dining", &"guest_class": &"warrior", &"time_band": &"EVENING"},
|
|
{&"facility_tag": &"leisure", &"guest_class": &"outsider", &"time_band": &"NIGHT"},
|
|
{&"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
|
|
const DEFAULT_SAVE_POLICY := {
|
|
&"enabled": false,
|
|
&"daily_checkpoint_enabled": true,
|
|
&"autosave_path": DEFAULT_AUTOSAVE_PATH,
|
|
&"autosave_generations": SaveService.DEFAULT_AUTOSAVE_GENERATIONS,
|
|
&"auto_migrate": true,
|
|
}
|
|
const RNG_STREAM_IDS: Array[StringName] = [
|
|
&"world",
|
|
&"guest_spawn",
|
|
&"guest_decision",
|
|
&"staff",
|
|
&"relationship",
|
|
&"authority",
|
|
&"defense",
|
|
&"event",
|
|
&"loot",
|
|
]
|
|
const DEFENSE_COMMAND_TYPES: Array[StringName] = [
|
|
&"create_defense_raid",
|
|
&"register_defense_combatant",
|
|
&"register_defense_asset",
|
|
&"start_defense_raid",
|
|
&"set_defense_retreat_policy",
|
|
&"queue_defense_skill",
|
|
&"resolve_defense_round",
|
|
&"begin_next_defense_wave",
|
|
&"retreat_defenders",
|
|
&"cancel_defense_raid",
|
|
]
|
|
|
|
var definition_registry := DefinitionRegistry.new()
|
|
var command_queue := CommandQueue.new()
|
|
var id_generator := StableIdGenerator.new()
|
|
var domain_events := DomainEventStream.new()
|
|
var world_service := WorldService.new()
|
|
var path_service := PathService.new()
|
|
var mana_network_service := ManaNetworkService.new()
|
|
var weather := WeatherTimeline.new()
|
|
var facility_cleanliness := FacilityCleanliness.new()
|
|
var cleaning_policy := CleaningPolicy.new()
|
|
var cleaning_bridge := CleaningBridge.new()
|
|
var facility_upkeep := FacilityUpkeepAccrual.new()
|
|
var upkeep_settlement := FacilityUpkeepSettlement.new()
|
|
var economy_service: EconomyService
|
|
var construction_resource_state: LedgerBackedResourceState
|
|
var operating_resource_state: LedgerBackedResourceState
|
|
var inventory_service := InventoryService.new()
|
|
var logistics_dispatcher := LogisticsDispatcher.new()
|
|
var logistics_requests: Dictionary = {}
|
|
var kitchen_production := KitchenProductionService.new()
|
|
var supply_management := SupplyManagementService.new()
|
|
var auto_purchase_plans := AutoPurchasePlansType.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()
|
|
var staff_service := StaffService.new()
|
|
var mvp_content_catalog := MvpContentCatalog.new()
|
|
var mvp_content_report: Dictionary = {}
|
|
# Cache only catalog-derived immutable metadata, never GuestDefinition objects.
|
|
var _mvp_manifest_cache_catalog: MvpContentCatalog
|
|
var _mvp_manifest_cache_generation := -1
|
|
var _mvp_manifest_cache: Dictionary = {}
|
|
var progression_service := ProgressionService.new()
|
|
var operations_coordinator: OperationsCoordinator
|
|
var relationship_service := RelationshipService.new()
|
|
var event_service := EventService.new()
|
|
var authority_service := AuthorityService.new()
|
|
var defense_service := DefenseService.new()
|
|
var construction_service: ConstructionService
|
|
var excavation_service: ExcavationService
|
|
var spatial_subslot_service: SpatialSubslotService
|
|
var route_subslot_path_adapter: RouteSubslotPathAdapter
|
|
var facility_infrastructure_projector: FacilityInfrastructureProjector
|
|
var latest_snapshot: Dictionary = {}
|
|
var _latest_digest := ""
|
|
var _latest_digest_dirty := false
|
|
var latest_digest: String:
|
|
get:
|
|
if _latest_digest_dirty:
|
|
_latest_digest = SimulationDigest.compute(latest_snapshot)
|
|
_latest_digest_dirty = false
|
|
return _latest_digest
|
|
var latest_save_operation_result: Dictionary = {}
|
|
var latest_restore_safety_report: Dictionary = {}
|
|
var world_seed := DEFAULT_WORLD_SEED
|
|
var save_migration_registry := SaveMigrationRegistry.new()
|
|
|
|
var _clock: SimulationKernel
|
|
var _state: Variant
|
|
var _rng_streams: Dictionary = {}
|
|
var _executors: Dictionary = {}
|
|
var _next_command_id := 1
|
|
var _staffing_projection_enabled := false
|
|
var _movement_credits: Dictionary = {}
|
|
var _hired_staff_candidate_ids: Dictionary = {}
|
|
var _save_policy: Dictionary = DEFAULT_SAVE_POLICY.duplicate(true)
|
|
var _last_daily_checkpoint_day := -1
|
|
var _pending_checkpoints: Array[Dictionary] = []
|
|
var _next_checkpoint_request_id := 1
|
|
var _initialized := false
|
|
var _active_command_minute := -1
|
|
var _operations_enabled := false
|
|
# Transient in-tick execution guards. Persisted timing lives inside Defense.
|
|
var _defense_round_attempt_minute := -1
|
|
var _defense_round_attempted_raids: Dictionary = {}
|
|
var _defense_automatic_failed_minute := -1
|
|
|
|
var _operations_tick_blocked := false
|
|
var _operations_runtime_failure_latched := false
|
|
var _operations_runtime_failure_report: Dictionary = {}
|
|
var _operations_deferred_runtime_minute := -1
|
|
var _operations_resume_publish_pending := false
|
|
var _personal_action_points := 0
|
|
var _operations_test_boundary_hooks: Dictionary = {}
|
|
var _operations_transaction_active := false
|
|
var _operations_queued_domain_events: Array[SimulationDomainEvent] = []
|
|
var _operations_queued_guest_spawn_notifications: Array[StringName] = []
|
|
var _operations_flushing_guest_spawn_notifications: Dictionary = {}
|
|
var _operations_queued_economy_notifications: Array[StringName] = []
|
|
var _operations_flushing_economy_notifications: Dictionary = {}
|
|
var _infrastructure_failure_latched := false
|
|
var _infrastructure_failure_report: Dictionary = {}
|
|
var _construction_completion_transaction_active := false
|
|
var _construction_committing_notifications := false
|
|
var _construction_queued_domain_events: Array[SimulationDomainEvent] = []
|
|
|
|
func _ready() -> void:
|
|
# The autoload is also present in headless test SceneTrees, so merely entering
|
|
# the tree must never opt into disk writes. The playable main scene enables
|
|
# the production policy explicitly.
|
|
if not _initialized:
|
|
initialize(GameClock, GameState, DEFAULT_WORLD_SEED)
|
|
|
|
func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_SEED) -> Dictionary:
|
|
if _initialized and _clock != null:
|
|
_clock.unregister_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.0_weather")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.a_upkeep")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.operations")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.APPLY_COMMANDS, &"simulation_runtime.commands")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.PUBLISH_SNAPSHOT, &"simulation_runtime.snapshot")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.CONSTRUCTION, &"simulation_runtime.construction")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.MANA, &"simulation_runtime.mana")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.LOGISTICS, &"simulation_runtime.logistics")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.FACILITY_COMPLETION, &"simulation_runtime.facilities")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.VITALS_AND_ENVIRONMENT, &"simulation_runtime.guest_vitals")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.FACILITY_QUEUES, &"simulation_runtime.guest_queues")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.INCIDENTS_AND_EVENTS, &"simulation_runtime.authority_incidents")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.INCIDENTS_AND_EVENTS, &"simulation_runtime.defense")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.INCIDENTS_AND_EVENTS, &"simulation_runtime.relationship_events")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.AI_DECISIONS, &"simulation_runtime.staff_ai")
|
|
_clock.unregister_tick_handler(_clock.TickPhase.PUBLISH_SNAPSHOT, &"simulation_runtime.zz_save_checkpoints")
|
|
_detach_defense_service(defense_service)
|
|
_clear_mvp_content_manifest_cache()
|
|
_clock = clock
|
|
_state = state
|
|
world_seed = seed
|
|
mvp_content_catalog = MvpContentCatalog.new()
|
|
mvp_content_report = mvp_content_catalog.rebuild()
|
|
progression_service = ProgressionService.new()
|
|
var initial_progression_ok := progression_service.configure(1, {
|
|
&"asset_gold": maxi(int(_state.get_resource(&"gold")), 0),
|
|
&"faith_lifetime": maxi(int(_state.get_resource(&"faith")), 0),
|
|
})
|
|
_hired_staff_candidate_ids.clear()
|
|
command_queue = CommandQueue.new()
|
|
id_generator = StableIdGenerator.new()
|
|
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()
|
|
_initialize_world(maxi(int(_state.world_tree_rank), 1))
|
|
_initialize_mana_network()
|
|
_initialize_economy()
|
|
_initialize_s2_operations()
|
|
_staffing_projection_enabled = false
|
|
_initialize_s3_actors()
|
|
_initialize_s4_relationship_events()
|
|
_initialize_s5_authority()
|
|
_initialize_s5_defense()
|
|
construction_service = ConstructionService.new(world_service, construction_resource_state)
|
|
construction_service.current_minute = _clock.absolute_minute
|
|
_connect_construction_signals()
|
|
var initial_infrastructure_ok := _initialize_infrastructure_services()
|
|
_next_command_id = 1
|
|
_last_daily_checkpoint_day = -1
|
|
_pending_checkpoints.clear()
|
|
_next_checkpoint_request_id = 1
|
|
_active_command_minute = -1
|
|
operations_coordinator = null
|
|
_operations_enabled = false
|
|
_operations_tick_blocked = false
|
|
_operations_runtime_failure_latched = false
|
|
_operations_runtime_failure_report.clear()
|
|
_operations_deferred_runtime_minute = -1
|
|
_operations_resume_publish_pending = false
|
|
_personal_action_points = 0
|
|
_operations_test_boundary_hooks.clear()
|
|
_operations_transaction_active = false
|
|
_operations_queued_domain_events.clear()
|
|
_operations_queued_guest_spawn_notifications.clear()
|
|
_operations_flushing_guest_spawn_notifications.clear()
|
|
_operations_queued_economy_notifications.clear()
|
|
_operations_flushing_economy_notifications.clear()
|
|
_infrastructure_failure_latched = false
|
|
_infrastructure_failure_report.clear()
|
|
_construction_completion_transaction_active = false
|
|
_construction_committing_notifications = false
|
|
_construction_queued_domain_events.clear()
|
|
latest_save_operation_result.clear()
|
|
latest_restore_safety_report.clear()
|
|
_initialize_rng_streams()
|
|
supply_logistics = SupplyLogisticsBridge.new()
|
|
supply_logistics.configure(self)
|
|
cleaning_bridge.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(&"repay_loan_principal", Callable(self, "_execute_repay_loan_principal"))
|
|
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"))
|
|
register_command_executor(&"set_world_tree_rank", Callable(self, "_execute_set_world_tree_rank"))
|
|
register_command_executor(&"update_progression_metrics", Callable(self, "_execute_update_progression_metrics"))
|
|
register_command_executor(&"complete_progression_quest", Callable(self, "_execute_complete_progression_quest"))
|
|
register_command_executor(&"acknowledge_operations_pause", Callable(self, "_execute_acknowledge_operations_pause"))
|
|
register_command_executor(&"purchase_supplies", Callable(self, "_execute_purchase_supplies"))
|
|
register_command_executor(&"set_supply_auto_purchase", Callable(self, "_execute_set_supply_auto_purchase"))
|
|
register_command_executor(&"set_kitchen_production", Callable(self, "_execute_set_kitchen_production"))
|
|
register_command_executor(&"add_inventory_stack", Callable(self, "_execute_add_inventory_stack"))
|
|
register_command_executor(&"create_logistics_request", Callable(self, "_execute_create_logistics_request"))
|
|
register_command_executor(&"dispatch_logistics", Callable(self, "_execute_dispatch_logistics"))
|
|
register_command_executor(&"advance_logistics_request", Callable(self, "_execute_advance_logistics_request"))
|
|
register_command_executor(&"register_guest_wallet", Callable(self, "_execute_register_guest_wallet"))
|
|
register_command_executor(&"create_service_request", Callable(self, "_execute_create_service_request"))
|
|
register_command_executor(&"set_facility_conditions", Callable(self, "_execute_set_facility_conditions"))
|
|
register_command_executor(&"set_facility_policy", Callable(self, "_execute_set_facility_policy"))
|
|
register_command_executor(&"leave_facility_queue", Callable(self, "_execute_leave_facility_queue"))
|
|
register_command_executor(&"spawn_guest", Callable(self, "_execute_spawn_guest"))
|
|
register_command_executor(&"transition_guest", Callable(self, "_execute_transition_guest"))
|
|
register_command_executor(&"decide_guest", Callable(self, "_execute_decide_guest"))
|
|
register_command_executor(&"register_staff", Callable(self, "_execute_register_staff"))
|
|
register_command_executor(&"hire_staff_candidate", Callable(self, "_execute_hire_staff_candidate"))
|
|
register_command_executor(&"create_staff_shift", Callable(self, "_execute_create_staff_shift"))
|
|
register_command_executor(&"set_staff_schedule_override", Callable(self, "_execute_set_staff_schedule_override"))
|
|
register_command_executor(&"set_staff_work_assignment", Callable(self, "_execute_set_staff_work_assignment"))
|
|
register_command_executor(&"create_staff_task", Callable(self, "_execute_create_staff_task"))
|
|
register_command_executor(&"begin_staff_task", Callable(self, "_execute_begin_staff_task"))
|
|
register_command_executor(&"progress_staff_task", Callable(self, "_execute_progress_staff_task"))
|
|
register_command_executor(&"create_relationship", Callable(self, "_execute_create_relationship"))
|
|
register_command_executor(&"register_original_relationship", Callable(self, "_execute_register_original_relationship"))
|
|
register_command_executor(&"change_relationship", Callable(self, "_execute_change_relationship"))
|
|
register_command_executor(&"attack_relationship_shield", Callable(self, "_execute_attack_relationship_shield"))
|
|
register_command_executor(&"request_route_lock", Callable(self, "_execute_request_route_lock"))
|
|
register_command_executor(&"confirm_route_lock", Callable(self, "_execute_confirm_route_lock"))
|
|
register_command_executor(&"register_event_definition", Callable(self, "_execute_register_event_definition"))
|
|
register_command_executor(&"enqueue_event", Callable(self, "_execute_enqueue_event"))
|
|
register_command_executor(&"reserve_event_choice_cost", Callable(self, "_execute_reserve_event_choice_cost"))
|
|
register_command_executor(&"submit_event_choice", Callable(self, "_execute_submit_event_choice"))
|
|
register_command_executor(&"report_authority_incident", Callable(self, "_execute_report_authority_incident"))
|
|
register_command_executor(&"register_authority_evidence", Callable(self, "_execute_register_authority_evidence"))
|
|
register_command_executor(&"discover_authority_evidence", Callable(self, "_execute_discover_authority_evidence"))
|
|
register_command_executor(&"evaluate_authority_inspection", Callable(self, "_execute_evaluate_authority_inspection"))
|
|
register_command_executor(&"schedule_authority_inspection", Callable(self, "_execute_schedule_authority_inspection"))
|
|
register_command_executor(&"start_authority_inspection", Callable(self, "_execute_start_authority_inspection"))
|
|
register_command_executor(&"arrive_authority_inspection", Callable(self, "_execute_arrive_authority_inspection"))
|
|
register_command_executor(&"plan_authority_inspection", Callable(self, "_execute_plan_authority_inspection"))
|
|
register_command_executor(&"advance_authority_inspection", Callable(self, "_execute_advance_authority_inspection"))
|
|
register_command_executor(&"inspect_authority_waypoint", Callable(self, "_execute_inspect_authority_waypoint"))
|
|
register_command_executor(&"resolve_authority_inspection", Callable(self, "_execute_resolve_authority_inspection"))
|
|
register_command_executor(&"depart_authority_inspection", Callable(self, "_execute_depart_authority_inspection"))
|
|
register_command_executor(&"create_defense_raid", Callable(self, "_execute_create_defense_raid"))
|
|
register_command_executor(&"register_defense_combatant", Callable(self, "_execute_register_defense_combatant"))
|
|
register_command_executor(&"register_defense_asset", Callable(self, "_execute_register_defense_asset"))
|
|
register_command_executor(&"start_defense_raid", Callable(self, "_execute_start_defense_raid"))
|
|
register_command_executor(&"set_defense_retreat_policy", Callable(self, "_execute_set_defense_retreat_policy"))
|
|
register_command_executor(&"queue_defense_skill", Callable(self, "_execute_queue_defense_skill"))
|
|
register_command_executor(&"resolve_defense_round", Callable(self, "_execute_resolve_defense_round"))
|
|
register_command_executor(&"begin_next_defense_wave", Callable(self, "_execute_begin_next_defense_wave"))
|
|
register_command_executor(&"retreat_defenders", Callable(self, "_execute_retreat_defenders"))
|
|
register_command_executor(&"cancel_defense_raid", Callable(self, "_execute_cancel_defense_raid"))
|
|
# Weather commits before upkeep and arrivals, including same-minute retries.
|
|
_clock.unregister_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.0_weather")
|
|
_clock.register_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.0_weather", Callable(self, "_advance_weather"))
|
|
_clock.unregister_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.a_upkeep")
|
|
_clock.register_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.a_upkeep", Callable(self, "_advance_facility_upkeep"))
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.APPLY_COMMANDS,
|
|
&"simulation_runtime.commands",
|
|
Callable(self, "_process_tick_commands")
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.PUBLISH_SNAPSHOT,
|
|
&"simulation_runtime.snapshot",
|
|
Callable(self, "_publish_snapshot")
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.MANA,
|
|
&"simulation_runtime.mana",
|
|
Callable(self, "_advance_mana")
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.CONSTRUCTION,
|
|
&"simulation_runtime.construction",
|
|
Callable(self, "_advance_construction")
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.LOGISTICS,
|
|
&"simulation_runtime.logistics",
|
|
Callable(self, "_advance_inventory_and_logistics")
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.FACILITY_COMPLETION,
|
|
&"simulation_runtime.facilities",
|
|
Callable(self, "_advance_facilities")
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.VITALS_AND_ENVIRONMENT,
|
|
&"simulation_runtime.guest_vitals",
|
|
Callable(self, "_advance_guest_vitals")
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.FACILITY_QUEUES,
|
|
&"simulation_runtime.guest_queues",
|
|
Callable(self, "_advance_guest_queues")
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.INCIDENTS_AND_EVENTS,
|
|
&"simulation_runtime.authority_incidents",
|
|
Callable(self, "_advance_authority_incidents")
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.INCIDENTS_AND_EVENTS,
|
|
&"simulation_runtime.defense",
|
|
Callable(self, "_advance_defense")
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.INCIDENTS_AND_EVENTS,
|
|
&"simulation_runtime.relationship_events",
|
|
Callable(self, "_advance_relationship_events")
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.AI_DECISIONS,
|
|
&"simulation_runtime.staff_ai",
|
|
Callable(self, "_advance_staff_ai")
|
|
)
|
|
# The `zz` ID deliberately orders persistence after the published simulation
|
|
# snapshot at the final phase of a fully completed minute.
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.PUBLISH_SNAPSHOT,
|
|
&"simulation_runtime.zz_save_checkpoints",
|
|
Callable(self, "_process_save_checkpoints")
|
|
)
|
|
_initialized = true
|
|
var report := definition_registry.rebuild_from_paths(
|
|
FacilityCatalog.FACILITY_PATHS + FacilityCatalog.INFRASTRUCTURE_FACILITY_PATHS + FacilityCatalog.RESCUE_FACILITY_PATHS + FacilityCatalog.ORIGINAL_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
|
|
report[&"catalog_guest_definitions_registered"] = catalog_registration_ok
|
|
report[&"infrastructure_initialized"] = initial_infrastructure_ok
|
|
report[&"cleaning_policy_valid"] = not cleaning_policy.create_snapshot().is_empty()
|
|
report[&"valid"] = bool(report[&"valid"]) \
|
|
and bool(mvp_content_report.get(&"valid", false)) \
|
|
and catalog_registration_ok \
|
|
and initial_progression_ok \
|
|
and initial_infrastructure_ok \
|
|
and bool(report[&"cleaning_policy_valid"]) \
|
|
and int(_state.world_tree_rank) == ProgressionService.MIN_RANK
|
|
if not report[&"valid"]:
|
|
_clock.set_paused(true)
|
|
definition_validation_failed.emit(report)
|
|
_publish_snapshot(_clock.absolute_minute)
|
|
return report
|
|
|
|
func register_command_executor(command_type: StringName, executor: Callable) -> bool:
|
|
if command_type.is_empty() or not executor.is_valid() or _executors.has(command_type):
|
|
return false
|
|
_executors[command_type] = executor
|
|
return true
|
|
|
|
func unregister_command_executor(command_type: StringName) -> void:
|
|
_executors.erase(command_type)
|
|
|
|
func submit_command(
|
|
command_type: StringName,
|
|
payload: Dictionary = {},
|
|
actor_id: StringName = &"player",
|
|
target_id: StringName = &"",
|
|
priority := 0,
|
|
execute_at := -1,
|
|
idempotency_key: StringName = &""
|
|
) -> CommandResult:
|
|
if not _initialized:
|
|
return CommandResult.failed(CommandResult.Code.EXECUTOR_FAILED, 0, "Simulation runtime is not initialized.")
|
|
if _operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal():
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED, 0,
|
|
"ゲーム終了後は操作できません。帳簿の確認・保存・読み込みは利用できます。",
|
|
{&"reason": &"BANKRUPT"}
|
|
)
|
|
var scheduled_tick := _clock.absolute_minute if execute_at < 0 else execute_at
|
|
var command := SimulationCommand.new(
|
|
_next_command_id,
|
|
_clock.absolute_minute,
|
|
scheduled_tick,
|
|
priority,
|
|
command_type,
|
|
actor_id,
|
|
target_id,
|
|
payload,
|
|
idempotency_key
|
|
)
|
|
var enqueue_result := command_queue.enqueue(command)
|
|
if not enqueue_result.is_success():
|
|
return enqueue_result
|
|
_next_command_id += 1
|
|
if _clock.paused and scheduled_tick <= _clock.absolute_minute:
|
|
var resume_snapshot_published := _process_commands(_clock.absolute_minute)
|
|
if not resume_snapshot_published:
|
|
_publish_snapshot(_clock.absolute_minute)
|
|
var completed := command_queue.get_processed_result(command.command_id)
|
|
if completed != null:
|
|
return completed
|
|
return enqueue_result
|
|
|
|
func get_rng_stream(stream_id: StringName) -> DeterministicRng:
|
|
return _rng_streams.get(stream_id) as DeterministicRng
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
var rng_data := {}
|
|
for stream_id: StringName in _sorted_name_keys(_rng_streams):
|
|
var stream: DeterministicRng = _rng_streams[stream_id]
|
|
rng_data[String(stream_id)] = stream.snapshot()
|
|
var owned_systems := {
|
|
&"runtime": {
|
|
&"runtime_schema_version": RUNTIME_SNAPSHOT_VERSION,
|
|
&"weather": weather.create_snapshot(),
|
|
&"world_seed": world_seed,
|
|
&"next_command_id": _next_command_id,
|
|
&"staffing_projection_enabled": _staffing_projection_enabled,
|
|
&"save_lifecycle": {
|
|
&"version": SAVE_LIFECYCLE_VERSION,
|
|
&"last_daily_checkpoint_day": _last_daily_checkpoint_day,
|
|
},
|
|
&"command_queue": command_queue.create_snapshot(),
|
|
&"id_generator": id_generator.create_snapshot(),
|
|
&"domain_events": domain_events.create_snapshot(),
|
|
&"rng_streams": rng_data,
|
|
&"world": world_service.create_snapshot(),
|
|
&"path": path_service.create_snapshot(),
|
|
&"movement_credits": _serialize_movement_credits(),
|
|
&"mana": mana_network_service.create_snapshot(),
|
|
&"economy": economy_service.create_snapshot(),
|
|
&"facility_upkeep": facility_upkeep.create_snapshot(),
|
|
&"upkeep_settlement": upkeep_settlement.create_snapshot(),
|
|
&"inventory": inventory_service.create_snapshot(),
|
|
&"kitchen_production": kitchen_production.create_snapshot(),
|
|
&"auto_purchase_plans": auto_purchase_plans.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(),
|
|
&"facility_cleanliness": facility_cleanliness.create_snapshot(),
|
|
&"guests": guest_service.create_snapshot(),
|
|
&"staff": staff_service.create_snapshot(),
|
|
&"catalog_manifest": _create_mvp_content_manifest(mvp_content_catalog),
|
|
&"progression": progression_service.create_snapshot(),
|
|
&"hired_staff_candidate_ids": _serialize_hired_staff_candidate_ids(),
|
|
&"operations": _create_operations_runtime_snapshot(),
|
|
&"relationships": relationship_service.create_snapshot(),
|
|
&"event_definitions": _serialize_event_definitions(),
|
|
&"events": event_service.create_snapshot(),
|
|
&"authority": authority_service.create_snapshot(),
|
|
&"defense": defense_service.create_snapshot(),
|
|
&"construction": construction_service.create_snapshot(),
|
|
&"excavation": excavation_service.create_snapshot(),
|
|
&"spatial_subslots": spatial_subslot_service.create_snapshot(),
|
|
&"route_projection": route_subslot_path_adapter.create_snapshot(),
|
|
&"facility_infrastructure": facility_infrastructure_projector.create_snapshot(),
|
|
},
|
|
}
|
|
if PurchaseQuoteV2.has_history(owned_systems[&"runtime"]):
|
|
owned_systems[&"runtime"][&"runtime_schema_version"] = MIMI_PURCHASE_RUNTIME_SNAPSHOT_VERSION
|
|
# Every component above returns detached containers. Transfer this fresh
|
|
# tree into the envelope instead of deep-copying all component snapshots a
|
|
# second time. Keep generic capture's caller-owned-extra contract unchanged.
|
|
var snapshot := SimulationSnapshot.capture(_clock, _state)
|
|
snapshot[&"systems"] = owned_systems
|
|
return snapshot
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
return _restore_snapshot_internal(snapshot, false)
|
|
|
|
|
|
func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bool:
|
|
var active_filter_profile := event_service.get_content_filter_profile()
|
|
var staff_repair_report := {
|
|
&"automatic_repairs_applied": false,
|
|
&"reopened_task_ids": [],
|
|
&"released_staff_ids": [],
|
|
&"release_failed_task_ids": [],
|
|
}
|
|
var event_lock_repair_report := {
|
|
&"automatic_repairs_applied": false,
|
|
&"released_actor_ids": [],
|
|
&"released_foreground_instance_id": &"",
|
|
&"reason": &"NONE",
|
|
}
|
|
var validation := SimulationSnapshot.validate(snapshot)
|
|
if not validation[&"ok"]:
|
|
return false
|
|
var systems: Variant = snapshot.get(&"systems", {})
|
|
if not systems is Dictionary or not systems.has(&"runtime") or not systems[&"runtime"] is Dictionary:
|
|
return false
|
|
var runtime_data: Dictionary = systems[&"runtime"]
|
|
var restored_minute := int(snapshot[&"clock"].get(&"absolute_minute", -1))
|
|
if restored_minute < 0:
|
|
return false
|
|
var runtime_schema_version_value: Variant = runtime_data.get(
|
|
&"runtime_schema_version",
|
|
runtime_data.get("runtime_schema_version", LEGACY_RUNTIME_SNAPSHOT_VERSION)
|
|
)
|
|
if typeof(runtime_schema_version_value) != TYPE_INT:
|
|
return false
|
|
var runtime_schema_version := int(runtime_schema_version_value)
|
|
if runtime_schema_version not in [
|
|
LEGACY_RUNTIME_SNAPSHOT_VERSION,
|
|
DEFENSE_RUNTIME_SNAPSHOT_VERSION,
|
|
PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION,
|
|
CONTENT_RUNTIME_SNAPSHOT_VERSION,
|
|
OPERATIONS_RUNTIME_SNAPSHOT_VERSION,
|
|
INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION,
|
|
SUPPLY_RUNTIME_SNAPSHOT_VERSION,
|
|
TRANSPORT_RUNTIME_SNAPSHOT_VERSION,
|
|
STAIRS_RUNTIME_SNAPSHOT_VERSION,
|
|
UPKEEP_RUNTIME_SNAPSHOT_VERSION,
|
|
WEATHER_RUNTIME_SNAPSHOT_VERSION,
|
|
CLEANING_RUNTIME_SNAPSHOT_VERSION,
|
|
EVENT_THREAT_RUNTIME_SNAPSHOT_VERSION,
|
|
RUNTIME_SNAPSHOT_VERSION,
|
|
MIMI_PURCHASE_RUNTIME_SNAPSHOT_VERSION,
|
|
]:
|
|
return false
|
|
var has_purchase_v2_history := PurchaseQuoteV2.has_history(runtime_data)
|
|
if (runtime_schema_version == MIMI_PURCHASE_RUNTIME_SNAPSHOT_VERSION) != has_purchase_v2_history:
|
|
return false
|
|
if runtime_schema_version < OPERATIONS_RUNTIME_SNAPSHOT_VERSION \
|
|
and not _legacy_operations_history_is_absent(runtime_data):
|
|
return false
|
|
if runtime_schema_version < INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION \
|
|
and not _legacy_infrastructure_history_is_absent(runtime_data):
|
|
return false
|
|
if runtime_schema_version >= OPERATIONS_RUNTIME_SNAPSHOT_VERSION:
|
|
if not _runtime_v5_outer_snapshot_shape_is_valid(snapshot):
|
|
return false
|
|
var runtime_current_keys: Array[StringName] = [
|
|
&"runtime_schema_version", &"world_seed", &"next_command_id",
|
|
&"staffing_projection_enabled", &"save_lifecycle", &"command_queue",
|
|
&"id_generator", &"domain_events", &"rng_streams", &"world", &"path",
|
|
&"movement_credits", &"mana", &"economy", &"inventory",
|
|
&"logistics_dispatcher", &"logistics_requests", &"facilities", &"guests",
|
|
&"staff", &"catalog_manifest", &"progression", &"hired_staff_candidate_ids",
|
|
&"operations", &"relationships", &"event_definitions", &"events",
|
|
&"authority", &"defense", &"construction",
|
|
]
|
|
if runtime_schema_version >= INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION:
|
|
runtime_current_keys.append_array([
|
|
&"excavation", &"spatial_subslots", &"route_projection",
|
|
&"facility_infrastructure",
|
|
])
|
|
if runtime_schema_version >= SUPPLY_RUNTIME_SNAPSHOT_VERSION:
|
|
runtime_current_keys.append(&"kitchen_production")
|
|
if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION:
|
|
runtime_current_keys.append(&"elevators")
|
|
if runtime_schema_version >= STAIRS_RUNTIME_SNAPSHOT_VERSION:
|
|
runtime_current_keys.append(&"legacy_stairs")
|
|
if runtime_schema_version >= UPKEEP_RUNTIME_SNAPSHOT_VERSION:
|
|
runtime_current_keys.append_array([&"facility_upkeep", &"upkeep_settlement"])
|
|
if runtime_schema_version >= WEATHER_RUNTIME_SNAPSHOT_VERSION:
|
|
runtime_current_keys.append(&"weather")
|
|
if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION:
|
|
runtime_current_keys.append(&"facility_cleanliness")
|
|
if runtime_schema_version >= AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION:
|
|
runtime_current_keys.append(&"auto_purchase_plans")
|
|
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 \
|
|
or typeof(runtime_data.get(&"next_command_id", null)) != TYPE_INT \
|
|
or typeof(runtime_data.get(&"staffing_projection_enabled", null)) != TYPE_BOOL:
|
|
return false
|
|
var has_defense_snapshot := runtime_data.has(&"defense") or runtime_data.has("defense")
|
|
if runtime_schema_version >= DEFENSE_RUNTIME_SNAPSHOT_VERSION and not has_defense_snapshot:
|
|
return false
|
|
if runtime_schema_version >= PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION \
|
|
and not runtime_data.get(&"movement_credits", null) is Array:
|
|
return false
|
|
if runtime_schema_version >= CONTENT_RUNTIME_SNAPSHOT_VERSION \
|
|
and (not runtime_data.get(&"catalog_manifest", null) is Dictionary \
|
|
or not runtime_data.get(&"progression", null) is Dictionary \
|
|
or not runtime_data.get(&"hired_staff_candidate_ids", null) is Array):
|
|
return false
|
|
if runtime_schema_version >= OPERATIONS_RUNTIME_SNAPSHOT_VERSION \
|
|
and not runtime_data.get(&"operations", null) is Dictionary:
|
|
return false
|
|
if runtime_schema_version >= INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION:
|
|
for infrastructure_key: StringName in [
|
|
&"excavation", &"spatial_subslots", &"route_projection",
|
|
&"facility_infrastructure",
|
|
]:
|
|
if not runtime_data.get(infrastructure_key, null) is Dictionary:
|
|
return false
|
|
if runtime_schema_version < SUPPLY_RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"kitchen_production"):
|
|
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 < TRANSPORT_RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"elevators"):
|
|
return false
|
|
if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"elevators", null) is Dictionary:
|
|
return false
|
|
if runtime_schema_version < STAIRS_RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"legacy_stairs"):
|
|
return false
|
|
if runtime_schema_version >= STAIRS_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"legacy_stairs", null) is Dictionary:
|
|
return false
|
|
if runtime_schema_version < AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"auto_purchase_plans"):
|
|
return false
|
|
if runtime_schema_version >= AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"auto_purchase_plans", null) is Dictionary:
|
|
return false
|
|
var restored_catalog := MvpContentCatalog.new()
|
|
var restored_catalog_report := restored_catalog.rebuild()
|
|
if not bool(restored_catalog_report.get(&"valid", false)):
|
|
return false
|
|
if runtime_schema_version >= CONTENT_RUNTIME_SNAPSHOT_VERSION \
|
|
and not _saved_content_manifest_matches(restored_catalog, runtime_data):
|
|
return false
|
|
var restored_last_daily_checkpoint_day := -1
|
|
var save_lifecycle_value: Variant = runtime_data.get(&"save_lifecycle", {})
|
|
if not save_lifecycle_value is Dictionary:
|
|
return false
|
|
var save_lifecycle: Dictionary = save_lifecycle_value
|
|
if not save_lifecycle.is_empty():
|
|
if (runtime_schema_version >= OPERATIONS_RUNTIME_SNAPSHOT_VERSION \
|
|
and (not _dictionary_has_exact_canonical_keys(
|
|
save_lifecycle, [&"version", &"last_daily_checkpoint_day"]
|
|
) \
|
|
or typeof(save_lifecycle.get(&"version", null)) != TYPE_INT \
|
|
or typeof(save_lifecycle.get(&"last_daily_checkpoint_day", null)) != TYPE_INT)) \
|
|
or int(save_lifecycle.get(&"version", -1)) != SAVE_LIFECYCLE_VERSION:
|
|
return false
|
|
restored_last_daily_checkpoint_day = int(
|
|
save_lifecycle.get(&"last_daily_checkpoint_day", -1)
|
|
)
|
|
if restored_last_daily_checkpoint_day < -1:
|
|
return false
|
|
if not runtime_data.get(&"command_queue", null) is Dictionary \
|
|
or not runtime_data.get(&"id_generator", null) is Dictionary \
|
|
or not runtime_data.get(&"domain_events", null) is Dictionary \
|
|
or not runtime_data.get(&"rng_streams", null) is Dictionary \
|
|
or not runtime_data.get(&"world", null) is Dictionary \
|
|
or not runtime_data.get(&"path", null) is Dictionary \
|
|
or not runtime_data.get(&"mana", null) is Dictionary \
|
|
or not runtime_data.get(&"economy", null) is Dictionary \
|
|
or not runtime_data.get(&"inventory", null) is Dictionary \
|
|
or not runtime_data.get(&"logistics_dispatcher", null) is Dictionary \
|
|
or not runtime_data.get(&"logistics_requests", null) is Array \
|
|
or not runtime_data.get(&"facilities", null) is Dictionary \
|
|
or not runtime_data.get(&"guests", null) is Dictionary \
|
|
or not runtime_data.get(&"staff", null) is Dictionary \
|
|
or not runtime_data.get(&"relationships", null) is Dictionary \
|
|
or not runtime_data.get(&"event_definitions", null) is Array \
|
|
or not runtime_data.get(&"events", null) is Dictionary \
|
|
or not runtime_data.get(&"authority", null) is Dictionary \
|
|
or (has_defense_snapshot and not runtime_data.get(&"defense", null) is Dictionary) \
|
|
or not runtime_data.get(&"construction", null) is Dictionary:
|
|
return false
|
|
var restored_queue := CommandQueue.new()
|
|
if not restored_queue.restore_snapshot(runtime_data[&"command_queue"]):
|
|
return false
|
|
if runtime_schema_version < AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION:
|
|
# Old saves must not reinterpret an unsupported queued action as a new
|
|
# paid feature when loading into this release. Failed historical actions
|
|
# remain ordinary recorded history; only resumable pending work is gated.
|
|
for row: Dictionary in restored_queue.create_snapshot()["pending"]:
|
|
if row["command_type"] == "set_supply_auto_purchase":
|
|
return false
|
|
var restored_id_generator := StableIdGenerator.new()
|
|
if not restored_id_generator.restore_snapshot(runtime_data[&"id_generator"]):
|
|
return false
|
|
var restored_domain_events := DomainEventStream.new()
|
|
if not restored_domain_events.restore_snapshot(runtime_data[&"domain_events"]):
|
|
return false
|
|
if runtime_schema_version >= OPERATIONS_RUNTIME_SNAPSHOT_VERSION \
|
|
and not _runtime_command_history_crosslinks_are_valid(
|
|
runtime_data[&"command_queue"],
|
|
int(runtime_data[&"next_command_id"]),
|
|
restored_domain_events
|
|
):
|
|
return false
|
|
var restored_world := WorldService.new()
|
|
if not restored_world.restore_snapshot(runtime_data[&"world"]):
|
|
return false
|
|
var restored_path := PathService.new()
|
|
var restored_spatial := SpatialSubslotService.new()
|
|
var restored_route_adapter := RouteSubslotPathAdapter.new()
|
|
var restored_infrastructure_projector := FacilityInfrastructureProjector.new()
|
|
if runtime_schema_version >= INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION:
|
|
# Configure against an empty Spatial source before any managed namespace is
|
|
# present. Then restore the source, rebuild the base+ROUTE topology, and
|
|
# finally restore Path traffic/actors and the two metadata owners.
|
|
if not restored_path.rebuild_graph(restored_world.get_walkable_cells()) \
|
|
or not restored_route_adapter.configure(
|
|
restored_spatial, restored_path, restored_world.get_walkable_cells()
|
|
) \
|
|
or not restored_infrastructure_projector.configure(
|
|
restored_route_adapter, restored_spatial, restored_path
|
|
) \
|
|
or not restored_spatial.restore_snapshot(runtime_data[&"spatial_subslots"]):
|
|
return false
|
|
var prepared_projection := restored_route_adapter.synchronize_projection(
|
|
&"runtime.v6.restore.prepare",
|
|
restored_world.get_walkable_cells()
|
|
)
|
|
if not prepared_projection.is_success() \
|
|
or not _path_snapshot_cells_match_world(
|
|
runtime_data[&"path"],
|
|
restored_route_adapter.get_projected_walkable_cells()
|
|
) \
|
|
or not restored_path.restore_snapshot(runtime_data[&"path"]) \
|
|
or not restored_route_adapter.restore_snapshot(runtime_data[&"route_projection"]) \
|
|
or not restored_infrastructure_projector.restore_snapshot(
|
|
runtime_data[&"facility_infrastructure"]
|
|
):
|
|
return false
|
|
else:
|
|
if runtime_schema_version >= PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION \
|
|
and not _path_snapshot_cells_match_world(
|
|
runtime_data[&"path"],
|
|
restored_world.get_walkable_cells()
|
|
):
|
|
return false
|
|
if not restored_path.restore_snapshot(runtime_data[&"path"]) \
|
|
or not restored_path.rebuild_graph(restored_world.get_walkable_cells()) \
|
|
or not restored_route_adapter.configure(
|
|
restored_spatial, restored_path, restored_world.get_walkable_cells()
|
|
) \
|
|
or not restored_infrastructure_projector.configure(
|
|
restored_route_adapter, restored_spatial, restored_path
|
|
):
|
|
return false
|
|
if runtime_schema_version < STAIRS_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 >= STAIRS_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
|
|
var restored_economy := EconomyService.new()
|
|
if not restored_economy.restore_snapshot(runtime_data[&"economy"]):
|
|
return false
|
|
if not _loan_principal_ledger_is_valid(restored_economy):
|
|
return false
|
|
if runtime_schema_version < OPERATIONS_RUNTIME_SNAPSHOT_VERSION:
|
|
if not restored_economy.has_account(WAGE_EXPENSE_ACCOUNT) \
|
|
and not restored_economy.register_account(WAGE_EXPENSE_ACCOUNT):
|
|
return false
|
|
elif not restored_economy.has_account(WAGE_EXPENSE_ACCOUNT):
|
|
return false
|
|
var restored_upkeep := FacilityUpkeepAccrual.new()
|
|
var restored_upkeep_settlement := FacilityUpkeepSettlement.new()
|
|
if runtime_schema_version >= UPKEEP_RUNTIME_SNAPSHOT_VERSION:
|
|
if not runtime_data.get(&"facility_upkeep") is Dictionary or not runtime_data.get(&"upkeep_settlement") is Dictionary:
|
|
return false
|
|
if not restored_upkeep.restore_snapshot(runtime_data[&"facility_upkeep"]) \
|
|
or restored_upkeep.get_current_minute() != restored_minute \
|
|
or not restored_upkeep_settlement.restore_snapshot(runtime_data[&"upkeep_settlement"], restored_economy):
|
|
return false
|
|
if not _upkeep_accrual_crosslinks_are_valid(restored_upkeep, restored_upkeep_settlement):
|
|
return false
|
|
else:
|
|
if runtime_data.has(&"facility_upkeep") or runtime_data.has(&"upkeep_settlement") \
|
|
or restored_economy.has_account(FacilityUpkeepSettlement.EXPENSE) \
|
|
or restored_economy.has_account(FacilityUpkeepSettlement.PAYABLE):
|
|
return false
|
|
if not FacilityUpkeepSettlement.prepare_accounts(restored_economy) or not restored_upkeep.start_at(restored_minute)[&"ok"]:
|
|
return false
|
|
var saved_resources: Variant = snapshot[&"game_state"].get(&"resources", null)
|
|
if not saved_resources is Dictionary \
|
|
or int(saved_resources.get(&"gold", saved_resources.get("gold", -1))) \
|
|
!= restored_economy.get_balance(PLAYER_CASH_ACCOUNT):
|
|
return false
|
|
var restored_inventory := InventoryService.new()
|
|
if not restored_inventory.restore_snapshot(runtime_data[&"inventory"]):
|
|
return false
|
|
var restored_dispatcher := LogisticsDispatcher.new()
|
|
if not restored_dispatcher.restore_snapshot(runtime_data[&"logistics_dispatcher"]):
|
|
return false
|
|
var restored_logistics: Variant = _deserialize_logistics_requests(runtime_data[&"logistics_requests"])
|
|
if restored_logistics == null:
|
|
return false
|
|
if restored_dispatcher.restored_snapshot_version == LogisticsDispatcher.LEGACY_SNAPSHOT_VERSION:
|
|
if runtime_schema_version >= PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION \
|
|
or not restored_dispatcher.migrate_legacy_assignment_ownership(
|
|
_derive_legacy_logistics_ownership(
|
|
restored_dispatcher,
|
|
restored_logistics,
|
|
restored_minute
|
|
)
|
|
):
|
|
return false
|
|
if not _validate_logistics_dispatcher_crosslinks(
|
|
restored_dispatcher,
|
|
restored_logistics,
|
|
restored_minute
|
|
):
|
|
return false
|
|
var restored_facilities := FacilityService.new()
|
|
if not restored_facilities.restore_snapshot(runtime_data[&"facilities"]):
|
|
return false
|
|
var restored_guests := GuestService.new()
|
|
if not restored_guests.restore_snapshot(runtime_data[&"guests"]):
|
|
return false
|
|
if runtime_schema_version >= CONTENT_RUNTIME_SNAPSHOT_VERSION:
|
|
if not _saved_catalog_guest_definitions_match(
|
|
runtime_data[&"guests"],
|
|
restored_catalog
|
|
):
|
|
return false
|
|
elif not _register_or_validate_legacy_catalog_definitions(
|
|
restored_guests,
|
|
restored_catalog
|
|
):
|
|
return false
|
|
var restored_staff := StaffService.new()
|
|
if recovery_mode:
|
|
restored_staff.configure_callbacks(
|
|
Callable(),
|
|
func(
|
|
reservation_ids: Array[StringName],
|
|
task: StaffTask,
|
|
absolute_minute: int
|
|
) -> Dictionary:
|
|
return _release_recovery_task_items(
|
|
restored_inventory,
|
|
reservation_ids,
|
|
task,
|
|
absolute_minute
|
|
),
|
|
Callable()
|
|
)
|
|
var restored_staff_ok := (
|
|
restored_staff.restore_snapshot_with_orphan_recovery(runtime_data[&"staff"])
|
|
if recovery_mode
|
|
else restored_staff.restore_snapshot(runtime_data[&"staff"])
|
|
)
|
|
if not restored_staff_ok:
|
|
return false
|
|
if recovery_mode:
|
|
staff_repair_report = restored_staff.last_recovery_repair_report.duplicate(true)
|
|
if not staff_repair_report[&"release_failed_task_ids"].is_empty():
|
|
return false
|
|
var saved_rank_value: Variant = snapshot[&"game_state"].get(&"world_tree_rank", null)
|
|
if not saved_rank_value is int:
|
|
return false
|
|
var saved_rank := int(saved_rank_value)
|
|
var restored_progression := ProgressionService.new()
|
|
if runtime_schema_version >= CONTENT_RUNTIME_SNAPSHOT_VERSION:
|
|
if not restored_progression.restore_snapshot(runtime_data[&"progression"]):
|
|
return false
|
|
else:
|
|
# Pre-v4 saves never recorded promotion evidence. Rank 1 can be migrated
|
|
# from authoritative live facts; a later rank would require guessing which
|
|
# requirements and quests were actually satisfied, so it is rejected.
|
|
if saved_rank != ProgressionService.MIN_RANK:
|
|
return false
|
|
var legacy_guest_lifetime := restored_guests.guest_count() \
|
|
+ int((runtime_data[&"guests"] as Dictionary)[&"persistent_records"].size())
|
|
if not restored_progression.configure(ProgressionService.MIN_RANK, {
|
|
&"active_above_ground_facilities": _count_active_facilities(restored_facilities, false),
|
|
&"active_underground_facilities": _count_active_facilities(restored_facilities, true),
|
|
&"asset_gold": restored_economy.get_balance(PLAYER_CASH_ACCOUNT),
|
|
&"faith_lifetime": maxi(int(saved_resources.get(&"faith", saved_resources.get("faith", 0))), 0),
|
|
&"guest_lifetime": legacy_guest_lifetime,
|
|
}):
|
|
return false
|
|
if saved_rank != restored_progression.current_rank \
|
|
or not _strict_variants_equal(
|
|
restored_progression.get_rank_bounds(saved_rank),
|
|
restored_world.get_bounds()
|
|
) \
|
|
or not _progression_guest_lifetime_matches(
|
|
restored_progression, restored_guests
|
|
):
|
|
return false
|
|
var restored_hired_staff_candidates: Variant
|
|
if runtime_schema_version >= CONTENT_RUNTIME_SNAPSHOT_VERSION:
|
|
restored_hired_staff_candidates = _deserialize_hired_staff_candidate_ids(
|
|
runtime_data[&"hired_staff_candidate_ids"],
|
|
restored_catalog
|
|
)
|
|
if restored_hired_staff_candidates == null:
|
|
return false
|
|
else:
|
|
restored_hired_staff_candidates = _derive_legacy_hired_staff_candidate_ids(
|
|
restored_staff,
|
|
runtime_data[&"staff"],
|
|
restored_catalog
|
|
)
|
|
if not _validate_catalog_staff_crosslinks(
|
|
restored_staff,
|
|
restored_hired_staff_candidates,
|
|
restored_catalog
|
|
):
|
|
return false
|
|
var restored_operations_state := _parse_operations_runtime_snapshot(
|
|
runtime_data.get(&"operations", {}),
|
|
runtime_schema_version,
|
|
restored_minute,
|
|
restored_guests,
|
|
restored_staff,
|
|
restored_economy,
|
|
int(runtime_data.get(&"world_seed", DEFAULT_WORLD_SEED)),
|
|
snapshot[&"clock"].get(&"paused", false),
|
|
restored_upkeep_settlement
|
|
)
|
|
if not bool(restored_operations_state.get(&"ok", false)):
|
|
return false
|
|
if bool(restored_operations_state.get(&"enabled", false)):
|
|
var restored_coordinator: OperationsCoordinator = restored_operations_state[&"coordinator"]
|
|
var expected_minimum := 0
|
|
var expected_maximum := 0
|
|
for period: Dictionary in restored_upkeep.get_report()[&"closed_periods"]:
|
|
if int(period[&"period_end"]) <= restored_coordinator.current_minute:
|
|
expected_minimum += 1
|
|
if int(period[&"period_end"]) <= restored_minute:
|
|
expected_maximum += 1
|
|
var actual_count := restored_upkeep_settlement.get_settled_period_count()
|
|
if actual_count < expected_minimum or actual_count > expected_maximum:
|
|
return false
|
|
var restored_movement_credits: Variant
|
|
if runtime_schema_version >= PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION:
|
|
restored_movement_credits = _deserialize_movement_credits(
|
|
runtime_data[&"movement_credits"],
|
|
restored_minute
|
|
)
|
|
if restored_movement_credits == null:
|
|
return false
|
|
else:
|
|
restored_movement_credits = _rebuild_legacy_path_actor_projection(
|
|
restored_path,
|
|
restored_guests,
|
|
restored_staff,
|
|
restored_minute
|
|
)
|
|
if restored_movement_credits == null:
|
|
return false
|
|
if not _validate_runtime_path_crosslinks(
|
|
restored_path,
|
|
restored_guests,
|
|
restored_staff,
|
|
restored_movement_credits,
|
|
restored_minute
|
|
):
|
|
return false
|
|
var restored_relationships := RelationshipService.new()
|
|
restored_relationships.configure_original_identity_validator(
|
|
func(payload: Dictionary) -> bool: return _original_relationship_identities_valid(payload, restored_staff, restored_catalog)
|
|
)
|
|
if not restored_relationships.restore_snapshot(runtime_data[&"relationships"]):
|
|
return false
|
|
var restored_events := EventService.new()
|
|
for raw_definition: Variant in runtime_data[&"event_definitions"]:
|
|
var definition := EventDefinition.from_variant(raw_definition)
|
|
if definition == null or not restored_events.register_definition(definition).is_success():
|
|
return false
|
|
if not bool(restored_events.validate_definitions().get(&"ok", false)):
|
|
return false
|
|
var restored_events_ok := (
|
|
restored_events.restore_snapshot_with_lock_recovery(runtime_data[&"events"])
|
|
if recovery_mode
|
|
else restored_events.restore_snapshot(runtime_data[&"events"])
|
|
)
|
|
if not restored_events_ok:
|
|
return false
|
|
if recovery_mode:
|
|
event_lock_repair_report = restored_events.last_recovery_repair_report.duplicate(true)
|
|
var saved_filter_profile := restored_events.get_content_filter_profile()
|
|
var effective_filter_profile := _merge_content_filter_profiles(
|
|
active_filter_profile,
|
|
saved_filter_profile
|
|
)
|
|
restored_events.configure_content_filter(
|
|
effective_filter_profile[&"filter_mode"],
|
|
effective_filter_profile[&"maximum_content_rating"],
|
|
effective_filter_profile[&"disabled_content_tags"]
|
|
)
|
|
var restored_authority := AuthorityService.new()
|
|
if not restored_authority.restore_snapshot(runtime_data[&"authority"]):
|
|
return false
|
|
var restored_defense := DefenseService.new()
|
|
if has_defense_snapshot:
|
|
if not restored_defense.restore_snapshot(runtime_data[&"defense"]):
|
|
return false
|
|
else:
|
|
# Runtime snapshots predating the Defense aggregate migrate to an empty
|
|
# aggregate at the saved clock. No external entity or consequence is
|
|
# synthesized by this compatibility path.
|
|
if not restored_defense.advance_time(restored_minute).is_success():
|
|
return false
|
|
var restored_staffing_projection := bool(
|
|
runtime_data.get(&"staffing_projection_enabled", false)
|
|
)
|
|
var restored_runtime_domain_minute := restored_minute
|
|
if bool(restored_operations_state.get(&"enabled", false)):
|
|
var restored_operations_coordinator: OperationsCoordinator = (
|
|
restored_operations_state.get(&"coordinator", null)
|
|
)
|
|
if int(restored_operations_state.get(&"deferred_runtime_minute", -1)) \
|
|
== restored_minute \
|
|
or (restored_operations_coordinator != null \
|
|
and restored_operations_coordinator.is_terminal()):
|
|
restored_runtime_domain_minute = restored_minute - 1
|
|
if not _validate_runtime_crosslinks(
|
|
restored_facilities,
|
|
restored_guests,
|
|
restored_economy,
|
|
restored_inventory,
|
|
restored_staff,
|
|
restored_runtime_domain_minute,
|
|
restored_staffing_projection
|
|
):
|
|
return false
|
|
if restored_authority.current_minute < restored_runtime_domain_minute \
|
|
or restored_authority.current_minute > restored_minute \
|
|
or restored_defense.current_minute < restored_runtime_domain_minute \
|
|
or restored_defense.current_minute > restored_minute:
|
|
return false
|
|
if not _validate_authority_economy_crosslinks(
|
|
restored_authority,
|
|
restored_economy,
|
|
restored_authority.current_minute
|
|
):
|
|
return false
|
|
if not _validate_defense_crosslinks(
|
|
restored_defense,
|
|
restored_staff,
|
|
restored_facilities,
|
|
restored_defense.current_minute
|
|
):
|
|
return false
|
|
if not _validate_event_defense_crosslinks(restored_events, restored_defense):
|
|
return false
|
|
var restored_construction_resource := _create_resource_adapter(
|
|
restored_economy,
|
|
CONSTRUCTION_EXPENSE_ACCOUNT,
|
|
&"CONSTRUCTION",
|
|
&"CONSTRUCTION_REFUND",
|
|
&"construction_service"
|
|
)
|
|
var restored_construction := ConstructionService.new(restored_world, restored_construction_resource)
|
|
if not restored_construction.restore_snapshot(runtime_data[&"construction"]):
|
|
return false
|
|
var restored_excavation := ExcavationService.new(
|
|
restored_world, restored_construction_resource
|
|
)
|
|
if runtime_schema_version >= INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION:
|
|
if not restored_excavation.restore_snapshot(runtime_data[&"excavation"]):
|
|
return false
|
|
else:
|
|
restored_excavation.current_minute = restored_construction.current_minute
|
|
if not _migrate_legacy_infrastructure_proxies(
|
|
restored_construction,
|
|
restored_facilities,
|
|
restored_infrastructure_projector
|
|
) or not restored_progression.update_metrics({
|
|
&"active_above_ground_facilities": _count_active_facilities(
|
|
restored_facilities, false
|
|
),
|
|
&"active_underground_facilities": _count_active_facilities(
|
|
restored_facilities, true
|
|
),
|
|
&"asset_gold": restored_economy.get_balance(PLAYER_CASH_ACCOUNT),
|
|
}) or not _validate_runtime_crosslinks(
|
|
restored_facilities,
|
|
restored_guests,
|
|
restored_economy,
|
|
restored_inventory,
|
|
restored_staff,
|
|
restored_runtime_domain_minute,
|
|
restored_staffing_projection
|
|
):
|
|
return false
|
|
if not _validate_construction_facility_crosslinks(
|
|
restored_world,
|
|
restored_construction,
|
|
restored_facilities,
|
|
restored_infrastructure_projector
|
|
) or not _validate_infrastructure_crosslinks(
|
|
restored_world,
|
|
restored_path,
|
|
restored_construction,
|
|
restored_construction_resource,
|
|
restored_excavation,
|
|
restored_spatial,
|
|
restored_route_adapter,
|
|
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) and not CleaningBridge.is_deployed(worker, facility, restored_staff):
|
|
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
|
|
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 >= TRANSPORT_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() and not CleaningBridge.is_deployed(restored_staff.get_staff(staff_id), facility, restored_staff):
|
|
return false
|
|
var restored_kitchen := KitchenProductionService.new()
|
|
restored_kitchen.configure(restored_inventory, restored_facilities, definition_registry, restored_runtime_domain_minute)
|
|
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():
|
|
return false
|
|
if runtime_schema_version == MIMI_PURCHASE_RUNTIME_SNAPSHOT_VERSION \
|
|
and not PurchaseQuoteV2.valid_history(runtime_data, restored_economy):
|
|
return false
|
|
var restored_auto_purchase := AutoPurchasePlansType.new()
|
|
if runtime_schema_version >= AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION:
|
|
if not restored_auto_purchase.restore_snapshot(runtime_data[&"auto_purchase_plans"], restored_facilities, restored_economy, restored_runtime_domain_minute):
|
|
return false
|
|
if not SupplyLogisticsBridge.validate_crosslinks(restored_logistics, restored_staff, restored_inventory, restored_facilities):
|
|
return false
|
|
var restored_weather := WeatherTimeline.new()
|
|
if runtime_schema_version >= WEATHER_RUNTIME_SNAPSHOT_VERSION:
|
|
if not runtime_data.get(&"weather") is Dictionary or not restored_weather.restore_snapshot(runtime_data[&"weather"]) or restored_weather.get_minute() != restored_minute:
|
|
return false
|
|
elif runtime_data.has(&"weather") or not restored_weather.initialize(&"clear", restored_minute):
|
|
return false
|
|
var restored_cleanliness := FacilityCleanliness.new()
|
|
if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION:
|
|
if not runtime_data.get(&"facility_cleanliness") is Dictionary or not restored_cleanliness.restore_snapshot(runtime_data[&"facility_cleanliness"]):
|
|
return false
|
|
else:
|
|
if runtime_data.has(&"facility_cleanliness"):
|
|
return false
|
|
var policy_values := cleaning_policy.create_snapshot()
|
|
for facility: FacilityInstance in restored_facilities.get_instances():
|
|
if not restored_cleanliness.register_facility(facility.instance_id, policy_values["initial_cleanliness"], policy_values["maximum_cleanliness"]):
|
|
return false
|
|
if not _validate_cleanliness_crosslinks(restored_cleanliness, restored_facilities, restored_staff, restored_path):
|
|
return false
|
|
var restored_world_seed := int(runtime_data.get(&"world_seed", DEFAULT_WORLD_SEED))
|
|
var restored_streams := {}
|
|
var saved_streams: Dictionary = runtime_data[&"rng_streams"]
|
|
for stream_key: Variant in saved_streams:
|
|
if not saved_streams[stream_key] is Dictionary:
|
|
return false
|
|
var normalized_stream_id := StringName(stream_key)
|
|
if normalized_stream_id.is_empty() or restored_streams.has(normalized_stream_id):
|
|
return false
|
|
var stream := DeterministicRng.from_root_seed(0, normalized_stream_id) as DeterministicRng
|
|
if stream == null or not stream.restore(saved_streams[stream_key]) \
|
|
or stream.get_stream_id() != normalized_stream_id \
|
|
or stream.get_root_seed() != restored_world_seed:
|
|
return false
|
|
restored_streams[normalized_stream_id] = stream
|
|
if not restored_streams.has(&"defense"):
|
|
if has_defense_snapshot:
|
|
return false
|
|
restored_streams[&"defense"] = DeterministicRng.from_root_seed(restored_world_seed, &"defense")
|
|
if runtime_schema_version >= DEFENSE_RUNTIME_SNAPSHOT_VERSION:
|
|
for required_stream_id: StringName in RNG_STREAM_IDS:
|
|
if not restored_streams.has(required_stream_id):
|
|
return false
|
|
var restored_next_id := int(runtime_data.get(&"next_command_id", 0))
|
|
if restored_next_id <= 0:
|
|
return false
|
|
if not SimulationSnapshot.restore(snapshot, _clock, _state):
|
|
return false
|
|
world_seed = restored_world_seed
|
|
_next_command_id = restored_next_id
|
|
_staffing_projection_enabled = restored_staffing_projection
|
|
command_queue = restored_queue
|
|
id_generator = restored_id_generator
|
|
domain_events = restored_domain_events
|
|
_rng_streams = restored_streams
|
|
world_service = restored_world
|
|
path_service = restored_path
|
|
_movement_credits = (restored_movement_credits as Dictionary).duplicate(true)
|
|
_connect_path_performance_signal()
|
|
mana_network_service = restored_mana
|
|
weather = restored_weather
|
|
facility_cleanliness = restored_cleanliness
|
|
facility_upkeep = restored_upkeep
|
|
upkeep_settlement = restored_upkeep_settlement
|
|
economy_service = restored_economy
|
|
inventory_service = restored_inventory
|
|
kitchen_production = restored_kitchen
|
|
auto_purchase_plans = restored_auto_purchase
|
|
logistics_dispatcher = restored_dispatcher
|
|
logistics_requests = restored_logistics
|
|
facility_service = restored_facilities
|
|
guest_service = restored_guests
|
|
staff_service = restored_staff
|
|
elevators = restored_elevators
|
|
legacy_stairs = restored_legacy_stairs
|
|
staff_commute.configure(self)
|
|
supply_logistics.configure(self)
|
|
cleaning_bridge.configure(self)
|
|
mvp_content_catalog = restored_catalog
|
|
_clear_mvp_content_manifest_cache()
|
|
mvp_content_report = restored_catalog_report.duplicate(true)
|
|
progression_service = restored_progression
|
|
_hired_staff_candidate_ids = (restored_hired_staff_candidates as Dictionary).duplicate(true)
|
|
operations_coordinator = restored_operations_state.get(&"coordinator", null)
|
|
_operations_enabled = bool(restored_operations_state[&"enabled"])
|
|
_personal_action_points = int(restored_operations_state[&"personal_action_points"])
|
|
_operations_runtime_failure_latched = bool(restored_operations_state[&"runtime_failure_latched"])
|
|
_operations_runtime_failure_report = (
|
|
restored_operations_state[&"runtime_failure_report"] as Dictionary
|
|
).duplicate(true)
|
|
_operations_tick_blocked = _operations_should_block_tick()
|
|
_operations_deferred_runtime_minute = int(
|
|
restored_operations_state[&"deferred_runtime_minute"]
|
|
)
|
|
_operations_resume_publish_pending = false
|
|
_operations_test_boundary_hooks.clear()
|
|
_operations_transaction_active = false
|
|
_operations_queued_domain_events.clear()
|
|
_operations_queued_guest_spawn_notifications.clear()
|
|
_operations_flushing_guest_spawn_notifications.clear()
|
|
_operations_queued_economy_notifications.clear()
|
|
_operations_flushing_economy_notifications.clear()
|
|
relationship_service = restored_relationships
|
|
event_service = restored_events
|
|
authority_service = restored_authority
|
|
_detach_defense_service(defense_service)
|
|
defense_service = restored_defense
|
|
_reset_defense_round_attempts()
|
|
construction_resource_state = restored_construction_resource
|
|
operating_resource_state = _create_resource_adapter(
|
|
economy_service,
|
|
OPERATING_EXPENSE_ACCOUNT,
|
|
&"OPERATING_EXPENSE",
|
|
&"OPERATING_REFUND",
|
|
&"simulation_runtime"
|
|
)
|
|
construction_service = restored_construction
|
|
excavation_service = restored_excavation
|
|
spatial_subslot_service = restored_spatial
|
|
route_subslot_path_adapter = restored_route_adapter
|
|
facility_infrastructure_projector = restored_infrastructure_projector
|
|
_infrastructure_failure_latched = false
|
|
_infrastructure_failure_report.clear()
|
|
_construction_completion_transaction_active = false
|
|
_construction_committing_notifications = false
|
|
_construction_queued_domain_events.clear()
|
|
_last_daily_checkpoint_day = restored_last_daily_checkpoint_day
|
|
_pending_checkpoints.clear()
|
|
_next_checkpoint_request_id = 1
|
|
_connect_economy_signals()
|
|
_configure_facility_boundaries()
|
|
_connect_facility_signals()
|
|
_configure_staff_boundaries()
|
|
_connect_guest_signals()
|
|
_connect_staff_signals()
|
|
_configure_relationship_event_boundaries()
|
|
_connect_relationship_signals()
|
|
_connect_event_signals()
|
|
_configure_authority_boundaries()
|
|
_connect_authority_signals()
|
|
_configure_defense_boundaries()
|
|
_connect_defense_signals()
|
|
_connect_construction_signals()
|
|
var excavation_callback := Callable(self, "_on_excavation_completed")
|
|
if not excavation_service.excavation_completed.is_connected(excavation_callback):
|
|
excavation_service.excavation_completed.connect(excavation_callback)
|
|
if _operations_enabled:
|
|
_configure_operations_callbacks()
|
|
_register_operations_tick_handler()
|
|
else:
|
|
_clock.unregister_tick_handler(
|
|
_clock.TickPhase.TIME_BOUNDARIES,
|
|
&"simulation_runtime.operations"
|
|
)
|
|
latest_restore_safety_report = {
|
|
&"report_version": 1,
|
|
&"recovery_mode": bool(recovery_mode),
|
|
&"repair_policy": &"WHITELISTED_AUTHORITATIVE_ONLY",
|
|
&"automatic_repairs_applied": (
|
|
bool(staff_repair_report[&"automatic_repairs_applied"])
|
|
or bool(event_lock_repair_report[&"automatic_repairs_applied"])
|
|
),
|
|
&"repairs": {
|
|
&"orphan_tasks": staff_repair_report,
|
|
&"orphan_event_locks": event_lock_repair_report,
|
|
},
|
|
&"content_filter": _content_filter_restore_report(
|
|
active_filter_profile,
|
|
saved_filter_profile,
|
|
effective_filter_profile
|
|
),
|
|
&"forbidden_repairs_applied": false,
|
|
&"forbidden_repair_categories": [
|
|
&"synthesize_gold",
|
|
&"delete_facilities",
|
|
&"rewrite_ledger",
|
|
&"guess_route_locks",
|
|
],
|
|
}
|
|
_publish_snapshot(_clock.absolute_minute)
|
|
return true
|
|
|
|
func configure_save_policy(overrides: Dictionary) -> Dictionary:
|
|
var configured := _save_policy.duplicate(true)
|
|
if overrides.has(&"enabled"):
|
|
configured[&"enabled"] = bool(overrides[&"enabled"])
|
|
if overrides.has(&"daily_checkpoint_enabled"):
|
|
configured[&"daily_checkpoint_enabled"] = bool(overrides[&"daily_checkpoint_enabled"])
|
|
if overrides.has(&"auto_migrate"):
|
|
configured[&"auto_migrate"] = bool(overrides[&"auto_migrate"])
|
|
if overrides.has(&"autosave_path"):
|
|
var autosave_path := str(overrides[&"autosave_path"])
|
|
if not _save_policy_path_is_safe(autosave_path):
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"INVALID_AUTOSAVE_PATH",
|
|
&"message": autosave_path,
|
|
}
|
|
configured[&"autosave_path"] = autosave_path
|
|
if overrides.has(&"autosave_generations"):
|
|
var generations := int(overrides[&"autosave_generations"])
|
|
if generations < 1 or generations > SaveService.MAX_AUTOSAVE_GENERATIONS:
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"INVALID_AUTOSAVE_GENERATIONS",
|
|
&"message": str(generations),
|
|
}
|
|
configured[&"autosave_generations"] = generations
|
|
_save_policy = configured
|
|
return {
|
|
&"ok": true,
|
|
&"code": &"OK",
|
|
&"policy": get_save_policy(),
|
|
}
|
|
|
|
|
|
func enable_production_save_policy() -> Dictionary:
|
|
return configure_save_policy({
|
|
&"enabled": true,
|
|
&"daily_checkpoint_enabled": true,
|
|
&"autosave_path": DEFAULT_AUTOSAVE_PATH,
|
|
})
|
|
|
|
|
|
func get_save_policy() -> Dictionary:
|
|
return _save_policy.duplicate(true)
|
|
|
|
|
|
func get_last_daily_checkpoint_day() -> int:
|
|
return _last_daily_checkpoint_day
|
|
|
|
|
|
func get_pending_checkpoint_count() -> int:
|
|
return _pending_checkpoints.size()
|
|
|
|
|
|
func get_latest_restore_safety_report() -> Dictionary:
|
|
return latest_restore_safety_report.duplicate(true)
|
|
|
|
|
|
func enable_production_operations() -> Dictionary:
|
|
if not _initialized or _clock == null or _state == null:
|
|
return _operations_api_failure(&"RUNTIME_NOT_INITIALIZED", "Runtime is not initialized.")
|
|
if _operations_enabled:
|
|
return _operations_api_failure(&"ALREADY_ENABLED", "Production operations are already enabled.")
|
|
if _clock.absolute_minute != OPERATIONS_START_MINUTE \
|
|
or _clock.day != 1 or _clock.minute_of_day != OPERATIONS_START_MINUTE:
|
|
return _operations_api_failure(
|
|
&"INVALID_START_TIME",
|
|
"Production operations must start at Day 1 06:00."
|
|
)
|
|
if not bool(mvp_content_report.get(&"valid", false)):
|
|
return _operations_api_failure(&"CONTENT_INVALID", "MVP content is unavailable.")
|
|
if _staff_payroll_arrears_total(staff_service) != 0:
|
|
return _operations_api_failure(
|
|
&"INITIAL_ARREARS_UNSUPPORTED",
|
|
"Production operations cannot infer pre-existing payroll arrears."
|
|
)
|
|
if not (staff_service.create_snapshot().get(&"payroll_settlements", []) as Array).is_empty():
|
|
return _operations_api_failure(
|
|
&"INITIAL_PAYROLL_HISTORY_UNSUPPORTED",
|
|
"Production operations require a fresh Day 1 payroll history."
|
|
)
|
|
if _guest_aggregate_has_reserved_operations_id(guest_service):
|
|
return _operations_api_failure(
|
|
&"RESERVED_GUEST_ID_PRESENT",
|
|
"Production operations require the guest-auto namespace to be unused."
|
|
)
|
|
var coordinator := OperationsCoordinator.new({
|
|
&"start_minute": OPERATIONS_COORDINATOR_ORIGIN_MINUTE,
|
|
&"world_seed": world_seed,
|
|
})
|
|
coordinator.configure_callbacks(
|
|
Callable(self, "_operations_daily_boundary"),
|
|
Callable(self, "_operations_arrival_boundary"),
|
|
Callable(self, "_operations_morning_boundary"),
|
|
Callable(self, "_operations_midnight_upkeep_boundary")
|
|
)
|
|
var demand_configuration := _configure_default_operations_demand(coordinator)
|
|
if not demand_configuration.is_success():
|
|
return _operations_api_failure(
|
|
&"DEMAND_CONFIGURATION_FAILED",
|
|
demand_configuration.message,
|
|
demand_configuration.details
|
|
)
|
|
var population := coordinator.set_population_count(guest_service.guest_count())
|
|
if not population.is_success():
|
|
return _operations_api_failure(
|
|
&"INITIAL_POPULATION_INVALID",
|
|
population.message,
|
|
population.details
|
|
)
|
|
var previous_coordinator := operations_coordinator
|
|
var previous_ap := _personal_action_points
|
|
var enable_external_before := _capture_operations_external_state()
|
|
if enable_external_before.is_empty():
|
|
return _operations_api_failure(
|
|
&"INITIAL_CAPTURE_FAILED",
|
|
"Production Operations could not capture the initial Runtime state."
|
|
)
|
|
operations_coordinator = coordinator
|
|
_personal_action_points = 0
|
|
var advanced := coordinator.advance_to(OPERATIONS_START_MINUTE, true)
|
|
if not advanced.is_success() \
|
|
or coordinator.current_minute != _clock.absolute_minute \
|
|
or coordinator.get_population_count() != guest_service.guest_count() \
|
|
or coordinator.get_arrears_total() != _operations_closed_arrears_total() \
|
|
or not _operations_notifications_are_committable() \
|
|
or not _commit_operations_domain_events() \
|
|
or not _commit_operations_notifications():
|
|
coordinator.discard_deferred_notifications()
|
|
_restore_operations_external_state(enable_external_before)
|
|
_discard_operations_domain_events()
|
|
operations_coordinator = previous_coordinator
|
|
_personal_action_points = previous_ap
|
|
return _operations_api_failure(
|
|
&"INITIAL_BOUNDARY_FAILED",
|
|
advanced.message if not advanced.is_success() else "Initial Operations cross-links failed.",
|
|
advanced.details
|
|
)
|
|
_operations_enabled = true
|
|
_operations_tick_blocked = false
|
|
_operations_runtime_failure_latched = false
|
|
_operations_runtime_failure_report.clear()
|
|
_register_operations_tick_handler()
|
|
_pause_for_waiting_foreground_event()
|
|
_publish_snapshot(_clock.absolute_minute)
|
|
return {
|
|
&"ok": true,
|
|
&"code": &"OK",
|
|
&"enabled": true,
|
|
&"current_minute": coordinator.current_minute,
|
|
&"personal_action_points": _personal_action_points,
|
|
}
|
|
|
|
|
|
func acknowledge_operations_pause() -> Dictionary:
|
|
if not _operations_enabled or operations_coordinator == null:
|
|
return _operations_api_failure(&"OPERATIONS_DISABLED", "Production operations are disabled.")
|
|
if operations_coordinator.is_terminal():
|
|
return _operations_api_failure(&"TERMINAL_STATE", "Bankruptcy cannot be acknowledged away.")
|
|
if operations_coordinator.is_external_quarantined():
|
|
return _operations_api_failure(
|
|
&"EXTERNAL_ROLLBACK_FAILED",
|
|
"A verified snapshot restore is required."
|
|
)
|
|
if _operations_deferred_runtime_minute < 0:
|
|
return _operations_api_failure(
|
|
&"NO_DEFERRED_RUNTIME_MINUTE",
|
|
"Operations have no deferred Runtime phase suffix."
|
|
)
|
|
if _operations_deferred_runtime_minute != _clock.absolute_minute:
|
|
return _operations_api_failure(
|
|
&"DEFERRED_MINUTE_MISMATCH",
|
|
"The deferred Operations minute no longer matches GameClock."
|
|
)
|
|
var had_runtime_failure := _operations_runtime_failure_latched
|
|
var acknowledged := operations_coordinator.acknowledge_pause()
|
|
if not acknowledged.is_success():
|
|
return _operations_api_failure(&"ACKNOWLEDGE_FAILED", acknowledged.message, acknowledged.details)
|
|
var deferred_minute := _operations_deferred_runtime_minute
|
|
var deferred := operations_coordinator.advance_to(deferred_minute, true)
|
|
if not deferred.is_success():
|
|
var partial_commit_ok := _commit_operations_domain_events() \
|
|
and _commit_operations_notifications()
|
|
if not partial_commit_ok:
|
|
operations_coordinator.discard_deferred_notifications()
|
|
_discard_operations_domain_events()
|
|
_operations_runtime_failure_latched = true
|
|
_operations_runtime_failure_report = deferred.to_dictionary()
|
|
_operations_tick_blocked = true
|
|
_clock.set_paused(true)
|
|
return _operations_api_failure(&"RETRY_FAILED", deferred.message, deferred.details)
|
|
if operations_coordinator.current_minute != deferred_minute \
|
|
or operations_coordinator.get_population_count() != guest_service.guest_count() \
|
|
or operations_coordinator.get_arrears_total() != _operations_closed_arrears_total() \
|
|
or not _operations_notifications_are_committable() \
|
|
or not _commit_operations_domain_events() \
|
|
or not _commit_operations_notifications():
|
|
operations_coordinator.discard_deferred_notifications()
|
|
_discard_operations_domain_events()
|
|
_operations_runtime_failure_latched = true
|
|
_operations_runtime_failure_report = {
|
|
&"code": &"OPERATIONS_CROSSLINK_MISMATCH",
|
|
&"minute": deferred_minute,
|
|
}
|
|
_operations_tick_blocked = true
|
|
_clock.set_paused(true)
|
|
return _operations_api_failure(
|
|
&"RETRY_CROSSLINK_FAILED",
|
|
"Operations retry did not restore Runtime cross-links."
|
|
)
|
|
_operations_runtime_failure_latched = false
|
|
_operations_runtime_failure_report.clear()
|
|
if operations_coordinator.is_terminal():
|
|
_operations_deferred_runtime_minute = -1
|
|
_operations_tick_blocked = true
|
|
_clock.set_paused(true)
|
|
_publish_snapshot(deferred_minute)
|
|
return {
|
|
&"ok": true,
|
|
&"code": &"TERMINAL_STATE",
|
|
&"had_runtime_failure": had_runtime_failure,
|
|
&"suffix_replayed": false,
|
|
}
|
|
if operations_coordinator.pause_requested:
|
|
_operations_tick_blocked = true
|
|
_clock.set_paused(true)
|
|
return _operations_api_failure(
|
|
&"PAUSE_REMAINS",
|
|
"Operations still require acknowledgement before Runtime phases can resume."
|
|
)
|
|
_operations_deferred_runtime_minute = -1
|
|
_operations_tick_blocked = false
|
|
_clock.set_paused(false)
|
|
_run_operations_deferred_suffix(deferred_minute)
|
|
_pause_for_waiting_foreground_event()
|
|
if operations_coordinator.get_population_count() != guest_service.guest_count() \
|
|
or operations_coordinator.get_arrears_total() != _operations_closed_arrears_total():
|
|
_operations_runtime_failure_latched = true
|
|
_operations_runtime_failure_report = {
|
|
&"code": &"DEFERRED_SUFFIX_CROSSLINK_MISMATCH",
|
|
&"minute": deferred_minute,
|
|
}
|
|
_operations_tick_blocked = true
|
|
_clock.set_paused(true)
|
|
return _operations_api_failure(
|
|
&"SUFFIX_CROSSLINK_FAILED",
|
|
"Deferred Runtime phases ended with invalid Operations cross-links."
|
|
)
|
|
return {
|
|
&"ok": true,
|
|
&"code": &"OK",
|
|
&"had_runtime_failure": had_runtime_failure,
|
|
&"coordinator": acknowledged.to_dictionary(),
|
|
&"suffix_replayed": true,
|
|
&"deferred_runtime_minute": deferred_minute,
|
|
}
|
|
|
|
|
|
func _run_operations_deferred_suffix(absolute_minute: int) -> void:
|
|
_advance_mana(absolute_minute)
|
|
_advance_construction(absolute_minute)
|
|
_advance_inventory_and_logistics(absolute_minute)
|
|
_advance_facilities(absolute_minute)
|
|
_advance_guest_vitals(absolute_minute)
|
|
_advance_authority_incidents(absolute_minute)
|
|
_advance_defense(absolute_minute)
|
|
_advance_relationship_events(absolute_minute)
|
|
_advance_guest_queues(absolute_minute)
|
|
_advance_staff_ai(absolute_minute)
|
|
if _active_command_minute >= 0:
|
|
_operations_resume_publish_pending = true
|
|
else:
|
|
_publish_snapshot(absolute_minute)
|
|
_process_save_checkpoints(absolute_minute)
|
|
|
|
|
|
func get_operations_dashboard_report() -> Dictionary:
|
|
return {&"enabled":_operations_enabled, &"arrears_total":_operations_total_arrears(),
|
|
&"tick_blocked":_operations_tick_blocked, &"runtime_failure_latched":_operations_runtime_failure_latched,
|
|
&"personal_action_points":_personal_action_points,
|
|
&"deferred_runtime_minute":_operations_deferred_runtime_minute,
|
|
&"coordinator":operations_coordinator.get_dashboard_summary() if operations_coordinator != null else {}}
|
|
|
|
|
|
func get_operations_report() -> Dictionary:
|
|
return {
|
|
&"enabled": _operations_enabled,
|
|
&"arrears_total": _operations_total_arrears(),
|
|
&"tick_blocked": _operations_tick_blocked,
|
|
&"runtime_failure_latched": _operations_runtime_failure_latched,
|
|
&"runtime_failure_report": _operations_runtime_failure_report.duplicate(true),
|
|
&"deferred_runtime_minute": _operations_deferred_runtime_minute,
|
|
&"personal_action_points": _personal_action_points,
|
|
&"coordinator": (
|
|
operations_coordinator.create_snapshot()
|
|
if operations_coordinator != null else {}
|
|
),
|
|
}
|
|
|
|
|
|
func set_operations_test_boundary_hook(operation: StringName, callback: Callable) -> bool:
|
|
# Test-only fault injection is invoked at PREPARE and, for arrivals, once at
|
|
# AFTER_FIRST_MUTATION. The latter proves staged service notifications are
|
|
# discarded when a real partial apply is rolled back.
|
|
if operation not in [&"DAILY_CLOSE", &"ARRIVAL_PLAN", &"MORNING_BOUNDARY", &"MIDNIGHT_UPKEEP"]:
|
|
return false
|
|
if callback.is_valid():
|
|
_operations_test_boundary_hooks[operation] = callback
|
|
else:
|
|
_operations_test_boundary_hooks.erase(operation)
|
|
return true
|
|
|
|
|
|
func _run_operations_test_boundary_hook(
|
|
operation: StringName,
|
|
payload: Dictionary,
|
|
phase: StringName,
|
|
token: Variant
|
|
) -> Dictionary:
|
|
var callback: Callable = _operations_test_boundary_hooks.get(operation, Callable())
|
|
if not callback.is_valid():
|
|
return {&"ok": true}
|
|
var returned: Variant = callback.call(payload.duplicate(true), phase, token)
|
|
if returned is bool:
|
|
return {&"ok": bool(returned), &"reason": &"TEST_HOOK_REJECTED"}
|
|
if returned is Dictionary:
|
|
var result: Dictionary = returned
|
|
if typeof(result.get(&"ok", null)) == TYPE_BOOL:
|
|
return result.duplicate(true)
|
|
return {&"ok": false, &"reason": &"TEST_HOOK_INVALID_RESULT"}
|
|
|
|
|
|
func _register_operations_tick_handler() -> void:
|
|
_clock.unregister_tick_handler(
|
|
_clock.TickPhase.TIME_BOUNDARIES,
|
|
&"simulation_runtime.operations"
|
|
)
|
|
_clock.register_tick_handler(
|
|
_clock.TickPhase.TIME_BOUNDARIES,
|
|
&"simulation_runtime.operations",
|
|
Callable(self, "_advance_operations_time")
|
|
)
|
|
|
|
|
|
func _configure_operations_callbacks() -> void:
|
|
if operations_coordinator == null:
|
|
return
|
|
operations_coordinator.configure_callbacks(
|
|
Callable(self, "_operations_daily_boundary"),
|
|
Callable(self, "_operations_arrival_boundary"),
|
|
Callable(self, "_operations_morning_boundary"),
|
|
Callable(self, "_operations_midnight_upkeep_boundary")
|
|
)
|
|
|
|
|
|
func _configure_default_operations_demand(coordinator: OperationsCoordinator) -> CommandResult:
|
|
if coordinator == null or DEFAULT_OPERATIONS_DEMAND_PROFILES.size() != 8:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
0,
|
|
"The canonical Operations demand profile set is unavailable."
|
|
)
|
|
var allowed_classes: Dictionary = {}
|
|
for class_id: StringName in mvp_content_catalog.get_guest_class_ids():
|
|
allowed_classes[class_id] = true
|
|
var seen_classes: Dictionary = {}
|
|
for raw_profile: Variant in DEFAULT_OPERATIONS_DEMAND_PROFILES:
|
|
if not raw_profile is Dictionary:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED, 0,
|
|
"A canonical Operations demand profile is malformed."
|
|
)
|
|
var profile: Dictionary = raw_profile
|
|
var guest_class := StringName(profile[&"guest_class"])
|
|
if not allowed_classes.has(guest_class) or seen_classes.has(guest_class):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED, 0,
|
|
"The canonical Operations demand classes are invalid."
|
|
)
|
|
seen_classes[guest_class] = true
|
|
var configured := coordinator.set_demand_input(
|
|
StringName(profile[&"facility_tag"]),
|
|
guest_class,
|
|
StringName(profile[&"time_band"]),
|
|
{
|
|
&"base": 100,
|
|
&"reputation": 0,
|
|
&"unmet_need": 0,
|
|
&"event": 0,
|
|
&"recent_price_penalty": 0,
|
|
&"wait_penalty": 0,
|
|
}
|
|
)
|
|
if not configured.is_success():
|
|
return configured
|
|
if seen_classes.size() != allowed_classes.size():
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED, 0,
|
|
"Every MVP Guest class must have exactly one demand profile."
|
|
)
|
|
return CommandResult.succeeded(0, {&"profile_count": seen_classes.size()})
|
|
|
|
|
|
func get_facility_upkeep_report(facility_id: StringName) -> Dictionary:
|
|
var definition_id: StringName = &""
|
|
var facility := facility_service.get_instance(facility_id)
|
|
if facility != null:
|
|
definition_id = facility.definition_id
|
|
elif construction_service != null:
|
|
for built: Dictionary in construction_service.get_facilities():
|
|
if StringName(built[&"instance_id"]) == facility_id:
|
|
definition_id = StringName(built[&"definition_id"])
|
|
break
|
|
var definition := definition_registry.get_facility(definition_id)
|
|
var balance := upkeep_settlement.get_facility_balance(facility_id)
|
|
var units := facility_upkeep.get_unsettled_units(facility_id, upkeep_settlement.get_settled_period_count())
|
|
return {
|
|
&"facility_id": facility_id,
|
|
&"owned": definition != null,
|
|
&"daily_rate": definition.maintenance_gold if definition != null else 0,
|
|
&"unsettled_units": units + int(balance[&"fraction_units"]),
|
|
&"gold_denominator": FacilityUpkeepAccrual.MINUTES_PER_DAY,
|
|
&"arrears": balance[&"arrears"],
|
|
}
|
|
|
|
|
|
func _advance_facility_upkeep(minute: int) -> void:
|
|
if _operations_deferred_runtime_minute >= 0:
|
|
return
|
|
var rates: Dictionary = {}
|
|
for facility: FacilityInstance in facility_service.get_instances():
|
|
var definition := definition_registry.get_facility(facility.definition_id)
|
|
if definition != null:
|
|
rates[String(facility.instance_id)] = definition.maintenance_gold
|
|
# Infrastructure modules are completed assets even when they have no service instance.
|
|
for facility: Dictionary in construction_service.get_facilities():
|
|
var definition := definition_registry.get_facility(StringName(facility[&"definition_id"]))
|
|
if definition != null:
|
|
rates[String(facility[&"instance_id"])] = definition.maintenance_gold
|
|
var advanced := facility_upkeep.advance_to(minute, rates)
|
|
if not advanced[&"ok"]:
|
|
_clock.set_paused(true)
|
|
push_error("Facility upkeep accrual failed: %s" % advanced)
|
|
|
|
|
|
func _upkeep_accrual_crosslinks_are_valid(accrual: FacilityUpkeepAccrual, settlement: FacilityUpkeepSettlement) -> bool:
|
|
var periods: Array = accrual.get_report()[&"closed_periods"]
|
|
var receipts: Array = settlement.get_report()[&"receipts"]
|
|
if receipts.size() > periods.size():
|
|
return false
|
|
for index in receipts.size():
|
|
if receipts[index][&"period"] != periods[index]:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _operations_closed_arrears_total() -> int:
|
|
# Coordinator arrears describe its last financial close. Interim calendar
|
|
# payments during a legacy 06:00 period must not rewrite that closed record.
|
|
var last_close := 0
|
|
if operations_coordinator != null and operations_coordinator.get_open_financial_day() > 1:
|
|
last_close = operations_coordinator.get_daily_close_minute(operations_coordinator.get_open_financial_day() - 1)
|
|
return _staff_payroll_arrears_total(staff_service) + upkeep_settlement.get_arrears_through(last_close)
|
|
|
|
|
|
func _operations_total_arrears() -> int:
|
|
return _staff_payroll_arrears_total(staff_service) + upkeep_settlement.get_arrears_total()
|
|
|
|
|
|
func _advance_operations_time(absolute_minute: int) -> void:
|
|
if not _operations_enabled or operations_coordinator == null:
|
|
return
|
|
if _operations_deferred_runtime_minute >= 0:
|
|
_operations_tick_blocked = true
|
|
_clock.set_paused(true)
|
|
return
|
|
_operations_tick_blocked = false
|
|
var advanced := operations_coordinator.advance_to(absolute_minute, true)
|
|
if not advanced.is_success():
|
|
# Earlier boundaries in the same minute can already be validly committed
|
|
# (for example demand before a failed arrival). Publish only those staged
|
|
# effects and notifications; the failing callback rolled itself back.
|
|
if not _commit_operations_domain_events() \
|
|
or not _commit_operations_notifications():
|
|
operations_coordinator.discard_deferred_notifications()
|
|
_discard_operations_domain_events()
|
|
_operations_runtime_failure_latched = true
|
|
_operations_runtime_failure_report = advanced.to_dictionary()
|
|
_operations_deferred_runtime_minute = absolute_minute
|
|
_operations_tick_blocked = true
|
|
_clock.set_paused(true)
|
|
return
|
|
if operations_coordinator.current_minute != absolute_minute \
|
|
or operations_coordinator.get_population_count() != guest_service.guest_count() \
|
|
or operations_coordinator.get_arrears_total() != _operations_closed_arrears_total() \
|
|
or not _operations_notifications_are_committable() \
|
|
or not _commit_operations_domain_events() \
|
|
or not _commit_operations_notifications():
|
|
operations_coordinator.discard_deferred_notifications()
|
|
_discard_operations_domain_events()
|
|
_operations_runtime_failure_latched = true
|
|
_operations_runtime_failure_report = {
|
|
&"code": &"OPERATIONS_CROSSLINK_MISMATCH",
|
|
&"minute": absolute_minute,
|
|
}
|
|
_operations_deferred_runtime_minute = absolute_minute
|
|
_operations_tick_blocked = true
|
|
_clock.set_paused(true)
|
|
return
|
|
_operations_runtime_failure_latched = false
|
|
_operations_runtime_failure_report.clear()
|
|
if operations_coordinator.is_terminal():
|
|
_operations_deferred_runtime_minute = -1
|
|
_operations_tick_blocked = true
|
|
_clock.set_paused(true)
|
|
return
|
|
if operations_coordinator.pause_requested:
|
|
_operations_deferred_runtime_minute = absolute_minute
|
|
_operations_tick_blocked = true
|
|
_clock.set_paused(true)
|
|
return
|
|
_operations_deferred_runtime_minute = -1
|
|
_pause_for_waiting_foreground_event()
|
|
|
|
|
|
func _operations_should_block_tick() -> bool:
|
|
if not _operations_enabled or operations_coordinator == null:
|
|
return false
|
|
return _operations_deferred_runtime_minute >= 0 \
|
|
or _operations_runtime_failure_latched \
|
|
or operations_coordinator.pause_requested \
|
|
or operations_coordinator.is_terminal() \
|
|
or operations_coordinator.is_external_quarantined()
|
|
|
|
|
|
func _pause_for_waiting_foreground_event() -> void:
|
|
if event_service == null or _clock == null:
|
|
return
|
|
var foreground := event_service.get_foreground_instance()
|
|
if foreground != null and foreground.state == EventInstance.State.WAITING_CHOICE:
|
|
_clock.set_paused(true)
|
|
|
|
|
|
func _capture_operations_external_state() -> Dictionary:
|
|
# This is deliberately a composed transaction token, not a Runtime snapshot.
|
|
# It contains every aggregate that an Operations callback can reach, while
|
|
# excluding clock/coordinator state owned by the caller of the callback.
|
|
if not _begin_operations_external_transaction():
|
|
return {}
|
|
return _build_operations_external_state()
|
|
|
|
|
|
func _build_operations_external_state() -> Dictionary:
|
|
var committed_minute := _runtime_aggregate_committed_minute()
|
|
if committed_minute < 0:
|
|
_discard_operations_domain_events()
|
|
return {}
|
|
var rng_snapshots: Dictionary = {}
|
|
for stream_id: StringName in _sorted_name_keys(_rng_streams):
|
|
var stream := _rng_streams.get(stream_id) as DeterministicRng
|
|
if stream == null:
|
|
_discard_operations_domain_events()
|
|
return {}
|
|
rng_snapshots[stream_id] = stream.snapshot()
|
|
return {
|
|
# TIME_BOUNDARIES runs before the Runtime suffix. During a normal
|
|
# Operations callback the Clock is already at X while minute-driven
|
|
# aggregates are still committed through X - 1. Capture that committed
|
|
# participant minute so rollback validates the state it actually saved.
|
|
&"captured_minute": committed_minute,
|
|
&"captured_clock_minute": _clock.absolute_minute,
|
|
&"captured_authority_minute": authority_service.current_minute,
|
|
&"captured_defense_minute": defense_service.current_minute,
|
|
&"game_state": _state.create_snapshot(),
|
|
&"command_queue": command_queue.create_snapshot(),
|
|
&"next_command_id": _next_command_id,
|
|
&"economy": economy_service.create_snapshot(),
|
|
&"facility_upkeep": facility_upkeep.create_snapshot(),
|
|
&"upkeep_settlement": upkeep_settlement.create_snapshot(),
|
|
&"inventory": inventory_service.create_snapshot(),
|
|
&"kitchen_production": kitchen_production.create_snapshot(),
|
|
&"auto_purchase_plans": auto_purchase_plans.create_snapshot(),
|
|
&"facilities": facility_service.create_snapshot(),
|
|
&"facility_cleanliness": facility_cleanliness.create_snapshot(),
|
|
&"guests": guest_service.create_snapshot(),
|
|
&"staff": staff_service.create_snapshot(),
|
|
&"relationships": relationship_service.create_snapshot(),
|
|
&"events": event_service.create_snapshot(),
|
|
&"authority": authority_service.create_snapshot(),
|
|
&"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(),
|
|
&"facility_infrastructure": facility_infrastructure_projector.create_snapshot(),
|
|
&"movement_credits": _movement_credits.duplicate(true),
|
|
&"domain_events": domain_events.create_snapshot(),
|
|
&"id_generator": id_generator.create_snapshot(),
|
|
&"rng_streams": rng_snapshots,
|
|
&"staffing_projection_enabled": _staffing_projection_enabled,
|
|
&"progression": progression_service.create_snapshot(),
|
|
&"hired_staff_candidate_ids": _hired_staff_candidate_ids.duplicate(true),
|
|
&"personal_action_points": _personal_action_points,
|
|
# The remaining aggregates are reachable through Event actions or signals.
|
|
&"mana": mana_network_service.create_snapshot(),
|
|
&"logistics_dispatcher": logistics_dispatcher.create_snapshot(),
|
|
&"logistics_requests": _serialize_logistics_requests(),
|
|
&"defense": defense_service.create_snapshot(),
|
|
&"construction": construction_service.create_snapshot(),
|
|
}
|
|
|
|
|
|
func _operations_external_committed_minute() -> int:
|
|
return _runtime_aggregate_committed_minute()
|
|
|
|
|
|
func _runtime_aggregate_committed_minute() -> int:
|
|
if facility_service == null or guest_service == null or inventory_service == null \
|
|
or staff_service == null or kitchen_production == null:
|
|
return -1
|
|
var minute := facility_service.current_minute
|
|
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 elevators.current_minute != minute:
|
|
return -1
|
|
return minute
|
|
|
|
|
|
func _restore_operations_external_state(token: Variant) -> bool:
|
|
if not token is Dictionary:
|
|
_discard_operations_domain_events()
|
|
return false
|
|
var saved: Dictionary = token
|
|
var required_dictionary_keys: Array[StringName] = [
|
|
&"facility_upkeep", &"upkeep_settlement", &"facility_cleanliness",
|
|
&"game_state", &"command_queue", &"economy", &"inventory", &"kitchen_production", &"auto_purchase_plans", &"facilities", &"guests",
|
|
&"staff", &"relationships", &"events", &"authority", &"world", &"path", &"elevators", &"legacy_stairs",
|
|
&"excavation", &"spatial_subslots", &"route_projection",
|
|
&"facility_infrastructure",
|
|
&"movement_credits", &"domain_events", &"id_generator", &"rng_streams",
|
|
&"progression", &"hired_staff_candidate_ids", &"mana",
|
|
&"logistics_dispatcher", &"defense", &"construction",
|
|
]
|
|
for key: StringName in required_dictionary_keys:
|
|
if not saved.get(key, null) is Dictionary:
|
|
_discard_operations_domain_events()
|
|
return false
|
|
if not saved.get(&"logistics_requests", null) is Array \
|
|
or typeof(saved.get(&"staffing_projection_enabled", null)) != TYPE_BOOL \
|
|
or typeof(saved.get(&"personal_action_points", null)) != TYPE_INT \
|
|
or typeof(saved.get(&"captured_minute", null)) != TYPE_INT \
|
|
or int(saved[&"captured_minute"]) < 0 \
|
|
or typeof(saved.get(&"captured_clock_minute", null)) != TYPE_INT \
|
|
or int(saved[&"captured_clock_minute"]) < int(saved[&"captured_minute"]) \
|
|
or typeof(saved.get(&"captured_authority_minute", null)) != TYPE_INT \
|
|
or int(saved[&"captured_authority_minute"]) < 0 \
|
|
or typeof(saved.get(&"captured_defense_minute", null)) != TYPE_INT \
|
|
or int(saved[&"captured_defense_minute"]) < 0 \
|
|
or typeof(saved.get(&"next_command_id", null)) != TYPE_INT \
|
|
or int(saved[&"next_command_id"]) <= 0:
|
|
_discard_operations_domain_events()
|
|
return false
|
|
# Validation/PREPARE failure did not mutate a participant. Avoid restore APIs
|
|
# here because GameState.restore_snapshot intentionally emits reset signals.
|
|
if _operations_external_state_matches(saved):
|
|
_discard_operations_domain_events()
|
|
return true
|
|
|
|
# Ask every participant to restore even when an earlier participant rejects
|
|
# its snapshot. This avoids knowingly leaving a half-restored external world.
|
|
var state_ok: bool = _state.restore_snapshot(saved[&"game_state"])
|
|
var command_queue_ok := command_queue.restore_snapshot(saved[&"command_queue"])
|
|
var economy_ok := economy_service.restore_snapshot(saved[&"economy"])
|
|
var upkeep_ok := facility_upkeep.restore_snapshot(saved[&"facility_upkeep"]) and upkeep_settlement.restore_snapshot(saved[&"upkeep_settlement"], economy_service)
|
|
var inventory_ok := inventory_service.restore_snapshot(saved[&"inventory"])
|
|
var facilities_ok := facility_service.restore_snapshot(saved[&"facilities"])
|
|
facilities_ok = facility_cleanliness.restore_snapshot(saved.get(&"facility_cleanliness", {})) and facilities_ok
|
|
var kitchen_ok := kitchen_production.restore_snapshot(saved[&"kitchen_production"])
|
|
var auto_purchase_ok := auto_purchase_plans.restore_snapshot(saved[&"auto_purchase_plans"], facility_service, economy_service, int(saved[&"captured_minute"]))
|
|
var guests_ok := guest_service.restore_snapshot(saved[&"guests"])
|
|
var staff_ok := staff_service.restore_snapshot(saved[&"staff"])
|
|
var relationships_ok := relationship_service.restore_snapshot(saved[&"relationships"])
|
|
var events_ok := event_service.restore_snapshot(saved[&"events"])
|
|
var authority_ok := authority_service.restore_snapshot(saved[&"authority"])
|
|
var world_ok := world_service.restore_snapshot(saved[&"world"])
|
|
var spatial_ok := spatial_subslot_service.restore_snapshot(saved[&"spatial_subslots"])
|
|
var route_snapshot: Dictionary = saved[&"route_projection"]
|
|
var projected_walkable: Variant = route_snapshot.get(&"projected_walkable_cells", null)
|
|
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
|
|
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"]
|
|
)
|
|
var domain_events_ok := domain_events.restore_snapshot(saved[&"domain_events"])
|
|
var ids_ok := id_generator.restore_snapshot(saved[&"id_generator"])
|
|
var progression_ok := progression_service.restore_snapshot(saved[&"progression"])
|
|
var mana_ok := mana_network_service.restore_snapshot(saved[&"mana"])
|
|
var dispatcher_ok := logistics_dispatcher.restore_snapshot(saved[&"logistics_dispatcher"])
|
|
var defense_ok := defense_service.restore_snapshot(saved[&"defense"])
|
|
var construction_ok := construction_service.restore_snapshot(saved[&"construction"])
|
|
var excavation_ok := excavation_service.restore_snapshot(saved[&"excavation"])
|
|
var restored_requests: Variant = _deserialize_logistics_requests(saved[&"logistics_requests"])
|
|
var requests_ok := restored_requests is Dictionary
|
|
if requests_ok:
|
|
logistics_requests = restored_requests
|
|
_movement_credits = (saved[&"movement_credits"] as Dictionary).duplicate(true)
|
|
_staffing_projection_enabled = bool(saved[&"staffing_projection_enabled"])
|
|
_hired_staff_candidate_ids = (
|
|
saved[&"hired_staff_candidate_ids"] as Dictionary
|
|
).duplicate(true)
|
|
_personal_action_points = int(saved[&"personal_action_points"])
|
|
_next_command_id = int(saved[&"next_command_id"])
|
|
|
|
var rng_ok := true
|
|
var saved_rngs: Dictionary = saved[&"rng_streams"]
|
|
if saved_rngs.size() != _rng_streams.size():
|
|
rng_ok = false
|
|
for raw_stream_id: Variant in saved_rngs:
|
|
var stream_id := StringName(raw_stream_id)
|
|
var stream := _rng_streams.get(stream_id) as DeterministicRng
|
|
if stream == null or not saved_rngs[raw_stream_id] is Dictionary \
|
|
or not stream.restore(saved_rngs[raw_stream_id]):
|
|
rng_ok = false
|
|
|
|
_discard_operations_domain_events()
|
|
var participants_ok := upkeep_ok and state_ok and command_queue_ok and economy_ok and inventory_ok and kitchen_ok and auto_purchase_ok \
|
|
and facilities_ok and guests_ok and staff_ok and relationships_ok \
|
|
and events_ok and authority_ok and world_ok and spatial_ok and path_ok \
|
|
and route_ok and infrastructure_ok and excavation_ok \
|
|
and domain_events_ok and ids_ok \
|
|
and progression_ok and mana_ok and dispatcher_ok and defense_ok \
|
|
and construction_ok and requests_ok and rng_ok
|
|
if not participants_ok:
|
|
return false
|
|
var validation_minute := int(saved[&"captured_minute"])
|
|
var clock_validation_minute := int(saved[&"captured_clock_minute"])
|
|
return _operations_external_state_matches(saved) \
|
|
and bool(economy_service.validate_ledger().get(&"ok", false)) \
|
|
and _validate_logistics_dispatcher_crosslinks(
|
|
logistics_dispatcher, logistics_requests, clock_validation_minute
|
|
) \
|
|
and _validate_runtime_crosslinks(
|
|
facility_service, guest_service, economy_service, inventory_service,
|
|
staff_service, validation_minute, _staffing_projection_enabled
|
|
) \
|
|
and _validate_runtime_path_crosslinks(
|
|
path_service, guest_service, staff_service, _movement_credits,
|
|
clock_validation_minute
|
|
) \
|
|
and _validate_authority_economy_crosslinks(
|
|
authority_service, economy_service,
|
|
int(saved[&"captured_authority_minute"])
|
|
) \
|
|
and _validate_defense_crosslinks(
|
|
defense_service, staff_service, facility_service,
|
|
int(saved[&"captured_defense_minute"])
|
|
) \
|
|
and _validate_construction_facility_crosslinks(
|
|
world_service, construction_service, facility_service,
|
|
facility_infrastructure_projector
|
|
) \
|
|
and _validate_infrastructure_crosslinks(
|
|
world_service,
|
|
path_service,
|
|
construction_service,
|
|
construction_resource_state,
|
|
excavation_service,
|
|
spatial_subslot_service,
|
|
route_subslot_path_adapter,
|
|
facility_infrastructure_projector
|
|
)
|
|
|
|
|
|
func _operations_external_state_matches(saved: Dictionary) -> bool:
|
|
if not _strict_variants_equal(_state.create_snapshot(), saved[&"game_state"]) \
|
|
or not _strict_variants_equal(command_queue.create_snapshot(), saved[&"command_queue"]) \
|
|
or _next_command_id != int(saved[&"next_command_id"]) \
|
|
or not _strict_variants_equal(facility_upkeep.create_snapshot(), saved[&"facility_upkeep"]) \
|
|
or not _strict_variants_equal(upkeep_settlement.create_snapshot(), saved[&"upkeep_settlement"]) \
|
|
or not _strict_variants_equal(economy_service.create_snapshot(), saved[&"economy"]) \
|
|
or not _strict_variants_equal(inventory_service.create_snapshot(), saved[&"inventory"]) \
|
|
or not _strict_variants_equal(kitchen_production.create_snapshot(), saved[&"kitchen_production"]) \
|
|
or not _strict_variants_equal(auto_purchase_plans.create_snapshot(), saved[&"auto_purchase_plans"]) \
|
|
or not _strict_variants_equal(facility_service.create_snapshot(), saved[&"facilities"]) \
|
|
or not _strict_variants_equal(facility_cleanliness.create_snapshot(), saved.get(&"facility_cleanliness", {})) \
|
|
or not _strict_variants_equal(guest_service.create_snapshot(), saved[&"guests"]) \
|
|
or not _strict_variants_equal(staff_service.create_snapshot(), saved[&"staff"]) \
|
|
or not _strict_variants_equal(relationship_service.create_snapshot(), saved[&"relationships"]) \
|
|
or not _strict_variants_equal(event_service.create_snapshot(), saved[&"events"]) \
|
|
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(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"]) \
|
|
or not _strict_variants_equal(facility_infrastructure_projector.create_snapshot(), saved[&"facility_infrastructure"]) \
|
|
or not _strict_variants_equal(_movement_credits, saved[&"movement_credits"]) \
|
|
or not _strict_variants_equal(domain_events.create_snapshot(), saved[&"domain_events"]) \
|
|
or not _strict_variants_equal(id_generator.create_snapshot(), saved[&"id_generator"]) \
|
|
or not _strict_variants_equal(progression_service.create_snapshot(), saved[&"progression"]) \
|
|
or not _strict_variants_equal(mana_network_service.create_snapshot(), saved[&"mana"]) \
|
|
or not _strict_variants_equal(logistics_dispatcher.create_snapshot(), saved[&"logistics_dispatcher"]) \
|
|
or not _strict_variants_equal(_serialize_logistics_requests(), saved[&"logistics_requests"]) \
|
|
or not _strict_variants_equal(defense_service.create_snapshot(), saved[&"defense"]) \
|
|
or not _strict_variants_equal(construction_service.create_snapshot(), saved[&"construction"]) \
|
|
or _staffing_projection_enabled != bool(saved[&"staffing_projection_enabled"]) \
|
|
or not _strict_variants_equal(_hired_staff_candidate_ids, saved[&"hired_staff_candidate_ids"]) \
|
|
or _personal_action_points != int(saved[&"personal_action_points"]):
|
|
return false
|
|
var saved_rngs: Dictionary = saved[&"rng_streams"]
|
|
if saved_rngs.size() != _rng_streams.size():
|
|
return false
|
|
for raw_stream_id: Variant in saved_rngs:
|
|
var stream := _rng_streams.get(StringName(raw_stream_id)) as DeterministicRng
|
|
if stream == null or not _strict_variants_equal(
|
|
stream.snapshot(), saved_rngs[raw_stream_id]
|
|
):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _begin_operations_external_transaction() -> bool:
|
|
if _operations_transaction_active:
|
|
# One Coordinator minute can contain daily, demand and arrival boundaries.
|
|
# Keep their Runtime side effects in one notification/event transaction
|
|
# until the Coordinator has committed the complete minute.
|
|
return true
|
|
_operations_transaction_active = true
|
|
_operations_queued_domain_events.clear()
|
|
_operations_queued_guest_spawn_notifications.clear()
|
|
_operations_queued_economy_notifications.clear()
|
|
return true
|
|
|
|
|
|
func _commit_operations_domain_events() -> bool:
|
|
if not _operations_transaction_active:
|
|
return true
|
|
var seen: Dictionary = {}
|
|
for event: SimulationDomainEvent in _operations_queued_domain_events:
|
|
if event == null or not event.is_valid() or seen.has(event.event_id) \
|
|
or domain_events.has_published(event.event_id):
|
|
return false
|
|
seen[event.event_id] = true
|
|
var committed := domain_events.publish_batch(_operations_queued_domain_events)
|
|
if not committed:
|
|
return false
|
|
_operations_queued_domain_events.clear()
|
|
_operations_transaction_active = false
|
|
return true
|
|
|
|
|
|
func _operations_notifications_are_committable() -> bool:
|
|
if operations_coordinator == null or guest_service == null:
|
|
return false
|
|
var seen_guest_ids: Dictionary = {}
|
|
for guest_id: StringName in _operations_queued_guest_spawn_notifications:
|
|
if guest_id.is_empty() or seen_guest_ids.has(guest_id) \
|
|
or not guest_service.has_guest(guest_id) \
|
|
or not guest_service.has_pending_guest_spawned_notification(guest_id):
|
|
return false
|
|
seen_guest_ids[guest_id] = true
|
|
var seen_transaction_ids: Dictionary = {}
|
|
for transaction_id: StringName in _operations_queued_economy_notifications:
|
|
if transaction_id.is_empty() or seen_transaction_ids.has(transaction_id) \
|
|
or not economy_service.has_pending_transaction_notification(transaction_id):
|
|
return false
|
|
seen_transaction_ids[transaction_id] = true
|
|
return true
|
|
|
|
|
|
func _stage_operations_economy_notification(result: CommandResult) -> bool:
|
|
if result == null or not result.is_success():
|
|
return false
|
|
var transaction_id := StringName(result.details.get(&"transaction_id", &""))
|
|
if transaction_id.is_empty() \
|
|
or not economy_service.has_pending_transaction_notification(transaction_id) \
|
|
or _operations_queued_economy_notifications.has(transaction_id):
|
|
return false
|
|
_operations_queued_economy_notifications.append(transaction_id)
|
|
# Preserve Runtime's authoritative GameState projection now; the matching raw
|
|
# Economy signal is delivered to external observers only after commit.
|
|
_on_ledger_transaction_posted(
|
|
transaction_id, economy_service.get_transaction_entries(transaction_id)
|
|
)
|
|
return true
|
|
|
|
|
|
func _discard_operations_domain_events() -> void:
|
|
for guest_id: StringName in _operations_queued_guest_spawn_notifications:
|
|
guest_service.discard_guest_spawned_notification(guest_id)
|
|
for transaction_id: StringName in _operations_queued_economy_notifications:
|
|
economy_service.discard_transaction_notification(transaction_id)
|
|
_operations_queued_guest_spawn_notifications.clear()
|
|
_operations_flushing_guest_spawn_notifications.clear()
|
|
_operations_queued_economy_notifications.clear()
|
|
_operations_flushing_economy_notifications.clear()
|
|
_operations_queued_domain_events.clear()
|
|
_operations_transaction_active = false
|
|
|
|
|
|
func _commit_operations_notifications() -> bool:
|
|
if operations_coordinator == null:
|
|
return false
|
|
guest_service.set_external_mutation_blocked(true)
|
|
var coordinator_notifications_ok := operations_coordinator.commit_deferred_notifications()
|
|
var guest_notifications_ok := true
|
|
if coordinator_notifications_ok:
|
|
for guest_id: StringName in _operations_queued_guest_spawn_notifications:
|
|
_operations_flushing_guest_spawn_notifications[guest_id] = true
|
|
if not guest_service.commit_guest_spawned_notification(guest_id):
|
|
guest_notifications_ok = false
|
|
_operations_flushing_guest_spawn_notifications.erase(guest_id)
|
|
else:
|
|
guest_notifications_ok = false
|
|
var economy_notifications_ok := coordinator_notifications_ok and guest_notifications_ok
|
|
if economy_notifications_ok:
|
|
for transaction_id: StringName in _operations_queued_economy_notifications:
|
|
_operations_flushing_economy_notifications[transaction_id] = true
|
|
if not economy_service.commit_transaction_notification(transaction_id):
|
|
economy_notifications_ok = false
|
|
_operations_flushing_economy_notifications.erase(transaction_id)
|
|
guest_service.set_external_mutation_blocked(false)
|
|
_operations_queued_guest_spawn_notifications.clear()
|
|
_operations_flushing_guest_spawn_notifications.clear()
|
|
_operations_queued_economy_notifications.clear()
|
|
_operations_flushing_economy_notifications.clear()
|
|
return coordinator_notifications_ok and guest_notifications_ok and economy_notifications_ok
|
|
|
|
|
|
func _publish_domain_event(event: SimulationDomainEvent) -> bool:
|
|
if _construction_completion_transaction_active:
|
|
if event == null or not event.is_valid() or domain_events.has_published(event.event_id):
|
|
return false
|
|
for queued: SimulationDomainEvent in _construction_queued_domain_events:
|
|
if queued.event_id == event.event_id:
|
|
return false
|
|
_construction_queued_domain_events.append(event.copy())
|
|
return true
|
|
if not _operations_transaction_active:
|
|
return domain_events.publish(event)
|
|
if event == null or not event.is_valid() or domain_events.has_published(event.event_id):
|
|
return false
|
|
for queued: SimulationDomainEvent in _operations_queued_domain_events:
|
|
if queued.event_id == event.event_id:
|
|
return false
|
|
_operations_queued_domain_events.append(event.copy())
|
|
return true
|
|
|
|
|
|
func _operations_daily_boundary(
|
|
payload: Dictionary,
|
|
phase: StringName,
|
|
token: Variant
|
|
) -> Dictionary:
|
|
if phase == OperationsCoordinator.PHASE_CAPTURE:
|
|
var captured := _capture_operations_external_state()
|
|
return {&"ok": not captured.is_empty(), &"token": captured}
|
|
if phase == OperationsCoordinator.PHASE_VALIDATE:
|
|
return {
|
|
&"ok": token is Dictionary and not token.is_empty() \
|
|
and _operations_daily_payload_is_valid(payload),
|
|
&"token": token,
|
|
}
|
|
if phase == OperationsCoordinator.PHASE_APPLY:
|
|
return _apply_operations_daily_close(payload, token)
|
|
if phase == OperationsCoordinator.PHASE_ROLLBACK:
|
|
return {&"ok": _restore_operations_external_state(token)}
|
|
return {&"ok": false}
|
|
|
|
|
|
func _operations_daily_payload_is_valid(payload: Dictionary) -> bool:
|
|
for key: StringName in [
|
|
&"day", &"boundary_minute", &"period_start_minute",
|
|
&"period_end_minute_exclusive",
|
|
]:
|
|
if typeof(payload.get(key, null)) != TYPE_INT:
|
|
return false
|
|
var day := int(payload[&"day"])
|
|
var boundary_minute := int(payload[&"boundary_minute"])
|
|
return day > 0 \
|
|
and boundary_minute == _clock.absolute_minute \
|
|
and boundary_minute == operations_coordinator.get_daily_close_minute(day) \
|
|
and int(payload[&"period_start_minute"]) == operations_coordinator.get_financial_period_start(day) \
|
|
and int(payload[&"period_end_minute_exclusive"]) == boundary_minute
|
|
|
|
|
|
func _settle_upkeep_through(boundary_minute: int) -> Dictionary:
|
|
var upkeep_paid := 0
|
|
for period: Dictionary in facility_upkeep.get_closed_periods_from(upkeep_settlement.get_settled_period_count()):
|
|
if int(period[&"period_end"]) > boundary_minute:
|
|
continue
|
|
var upkeep_result := upkeep_settlement.settle(period, economy_service)
|
|
if not upkeep_result[&"ok"]:
|
|
return {&"ok": false, &"reason": &"UPKEEP_SETTLEMENT_FAILED"}
|
|
if not upkeep_result[&"replayed"]:
|
|
for row: Dictionary in upkeep_result[&"receipt"][&"rows"]:
|
|
upkeep_paid += int(row[&"paid"])
|
|
if not String(upkeep_result[&"transaction_id"]).is_empty() and not _stage_operations_economy_notification(CommandResult.succeeded(0, {&"transaction_id": upkeep_result[&"transaction_id"]})):
|
|
return {&"ok": false, &"reason": &"UPKEEP_NOTIFICATION_STAGE_FAILED"}
|
|
return {&"ok": true, &"paid_total": upkeep_paid}
|
|
|
|
|
|
func _operations_midnight_upkeep_boundary(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary:
|
|
if phase == OperationsCoordinator.PHASE_CAPTURE:
|
|
var captured := _capture_operations_external_state()
|
|
return {&"ok": not captured.is_empty(), &"token": captured}
|
|
if phase == OperationsCoordinator.PHASE_ROLLBACK:
|
|
return {&"ok": _restore_operations_external_state(token)}
|
|
var minute: Variant = payload.get(&"boundary_minute")
|
|
var valid: bool = token is Dictionary and not token.is_empty() and typeof(minute) == TYPE_INT \
|
|
and int(minute) == _clock.absolute_minute and int(minute) % 1440 == 0
|
|
if phase == OperationsCoordinator.PHASE_VALIDATE:
|
|
return {&"ok": valid, &"token": token}
|
|
if phase != OperationsCoordinator.PHASE_APPLY or not valid:
|
|
return {&"ok": false}
|
|
var prepared := _run_operations_test_boundary_hook(&"MIDNIGHT_UPKEEP", payload, OPERATIONS_TEST_PREPARE_PHASE, token)
|
|
if not bool(prepared.get(&"ok", false)):
|
|
return prepared
|
|
var result := _settle_upkeep_through(int(minute))
|
|
if not bool(result[&"ok"]):
|
|
return result
|
|
var after := _run_operations_test_boundary_hook(&"MIDNIGHT_UPKEEP", payload, OPERATIONS_TEST_AFTER_FIRST_MUTATION_PHASE, token)
|
|
if not bool(after.get(&"ok", false)):
|
|
return after
|
|
return {&"ok": upkeep_settlement.validate_ledger(economy_service) \
|
|
and int(_state.get_resource(&"gold")) == economy_service.get_balance(PLAYER_CASH_ACCOUNT)}
|
|
|
|
|
|
func _apply_operations_daily_close(payload: Dictionary, token: Variant) -> Dictionary:
|
|
if not token is Dictionary or token.is_empty() \
|
|
or not _operations_daily_payload_is_valid(payload):
|
|
return {&"ok": false, &"reason": &"DAILY_CLOSE_PRECONDITION_FAILED"}
|
|
var prepare_hook := _run_operations_test_boundary_hook(
|
|
&"DAILY_CLOSE", payload, OPERATIONS_TEST_PREPARE_PHASE, token
|
|
)
|
|
if not bool(prepare_hook.get(&"ok", false)):
|
|
return prepare_hook
|
|
var day := int(payload[&"day"])
|
|
var preview := staff_service.preview_daily_payroll(day)
|
|
if not bool(preview.get(&"ok", false)) \
|
|
or not preview.get(&"rows", null) is Array:
|
|
return {&"ok": false, &"reason": &"PAYROLL_PREVIEW_FAILED"}
|
|
var available_gold := economy_service.get_balance(PLAYER_CASH_ACCOUNT)
|
|
var remaining_gold := available_gold
|
|
var payments: Dictionary = {}
|
|
var expected_paid_total := 0
|
|
var previous_staff_id := ""
|
|
for raw_row: Variant in preview[&"rows"]:
|
|
if not raw_row is Dictionary:
|
|
return {&"ok": false, &"reason": &"PAYROLL_PREVIEW_INVALID"}
|
|
var row: Dictionary = raw_row
|
|
var staff_id_value: Variant = row.get(&"staff_id", null)
|
|
var total_due_value: Variant = row.get(&"total_due", null)
|
|
if typeof(staff_id_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or StringName(staff_id_value).is_empty() \
|
|
or typeof(total_due_value) != TYPE_INT or int(total_due_value) < 0:
|
|
return {&"ok": false, &"reason": &"PAYROLL_PREVIEW_INVALID"}
|
|
var staff_text := String(staff_id_value)
|
|
if not previous_staff_id.is_empty() and staff_text <= previous_staff_id:
|
|
return {&"ok": false, &"reason": &"PAYROLL_ORDER_INVALID"}
|
|
previous_staff_id = staff_text
|
|
var payment := mini(int(total_due_value), remaining_gold)
|
|
payments[StringName(staff_id_value)] = payment
|
|
remaining_gold -= payment
|
|
expected_paid_total += payment
|
|
|
|
var settlement_id := StringName("payroll-day-%08d" % day)
|
|
var settlement := staff_service.apply_daily_payroll_settlement(
|
|
settlement_id, day, payments
|
|
)
|
|
if not settlement.is_success() \
|
|
or int(settlement.details.get(&"paid_total", -1)) != expected_paid_total:
|
|
return {
|
|
&"ok": false,
|
|
&"reason": &"PAYROLL_SETTLEMENT_FAILED",
|
|
&"result": settlement.to_dictionary(),
|
|
}
|
|
if expected_paid_total > 0:
|
|
var wage_payment := economy_service.transfer(
|
|
PLAYER_CASH_ACCOUNT,
|
|
WAGE_EXPENSE_ACCOUNT,
|
|
expected_paid_total,
|
|
int(payload[&"boundary_minute"]),
|
|
&"WAGE",
|
|
settlement_id,
|
|
StringName("operations.wage.day.%08d" % day),
|
|
&"operations.payroll.settlement",
|
|
&"",
|
|
false
|
|
)
|
|
if not wage_payment.is_success():
|
|
return {
|
|
&"ok": false,
|
|
&"reason": &"WAGE_LEDGER_POST_FAILED",
|
|
&"result": wage_payment.to_dictionary(),
|
|
}
|
|
if not _stage_operations_economy_notification(wage_payment):
|
|
return {
|
|
&"ok": false,
|
|
&"reason": &"WAGE_NOTIFICATION_STAGE_FAILED",
|
|
&"result": wage_payment.to_dictionary(),
|
|
}
|
|
var upkeep_result := _settle_upkeep_through(int(payload[&"boundary_minute"]))
|
|
if not bool(upkeep_result[&"ok"]):
|
|
return upkeep_result
|
|
var upkeep_paid := int(upkeep_result[&"paid_total"])
|
|
var financials := _build_operations_daily_financials(
|
|
int(payload[&"period_start_minute"]),
|
|
int(payload[&"period_end_minute_exclusive"]),
|
|
int(preview.get(&"new_wages_total", -1)),
|
|
preview[&"rows"]
|
|
)
|
|
if not bool(financials.get(&"ok", false)):
|
|
return financials
|
|
var arrears_total := int(settlement.details.get(&"unpaid_after_total", -1))
|
|
if arrears_total < 0 \
|
|
or arrears_total != _staff_payroll_arrears_total(staff_service) \
|
|
or economy_service.get_balance(PLAYER_CASH_ACCOUNT) != available_gold - expected_paid_total - upkeep_paid \
|
|
or int(_state.get_resource(&"gold")) != economy_service.get_balance(PLAYER_CASH_ACCOUNT) \
|
|
or not bool(economy_service.validate_ledger().get(&"ok", false)) \
|
|
or not bool(staff_service.validate_invariants().get(&"ok", false)):
|
|
return {&"ok": false, &"reason": &"DAILY_CLOSE_POSTCONDITION_FAILED"}
|
|
var settlement_report := settlement.details.duplicate(true)
|
|
settlement_report[&"upkeep_arrears_total"] = upkeep_settlement.get_arrears_total()
|
|
settlement_report[&"arrears_total"] = arrears_total + int(settlement_report[&"upkeep_arrears_total"])
|
|
return {
|
|
&"ok": true,
|
|
&"category_totals": financials[&"category_totals"],
|
|
&"reconciliation_difference": financials[&"reconciliation_difference"],
|
|
&"facility_profit_loss": financials[&"facility_profit_loss"],
|
|
&"anomalies": financials[&"anomalies"],
|
|
&"settlement": settlement_report,
|
|
}
|
|
|
|
|
|
func _build_operations_daily_financials(
|
|
period_start: int,
|
|
period_end_exclusive: int,
|
|
new_wages_total: int,
|
|
payroll_rows: Array
|
|
) -> Dictionary:
|
|
if period_start < 0 or period_end_exclusive <= period_start or new_wages_total < 0:
|
|
return {&"ok": false, &"reason": &"DAILY_FINANCIAL_PERIOD_INVALID"}
|
|
if not _loan_principal_ledger_is_valid(economy_service):
|
|
return {&"ok": false, &"reason": &"INVALID_LOAN_PRINCIPAL_LEDGER"}
|
|
var category_totals := {
|
|
&"revenue": 0,
|
|
&"refunds": 0,
|
|
&"cost_of_goods": 0,
|
|
&"wages": new_wages_total,
|
|
&"maintenance": 0,
|
|
&"utilities": 0,
|
|
&"taxes_and_interest": 0,
|
|
&"fines": 0,
|
|
&"construction_cash_flow": 0,
|
|
}
|
|
var facility_rows: Dictionary = {}
|
|
var excluded_categories: Array[StringName] = [
|
|
&"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION",
|
|
&"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION",
|
|
&"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT",
|
|
]
|
|
var category_mapping := {
|
|
&"SERVICE_SALE": &"revenue",
|
|
&"REFUND": &"refunds",
|
|
&"CONSTRUCTION": &"construction_cash_flow",
|
|
&"CONSTRUCTION_REFUND": &"construction_cash_flow",
|
|
&"AUTHORITY_FINE": &"fines",
|
|
&"OPERATING_EXPENSE": &"cost_of_goods",
|
|
&"STAFF_SIGNING_COST": &"cost_of_goods",
|
|
&"EVENT_ACTION": &"cost_of_goods",
|
|
&"EVENT_COST_COMMITTED": &"cost_of_goods",
|
|
&"DEFENSE_SKILL_COST": &"cost_of_goods",
|
|
&"MAINTENANCE_ACCRUAL": &"maintenance",
|
|
&"LOAN_PRINCIPAL_BORROWED": &"financing_principal_inflows",
|
|
&"LOAN_PRINCIPAL_REPAID": &"financing_principal_outflows",
|
|
}
|
|
var operating_refund_total := 0
|
|
for entry: LedgerEntry in economy_service.get_entries():
|
|
if entry.absolute_minute < period_start \
|
|
or entry.absolute_minute >= period_end_exclusive:
|
|
continue
|
|
if entry.category in excluded_categories:
|
|
continue
|
|
if entry.category == &"OPERATING_REFUND":
|
|
operating_refund_total += entry.amount
|
|
continue
|
|
if not category_mapping.has(entry.category):
|
|
return {
|
|
&"ok": false,
|
|
&"reason": &"UNKNOWN_LEDGER_CATEGORY",
|
|
&"category": entry.category,
|
|
&"transaction_id": entry.transaction_id,
|
|
}
|
|
var category: StringName = category_mapping[entry.category]
|
|
category_totals[category] = int(category_totals.get(category, 0)) + entry.amount
|
|
if not entry.source_entity_id.is_empty() \
|
|
and (facility_service.has_instance(entry.source_entity_id) or entry.category == &"MAINTENANCE_ACCRUAL") \
|
|
and category not in [&"construction_cash_flow", &"financing_principal_inflows", &"financing_principal_outflows"]:
|
|
if not facility_rows.has(entry.source_entity_id):
|
|
facility_rows[entry.source_entity_id] = {
|
|
&"revenue": 0, &"expenses": 0, &"net_profit": 0,
|
|
}
|
|
var facility_row: Dictionary = facility_rows[entry.source_entity_id]
|
|
if category == &"revenue":
|
|
facility_row[&"revenue"] = int(facility_row[&"revenue"]) + entry.amount
|
|
else:
|
|
facility_row[&"expenses"] = int(facility_row[&"expenses"]) + entry.amount
|
|
var anomalies: Array[Dictionary] = []
|
|
var wage_allocation := _allocate_operations_wages_to_facilities(
|
|
period_start,
|
|
period_end_exclusive,
|
|
new_wages_total,
|
|
payroll_rows,
|
|
facility_rows
|
|
)
|
|
if not bool(wage_allocation.get(&"ok", false)):
|
|
return wage_allocation
|
|
anomalies.append_array(wage_allocation[&"anomalies"])
|
|
var operating_refund_offset := mini(
|
|
operating_refund_total,
|
|
int(category_totals[&"cost_of_goods"])
|
|
)
|
|
category_totals[&"cost_of_goods"] = int(category_totals[&"cost_of_goods"]) \
|
|
- operating_refund_offset
|
|
var excess_operating_refund := operating_refund_total - operating_refund_offset
|
|
if excess_operating_refund > 0:
|
|
category_totals[&"revenue"] = int(category_totals[&"revenue"]) \
|
|
+ excess_operating_refund
|
|
anomalies.append({
|
|
&"code": &"OPERATING_REFUND_EXCEEDS_PERIOD_COST",
|
|
&"refund_total": operating_refund_total,
|
|
&"cost_offset": operating_refund_offset,
|
|
&"excess_revenue": excess_operating_refund,
|
|
})
|
|
for facility_id: StringName in _sorted_name_keys(facility_rows):
|
|
var row: Dictionary = facility_rows[facility_id]
|
|
row[&"net_profit"] = int(row[&"revenue"]) - int(row[&"expenses"])
|
|
var allocation_rows: Array[Dictionary] = []
|
|
for facility_id: StringName in _sorted_name_keys(
|
|
wage_allocation[&"allocated_by_facility"]
|
|
):
|
|
allocation_rows.append({
|
|
&"facility_id": String(facility_id),
|
|
&"amount": int(wage_allocation[&"allocated_by_facility"][facility_id]),
|
|
})
|
|
var unallocated_rows: Array[Dictionary] = []
|
|
for staff_id: StringName in _sorted_name_keys(
|
|
wage_allocation[&"unallocated_by_staff"]
|
|
):
|
|
unallocated_rows.append({
|
|
&"staff_id": String(staff_id),
|
|
&"amount": int(wage_allocation[&"unallocated_by_staff"][staff_id]),
|
|
})
|
|
var profit_loss_facility_ids: Array[String] = []
|
|
for facility_id: StringName in _sorted_name_keys(facility_rows):
|
|
profit_loss_facility_ids.append(String(facility_id))
|
|
anomalies.append({
|
|
&"code": &"WAGE_ALLOCATION_EVIDENCE",
|
|
&"version": 1,
|
|
&"new_wages_total": new_wages_total,
|
|
&"allocated_total": int(wage_allocation[&"allocated_total"]),
|
|
&"unallocated_total": int(wage_allocation[&"unallocated_total"]),
|
|
&"allocations": allocation_rows,
|
|
&"unallocated_by_staff": unallocated_rows,
|
|
&"facility_ids": profit_loss_facility_ids,
|
|
})
|
|
var daily_summary := economy_service.get_daily_summary(period_start / 1_440 + 1)
|
|
if daily_summary.is_empty():
|
|
return {&"ok": false, &"reason": &"LEDGER_PERIOD_SUMMARY_MISSING"}
|
|
var difference := int(daily_summary.get(&"total_debit", 0)) \
|
|
- int(daily_summary.get(&"total_credit", 0))
|
|
return {
|
|
&"ok": true,
|
|
&"category_totals": category_totals,
|
|
&"reconciliation_difference": difference,
|
|
&"facility_profit_loss": facility_rows,
|
|
&"anomalies": anomalies,
|
|
}
|
|
|
|
|
|
func _allocate_operations_wages_to_facilities(
|
|
period_start: int,
|
|
period_end_exclusive: int,
|
|
new_wages_total: int,
|
|
payroll_rows: Array,
|
|
facility_rows: Dictionary
|
|
) -> Dictionary:
|
|
var new_wages_by_staff: Dictionary = {}
|
|
var observed_new_wages := 0
|
|
var previous_staff_text := ""
|
|
for raw_row: Variant in payroll_rows:
|
|
if not raw_row is Dictionary:
|
|
return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_INPUT_INVALID"}
|
|
var row: Dictionary = raw_row
|
|
var staff_id_value: Variant = row.get(&"staff_id", null)
|
|
var new_wages_value: Variant = row.get(&"new_wages", null)
|
|
if typeof(staff_id_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or StringName(staff_id_value).is_empty() \
|
|
or typeof(new_wages_value) != TYPE_INT or int(new_wages_value) < 0:
|
|
return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_INPUT_INVALID"}
|
|
var staff_text := String(staff_id_value)
|
|
if not previous_staff_text.is_empty() and staff_text <= previous_staff_text:
|
|
return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_ORDER_INVALID"}
|
|
previous_staff_text = staff_text
|
|
new_wages_by_staff[StringName(staff_id_value)] = int(new_wages_value)
|
|
observed_new_wages += int(new_wages_value)
|
|
if observed_new_wages != new_wages_total:
|
|
return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_TOTAL_MISMATCH"}
|
|
|
|
var weights_by_staff: Dictionary = {}
|
|
for shift: ShiftInstance in staff_service.get_shifts():
|
|
if shift == null or shift.cancelled or shift.assigned_facility_id.is_empty() \
|
|
or not new_wages_by_staff.has(shift.staff_id) \
|
|
or int(new_wages_by_staff[shift.staff_id]) <= 0 \
|
|
or not facility_service.has_instance(shift.assigned_facility_id):
|
|
continue
|
|
var overlap := maxi(
|
|
mini(shift.end_at, period_end_exclusive) - maxi(shift.start_at, period_start),
|
|
0
|
|
)
|
|
if overlap <= 0:
|
|
continue
|
|
# Recorded duty is the strongest attribution fact. A future/unadvanced Shift
|
|
# still uses its scheduled overlap so daily wages never disappear merely
|
|
# because the Runtime was restored immediately before the close.
|
|
var recorded_minutes := shift.worked_minutes + shift.overtime_minutes
|
|
var weight := mini(recorded_minutes, overlap) if recorded_minutes > 0 else overlap
|
|
if weight <= 0:
|
|
continue
|
|
if not weights_by_staff.has(shift.staff_id):
|
|
weights_by_staff[shift.staff_id] = {}
|
|
var facility_weights: Dictionary = weights_by_staff[shift.staff_id]
|
|
facility_weights[shift.assigned_facility_id] = int(
|
|
facility_weights.get(shift.assigned_facility_id, 0)
|
|
) + weight
|
|
|
|
var anomalies: Array[Dictionary] = []
|
|
var allocated_total := 0
|
|
var allocated_by_facility: Dictionary = {}
|
|
var unallocated_by_staff: Dictionary = {}
|
|
for staff_id: StringName in _sorted_name_keys(new_wages_by_staff):
|
|
var wage := int(new_wages_by_staff[staff_id])
|
|
if wage <= 0:
|
|
continue
|
|
var weights: Dictionary = weights_by_staff.get(staff_id, {})
|
|
if weights.is_empty():
|
|
unallocated_by_staff[staff_id] = wage
|
|
anomalies.append({
|
|
&"code": &"UNALLOCATED_WAGES",
|
|
&"staff_id": staff_id,
|
|
&"amount": wage,
|
|
})
|
|
continue
|
|
var total_weight := 0
|
|
for facility_id: StringName in _sorted_name_keys(weights):
|
|
total_weight += int(weights[facility_id])
|
|
if total_weight <= 0:
|
|
return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_WEIGHT_INVALID"}
|
|
var allocations: Dictionary = {}
|
|
var floor_total := 0
|
|
var facility_ids := _sorted_name_keys(weights)
|
|
for facility_id: StringName in facility_ids:
|
|
var amount := wage * int(weights[facility_id]) / total_weight
|
|
allocations[facility_id] = amount
|
|
floor_total += amount
|
|
var remainder := wage - floor_total
|
|
for index in range(remainder):
|
|
var facility_id: StringName = facility_ids[index]
|
|
allocations[facility_id] = int(allocations[facility_id]) + 1
|
|
for facility_id: StringName in facility_ids:
|
|
var amount := int(allocations[facility_id])
|
|
if amount <= 0:
|
|
continue
|
|
if not facility_rows.has(facility_id):
|
|
facility_rows[facility_id] = {
|
|
&"revenue": 0, &"expenses": 0, &"net_profit": 0,
|
|
}
|
|
var facility_row: Dictionary = facility_rows[facility_id]
|
|
facility_row[&"expenses"] = int(facility_row[&"expenses"]) + amount
|
|
allocated_by_facility[facility_id] = int(
|
|
allocated_by_facility.get(facility_id, 0)
|
|
) + amount
|
|
allocated_total += amount
|
|
if allocated_total > new_wages_total:
|
|
return {&"ok": false, &"reason": &"PAYROLL_ALLOCATION_EXCEEDS_TOTAL"}
|
|
return {
|
|
&"ok": true,
|
|
&"allocated_total": allocated_total,
|
|
&"unallocated_total": new_wages_total - allocated_total,
|
|
&"allocated_by_facility": allocated_by_facility,
|
|
&"unallocated_by_staff": unallocated_by_staff,
|
|
&"anomalies": anomalies,
|
|
}
|
|
|
|
|
|
func _operations_arrival_boundary(
|
|
payload: Dictionary,
|
|
phase: StringName,
|
|
token: Variant
|
|
) -> Dictionary:
|
|
if phase == OperationsCoordinator.PHASE_CAPTURE:
|
|
var captured := _capture_operations_external_state()
|
|
var population_cap := (
|
|
int(operations_coordinator.get_config().get(&"population_cap", 0))
|
|
if operations_coordinator != null else 0
|
|
)
|
|
return {
|
|
&"ok": not captured.is_empty(),
|
|
&"token": captured,
|
|
&"capacity_remaining": maxi(population_cap - guest_service.guest_count(), 0) if int(_state.get_resource(&"mana")) > 0 and weather.permits_new_arrivals() else 0,
|
|
}
|
|
if phase == OperationsCoordinator.PHASE_VALIDATE:
|
|
return {
|
|
&"ok": token is Dictionary and not token.is_empty() \
|
|
and _operations_arrival_payload_is_valid(payload),
|
|
&"token": token,
|
|
}
|
|
if phase == OperationsCoordinator.PHASE_APPLY:
|
|
return _apply_operations_arrivals(payload, token)
|
|
if phase == OperationsCoordinator.PHASE_ROLLBACK:
|
|
return {&"ok": _restore_operations_external_state(token)}
|
|
return {&"ok": false}
|
|
|
|
|
|
func _operations_arrival_payload_is_valid(payload: Dictionary) -> bool:
|
|
var plans_value: Variant = payload.get(&"plans", null)
|
|
var total_value: Variant = payload.get(&"total_count", null)
|
|
var minute_value: Variant = payload.get(&"minute", null)
|
|
if not plans_value is Array or typeof(total_value) != TYPE_INT \
|
|
or int(total_value) < 0 or typeof(minute_value) != TYPE_INT \
|
|
or int(minute_value) != _clock.absolute_minute:
|
|
return false
|
|
var allowed_classes: Dictionary = {}
|
|
for class_id: StringName in mvp_content_catalog.get_guest_class_ids():
|
|
allowed_classes[class_id] = true
|
|
var species_ids := mvp_content_catalog.get_species_ids()
|
|
if allowed_classes.size() != 8 or species_ids.size() != 4:
|
|
return false
|
|
var seen_guest_ids: Dictionary = {}
|
|
var seen_plan_ids: Dictionary = {}
|
|
var counted := 0
|
|
for raw_plan: Variant in plans_value:
|
|
if not raw_plan is Dictionary:
|
|
return false
|
|
var plan: Dictionary = raw_plan
|
|
var plan_id_value: Variant = plan.get(&"plan_id", null)
|
|
var guest_class_value: Variant = plan.get(&"guest_class", null)
|
|
var facility_tag_value: Variant = plan.get(&"facility_tag", null)
|
|
var time_band_value: Variant = plan.get(&"time_band", null)
|
|
var plan_minute_value: Variant = plan.get(&"minute", null)
|
|
var count_value: Variant = plan.get(&"count", null)
|
|
var guest_ids_value: Variant = plan.get(&"guest_ids", null)
|
|
if typeof(plan_id_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or not _operations_sequence_id_is_valid(String(plan_id_value), "arrival-plan-") \
|
|
or seen_plan_ids.has(StringName(plan_id_value)) \
|
|
or typeof(guest_class_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or not allowed_classes.has(StringName(guest_class_value)) \
|
|
or typeof(facility_tag_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or StringName(facility_tag_value).is_empty() \
|
|
or typeof(time_band_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or StringName(time_band_value) not in OperationsCoordinator.TIME_BANDS \
|
|
or typeof(plan_minute_value) != TYPE_INT \
|
|
or int(plan_minute_value) != int(minute_value) \
|
|
or typeof(count_value) != TYPE_INT or int(count_value) <= 0 \
|
|
or not guest_ids_value is Array \
|
|
or guest_ids_value.size() != int(count_value):
|
|
return false
|
|
seen_plan_ids[StringName(plan_id_value)] = true
|
|
for raw_guest_id: Variant in guest_ids_value:
|
|
if typeof(raw_guest_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return false
|
|
var guest_id := StringName(raw_guest_id)
|
|
var actor_id := _guest_path_actor_id(guest_id)
|
|
if not _operations_sequence_id_is_valid(String(guest_id), "guest-auto-") \
|
|
or seen_guest_ids.has(guest_id) or guest_service.has_guest(guest_id) \
|
|
or not guest_service.get_persistent_record(guest_id).is_empty() \
|
|
or economy_service.has_account(_guest_wallet_account(guest_id)) \
|
|
or path_service.has_actor_projection(actor_id) \
|
|
or _movement_credits.has(actor_id):
|
|
return false
|
|
seen_guest_ids[guest_id] = true
|
|
counted += 1
|
|
if counted != int(total_value):
|
|
return false
|
|
var population_cap := int(operations_coordinator.get_config().get(&"population_cap", 0))
|
|
return guest_service.guest_count() + counted <= population_cap \
|
|
and _operations_entrance_cell() != null
|
|
|
|
|
|
func _operations_sequence_id_is_valid(value: String, prefix: String) -> bool:
|
|
if not value.begins_with(prefix) or value.length() != prefix.length() + 8:
|
|
return false
|
|
var suffix := value.substr(prefix.length(), 8)
|
|
if not suffix.is_valid_int():
|
|
return false
|
|
var sequence := int(suffix)
|
|
return sequence > 0 and value == "%s%08d" % [prefix, sequence]
|
|
|
|
|
|
func _operations_entrance_cell() -> Variant:
|
|
var bounds := world_service.get_bounds()
|
|
for x in range(int(bounds.get(&"min_x", 0)), int(bounds.get(&"max_x", -1)) + 1):
|
|
var cell := Vector3i(x, 0, WorldService.LAYER_FRONT)
|
|
if not world_service.is_in_bounds(cell) \
|
|
or world_service.get_zone(cell) != WorldService.ZONE_BOUNDARY:
|
|
continue
|
|
var traffic := path_service.get_cell_traffic(cell)
|
|
if traffic.is_empty() or bool(traffic.get(&"temporary_block", false)):
|
|
continue
|
|
return cell
|
|
return null
|
|
|
|
|
|
func _apply_operations_arrivals(payload: Dictionary, token: Variant) -> Dictionary:
|
|
if not token is Dictionary or token.is_empty() \
|
|
or not _operations_arrival_payload_is_valid(payload):
|
|
return {&"ok": false, &"reason": &"ARRIVAL_PRECONDITION_FAILED"}
|
|
var prepare_hook := _run_operations_test_boundary_hook(
|
|
&"ARRIVAL_PLAN", payload, OPERATIONS_TEST_PREPARE_PHASE, token
|
|
)
|
|
if not bool(prepare_hook.get(&"ok", false)):
|
|
return prepare_hook
|
|
if int(progression_service.metrics.get(&"guest_lifetime", 0)) \
|
|
> ProgressionService.MAX_SAFE_INTEGER - int(payload[&"total_count"]):
|
|
return {&"ok": false, &"reason": &"ARRIVAL_LIFETIME_LIMIT"}
|
|
var entrance_value: Variant = _operations_entrance_cell()
|
|
var spawn_rng := get_rng_stream(&"guest_spawn")
|
|
var species_ids := mvp_content_catalog.get_species_ids()
|
|
if entrance_value == null or spawn_rng == null or species_ids.size() != 4:
|
|
return {&"ok": false, &"reason": &"ARRIVAL_CONTENT_UNAVAILABLE"}
|
|
var entrance: Vector3i = entrance_value
|
|
var accepted_count := 0
|
|
var arrivals: Array[Dictionary] = []
|
|
for raw_plan: Variant in payload[&"plans"]:
|
|
var plan: Dictionary = raw_plan
|
|
var guest_class := StringName(plan[&"guest_class"])
|
|
for raw_guest_id: Variant in plan[&"guest_ids"]:
|
|
var guest_id := StringName(raw_guest_id)
|
|
var species := species_ids[spawn_rng.randi_range(0, species_ids.size() - 1)]
|
|
var definition_id := StringName("%s_%s" % [guest_class, species])
|
|
var definition := guest_service.get_definition(definition_id)
|
|
if definition == null or not definition.adult_verified \
|
|
or definition.wealth_min < 0 or definition.wealth_max < definition.wealth_min:
|
|
return {&"ok": false, &"reason": &"ARRIVAL_DEFINITION_INVALID"}
|
|
var wallet_gold := spawn_rng.randi_range(
|
|
definition.wealth_min, definition.wealth_max
|
|
)
|
|
var command := SimulationCommand.new(
|
|
1,
|
|
_clock.absolute_minute,
|
|
_clock.absolute_minute,
|
|
0,
|
|
&"spawn_guest",
|
|
&"operations_coordinator",
|
|
guest_id,
|
|
{
|
|
&"guest_id": guest_id,
|
|
&"guest_type_id": definition_id,
|
|
&"gold": wallet_gold,
|
|
&"options": {
|
|
&"current_cell": entrance,
|
|
&"needs": {
|
|
&"hunger": 0, &"sleep": 0, &"hygiene": 0,
|
|
&"medical": 0, &"leisure": 0, &"intimacy": 0,
|
|
},
|
|
&"planned_exit_at": _clock.absolute_minute + 720,
|
|
},
|
|
},
|
|
StringName("operations.arrival.%s" % guest_id)
|
|
)
|
|
var spawned := _execute_spawn_guest(command)
|
|
var guest := guest_service.get_guest(guest_id)
|
|
if not spawned.is_success() or guest == null \
|
|
or guest.guest_type_id != definition_id \
|
|
or guest.current_cell != entrance \
|
|
or guest.wallet_gold != wallet_gold \
|
|
or economy_service.get_balance(_guest_wallet_account(guest_id)) != wallet_gold:
|
|
return {
|
|
&"ok": false,
|
|
&"reason": &"ARRIVAL_SPAWN_FAILED",
|
|
&"guest_id": guest_id,
|
|
&"result": spawned.to_dictionary(),
|
|
}
|
|
var arrival_row := {
|
|
&"guest_id": guest_id,
|
|
&"plan_id": StringName(plan[&"plan_id"]),
|
|
&"guest_class": guest_class,
|
|
&"species": species,
|
|
&"definition_id": definition_id,
|
|
&"adult_verified": true,
|
|
&"wallet_gold": wallet_gold,
|
|
&"entrance_cell": entrance,
|
|
}
|
|
if not _publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"),
|
|
&"GUEST_AUTO_ARRIVED",
|
|
_clock.absolute_minute,
|
|
&"operations_coordinator",
|
|
[guest_id, definition_id],
|
|
arrival_row,
|
|
0
|
|
)):
|
|
return {&"ok": false, &"reason": &"ARRIVAL_EVENT_FAILED"}
|
|
arrivals.append(arrival_row)
|
|
accepted_count += 1
|
|
if accepted_count == 1:
|
|
var after_mutation_hook := _run_operations_test_boundary_hook(
|
|
&"ARRIVAL_PLAN",
|
|
payload,
|
|
OPERATIONS_TEST_AFTER_FIRST_MUTATION_PHASE,
|
|
token
|
|
)
|
|
if not bool(after_mutation_hook.get(&"ok", false)):
|
|
return after_mutation_hook
|
|
var runtime_crosslinks_ok := _validate_runtime_crosslinks(
|
|
facility_service, guest_service, economy_service, inventory_service,
|
|
staff_service, _operations_external_committed_minute(),
|
|
_staffing_projection_enabled
|
|
)
|
|
var path_crosslinks_ok := _validate_runtime_path_crosslinks(
|
|
path_service, guest_service, staff_service, _movement_credits,
|
|
_clock.absolute_minute
|
|
)
|
|
if accepted_count != int(payload[&"total_count"]) \
|
|
or not runtime_crosslinks_ok or not path_crosslinks_ok:
|
|
return {
|
|
&"ok": false,
|
|
&"reason": &"ARRIVAL_POSTCONDITION_FAILED",
|
|
&"accepted_count": accepted_count,
|
|
&"expected_count": int(payload[&"total_count"]),
|
|
&"runtime_crosslinks_ok": runtime_crosslinks_ok,
|
|
&"path_crosslinks_ok": path_crosslinks_ok,
|
|
}
|
|
return {
|
|
&"ok": true,
|
|
&"accepted_count": accepted_count,
|
|
}
|
|
|
|
|
|
func _operations_morning_boundary(
|
|
payload: Dictionary,
|
|
phase: StringName,
|
|
token: Variant
|
|
) -> Dictionary:
|
|
if phase == OperationsCoordinator.PHASE_CAPTURE:
|
|
var captured := _capture_operations_external_state()
|
|
return {&"ok": not captured.is_empty(), &"token": captured}
|
|
if phase == OperationsCoordinator.PHASE_VALIDATE:
|
|
return {
|
|
&"ok": token is Dictionary and not token.is_empty() \
|
|
and _operations_morning_payload_is_valid(payload),
|
|
&"token": token,
|
|
}
|
|
if phase == OperationsCoordinator.PHASE_APPLY:
|
|
return _apply_operations_morning(payload, token)
|
|
if phase == OperationsCoordinator.PHASE_ROLLBACK:
|
|
return {&"ok": _restore_operations_external_state(token)}
|
|
return {&"ok": false}
|
|
|
|
|
|
func _operations_morning_payload_is_valid(payload: Dictionary) -> bool:
|
|
var day_value: Variant = payload.get(&"day", null)
|
|
var minute_value: Variant = payload.get(&"boundary_minute", null)
|
|
if typeof(day_value) != TYPE_INT or int(day_value) <= 0 \
|
|
or typeof(minute_value) != TYPE_INT:
|
|
return false
|
|
var minute := int(minute_value)
|
|
return minute == _clock.absolute_minute \
|
|
and posmod(minute, SimulationKernel.MINUTES_PER_DAY) == OPERATIONS_START_MINUTE \
|
|
and int(day_value) == minute / SimulationKernel.MINUTES_PER_DAY + 1
|
|
|
|
|
|
func _apply_operations_morning(payload: Dictionary, token: Variant) -> Dictionary:
|
|
if not token is Dictionary or token.is_empty() \
|
|
or not _operations_morning_payload_is_valid(payload):
|
|
return {&"ok": false, &"reason": &"MORNING_PRECONDITION_FAILED"}
|
|
var prepare_hook := _run_operations_test_boundary_hook(
|
|
&"MORNING_BOUNDARY", payload, OPERATIONS_TEST_PREPARE_PHASE, token
|
|
)
|
|
if not bool(prepare_hook.get(&"ok", false)):
|
|
return prepare_hook
|
|
var minute := int(payload[&"boundary_minute"])
|
|
var before_ap := _personal_action_points
|
|
_personal_action_points = mini(
|
|
_personal_action_points + 2,
|
|
MAX_PERSONAL_ACTION_POINTS
|
|
)
|
|
var authority_advance := authority_service.advance_time(minute)
|
|
if not authority_advance.is_success():
|
|
return {
|
|
&"ok": false,
|
|
&"reason": &"MORNING_AUTHORITY_ADVANCE_FAILED",
|
|
&"result": authority_advance.to_dictionary(),
|
|
}
|
|
var authority_evaluation := authority_service.evaluate_inspection(minute)
|
|
if not authority_evaluation.is_success():
|
|
return {
|
|
&"ok": false,
|
|
&"reason": &"MORNING_AUTHORITY_EVALUATION_FAILED",
|
|
&"result": authority_evaluation.to_dictionary(),
|
|
}
|
|
if not authority_evaluation.is_replay:
|
|
_publish_authority_evaluation(authority_evaluation.details)
|
|
_enqueue_pending_relationship_candidates()
|
|
# start_next_event owns exactly one expiry pass and one actor audit pass.
|
|
# Calling those helpers separately here would double-apply morning effects.
|
|
var event_start := event_service.start_next_event(
|
|
minute,
|
|
_build_event_context(minute)
|
|
)
|
|
var no_event_ready := (
|
|
not event_start.is_success()
|
|
and event_start.message == "No eligible Event candidate is ready."
|
|
)
|
|
var foreground_already_active := (
|
|
not event_start.is_success()
|
|
and event_start.message == "A foreground Event is already active."
|
|
)
|
|
if not event_start.is_success() and not no_event_ready and not foreground_already_active:
|
|
return {
|
|
&"ok": false,
|
|
&"reason": &"MORNING_EVENT_START_FAILED",
|
|
&"result": event_start.to_dictionary(),
|
|
}
|
|
if _personal_action_points < 0 \
|
|
or _personal_action_points > MAX_PERSONAL_ACTION_POINTS \
|
|
or authority_service.current_minute != minute \
|
|
or not bool(authority_service.validate_invariants().get(&"ok", false)) \
|
|
or not bool(event_service.validate_invariants().get(&"ok", false)) \
|
|
or not bool(relationship_service.validate_invariants().get(&"ok", false)):
|
|
return {&"ok": false, &"reason": &"MORNING_POSTCONDITION_FAILED"}
|
|
return {
|
|
&"ok": true,
|
|
&"event_payload": {
|
|
&"personal_action_points_before": before_ap,
|
|
&"personal_action_points_after": _personal_action_points,
|
|
&"authority_advance": authority_advance.to_dictionary(),
|
|
&"authority_evaluation": authority_evaluation.to_dictionary(),
|
|
&"event_start": event_start.to_dictionary(),
|
|
&"no_event_ready": no_event_ready,
|
|
&"foreground_already_active": foreground_already_active,
|
|
},
|
|
}
|
|
|
|
|
|
func _create_operations_runtime_snapshot() -> Dictionary:
|
|
return {
|
|
&"wrapper_version": OPERATIONS_RUNTIME_WRAPPER_VERSION,
|
|
&"enabled": _operations_enabled,
|
|
&"personal_action_points": _personal_action_points,
|
|
&"runtime_failure_latched": _operations_runtime_failure_latched,
|
|
&"runtime_failure_report": _operations_runtime_failure_report.duplicate(true),
|
|
&"deferred_runtime_minute": _operations_deferred_runtime_minute,
|
|
&"coordinator": (
|
|
operations_coordinator.create_snapshot()
|
|
if _operations_enabled and operations_coordinator != null else {}
|
|
),
|
|
}
|
|
|
|
|
|
func _parse_operations_runtime_snapshot(
|
|
raw_wrapper: Variant,
|
|
runtime_schema_version: int,
|
|
restored_minute: int,
|
|
restored_guests: GuestService,
|
|
restored_staff: StaffService,
|
|
restored_economy: EconomyService,
|
|
restored_world_seed: int,
|
|
restored_clock_paused: Variant,
|
|
restored_upkeep_settlement: FacilityUpkeepSettlement
|
|
) -> Dictionary:
|
|
var disabled := {
|
|
&"ok": true,
|
|
&"enabled": false,
|
|
&"personal_action_points": 0,
|
|
&"runtime_failure_latched": false,
|
|
&"runtime_failure_report": {},
|
|
&"deferred_runtime_minute": -1,
|
|
&"coordinator": null,
|
|
}
|
|
if runtime_schema_version < OPERATIONS_RUNTIME_SNAPSHOT_VERSION:
|
|
return disabled
|
|
if typeof(restored_clock_paused) != TYPE_BOOL \
|
|
or not restored_economy.has_account(WAGE_EXPENSE_ACCOUNT):
|
|
return {&"ok": false}
|
|
if not raw_wrapper is Dictionary:
|
|
return {&"ok": false}
|
|
var wrapper: Dictionary = raw_wrapper
|
|
var expected_keys: Array[StringName] = [
|
|
&"wrapper_version", &"enabled", &"personal_action_points",
|
|
&"runtime_failure_latched", &"runtime_failure_report",
|
|
&"deferred_runtime_minute", &"coordinator",
|
|
]
|
|
if not _dictionary_has_exact_canonical_keys(wrapper, expected_keys):
|
|
return {&"ok": false}
|
|
if typeof(wrapper[&"wrapper_version"]) != TYPE_INT \
|
|
or int(wrapper[&"wrapper_version"]) != OPERATIONS_RUNTIME_WRAPPER_VERSION \
|
|
or typeof(wrapper[&"enabled"]) != TYPE_BOOL \
|
|
or typeof(wrapper[&"personal_action_points"]) != TYPE_INT \
|
|
or typeof(wrapper[&"runtime_failure_latched"]) != TYPE_BOOL \
|
|
or not wrapper[&"runtime_failure_report"] is Dictionary \
|
|
or typeof(wrapper[&"deferred_runtime_minute"]) != TYPE_INT \
|
|
or not wrapper[&"coordinator"] is Dictionary:
|
|
return {&"ok": false}
|
|
var enabled := bool(wrapper[&"enabled"])
|
|
var action_points := int(wrapper[&"personal_action_points"])
|
|
var failure_latched := bool(wrapper[&"runtime_failure_latched"])
|
|
var failure_report: Dictionary = wrapper[&"runtime_failure_report"]
|
|
var deferred_runtime_minute := int(wrapper[&"deferred_runtime_minute"])
|
|
var coordinator_snapshot: Dictionary = wrapper[&"coordinator"]
|
|
if action_points < 0 or action_points > MAX_PERSONAL_ACTION_POINTS \
|
|
or failure_latched != not failure_report.is_empty() \
|
|
or (failure_latched \
|
|
and not _operations_failure_report_is_valid(failure_report, restored_minute)):
|
|
return {&"ok": false}
|
|
if not enabled:
|
|
if action_points != 0 or failure_latched or deferred_runtime_minute != -1 \
|
|
or not coordinator_snapshot.is_empty():
|
|
return {&"ok": false}
|
|
return disabled
|
|
var restored_coordinator := OperationsCoordinator.new()
|
|
if not restored_coordinator.restore_snapshot(coordinator_snapshot):
|
|
return {&"ok": false}
|
|
var coordinator_current_matches := restored_coordinator.current_minute == restored_minute
|
|
var pending_boundary_minute := int(
|
|
coordinator_snapshot.get(&"pending_boundary_minute", -1)
|
|
)
|
|
var deferred_boundary_minute := int(
|
|
coordinator_snapshot.get(&"deferred_boundary_minute", -1)
|
|
)
|
|
var normal_shape := deferred_runtime_minute == -1 \
|
|
and not failure_latched and not restored_coordinator.pause_requested \
|
|
and not restored_coordinator.is_terminal() \
|
|
and coordinator_current_matches
|
|
var failure_shape := deferred_runtime_minute == restored_minute \
|
|
and failure_latched and bool(restored_clock_paused) \
|
|
and restored_coordinator.current_minute == restored_minute - 1 \
|
|
and pending_boundary_minute == restored_minute
|
|
var pause_shape := deferred_runtime_minute == restored_minute \
|
|
and not failure_latched and bool(restored_clock_paused) \
|
|
and restored_coordinator.pause_requested and coordinator_current_matches \
|
|
and deferred_boundary_minute == restored_minute
|
|
var terminal_shape := deferred_runtime_minute == -1 \
|
|
and not failure_latched and restored_coordinator.is_terminal() \
|
|
and coordinator_current_matches and bool(restored_clock_paused)
|
|
if not bool(restored_coordinator.validate_invariants().get(&"ok", false)) \
|
|
or restored_coordinator.get_origin_minute() != OPERATIONS_COORDINATOR_ORIGIN_MINUTE \
|
|
or not (normal_shape or failure_shape or pause_shape or terminal_shape) \
|
|
or restored_coordinator.get_population_count() != restored_guests.guest_count() \
|
|
or restored_coordinator.get_arrears_total() != (_staff_payroll_arrears_total(restored_staff) + restored_upkeep_settlement.get_arrears_through(restored_coordinator.get_daily_close_minute(restored_coordinator.get_open_financial_day() - 1))) \
|
|
or not _operations_payroll_crosslinks_are_valid(
|
|
restored_coordinator, restored_staff, restored_economy, restored_upkeep_settlement
|
|
) \
|
|
or not _operations_coordinator_configuration_is_canonical(
|
|
restored_coordinator, restored_world_seed
|
|
) \
|
|
or not _operations_arrival_guest_crosslinks_are_valid(
|
|
restored_coordinator, restored_guests
|
|
) \
|
|
or (restored_coordinator.is_external_quarantined() \
|
|
and not (failure_shape or pause_shape)):
|
|
return {&"ok": false}
|
|
return {
|
|
&"ok": true,
|
|
&"enabled": true,
|
|
&"personal_action_points": action_points,
|
|
&"runtime_failure_latched": failure_latched,
|
|
&"runtime_failure_report": failure_report.duplicate(true),
|
|
&"deferred_runtime_minute": deferred_runtime_minute,
|
|
&"coordinator": restored_coordinator,
|
|
}
|
|
|
|
|
|
func _progression_guest_lifetime_matches(
|
|
progression: ProgressionService,
|
|
guests: GuestService
|
|
) -> bool:
|
|
if progression == null or guests == null:
|
|
return false
|
|
var guest_snapshot := guests.create_snapshot()
|
|
var persistent_value: Variant = guest_snapshot.get(&"persistent_records", null)
|
|
var metrics_value: Variant = progression.create_snapshot().get(&"metrics", null)
|
|
if not persistent_value is Array or not metrics_value is Dictionary:
|
|
return false
|
|
var lifetime_value: Variant = (metrics_value as Dictionary).get(&"guest_lifetime", null)
|
|
return typeof(lifetime_value) == TYPE_INT \
|
|
and int(lifetime_value) == guests.guest_count() + persistent_value.size()
|
|
|
|
|
|
func _operations_arrival_guest_crosslinks_are_valid(
|
|
coordinator: OperationsCoordinator,
|
|
guests: GuestService
|
|
) -> bool:
|
|
if coordinator == null or guests == null:
|
|
return false
|
|
var definitions_by_guest: Dictionary = {}
|
|
for guest: GuestRuntime in guests.get_guests():
|
|
definitions_by_guest[guest.guest_id] = guest.guest_type_id
|
|
var guest_snapshot := guests.create_snapshot()
|
|
var persistent_value: Variant = guest_snapshot.get(&"persistent_records", null)
|
|
if not persistent_value is Array:
|
|
return false
|
|
for raw_record: Variant in persistent_value:
|
|
if not raw_record is Dictionary:
|
|
return false
|
|
var guest_id := StringName((raw_record as Dictionary).get(&"guest_id", &""))
|
|
var definition_id := StringName(
|
|
(raw_record as Dictionary).get(&"guest_type_id", &"")
|
|
)
|
|
if guest_id.is_empty() or definition_id.is_empty() \
|
|
or definitions_by_guest.has(guest_id):
|
|
return false
|
|
definitions_by_guest[guest_id] = definition_id
|
|
var history_ids: Dictionary = {}
|
|
var expected_sequence := 1
|
|
for report: Dictionary in coordinator.get_arrival_reports():
|
|
var plans_value: Variant = report.get(&"plans", null)
|
|
if not plans_value is Array:
|
|
return false
|
|
for raw_plan: Variant in plans_value:
|
|
if not raw_plan is Dictionary:
|
|
return false
|
|
var plan: Dictionary = raw_plan
|
|
var guest_class := StringName(plan.get(&"guest_class", &""))
|
|
var ids_value: Variant = plan.get(&"guest_ids", null)
|
|
if guest_class.is_empty() or not ids_value is Array:
|
|
return false
|
|
for raw_guest_id: Variant in ids_value:
|
|
if typeof(raw_guest_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return false
|
|
var guest_id := StringName(raw_guest_id)
|
|
if guest_id != StringName("guest-auto-%08d" % expected_sequence) \
|
|
or history_ids.has(guest_id) \
|
|
or not definitions_by_guest.has(guest_id):
|
|
return false
|
|
var definition_id := String(definitions_by_guest[guest_id])
|
|
if not definition_id.begins_with("%s_" % String(guest_class)):
|
|
return false
|
|
history_ids[guest_id] = true
|
|
expected_sequence += 1
|
|
for raw_guest_id: Variant in definitions_by_guest:
|
|
var guest_id := StringName(raw_guest_id)
|
|
if String(guest_id).begins_with("guest-auto-") \
|
|
and not history_ids.has(guest_id):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _guest_aggregate_has_reserved_operations_id(guests: GuestService) -> bool:
|
|
if guests == null:
|
|
return true
|
|
for guest: GuestRuntime in guests.get_guests():
|
|
if String(guest.guest_id).begins_with("guest-auto-"):
|
|
return true
|
|
var records: Variant = guests.create_snapshot().get(&"persistent_records", null)
|
|
if not records is Array:
|
|
return true
|
|
for raw_record: Variant in records:
|
|
if not raw_record is Dictionary \
|
|
or String((raw_record as Dictionary).get(&"guest_id", "")).begins_with(
|
|
"guest-auto-"
|
|
):
|
|
return true
|
|
return false
|
|
|
|
|
|
func _operations_payroll_crosslinks_are_valid(
|
|
coordinator: OperationsCoordinator,
|
|
staff: StaffService,
|
|
economy: EconomyService,
|
|
upkeep: FacilityUpkeepSettlement
|
|
) -> bool:
|
|
if coordinator == null or staff == null or economy == null:
|
|
return false
|
|
var staff_snapshot := staff.create_snapshot()
|
|
var settlement_values: Variant = staff_snapshot.get(&"payroll_settlements", null)
|
|
if not settlement_values is Array:
|
|
return false
|
|
var summaries := coordinator.get_day_summaries()
|
|
if summaries.size() != settlement_values.size():
|
|
return false
|
|
var expected_wage_transactions: Dictionary = {}
|
|
var settlements_by_day: Dictionary = {}
|
|
for raw_settlement: Variant in settlement_values:
|
|
if not raw_settlement is Dictionary:
|
|
return false
|
|
var serialized: Dictionary = raw_settlement
|
|
var day_value: Variant = serialized.get(&"day", null)
|
|
var settlement_id_value: Variant = serialized.get(&"settlement_id", null)
|
|
if typeof(day_value) != TYPE_INT \
|
|
or typeof(settlement_id_value) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return false
|
|
var day := int(day_value)
|
|
var settlement_id := StringName(settlement_id_value)
|
|
if day <= 0 or settlement_id != StringName("payroll-day-%08d" % day) \
|
|
or settlements_by_day.has(day):
|
|
return false
|
|
var staff_record := staff.get_payroll_settlement(settlement_id)
|
|
if staff_record.is_empty():
|
|
return false
|
|
settlements_by_day[day] = staff_record
|
|
var paid_total := int(staff_record.get(&"paid_total", -1))
|
|
if paid_total < 0:
|
|
return false
|
|
var idempotency_key := StringName("operations.wage.day.%08d" % day)
|
|
var transaction_id := economy.get_transaction_id_for_idempotency_key(idempotency_key)
|
|
if paid_total == 0:
|
|
if not transaction_id.is_empty():
|
|
return false
|
|
continue
|
|
if transaction_id.is_empty() or expected_wage_transactions.has(transaction_id):
|
|
return false
|
|
var entries := economy.get_transaction_entries(transaction_id)
|
|
if entries.size() != 1:
|
|
return false
|
|
var entry: LedgerEntry = entries[0]
|
|
if entry.absolute_minute != coordinator.get_daily_close_minute(day) \
|
|
or entry.debit_account != WAGE_EXPENSE_ACCOUNT \
|
|
or entry.credit_account != PLAYER_CASH_ACCOUNT \
|
|
or entry.amount != paid_total or entry.category != &"WAGE" \
|
|
or entry.source_entity_id != settlement_id \
|
|
or entry.memo_key != &"operations.payroll.settlement" \
|
|
or not entry.related_transaction_id.is_empty():
|
|
return false
|
|
expected_wage_transactions[transaction_id] = true
|
|
|
|
for summary: Dictionary in summaries:
|
|
var day := int(summary.get(&"day", -1))
|
|
if not settlements_by_day.has(day):
|
|
return false
|
|
var summary_settlement_value: Variant = summary.get(&"settlement", null)
|
|
if not summary_settlement_value is Dictionary:
|
|
return false
|
|
var summary_settlement: Dictionary = summary_settlement_value.duplicate(true)
|
|
var staff_record: Dictionary = settlements_by_day[day]
|
|
var expected_categories: Variant = _operations_expected_category_totals(
|
|
economy,
|
|
coordinator.get_financial_period_start(day),
|
|
coordinator.get_daily_close_minute(day),
|
|
int(staff_record.get(&"new_wages_total", -1))
|
|
)
|
|
if expected_categories == null \
|
|
or not _strict_variants_equal(
|
|
_operations_snapshot_comparison_value(
|
|
summary.get(&"category_totals", {})
|
|
),
|
|
_operations_snapshot_comparison_value(expected_categories)
|
|
) \
|
|
or not _operations_facility_wage_summary_is_valid(
|
|
summary, staff_record, staff, economy
|
|
):
|
|
return false
|
|
var upkeep_debt := 0
|
|
for receipt: Dictionary in upkeep.get_report()[&"receipts"]:
|
|
if int(receipt[&"period"][&"period_end"]) <= coordinator.get_daily_close_minute(day):
|
|
upkeep_debt = 0
|
|
for row: Dictionary in receipt[&"rows"]:
|
|
upkeep_debt += int(row[&"arrears"])
|
|
if summary_settlement.has(&"upkeep_arrears_total"):
|
|
if typeof(summary_settlement[&"upkeep_arrears_total"]) != TYPE_INT or int(summary_settlement[&"upkeep_arrears_total"]) != upkeep_debt:
|
|
return false
|
|
summary_settlement.erase(&"upkeep_arrears_total")
|
|
elif upkeep_debt != 0:
|
|
return false
|
|
if int(summary_settlement.get(&"arrears_total", -1)) \
|
|
!= int(staff_record.get(&"unpaid_after_total", -2)) + upkeep_debt \
|
|
or summary_settlement.get(&"replayed", null) != false:
|
|
return false
|
|
summary_settlement.erase(&"arrears_total")
|
|
summary_settlement.erase(&"replayed")
|
|
# OperationsCoordinator persists history in a transport-safe form, so
|
|
# StringName identifiers become String values after a round-trip. Compare
|
|
# the redundant Staff/Coordinator evidence in that same canonical form;
|
|
# numeric and structural types remain strict.
|
|
if not _strict_variants_equal(
|
|
_operations_snapshot_comparison_value(summary_settlement),
|
|
_operations_snapshot_comparison_value(staff_record)
|
|
):
|
|
return false
|
|
|
|
for entry: LedgerEntry in economy.get_entries():
|
|
if entry.category == &"WAGE" \
|
|
and not expected_wage_transactions.has(entry.transaction_id):
|
|
return false
|
|
return true
|
|
|
|
|
|
## Existing liabilities only. This projection does not issue financing or infer terms.
|
|
func get_loan_principal_report() -> Dictionary:
|
|
if not _initialized or economy_service == null or _state == null:
|
|
return {&"available": false, &"loans": []}
|
|
var cash := economy_service.get_balance(PLAYER_CASH_ACCOUNT)
|
|
var pending: Dictionary = {}
|
|
for command: SimulationCommand in command_queue.get_pending_commands():
|
|
if command.command_type != &"repay_loan_principal":
|
|
continue
|
|
var id: Variant = command.payload.get(&"loan_id", null)
|
|
if typeof(id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
continue
|
|
var key := StringName(id)
|
|
if not pending.has(key): pending[key] = []
|
|
pending[key].append(command.command_id)
|
|
var loans: Array[Dictionary] = []
|
|
for account: Dictionary in economy_service.get_accounts():
|
|
var id := String(account[&"account_id"])
|
|
if not id.begins_with("LOAN_LIABILITY:"):
|
|
continue
|
|
var loan := StringName(id.trim_prefix("LOAN_LIABILITY:"))
|
|
loans.append({&"loan_id": loan, &"outstanding_principal": -economy_service.get_balance(StringName(id)), &"pending_command_ids": (pending.get(loan, []) as Array).duplicate()})
|
|
loans.sort_custom(func(left: Dictionary, right: Dictionary) -> bool: return String(left[&"loan_id"]) < String(right[&"loan_id"]))
|
|
var valid: bool = _state.get_resource(&"gold") == cash and (loans.is_empty() or _loan_principal_ledger_is_valid(economy_service))
|
|
var ended: bool = _operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal()
|
|
return {&"available": true, &"ledger_valid": valid, &"terminal": ended, &"cash": cash, &"next_command_id": _next_command_id, &"loans": loans}
|
|
|
|
|
|
## Atomic principal payment against an already attested loan liability.
|
|
## Loan issuance, rates, due dates and bank eligibility belong to the bank
|
|
## producer; this API does not create or approve any financing contract.
|
|
func repay_loan_principal(
|
|
loan_id: StringName,
|
|
amount: Variant,
|
|
idempotency_key: StringName
|
|
) -> CommandResult:
|
|
if not _initialized or economy_service == null or _state == null:
|
|
return CommandResult.failed(CommandResult.Code.EXECUTOR_FAILED, 0, "Simulation runtime is not initialized.")
|
|
if _operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Repayment is unavailable after the game ends.")
|
|
if loan_id.is_empty() or idempotency_key.is_empty() or typeof(amount) != TYPE_INT \
|
|
or amount <= 0 or amount > OperationsCoordinator.MAX_FINANCIAL_AMOUNT:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, 0, "A loan id, positive integer principal and idempotency key are required.")
|
|
if not economy_service.get_transaction_id_for_idempotency_key(idempotency_key).is_empty():
|
|
return CommandResult.failed(CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY, 0, "The principal payment key has already been posted.")
|
|
if not _loan_principal_ledger_is_valid(economy_service):
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "The principal ledger is inconsistent.")
|
|
var liability := StringName("LOAN_LIABILITY:" + String(loan_id))
|
|
if not economy_service.has_account(liability):
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, 0, "The loan liability does not exist.")
|
|
var remaining := -economy_service.get_balance(liability)
|
|
var cash := economy_service.get_balance(PLAYER_CASH_ACCOUNT)
|
|
if amount > remaining or amount > cash or _state.get_resource(&"gold") != cash:
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Principal payment exceeds the outstanding principal or available Gold, or its mirror is inconsistent.")
|
|
# The single balanced posting is atomic. Defer receipts until the Gold
|
|
# mirror is synchronized, also respecting any enclosing effect scope.
|
|
economy_service.begin_notification_scope()
|
|
var result := economy_service.transfer(
|
|
PLAYER_CASH_ACCOUNT, liability, amount, _runtime_event_minute(),
|
|
&"LOAN_PRINCIPAL_REPAID", loan_id, idempotency_key
|
|
)
|
|
if result.is_success():
|
|
_state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT))
|
|
result.details[&"loan_id"] = loan_id
|
|
result.details[&"paid_principal"] = amount
|
|
result.details[&"outstanding_principal"] = remaining - amount
|
|
economy_service.end_notification_scope()
|
|
return result
|
|
|
|
|
|
func _loan_principal_ledger_is_valid(economy: EconomyService) -> bool:
|
|
if economy == null:
|
|
return false
|
|
var outstanding: Dictionary = {}
|
|
for entry: LedgerEntry in economy.get_entries():
|
|
if entry.category not in [&"LOAN_PRINCIPAL_BORROWED", &"LOAN_PRINCIPAL_REPAID"]:
|
|
continue
|
|
if entry.source_entity_id.is_empty():
|
|
return false
|
|
var liability := StringName("LOAN_LIABILITY:" + String(entry.source_entity_id))
|
|
if not economy.has_account(liability) or not economy.account_allows_negative(liability):
|
|
return false
|
|
var remaining := int(outstanding.get(liability, 0))
|
|
if entry.category == &"LOAN_PRINCIPAL_BORROWED":
|
|
if entry.debit_account != PLAYER_CASH_ACCOUNT or entry.credit_account != liability \
|
|
or entry.amount > OperationsCoordinator.MAX_FINANCIAL_AMOUNT - remaining:
|
|
return false
|
|
remaining += entry.amount
|
|
else:
|
|
if entry.debit_account != liability or entry.credit_account != PLAYER_CASH_ACCOUNT \
|
|
or entry.amount > remaining:
|
|
return false
|
|
remaining -= entry.amount
|
|
outstanding[liability] = remaining
|
|
for account: Dictionary in economy.get_accounts():
|
|
var id: StringName = account[&"account_id"]
|
|
if String(id).begins_with("LOAN_LIABILITY:"):
|
|
if not economy.account_allows_negative(id) or economy.get_balance(id) != -int(outstanding.get(id, 0)):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _operations_expected_category_totals(
|
|
economy: EconomyService,
|
|
period_start: int,
|
|
period_end_exclusive: int,
|
|
new_wages_total: int
|
|
) -> Variant:
|
|
if economy == null or period_start < 0 or period_end_exclusive <= period_start \
|
|
or new_wages_total < 0:
|
|
return null
|
|
if not _loan_principal_ledger_is_valid(economy):
|
|
return null
|
|
var totals := {
|
|
&"revenue": 0, &"refunds": 0, &"cost_of_goods": 0,
|
|
&"wages": new_wages_total, &"maintenance": 0, &"utilities": 0,
|
|
&"taxes_and_interest": 0, &"fines": 0,
|
|
&"construction_cash_flow": 0,
|
|
}
|
|
var excluded: Array[StringName] = [
|
|
&"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION",
|
|
&"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION",
|
|
&"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT",
|
|
]
|
|
var mapping := {
|
|
&"SERVICE_SALE": &"revenue", &"REFUND": &"refunds",
|
|
&"CONSTRUCTION": &"construction_cash_flow",
|
|
&"CONSTRUCTION_REFUND": &"construction_cash_flow",
|
|
&"AUTHORITY_FINE": &"fines", &"OPERATING_EXPENSE": &"cost_of_goods",
|
|
&"STAFF_SIGNING_COST": &"cost_of_goods", &"EVENT_ACTION": &"cost_of_goods",
|
|
&"EVENT_COST_COMMITTED": &"cost_of_goods",
|
|
&"DEFENSE_SKILL_COST": &"cost_of_goods",
|
|
&"MAINTENANCE_ACCRUAL": &"maintenance",
|
|
&"LOAN_PRINCIPAL_BORROWED": &"financing_principal_inflows",
|
|
&"LOAN_PRINCIPAL_REPAID": &"financing_principal_outflows",
|
|
}
|
|
var operating_refunds := 0
|
|
for entry: LedgerEntry in economy.get_entries():
|
|
if entry.absolute_minute < period_start \
|
|
or entry.absolute_minute >= period_end_exclusive:
|
|
continue
|
|
if entry.category in excluded:
|
|
continue
|
|
if entry.category == &"OPERATING_REFUND":
|
|
operating_refunds += entry.amount
|
|
continue
|
|
if not mapping.has(entry.category):
|
|
return null
|
|
var category: StringName = mapping[entry.category]
|
|
totals[category] = int(totals.get(category, 0)) + entry.amount
|
|
var offset := mini(operating_refunds, int(totals[&"cost_of_goods"]))
|
|
totals[&"cost_of_goods"] = int(totals[&"cost_of_goods"]) - offset
|
|
totals[&"revenue"] = int(totals[&"revenue"]) + operating_refunds - offset
|
|
return totals
|
|
|
|
|
|
func _operations_facility_wage_summary_is_valid(
|
|
summary: Dictionary,
|
|
staff_record: Dictionary,
|
|
_staff: StaffService,
|
|
economy: EconomyService
|
|
) -> bool:
|
|
var facilities_value: Variant = summary.get(&"facility_profit_loss", null)
|
|
var anomalies_value: Variant = summary.get(&"anomalies", null)
|
|
if not facilities_value is Dictionary or not anomalies_value is Array:
|
|
return false
|
|
var evidence: Dictionary = {}
|
|
var observed_unallocated: Dictionary = {}
|
|
for raw_anomaly: Variant in anomalies_value:
|
|
if not raw_anomaly is Dictionary:
|
|
return false
|
|
var anomaly: Dictionary = raw_anomaly
|
|
match StringName(anomaly.get(&"code", &"")):
|
|
&"WAGE_ALLOCATION_EVIDENCE":
|
|
if not evidence.is_empty() or not _dictionary_has_exact_canonical_keys(
|
|
anomaly,
|
|
[&"code", &"version", &"new_wages_total", &"allocated_total",
|
|
&"unallocated_total", &"allocations", &"unallocated_by_staff",
|
|
&"facility_ids"]
|
|
):
|
|
return false
|
|
evidence = anomaly
|
|
&"UNALLOCATED_WAGES":
|
|
if not _dictionary_has_exact_canonical_keys(
|
|
anomaly, [&"code", &"staff_id", &"amount"]
|
|
):
|
|
return false
|
|
var staff_id := StringName(anomaly.get(&"staff_id", &""))
|
|
var amount_value: Variant = anomaly.get(&"amount", null)
|
|
if staff_id.is_empty() or typeof(amount_value) != TYPE_INT \
|
|
or int(amount_value) <= 0 or observed_unallocated.has(staff_id):
|
|
return false
|
|
observed_unallocated[staff_id] = int(amount_value)
|
|
&"OPERATING_REFUND_EXCEEDS_PERIOD_COST":
|
|
if not _dictionary_has_exact_canonical_keys(
|
|
anomaly, [&"code", &"refund_total", &"cost_offset", &"excess_revenue"]
|
|
):
|
|
return false
|
|
_:
|
|
return false
|
|
if evidence.is_empty() or int(evidence.get(&"version", -1)) != 1 \
|
|
or typeof(evidence.get(&"new_wages_total", null)) != TYPE_INT \
|
|
or int(evidence[&"new_wages_total"]) \
|
|
!= int(staff_record.get(&"new_wages_total", -1)) \
|
|
or typeof(evidence.get(&"allocated_total", null)) != TYPE_INT \
|
|
or typeof(evidence.get(&"unallocated_total", null)) != TYPE_INT \
|
|
or not evidence.get(&"allocations", null) is Array \
|
|
or not evidence.get(&"unallocated_by_staff", null) is Array \
|
|
or not evidence.get(&"facility_ids", null) is Array:
|
|
return false
|
|
var allocations: Dictionary = {}
|
|
var previous_facility := ""
|
|
var allocated_total := 0
|
|
for raw_row: Variant in evidence[&"allocations"]:
|
|
if not raw_row is Dictionary or not _dictionary_has_exact_canonical_keys(
|
|
raw_row, [&"facility_id", &"amount"]
|
|
):
|
|
return false
|
|
var facility_id := StringName((raw_row as Dictionary).get(&"facility_id", &""))
|
|
var amount_value: Variant = (raw_row as Dictionary).get(&"amount", null)
|
|
if facility_id.is_empty() or typeof(amount_value) != TYPE_INT \
|
|
or int(amount_value) <= 0 or allocations.has(facility_id) \
|
|
or (not previous_facility.is_empty() \
|
|
and String(facility_id) <= previous_facility):
|
|
return false
|
|
allocations[facility_id] = int(amount_value)
|
|
allocated_total += int(amount_value)
|
|
previous_facility = String(facility_id)
|
|
var unallocated: Dictionary = {}
|
|
var previous_staff := ""
|
|
var unallocated_total := 0
|
|
for raw_row: Variant in evidence[&"unallocated_by_staff"]:
|
|
if not raw_row is Dictionary or not _dictionary_has_exact_canonical_keys(
|
|
raw_row, [&"staff_id", &"amount"]
|
|
):
|
|
return false
|
|
var staff_id := StringName((raw_row as Dictionary).get(&"staff_id", &""))
|
|
var amount_value: Variant = (raw_row as Dictionary).get(&"amount", null)
|
|
if staff_id.is_empty() or typeof(amount_value) != TYPE_INT \
|
|
or int(amount_value) <= 0 or unallocated.has(staff_id) \
|
|
or (not previous_staff.is_empty() and String(staff_id) <= previous_staff):
|
|
return false
|
|
unallocated[staff_id] = int(amount_value)
|
|
unallocated_total += int(amount_value)
|
|
previous_staff = String(staff_id)
|
|
var expected_facility_ids: Dictionary = {}
|
|
var previous_id := ""
|
|
for raw_id: Variant in evidence[&"facility_ids"]:
|
|
if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return false
|
|
var facility_id := StringName(raw_id)
|
|
if facility_id.is_empty() or expected_facility_ids.has(facility_id) \
|
|
or (not previous_id.is_empty() and String(facility_id) <= previous_id):
|
|
return false
|
|
expected_facility_ids[facility_id] = true
|
|
previous_id = String(facility_id)
|
|
var new_wages_total := int(evidence[&"new_wages_total"])
|
|
if allocated_total != int(evidence[&"allocated_total"]) \
|
|
or unallocated_total != int(evidence[&"unallocated_total"]) \
|
|
or allocated_total + unallocated_total != new_wages_total \
|
|
or not _strict_variants_equal(observed_unallocated, unallocated):
|
|
return false
|
|
for facility_id: StringName in allocations:
|
|
if not expected_facility_ids.has(facility_id):
|
|
return false
|
|
var facility_rows: Dictionary = facilities_value
|
|
if facility_rows.size() != expected_facility_ids.size():
|
|
return false
|
|
for raw_id: Variant in facility_rows:
|
|
if not expected_facility_ids.has(StringName(raw_id)):
|
|
return false
|
|
var period_start := int(summary.get(&"period_start_minute", -1))
|
|
var period_end := int(summary.get(&"period_end_minute_exclusive", -1))
|
|
if period_start < 0 or period_end <= period_start:
|
|
return false
|
|
var base_rows: Dictionary = {}
|
|
for facility_id: StringName in expected_facility_ids:
|
|
base_rows[facility_id] = {&"revenue": 0, &"expenses": 0}
|
|
var excluded: Array[StringName] = [
|
|
&"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION",
|
|
&"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION",
|
|
&"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT", &"OPERATING_REFUND",
|
|
]
|
|
var mapping := {
|
|
&"SERVICE_SALE": &"revenue", &"REFUND": &"expenses",
|
|
&"AUTHORITY_FINE": &"expenses", &"OPERATING_EXPENSE": &"expenses",
|
|
&"STAFF_SIGNING_COST": &"expenses", &"EVENT_ACTION": &"expenses",
|
|
&"EVENT_COST_COMMITTED": &"expenses", &"DEFENSE_SKILL_COST": &"expenses",
|
|
&"MAINTENANCE_ACCRUAL": &"expenses",
|
|
}
|
|
for entry: LedgerEntry in economy.get_entries():
|
|
if entry.absolute_minute < period_start or entry.absolute_minute >= period_end \
|
|
or entry.category in excluded \
|
|
or entry.category in [&"CONSTRUCTION", &"CONSTRUCTION_REFUND"] \
|
|
or not expected_facility_ids.has(entry.source_entity_id):
|
|
continue
|
|
if not mapping.has(entry.category):
|
|
return false
|
|
var base: Dictionary = base_rows[entry.source_entity_id]
|
|
var key: StringName = mapping[entry.category]
|
|
base[key] = int(base[key]) + entry.amount
|
|
for facility_id: StringName in expected_facility_ids:
|
|
var row_value: Variant = facility_rows.get(facility_id, null)
|
|
if not row_value is Dictionary:
|
|
return false
|
|
var row: Dictionary = row_value
|
|
var base: Dictionary = base_rows[facility_id]
|
|
var expected_revenue := int(base[&"revenue"])
|
|
var expected_expenses := int(base[&"expenses"]) \
|
|
+ int(allocations.get(facility_id, 0))
|
|
if int(row.get(&"revenue", -1)) != expected_revenue \
|
|
or int(row.get(&"expenses", -1)) != expected_expenses \
|
|
or int(row.get(&"net_profit", 0)) != expected_revenue - expected_expenses:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _operations_facility_wage_summary_from_live_shifts_is_valid(
|
|
summary: Dictionary,
|
|
staff_record: Dictionary,
|
|
staff: StaffService,
|
|
economy: EconomyService
|
|
) -> bool:
|
|
var facilities_value: Variant = summary.get(&"facility_profit_loss", null)
|
|
var rows_value: Variant = staff_record.get(&"rows", null)
|
|
var anomalies_value: Variant = summary.get(&"anomalies", null)
|
|
if not facilities_value is Dictionary or not rows_value is Array \
|
|
or not anomalies_value is Array:
|
|
return false
|
|
var facility_rows: Dictionary = facilities_value
|
|
var period_start := int(summary.get(&"period_start_minute", -1))
|
|
var period_end := int(summary.get(&"period_end_minute_exclusive", -1))
|
|
if period_start < 0 or period_end <= period_start:
|
|
return false
|
|
var wages_by_staff: Dictionary = {}
|
|
for raw_row: Variant in rows_value:
|
|
if not raw_row is Dictionary:
|
|
return false
|
|
var row: Dictionary = raw_row
|
|
var staff_id := StringName(row.get(&"staff_id", &""))
|
|
var wage_value: Variant = row.get(&"new_wages", null)
|
|
if staff_id.is_empty() or typeof(wage_value) != TYPE_INT \
|
|
or int(wage_value) < 0 or wages_by_staff.has(staff_id):
|
|
return false
|
|
wages_by_staff[staff_id] = int(wage_value)
|
|
var weights_by_staff: Dictionary = {}
|
|
for shift: ShiftInstance in staff.get_shifts():
|
|
if shift == null or shift.cancelled or shift.assigned_facility_id.is_empty() \
|
|
or not wages_by_staff.has(shift.staff_id) \
|
|
or int(wages_by_staff[shift.staff_id]) <= 0:
|
|
continue
|
|
var overlap := maxi(
|
|
mini(shift.end_at, period_end) - maxi(shift.start_at, period_start), 0
|
|
)
|
|
if overlap <= 0:
|
|
continue
|
|
var recorded := shift.worked_minutes + shift.overtime_minutes
|
|
var weight := mini(recorded, overlap) if recorded > 0 else overlap
|
|
if weight <= 0:
|
|
continue
|
|
if not weights_by_staff.has(shift.staff_id):
|
|
weights_by_staff[shift.staff_id] = {}
|
|
var weights: Dictionary = weights_by_staff[shift.staff_id]
|
|
weights[shift.assigned_facility_id] = int(
|
|
weights.get(shift.assigned_facility_id, 0)
|
|
) + weight
|
|
var expected_allocations: Dictionary = {}
|
|
var expected_unallocated: Dictionary = {}
|
|
for staff_id: StringName in _sorted_name_keys(wages_by_staff):
|
|
var wage := int(wages_by_staff[staff_id])
|
|
if wage <= 0:
|
|
continue
|
|
var weights: Dictionary = weights_by_staff.get(staff_id, {})
|
|
if weights.is_empty():
|
|
expected_unallocated[staff_id] = wage
|
|
continue
|
|
var facility_ids := _sorted_name_keys(weights)
|
|
var total_weight := 0
|
|
for facility_id: StringName in facility_ids:
|
|
total_weight += int(weights[facility_id])
|
|
if total_weight <= 0:
|
|
return false
|
|
var allocated := 0
|
|
for facility_id: StringName in facility_ids:
|
|
var amount := wage * int(weights[facility_id]) / total_weight
|
|
expected_allocations[facility_id] = int(
|
|
expected_allocations.get(facility_id, 0)
|
|
) + amount
|
|
allocated += amount
|
|
var remainder := wage - allocated
|
|
for index in range(remainder):
|
|
var facility_id: StringName = facility_ids[index]
|
|
expected_allocations[facility_id] = int(expected_allocations[facility_id]) + 1
|
|
|
|
var base_rows: Dictionary = {}
|
|
var excluded: Array[StringName] = [
|
|
&"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION",
|
|
&"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION",
|
|
&"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT", &"OPERATING_REFUND",
|
|
]
|
|
var mapping := {
|
|
&"SERVICE_SALE": &"revenue", &"REFUND": &"expense",
|
|
&"AUTHORITY_FINE": &"expense", &"OPERATING_EXPENSE": &"expense",
|
|
&"STAFF_SIGNING_COST": &"expense", &"EVENT_ACTION": &"expense",
|
|
&"EVENT_COST_COMMITTED": &"expense", &"DEFENSE_SKILL_COST": &"expense",
|
|
}
|
|
for entry: LedgerEntry in economy.get_entries():
|
|
if entry.absolute_minute < period_start or entry.absolute_minute >= period_end \
|
|
or entry.category in excluded \
|
|
or entry.category in [&"CONSTRUCTION", &"CONSTRUCTION_REFUND"] \
|
|
or entry.source_entity_id.is_empty() \
|
|
or not facility_rows.has(entry.source_entity_id):
|
|
continue
|
|
if not mapping.has(entry.category):
|
|
return false
|
|
if not base_rows.has(entry.source_entity_id):
|
|
base_rows[entry.source_entity_id] = {&"revenue": 0, &"expenses": 0}
|
|
var base: Dictionary = base_rows[entry.source_entity_id]
|
|
var key: StringName = mapping[entry.category]
|
|
if key == &"revenue":
|
|
base[&"revenue"] = int(base[&"revenue"]) + entry.amount
|
|
else:
|
|
base[&"expenses"] = int(base[&"expenses"]) + entry.amount
|
|
for raw_facility_id: Variant in facility_rows:
|
|
var facility_id := StringName(raw_facility_id)
|
|
var row_value: Variant = facility_rows[raw_facility_id]
|
|
if facility_id.is_empty() or not row_value is Dictionary:
|
|
return false
|
|
var row: Dictionary = row_value
|
|
var base: Dictionary = base_rows.get(
|
|
facility_id, {&"revenue": 0, &"expenses": 0}
|
|
)
|
|
if int(row.get(&"revenue", -1)) != int(base[&"revenue"]) \
|
|
or int(row.get(&"expenses", -1)) \
|
|
!= int(base[&"expenses"]) + int(expected_allocations.get(facility_id, 0)):
|
|
return false
|
|
for facility_id: StringName in expected_allocations:
|
|
if not facility_rows.has(facility_id):
|
|
return false
|
|
var observed_unallocated: Dictionary = {}
|
|
for raw_anomaly: Variant in anomalies_value:
|
|
if not raw_anomaly is Dictionary:
|
|
return false
|
|
var anomaly: Dictionary = raw_anomaly
|
|
if StringName(anomaly.get(&"code", &"")) != &"UNALLOCATED_WAGES":
|
|
continue
|
|
var staff_id := StringName(anomaly.get(&"staff_id", &""))
|
|
var amount_value: Variant = anomaly.get(&"amount", null)
|
|
if staff_id.is_empty() or typeof(amount_value) != TYPE_INT \
|
|
or int(amount_value) <= 0 or observed_unallocated.has(staff_id):
|
|
return false
|
|
observed_unallocated[staff_id] = int(amount_value)
|
|
return _strict_variants_equal(observed_unallocated, expected_unallocated)
|
|
|
|
|
|
func _operations_coordinator_configuration_is_canonical(
|
|
coordinator: OperationsCoordinator,
|
|
expected_world_seed: int
|
|
) -> bool:
|
|
if coordinator == null:
|
|
return false
|
|
var expected := OperationsCoordinator.new({
|
|
&"start_minute": OPERATIONS_COORDINATOR_ORIGIN_MINUTE,
|
|
&"world_seed": expected_world_seed,
|
|
})
|
|
if not _strict_variants_equal(coordinator.get_config(), expected.get_config()):
|
|
return false
|
|
var profiles := coordinator.get_demand_profiles()
|
|
if profiles.size() != DEFAULT_OPERATIONS_DEMAND_PROFILES.size():
|
|
return false
|
|
var expected_identities: Dictionary = {}
|
|
for raw_profile: Variant in DEFAULT_OPERATIONS_DEMAND_PROFILES:
|
|
var profile: Dictionary = raw_profile
|
|
var key := "%s|%s|%s" % [
|
|
profile[&"facility_tag"], profile[&"guest_class"], profile[&"time_band"],
|
|
]
|
|
expected_identities[key] = true
|
|
var seen: Dictionary = {}
|
|
var expected_components := {
|
|
&"base": 100, &"reputation": 0, &"unmet_need": 0,
|
|
&"event": 0, &"recent_price_penalty": 0, &"wait_penalty": 0,
|
|
}
|
|
for profile: Dictionary in profiles:
|
|
var key := "%s|%s|%s" % [
|
|
profile.get(&"facility_tag", &""),
|
|
profile.get(&"guest_class", &""),
|
|
profile.get(&"time_band", &""),
|
|
]
|
|
if not expected_identities.has(key) or seen.has(key) \
|
|
or not _strict_variants_equal(
|
|
profile.get(&"components", {}), expected_components
|
|
):
|
|
return false
|
|
seen[key] = true
|
|
return seen.size() == expected_identities.size()
|
|
|
|
|
|
func _staff_payroll_arrears_total(service: StaffService) -> int:
|
|
var total := 0
|
|
for row: Dictionary in service.get_payroll_obligations():
|
|
var unpaid: Variant = row.get(&"unpaid_wages", null)
|
|
if typeof(unpaid) != TYPE_INT or int(unpaid) < 0:
|
|
return -1
|
|
total += int(unpaid)
|
|
return total
|
|
|
|
|
|
func _operations_failure_report_is_valid(report: Dictionary, minute: int) -> bool:
|
|
if report.size() == 2 \
|
|
and _dictionary_has_exact_canonical_keys(report, [&"code", &"minute"]):
|
|
var simple_code: Variant = report.get(&"code", null)
|
|
var simple_minute: Variant = report.get(&"minute", null)
|
|
return typeof(simple_code) in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
and not String(simple_code).is_empty() \
|
|
and typeof(simple_minute) == TYPE_INT and int(simple_minute) == minute
|
|
var result_keys: Array[StringName] = [
|
|
&"code", &"command_id", &"target_id", &"message",
|
|
&"resolution_hints", &"details", &"is_replay",
|
|
]
|
|
if not _dictionary_has_exact_canonical_keys(report, result_keys):
|
|
return false
|
|
var code_value: Variant = report.get(&"code", null)
|
|
var command_id_value: Variant = report.get(&"command_id", null)
|
|
var target_value: Variant = report.get(&"target_id", null)
|
|
var message_value: Variant = report.get(&"message", null)
|
|
var hints_value: Variant = report.get(&"resolution_hints", null)
|
|
var details_value: Variant = report.get(&"details", null)
|
|
if typeof(code_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or String(code_value).is_empty() or String(code_value) == "OK" \
|
|
or typeof(command_id_value) != TYPE_INT or int(command_id_value) < 0 \
|
|
or typeof(target_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or typeof(message_value) != TYPE_STRING \
|
|
or not hints_value is Array or hints_value.size() > 32 \
|
|
or not details_value is Dictionary \
|
|
or typeof(report.get(&"is_replay", null)) != TYPE_BOOL:
|
|
return false
|
|
for raw_hint: Variant in hints_value:
|
|
if typeof(raw_hint) != TYPE_STRING or String(raw_hint).length() > 4_096:
|
|
return false
|
|
return JSON.stringify(details_value).length() <= 262_144
|
|
|
|
|
|
func _dictionary_has_exact_canonical_keys(
|
|
source: Dictionary,
|
|
expected_keys: Array[StringName]
|
|
) -> bool:
|
|
if source.size() != expected_keys.size():
|
|
return false
|
|
var seen: Dictionary = {}
|
|
for raw_key: Variant in source:
|
|
if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return false
|
|
var key := StringName(raw_key)
|
|
if not expected_keys.has(key) or seen.has(key):
|
|
return false
|
|
seen[key] = true
|
|
return seen.size() == expected_keys.size()
|
|
|
|
|
|
func _operations_api_failure(
|
|
code: StringName,
|
|
message: String,
|
|
details: Dictionary = {}
|
|
) -> Dictionary:
|
|
return {
|
|
&"ok": false,
|
|
&"code": code,
|
|
&"message": message,
|
|
&"details": details.duplicate(true),
|
|
}
|
|
|
|
|
|
func register_save_migration_step(
|
|
from_version: int,
|
|
migration_id: StringName,
|
|
migrator: Callable
|
|
) -> Dictionary:
|
|
return save_migration_registry.register_step(from_version, migration_id, migrator)
|
|
|
|
|
|
func queue_checkpoint(
|
|
save_path := "",
|
|
metadata := {},
|
|
use_autosave := false,
|
|
reason: StringName = &"manual"
|
|
) -> Dictionary:
|
|
if not _initialized:
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"RUNTIME_NOT_INITIALIZED",
|
|
&"message": "Simulation runtime is not initialized.",
|
|
}
|
|
var resolved_path := str(save_path)
|
|
if resolved_path.is_empty():
|
|
resolved_path = str(_save_policy[&"autosave_path"])
|
|
var request_id := _next_checkpoint_request_id
|
|
_next_checkpoint_request_id += 1
|
|
_pending_checkpoints.append({
|
|
&"request_id": request_id,
|
|
&"save_path": resolved_path,
|
|
&"metadata": metadata.duplicate(true) if metadata is Dictionary else {},
|
|
&"use_autosave": bool(use_autosave),
|
|
&"reason": reason if not reason.is_empty() else &"manual",
|
|
})
|
|
return {
|
|
&"ok": true,
|
|
&"code": &"CHECKPOINT_QUEUED",
|
|
&"request_id": request_id,
|
|
&"save_path": resolved_path,
|
|
&"queued_for_tick_boundary": true,
|
|
&"queued_at_absolute_minute": _clock.absolute_minute,
|
|
}
|
|
|
|
|
|
func queue_manual_checkpoint(save_path: String, metadata := {}) -> Dictionary:
|
|
return queue_checkpoint(save_path, metadata, false, &"manual")
|
|
|
|
|
|
func save_to_path(save_path: String, metadata := {}) -> Dictionary:
|
|
return _perform_checkpoint_save(save_path, metadata, false, &"manual", 0)
|
|
|
|
|
|
func inspect_recovery_candidates(
|
|
save_path := "",
|
|
generation_count := -1
|
|
) -> Dictionary:
|
|
var resolved_path := str(save_path)
|
|
if resolved_path.is_empty():
|
|
resolved_path = str(_save_policy[&"autosave_path"])
|
|
var resolved_generations := generation_count
|
|
if resolved_generations < 0:
|
|
resolved_generations = int(_save_policy[&"autosave_generations"])
|
|
var started_at_us := Time.get_ticks_usec()
|
|
var result := SaveService.inspect_recovery_candidates(
|
|
resolved_path,
|
|
resolved_generations
|
|
)
|
|
var runtime_duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0)
|
|
_record_runtime_performance(&"load", runtime_duration_us)
|
|
return _with_operation_context(result, {
|
|
&"operation": &"inspect_recovery",
|
|
&"save_path": resolved_path,
|
|
&"runtime_duration_us": runtime_duration_us,
|
|
})
|
|
|
|
|
|
func load_from_path(save_path: String) -> Dictionary:
|
|
var started_at_us := Time.get_ticks_usec()
|
|
var previous_paused := _clock.paused
|
|
_clock.set_paused(true)
|
|
var result := SaveService.load_snapshot(save_path)
|
|
if not result[&"ok"] and result[&"code"] == &"UNSUPPORTED_FORMAT" \
|
|
and bool(_save_policy.get(&"auto_migrate", true)):
|
|
var migration := SaveService.migrate_save(
|
|
save_path,
|
|
save_migration_registry,
|
|
SaveService.FORMAT_VERSION
|
|
)
|
|
_record_runtime_performance(&"save", int(migration.get(&"duration_us", 0)))
|
|
if migration[&"ok"]:
|
|
result = SaveService.load_snapshot(save_path)
|
|
else:
|
|
result = _with_operation_context(migration, {
|
|
&"operation": &"load",
|
|
&"migration_attempted": true,
|
|
})
|
|
return _finish_load_operation(
|
|
result,
|
|
save_path,
|
|
previous_paused,
|
|
started_at_us,
|
|
&"load",
|
|
&"SAVE_LOADED",
|
|
&"SAVE_LOAD_FAILED"
|
|
)
|
|
|
|
|
|
func load_with_recovery(
|
|
save_path := "",
|
|
generation_count := -1
|
|
) -> Dictionary:
|
|
var resolved_path := str(save_path)
|
|
if resolved_path.is_empty():
|
|
resolved_path = str(_save_policy[&"autosave_path"])
|
|
var resolved_generations := generation_count
|
|
if resolved_generations < 0:
|
|
resolved_generations = int(_save_policy[&"autosave_generations"])
|
|
var started_at_us := Time.get_ticks_usec()
|
|
var previous_paused := _clock.paused
|
|
# Recovery is a two-stage decision. SaveService establishes package,
|
|
# checksum, format, and generic snapshot validity; Runtime then tries every
|
|
# package-loadable candidate in the same stable order against authoritative
|
|
# domain and cross-link rules. Failed domain attempts never reach the commit
|
|
# boundary in _restore_snapshot_internal.
|
|
var live_snapshot_before := create_snapshot()
|
|
_clock.set_paused(true)
|
|
var package_inventory := SaveService.inspect_recovery_candidates(
|
|
resolved_path,
|
|
resolved_generations
|
|
)
|
|
var result := _attempt_domain_valid_recovery(
|
|
resolved_path,
|
|
package_inventory
|
|
)
|
|
return _finish_recovery_operation(
|
|
result,
|
|
resolved_path,
|
|
previous_paused,
|
|
started_at_us,
|
|
live_snapshot_before
|
|
)
|
|
|
|
|
|
func _attempt_domain_valid_recovery(
|
|
save_path: String,
|
|
package_inventory: Dictionary
|
|
) -> Dictionary:
|
|
var package_report := package_inventory.duplicate(true)
|
|
# The selected package snapshot is an implementation detail of the package
|
|
# inspection pass. Every actual attempt is loaded again through the explicit,
|
|
# read-only candidate API so the final selection cannot accidentally reuse a
|
|
# different candidate's payload.
|
|
package_report.erase(&"snapshot")
|
|
var package_recovery_report: Dictionary = {}
|
|
var package_recovery_value: Variant = package_inventory.get(&"recovery_report", {})
|
|
if package_recovery_value is Dictionary:
|
|
package_recovery_report = package_recovery_value.duplicate(true)
|
|
var raw_candidates_value: Variant = package_inventory.get(&"candidates", [])
|
|
var candidates: Array[Dictionary] = []
|
|
if raw_candidates_value is Array:
|
|
for raw_candidate: Variant in raw_candidates_value:
|
|
if not raw_candidate is Dictionary:
|
|
continue
|
|
var candidate: Dictionary = raw_candidate.duplicate(true)
|
|
var package_loadable := bool(candidate.get(&"valid", false))
|
|
candidate[&"package_loadable"] = package_loadable
|
|
candidate[&"package_status"] = &"VALID" if package_loadable else &"REJECTED"
|
|
candidate[&"package_rejected"] = not package_loadable
|
|
candidate[&"package_rejection_code"] = (
|
|
&"" if package_loadable else StringName(candidate.get(&"code", &"PACKAGE_REJECTED"))
|
|
)
|
|
candidate[&"domain_status"] = &"NOT_ATTEMPTED"
|
|
candidate[&"domain_attempted"] = false
|
|
candidate[&"domain_valid"] = false
|
|
candidate[&"domain_rejected"] = false
|
|
candidate[&"domain_rejection_code"] = &""
|
|
candidate[&"failed_attempt_live_state_preserved"] = true
|
|
candidate[&"selected"] = false
|
|
candidates.append(candidate)
|
|
|
|
if not bool(package_inventory.get(&"ok", false)) \
|
|
and not _has_package_loadable_recovery_candidate(candidates):
|
|
return _recovery_failure_result(
|
|
StringName(package_inventory.get(&"code", &"NO_VALID_RECOVERY_CANDIDATE")),
|
|
str(package_inventory.get(&"message", save_path)),
|
|
candidates,
|
|
package_report,
|
|
package_recovery_report,
|
|
&"NO_PACKAGE_VALID_RECOVERY_CANDIDATE"
|
|
)
|
|
|
|
for candidate_index in range(candidates.size()):
|
|
var candidate: Dictionary = candidates[candidate_index]
|
|
if not bool(candidate[&"package_loadable"]):
|
|
continue
|
|
var candidate_id := StringName(candidate.get(&"id", &""))
|
|
var generation := int(candidate.get(&"generation", -1))
|
|
var loaded := SaveService.load_recovery_candidate(
|
|
save_path,
|
|
candidate_id,
|
|
generation
|
|
)
|
|
if not bool(loaded.get(&"ok", false)):
|
|
# A candidate may disappear or become corrupt between inventory and
|
|
# its explicit load. Treat that race as a package rejection and keep
|
|
# walking the already-established priority order.
|
|
candidate[&"package_status"] = &"REJECTED"
|
|
candidate[&"package_rejected"] = true
|
|
candidate[&"package_load_succeeded"] = false
|
|
candidate[&"package_rejection_code"] = StringName(
|
|
loaded.get(&"code", &"PACKAGE_LOAD_FAILED")
|
|
)
|
|
candidate[&"package_rejection_message"] = str(loaded.get(&"message", ""))
|
|
candidates[candidate_index] = candidate
|
|
continue
|
|
|
|
candidate[&"package_load_succeeded"] = true
|
|
candidate[&"domain_attempted"] = true
|
|
var package_attempt_report: Dictionary = {}
|
|
var loaded_report_value: Variant = loaded.get(&"recovery_report", {})
|
|
if loaded_report_value is Dictionary:
|
|
package_attempt_report = loaded_report_value.duplicate(true)
|
|
var attempt_live_guard := create_snapshot()
|
|
var attempt_pause_guard := _clock.paused
|
|
if not _restore_snapshot_internal(loaded[&"snapshot"], true):
|
|
candidate[&"domain_status"] = &"REJECTED"
|
|
candidate[&"domain_rejected"] = true
|
|
candidate[&"domain_rejection_code"] = &"DOMAIN_OR_CROSSLINK_VALIDATION_FAILED"
|
|
candidate[&"failed_attempt_live_state_preserved"] = (
|
|
create_snapshot() == attempt_live_guard
|
|
and _clock.paused == attempt_pause_guard
|
|
)
|
|
candidates[candidate_index] = candidate
|
|
continue
|
|
|
|
candidate[&"domain_status"] = &"VALID"
|
|
candidate[&"domain_valid"] = true
|
|
candidate[&"selected"] = true
|
|
candidates[candidate_index] = candidate
|
|
var result := loaded.duplicate(true)
|
|
result[&"ok"] = true
|
|
result[&"code"] = &"OK"
|
|
result[&"selection_rule"] = package_inventory.get(
|
|
&"selection_rule",
|
|
&"FORMAL_BACKUP_MIGRATION_AUTOSAVE_NEWEST_FIRST"
|
|
)
|
|
result[&"selected"] = candidate.duplicate(true)
|
|
result[&"candidates"] = candidates.duplicate(true)
|
|
result[&"candidate_attempts"] = candidates.duplicate(true)
|
|
result[&"package_candidate_report"] = package_report
|
|
result[&"package_recovery_report"] = package_attempt_report
|
|
result[&"restore_safety_report"] = latest_restore_safety_report.duplicate(true)
|
|
result[&"recovery_report"] = latest_restore_safety_report.duplicate(true)
|
|
result[&"recovery_report"][&"restore_committed"] = true
|
|
result[&"recovery_report"][&"selected_candidate_id"] = candidate_id
|
|
result[&"recovery_report"][&"package_rejections"] = _recovery_package_rejections(candidates)
|
|
result[&"recovery_report"][&"domain_rejections"] = _recovery_domain_rejections(candidates)
|
|
return result
|
|
|
|
return _recovery_failure_result(
|
|
&"RESTORE_FAILED",
|
|
save_path,
|
|
candidates,
|
|
package_report,
|
|
package_recovery_report,
|
|
&"NO_DOMAIN_VALID_RECOVERY_CANDIDATE"
|
|
)
|
|
|
|
|
|
func _finish_recovery_operation(
|
|
loaded_result: Dictionary,
|
|
save_path: String,
|
|
previous_paused: bool,
|
|
started_at_us: int,
|
|
live_snapshot_before: Dictionary
|
|
) -> Dictionary:
|
|
var result := loaded_result.duplicate(true)
|
|
if not bool(result.get(&"ok", false)):
|
|
_clock.set_paused(previous_paused)
|
|
var runtime_duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0)
|
|
_record_runtime_performance(&"load", runtime_duration_us)
|
|
var live_state_preserved := true
|
|
if not bool(result.get(&"ok", false)):
|
|
live_state_preserved = create_snapshot() == live_snapshot_before
|
|
result = _with_operation_context(result, {
|
|
&"operation": &"recovery",
|
|
&"save_path": save_path,
|
|
&"runtime_duration_us": runtime_duration_us,
|
|
&"loaded_clock_paused": _clock.paused if result.get(&"ok", false) else previous_paused,
|
|
&"preload_pause_restored": true if result.get(&"ok", false) else _clock.paused == previous_paused,
|
|
&"live_state_preserved": live_state_preserved,
|
|
})
|
|
if bool(result.get(&"ok", false)):
|
|
_complete_save_operation(result, &"SAVE_RECOVERY_LOADED")
|
|
else:
|
|
# A failed recovery is an observational operation. Publishing a persisted
|
|
# DomainEvent here would itself violate the promised all-live-state atomicity.
|
|
latest_save_operation_result = _save_event_payload(result)
|
|
save_operation_completed.emit(result.duplicate(true))
|
|
return result
|
|
|
|
|
|
func _has_package_loadable_recovery_candidate(candidates: Array[Dictionary]) -> bool:
|
|
for candidate: Dictionary in candidates:
|
|
if bool(candidate.get(&"package_loadable", false)):
|
|
return true
|
|
return false
|
|
|
|
|
|
func _recovery_package_rejections(candidates: Array[Dictionary]) -> Array[Dictionary]:
|
|
var rejected: Array[Dictionary] = []
|
|
for candidate: Dictionary in candidates:
|
|
if not bool(candidate.get(&"package_rejected", false)):
|
|
continue
|
|
rejected.append({
|
|
&"id": StringName(candidate.get(&"id", &"")),
|
|
&"code": StringName(candidate.get(&"package_rejection_code", &"PACKAGE_REJECTED")),
|
|
})
|
|
return rejected
|
|
|
|
|
|
func _recovery_domain_rejections(candidates: Array[Dictionary]) -> Array[Dictionary]:
|
|
var rejected: Array[Dictionary] = []
|
|
for candidate: Dictionary in candidates:
|
|
if not bool(candidate.get(&"domain_rejected", false)):
|
|
continue
|
|
rejected.append({
|
|
&"id": StringName(candidate.get(&"id", &"")),
|
|
&"code": StringName(candidate.get(
|
|
&"domain_rejection_code",
|
|
&"DOMAIN_OR_CROSSLINK_VALIDATION_FAILED"
|
|
)),
|
|
})
|
|
return rejected
|
|
|
|
|
|
func _recovery_failure_result(
|
|
code: StringName,
|
|
message: String,
|
|
candidates: Array[Dictionary],
|
|
package_report: Dictionary,
|
|
package_recovery_report: Dictionary,
|
|
rejection_reason: StringName
|
|
) -> Dictionary:
|
|
var restore_safety_report := {
|
|
&"report_version": 2,
|
|
&"recovery_mode": true,
|
|
&"repair_policy": &"WHITELISTED_AUTHORITATIVE_ONLY",
|
|
&"automatic_repairs_applied": false,
|
|
&"restore_committed": false,
|
|
&"rejection_reason": rejection_reason,
|
|
&"package_rejections": _recovery_package_rejections(candidates),
|
|
&"domain_rejections": _recovery_domain_rejections(candidates),
|
|
&"forbidden_repairs_applied": false,
|
|
}
|
|
return {
|
|
&"ok": false,
|
|
&"code": code,
|
|
&"message": message,
|
|
&"selection_rule": package_report.get(
|
|
&"selection_rule",
|
|
&"FORMAL_BACKUP_MIGRATION_AUTOSAVE_NEWEST_FIRST"
|
|
),
|
|
&"selected": {},
|
|
&"candidates": candidates.duplicate(true),
|
|
&"candidate_attempts": candidates.duplicate(true),
|
|
&"package_candidate_report": package_report.duplicate(true),
|
|
&"package_recovery_report": package_recovery_report.duplicate(true),
|
|
&"restore_safety_report": restore_safety_report.duplicate(true),
|
|
&"recovery_report": restore_safety_report,
|
|
}
|
|
|
|
|
|
func load_recovery_candidate(
|
|
save_path: String,
|
|
candidate_id: StringName,
|
|
generation := -1
|
|
) -> Dictionary:
|
|
var started_at_us := Time.get_ticks_usec()
|
|
var previous_paused := _clock.paused
|
|
_clock.set_paused(true)
|
|
var result := SaveService.load_recovery_candidate(save_path, candidate_id, generation)
|
|
return _finish_load_operation(
|
|
result,
|
|
save_path,
|
|
previous_paused,
|
|
started_at_us,
|
|
&"recovery_candidate",
|
|
&"SAVE_RECOVERY_CANDIDATE_LOADED",
|
|
&"SAVE_RECOVERY_FAILED"
|
|
)
|
|
|
|
|
|
func migrate_save_path(
|
|
save_path: String,
|
|
target_version := SaveService.FORMAT_VERSION
|
|
) -> Dictionary:
|
|
var started_at_us := Time.get_ticks_usec()
|
|
var previous_paused := _clock.paused
|
|
_clock.set_paused(true)
|
|
var result := SaveService.migrate_save(save_path, save_migration_registry, target_version)
|
|
_clock.set_paused(previous_paused)
|
|
var runtime_duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0)
|
|
_record_runtime_performance(&"save", runtime_duration_us)
|
|
result = _with_operation_context(result, {
|
|
&"operation": &"migration",
|
|
&"save_path": save_path,
|
|
&"runtime_duration_us": runtime_duration_us,
|
|
&"clock_pause_restored": _clock.paused == previous_paused,
|
|
})
|
|
_complete_save_operation(
|
|
result,
|
|
&"SAVE_MIGRATION_SUCCEEDED" if result[&"ok"] else &"SAVE_MIGRATION_FAILED"
|
|
)
|
|
return result
|
|
|
|
|
|
func _process_save_checkpoints(absolute_minute: int) -> void:
|
|
if _operations_tick_blocked:
|
|
return
|
|
if bool(_save_policy.get(&"enabled", false)) \
|
|
and bool(_save_policy.get(&"daily_checkpoint_enabled", true)) \
|
|
and posmod(absolute_minute, SimulationKernel.MINUTES_PER_DAY) == SimulationKernel.START_ABSOLUTE_MINUTE:
|
|
var checkpoint_day := absolute_minute / SimulationKernel.MINUTES_PER_DAY + 1
|
|
if checkpoint_day != _last_daily_checkpoint_day:
|
|
var previous_checkpoint_day := _last_daily_checkpoint_day
|
|
_last_daily_checkpoint_day = checkpoint_day
|
|
var daily_result := _perform_checkpoint_save(
|
|
str(_save_policy[&"autosave_path"]),
|
|
{
|
|
&"display_name": "Daily Autosave",
|
|
&"checkpoint_day": checkpoint_day,
|
|
&"checkpoint_absolute_minute": absolute_minute,
|
|
},
|
|
true,
|
|
&"daily_0600",
|
|
0
|
|
)
|
|
if not daily_result[&"ok"]:
|
|
_last_daily_checkpoint_day = previous_checkpoint_day
|
|
|
|
if _pending_checkpoints.is_empty():
|
|
return
|
|
var requests := _pending_checkpoints.duplicate(true)
|
|
_pending_checkpoints.clear()
|
|
for request_value: Variant in requests:
|
|
if not request_value is Dictionary:
|
|
continue
|
|
var request: Dictionary = request_value
|
|
_perform_checkpoint_save(
|
|
str(request[&"save_path"]),
|
|
request[&"metadata"],
|
|
bool(request[&"use_autosave"]),
|
|
StringName(request[&"reason"]),
|
|
int(request[&"request_id"])
|
|
)
|
|
|
|
|
|
func _perform_checkpoint_save(
|
|
save_path: String,
|
|
metadata: Variant,
|
|
use_autosave: bool,
|
|
reason: StringName,
|
|
request_id: int
|
|
) -> Dictionary:
|
|
if not _initialized or _clock == null:
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"RUNTIME_NOT_INITIALIZED",
|
|
&"message": "Simulation runtime is not initialized.",
|
|
}
|
|
var started_at_us := Time.get_ticks_usec()
|
|
# A rejected Path projection is not a complete Runtime save. Refuse before
|
|
# pausing or touching formal/tmp/backup files, and keep the refusal observable
|
|
# without publishing a persisted event into the live snapshot.
|
|
if path_service.create_snapshot().is_empty():
|
|
var refused: Dictionary = {
|
|
&"ok": false, &"code": &"INVALID_SNAPSHOT",
|
|
&"message": "Incomplete Path snapshot; original save preserved.",
|
|
&"operation": &"autosave" if use_autosave else &"save",
|
|
&"save_path": save_path, &"reason": reason, &"request_id": request_id,
|
|
&"checkpoint_absolute_minute": _clock.absolute_minute,
|
|
&"runtime_duration_us": maxi(int(Time.get_ticks_usec() - started_at_us), 0),
|
|
&"clock_pause_restored": true, &"formal_save_preserved": true,
|
|
&"live_state_preserved": true,
|
|
}
|
|
latest_save_operation_result = _save_event_payload(refused)
|
|
save_operation_completed.emit(refused.duplicate(true))
|
|
return refused
|
|
var previous_paused := _clock.paused
|
|
_clock.set_paused(true)
|
|
var snapshot := create_snapshot()
|
|
# Persistence pauses I/O, not game semantics. The package must retain the
|
|
# state that should resume after load rather than the temporary I/O pause.
|
|
snapshot[&"clock"][&"paused"] = previous_paused
|
|
var effective_metadata: Dictionary = metadata.duplicate(true) if metadata is Dictionary else {}
|
|
var presentation: Dictionary = {}
|
|
checkpoint_presentation_requested.emit(presentation)
|
|
if not effective_metadata.has(&"interface_selection") and presentation.get(&"interface_selection") is Dictionary:
|
|
effective_metadata[&"interface_selection"] = presentation[&"interface_selection"].duplicate(true)
|
|
effective_metadata[&"checkpoint_reason"] = reason
|
|
var result := (
|
|
SaveService.save_autosave(
|
|
save_path,
|
|
snapshot,
|
|
effective_metadata,
|
|
int(_save_policy[&"autosave_generations"])
|
|
)
|
|
if use_autosave
|
|
else SaveService.save_snapshot(save_path, snapshot, effective_metadata)
|
|
)
|
|
_clock.set_paused(previous_paused)
|
|
var runtime_duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0)
|
|
_record_runtime_performance(&"save", runtime_duration_us)
|
|
result = _with_operation_context(result, {
|
|
&"operation": &"autosave" if use_autosave else &"save",
|
|
&"save_path": save_path,
|
|
&"reason": reason,
|
|
&"request_id": request_id,
|
|
&"checkpoint_absolute_minute": int(snapshot[&"clock"][&"absolute_minute"]),
|
|
&"runtime_duration_us": runtime_duration_us,
|
|
&"clock_pause_restored": _clock.paused == previous_paused,
|
|
})
|
|
var event_type := (
|
|
&"AUTOSAVE_CHECKPOINT_SUCCEEDED"
|
|
if use_autosave and result[&"ok"]
|
|
else &"AUTOSAVE_CHECKPOINT_FAILED"
|
|
if use_autosave
|
|
else &"SAVE_CHECKPOINT_SUCCEEDED"
|
|
if result[&"ok"]
|
|
else &"SAVE_CHECKPOINT_FAILED"
|
|
)
|
|
_complete_save_operation(result, event_type)
|
|
return result
|
|
|
|
|
|
func _finish_load_operation(
|
|
loaded_result: Dictionary,
|
|
save_path: String,
|
|
previous_paused: bool,
|
|
started_at_us: int,
|
|
operation: StringName,
|
|
success_event_type: StringName,
|
|
failure_event_type: StringName
|
|
) -> Dictionary:
|
|
var result := loaded_result.duplicate(true)
|
|
var recovery_mode := operation in [&"recovery", &"recovery_candidate"]
|
|
var package_recovery_report: Variant = result.get(&"recovery_report", null)
|
|
if result[&"ok"] and not _restore_snapshot_internal(result[&"snapshot"], recovery_mode):
|
|
result[&"ok"] = false
|
|
result[&"source_code"] = result.get(&"code", &"OK")
|
|
result[&"code"] = &"RESTORE_FAILED"
|
|
result[&"message"] = save_path
|
|
result[&"recovery_report"] = {
|
|
&"report_version": 1,
|
|
&"recovery_mode": recovery_mode,
|
|
&"repair_policy": &"WHITELISTED_AUTHORITATIVE_ONLY",
|
|
&"automatic_repairs_applied": false,
|
|
&"restore_committed": false,
|
|
&"rejection_reason": &"DOMAIN_OR_CROSSLINK_VALIDATION_FAILED",
|
|
&"forbidden_repairs_applied": false,
|
|
}
|
|
elif result[&"ok"]:
|
|
result[&"restore_safety_report"] = latest_restore_safety_report.duplicate(true)
|
|
if recovery_mode:
|
|
result[&"recovery_report"] = latest_restore_safety_report.duplicate(true)
|
|
result[&"recovery_report"][&"restore_committed"] = true
|
|
if package_recovery_report is Dictionary:
|
|
result[&"package_recovery_report"] = package_recovery_report.duplicate(true)
|
|
if not result[&"ok"]:
|
|
_clock.set_paused(previous_paused)
|
|
var runtime_duration_us := maxi(int(Time.get_ticks_usec() - started_at_us), 0)
|
|
_record_runtime_performance(&"load", runtime_duration_us)
|
|
result = _with_operation_context(result, {
|
|
&"operation": operation,
|
|
&"save_path": save_path,
|
|
&"runtime_duration_us": runtime_duration_us,
|
|
&"loaded_clock_paused": _clock.paused if result[&"ok"] else previous_paused,
|
|
&"preload_pause_restored": true if result[&"ok"] else _clock.paused == previous_paused,
|
|
})
|
|
_complete_save_operation(
|
|
result,
|
|
success_event_type if result[&"ok"] else failure_event_type
|
|
)
|
|
return result
|
|
|
|
|
|
func _complete_save_operation(result: Dictionary, event_type: StringName) -> void:
|
|
latest_save_operation_result = _save_event_payload(result)
|
|
_publish_actor_event(
|
|
event_type,
|
|
&"save_service",
|
|
[],
|
|
latest_save_operation_result
|
|
)
|
|
save_operation_completed.emit(result.duplicate(true))
|
|
|
|
|
|
func _save_event_payload(result: Dictionary) -> Dictionary:
|
|
var payload := {
|
|
&"ok": bool(result.get(&"ok", false)),
|
|
&"code": StringName(result.get(&"code", &"UNKNOWN")),
|
|
&"message": str(result.get(&"message", "")),
|
|
&"operation": StringName(result.get(&"operation", &"unknown")),
|
|
&"save_path": str(result.get(&"save_path", result.get(&"path", ""))),
|
|
&"reason": StringName(result.get(&"reason", &"")),
|
|
&"request_id": int(result.get(&"request_id", 0)),
|
|
&"checkpoint_absolute_minute": int(result.get(&"checkpoint_absolute_minute", _clock.absolute_minute)),
|
|
&"duration_us": int(result.get(&"duration_us", 0)),
|
|
&"runtime_duration_us": int(result.get(&"runtime_duration_us", 0)),
|
|
}
|
|
var selected_value: Variant = result.get(&"selected", null)
|
|
if selected_value is Dictionary:
|
|
payload[&"selected_candidate_id"] = StringName(selected_value.get(&"id", &""))
|
|
payload[&"selected_candidate_kind"] = StringName(selected_value.get(&"kind", &""))
|
|
payload[&"selected_generation"] = int(selected_value.get(&"generation", -1))
|
|
var manifest_value: Variant = result.get(&"manifest", null)
|
|
if manifest_value is Dictionary:
|
|
payload[&"save_id"] = str(manifest_value.get("save_id", ""))
|
|
payload[&"saved_absolute_minute"] = int(manifest_value.get("absolute_minute", -1))
|
|
return payload
|
|
|
|
|
|
func _with_operation_context(result: Dictionary, context: Dictionary) -> Dictionary:
|
|
var enriched := result.duplicate(true)
|
|
for key: Variant in context:
|
|
enriched[key] = context[key]
|
|
return enriched
|
|
|
|
|
|
func _record_runtime_performance(metric_id: StringName, duration_us: int) -> void:
|
|
if _clock != null and _clock.performance_budget != null:
|
|
_clock.performance_budget.record_sample(metric_id, maxi(duration_us, 0))
|
|
|
|
|
|
func _release_recovery_task_items(
|
|
inventory: InventoryService,
|
|
reservation_ids: Array[StringName],
|
|
task: StaffTask,
|
|
absolute_minute: int
|
|
) -> Dictionary:
|
|
var before := inventory.create_snapshot()
|
|
for reservation_id: StringName in reservation_ids:
|
|
var result := inventory.release_reservation(
|
|
reservation_id,
|
|
StringName("recovery.staff.task.release.%s.%s" % [task.task_id, reservation_id]),
|
|
absolute_minute
|
|
)
|
|
if not bool(result.get(&"ok", false)):
|
|
inventory.restore_snapshot(before)
|
|
return result
|
|
return {&"ok": true}
|
|
|
|
|
|
func _merge_content_filter_profiles(current: Dictionary, saved: Dictionary) -> Dictionary:
|
|
var disabled_by_name: Dictionary = {}
|
|
for raw_tag: Variant in current.get(&"disabled_content_tags", []):
|
|
var tag := StringName(raw_tag)
|
|
if not tag.is_empty():
|
|
disabled_by_name[tag] = true
|
|
for raw_tag: Variant in saved.get(&"disabled_content_tags", []):
|
|
var tag := StringName(raw_tag)
|
|
if not tag.is_empty():
|
|
disabled_by_name[tag] = true
|
|
var disabled_tags: Array[StringName] = []
|
|
for raw_tag: Variant in disabled_by_name:
|
|
disabled_tags.append(StringName(raw_tag))
|
|
disabled_tags.sort_custom(
|
|
func(left: StringName, right: StringName) -> bool:
|
|
return String(left) < String(right)
|
|
)
|
|
return {
|
|
&"filter_mode": maxi(
|
|
int(current.get(&"filter_mode", EventDefinition.FilterBehavior.NONE)),
|
|
int(saved.get(&"filter_mode", EventDefinition.FilterBehavior.NONE))
|
|
) as EventDefinition.FilterBehavior,
|
|
&"maximum_content_rating": mini(
|
|
int(current.get(&"maximum_content_rating", EventDefinition.ContentRating.GENERAL)),
|
|
int(saved.get(&"maximum_content_rating", EventDefinition.ContentRating.GENERAL))
|
|
) as EventDefinition.ContentRating,
|
|
&"disabled_content_tags": disabled_tags,
|
|
}
|
|
|
|
|
|
func _content_filter_restore_report(
|
|
current: Dictionary,
|
|
saved: Dictionary,
|
|
effective: Dictionary
|
|
) -> Dictionary:
|
|
var current_tags: Array = current.get(&"disabled_content_tags", [])
|
|
var saved_tags: Array = saved.get(&"disabled_content_tags", [])
|
|
var saved_was_weaker := (
|
|
int(saved.get(&"filter_mode", 0)) < int(current.get(&"filter_mode", 0))
|
|
or int(saved.get(&"maximum_content_rating", 0)) > int(current.get(&"maximum_content_rating", 0))
|
|
)
|
|
for raw_tag: Variant in current_tags:
|
|
if not saved_tags.has(StringName(raw_tag)) and not saved_tags.has(String(raw_tag)):
|
|
saved_was_weaker = true
|
|
break
|
|
return {
|
|
&"saved_profile_was_weaker": saved_was_weaker,
|
|
&"strict_profile_preserved": true,
|
|
&"current_before_load": _content_filter_profile_payload(current),
|
|
&"saved_profile": _content_filter_profile_payload(saved),
|
|
&"effective_profile": _content_filter_profile_payload(effective),
|
|
}
|
|
|
|
|
|
func _content_filter_profile_payload(profile: Dictionary) -> Dictionary:
|
|
var mode := clampi(
|
|
int(profile.get(&"filter_mode", EventDefinition.FilterBehavior.NONE)),
|
|
EventDefinition.FilterBehavior.NONE,
|
|
EventDefinition.FilterBehavior.DISABLE
|
|
)
|
|
var rating := clampi(
|
|
int(profile.get(&"maximum_content_rating", EventDefinition.ContentRating.GENERAL)),
|
|
EventDefinition.ContentRating.GENERAL,
|
|
EventDefinition.ContentRating.RESTRICTED
|
|
)
|
|
var tags: Array[String] = []
|
|
for raw_tag: Variant in profile.get(&"disabled_content_tags", []):
|
|
tags.append(String(raw_tag))
|
|
tags.sort()
|
|
return {
|
|
&"filter_mode": EventDefinition.FilterBehavior.keys()[mode],
|
|
&"maximum_content_rating": EventDefinition.ContentRating.keys()[rating],
|
|
&"disabled_content_tags": tags,
|
|
}
|
|
|
|
|
|
func _save_policy_path_is_safe(save_path: String) -> bool:
|
|
if not save_path.begins_with("user://") or save_path == "user://":
|
|
return false
|
|
var relative := save_path.trim_prefix("user://").replace("\\", "/")
|
|
if relative.is_empty() or relative.is_absolute_path():
|
|
return false
|
|
for segment: String in relative.split("/", false):
|
|
if segment.is_empty() or segment == "." or segment == "..":
|
|
return false
|
|
return true
|
|
|
|
|
|
func _runtime_command_history_crosslinks_are_valid(
|
|
queue_snapshot: Dictionary,
|
|
next_command_id: int,
|
|
events: DomainEventStream
|
|
) -> bool:
|
|
if next_command_id <= 0 or events == null \
|
|
or typeof(queue_snapshot.get("version", null)) != TYPE_INT \
|
|
or int(queue_snapshot["version"]) != CommandQueue.SNAPSHOT_VERSION \
|
|
or not queue_snapshot.get("accepted", null) is Array:
|
|
return false
|
|
var seen_ids: Dictionary = {}
|
|
var processed_by_id: Dictionary = {}
|
|
var command_idempotency: Dictionary = {}
|
|
for collection_key: String in ["pending", "in_flight", "processed"]:
|
|
var rows_value: Variant = queue_snapshot.get(collection_key, null)
|
|
if not rows_value is Array:
|
|
return false
|
|
if collection_key == "in_flight" and not rows_value.is_empty():
|
|
# Runtime commands are synchronously completed in APPLY_COMMANDS and
|
|
# have no resumable in-flight execution path.
|
|
return false
|
|
for row_value: Variant in rows_value:
|
|
if not row_value is Dictionary:
|
|
return false
|
|
var row: Dictionary = row_value
|
|
var command_id_value: Variant = row.get("command_id", null)
|
|
if typeof(command_id_value) != TYPE_INT or int(command_id_value) <= 0 \
|
|
or seen_ids.has(int(command_id_value)):
|
|
return false
|
|
var command_id := int(command_id_value)
|
|
seen_ids[command_id] = true
|
|
if collection_key == "processed":
|
|
processed_by_id[command_id] = row
|
|
else:
|
|
var raw_key: Variant = row.get("idempotency_key", "")
|
|
if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return false
|
|
var key := StringName(raw_key)
|
|
if not key.is_empty():
|
|
command_idempotency[command_id] = key
|
|
if seen_ids.size() != next_command_id - 1:
|
|
return false
|
|
for command_id in range(1, next_command_id):
|
|
if not seen_ids.has(command_id):
|
|
return false
|
|
var accepted_by_id: Dictionary = {}
|
|
var previous_accepted_id := 0
|
|
for raw_metadata: Variant in queue_snapshot["accepted"]:
|
|
if not raw_metadata is Dictionary:
|
|
return false
|
|
var metadata: Dictionary = raw_metadata
|
|
var command_id_value: Variant = metadata.get("command_id", null)
|
|
var key_value: Variant = metadata.get("idempotency_key", null)
|
|
if metadata.size() != 2 or typeof(command_id_value) != TYPE_INT \
|
|
or int(command_id_value) <= previous_accepted_id \
|
|
or typeof(key_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or not seen_ids.has(int(command_id_value)):
|
|
return false
|
|
var command_id := int(command_id_value)
|
|
if command_idempotency.has(command_id) \
|
|
and StringName(command_idempotency[command_id]) != StringName(key_value):
|
|
return false
|
|
accepted_by_id[command_id] = StringName(key_value)
|
|
command_idempotency[command_id] = StringName(key_value)
|
|
previous_accepted_id = command_id
|
|
if accepted_by_id.size() != seen_ids.size():
|
|
return false
|
|
var idempotency_value: Variant = queue_snapshot.get("idempotency", null)
|
|
if not idempotency_value is Dictionary:
|
|
return false
|
|
var mapped_ids: Dictionary = {}
|
|
for raw_key: Variant in idempotency_value:
|
|
if typeof(raw_key) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or StringName(raw_key).is_empty() \
|
|
or typeof(idempotency_value[raw_key]) != TYPE_INT:
|
|
return false
|
|
var command_id := int(idempotency_value[raw_key])
|
|
if not seen_ids.has(command_id) or mapped_ids.has(command_id):
|
|
return false
|
|
mapped_ids[command_id] = StringName(raw_key)
|
|
for command_id: int in command_idempotency:
|
|
var accepted_key := StringName(command_idempotency[command_id])
|
|
if accepted_key.is_empty():
|
|
if mapped_ids.has(command_id):
|
|
return false
|
|
elif mapped_ids.get(command_id, &"") != accepted_key:
|
|
return false
|
|
|
|
var completion_by_id: Dictionary = {}
|
|
for event: SimulationDomainEvent in events.recent_events():
|
|
if event.event_type not in [&"COMMAND_COMPLETED", &"COMMAND_FAILED"]:
|
|
continue
|
|
var command_id := event.causation_command_id
|
|
if command_id <= 0 or not processed_by_id.has(command_id) \
|
|
or completion_by_id.has(command_id) \
|
|
or event.source_service != &"simulation_runtime" \
|
|
or not _strict_variants_equal(event.payload, processed_by_id[command_id]):
|
|
return false
|
|
var result_code := String((processed_by_id[command_id] as Dictionary).get("code", ""))
|
|
if (result_code == "OK") != (event.event_type == &"COMMAND_COMPLETED"):
|
|
return false
|
|
completion_by_id[command_id] = true
|
|
# Completion events share a bounded stream with all other DomainEvents. Any
|
|
# retained completion must match exactly; absence alone is not corruption
|
|
# because unrelated events can legitimately evict it.
|
|
return true
|
|
|
|
|
|
func _legacy_operations_history_is_absent(runtime_data: Dictionary) -> bool:
|
|
# v1-v4 had no Operations wrapper or payroll settlement semantics. Reject a
|
|
# relabelled v5 payload instead of silently discarding production history.
|
|
if runtime_data.has(&"operations") or runtime_data.has("operations"):
|
|
return false
|
|
var staff_value: Variant = runtime_data.get(&"staff", null)
|
|
if staff_value is Dictionary:
|
|
var settlements: Variant = (staff_value as Dictionary).get(&"payroll_settlements", [])
|
|
if settlements is Array and not settlements.is_empty():
|
|
return false
|
|
var economy_value: Variant = runtime_data.get(&"economy", null)
|
|
if economy_value is Dictionary:
|
|
var entries: Variant = (economy_value as Dictionary).get(&"entries", [])
|
|
if entries is Array:
|
|
for raw_entry: Variant in entries:
|
|
if raw_entry is Dictionary \
|
|
and StringName((raw_entry as Dictionary).get(&"category", "")) == &"WAGE":
|
|
return false
|
|
return true
|
|
|
|
|
|
func _legacy_infrastructure_history_is_absent(runtime_data: Dictionary) -> bool:
|
|
# Infrastructure aggregates first exist in Runtime v6. A lower version with
|
|
# any look-alike field or retained v6-only history is a relabelled payload,
|
|
# not a migratable legacy save.
|
|
for key: StringName in [
|
|
&"excavation", &"spatial_subslots", &"route_projection",
|
|
&"facility_infrastructure",
|
|
]:
|
|
if runtime_data.has(key) or runtime_data.has(String(key)):
|
|
return false
|
|
var stream_value: Variant = runtime_data.get(&"domain_events", null)
|
|
if stream_value is Dictionary:
|
|
var recent_events: Variant = (stream_value as Dictionary).get("recent_events", [])
|
|
if recent_events is Array:
|
|
for raw_event: Variant in recent_events:
|
|
if raw_event is Dictionary and StringName(
|
|
(raw_event as Dictionary).get("event_type", "")
|
|
) in [&"FACILITY_INFRASTRUCTURE_PROJECTED", &"EXCAVATION_COMPLETED"]:
|
|
return false
|
|
var queue_value: Variant = runtime_data.get(&"command_queue", null)
|
|
if queue_value is Dictionary:
|
|
for queue_key: String in ["pending", "in_flight"]:
|
|
var commands: Variant = (queue_value as Dictionary).get(queue_key, [])
|
|
if not commands is Array:
|
|
continue
|
|
for raw_command: Variant in commands:
|
|
if raw_command is Dictionary and StringName(
|
|
(raw_command as Dictionary).get("command_type", "")
|
|
) in [&"start_excavation", &"pause_excavation", &"resume_excavation"]:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _runtime_v5_outer_snapshot_shape_is_valid(snapshot: Dictionary) -> bool:
|
|
if not _dictionary_has_exact_canonical_keys(
|
|
snapshot, [&"schema_version", &"clock", &"game_state", &"systems"]
|
|
) or typeof(snapshot.get(&"schema_version", null)) != TYPE_INT \
|
|
or int(snapshot[&"schema_version"]) != SimulationSnapshot.SCHEMA_VERSION:
|
|
return false
|
|
var systems_value: Variant = snapshot.get(&"systems", null)
|
|
var clock_value: Variant = snapshot.get(&"clock", null)
|
|
var state_value: Variant = snapshot.get(&"game_state", null)
|
|
if not systems_value is Dictionary or not clock_value is Dictionary \
|
|
or not state_value is Dictionary:
|
|
return false
|
|
var systems: Dictionary = systems_value
|
|
var clock_data: Dictionary = clock_value
|
|
var state_data: Dictionary = state_value
|
|
if not _dictionary_has_exact_canonical_keys(systems, [&"runtime"]) \
|
|
or not systems[&"runtime"] is Dictionary \
|
|
or not _dictionary_has_exact_canonical_keys(
|
|
clock_data,
|
|
[&"absolute_minute", &"speed", &"paused", &"snapshot_version"]
|
|
) \
|
|
or typeof(clock_data.get(&"absolute_minute", null)) != TYPE_INT \
|
|
or int(clock_data[&"absolute_minute"]) < 0 \
|
|
or typeof(clock_data.get(&"speed", null)) != TYPE_INT \
|
|
or int(clock_data[&"speed"]) not in [1, 2, 3, 4] \
|
|
or typeof(clock_data.get(&"paused", null)) != TYPE_BOOL \
|
|
or typeof(clock_data.get(&"snapshot_version", null)) != TYPE_INT \
|
|
or int(clock_data[&"snapshot_version"]) < 0:
|
|
return false
|
|
if not _dictionary_has_exact_canonical_keys(
|
|
state_data,
|
|
[&"resources", &"world_tree_rank", &"active_layer", &"revision"]
|
|
) or not state_data[&"resources"] is Dictionary \
|
|
or typeof(state_data.get(&"world_tree_rank", null)) != TYPE_INT \
|
|
or int(state_data[&"world_tree_rank"]) < 1 \
|
|
or int(state_data[&"world_tree_rank"]) > 7 \
|
|
or typeof(state_data.get(&"active_layer", null)) != TYPE_INT \
|
|
or int(state_data[&"active_layer"]) not in [0, 1] \
|
|
or typeof(state_data.get(&"revision", null)) != TYPE_INT \
|
|
or int(state_data[&"revision"]) < 0:
|
|
return false
|
|
var resources: Dictionary = state_data[&"resources"]
|
|
var seen_resources: Dictionary = {}
|
|
for raw_resource_id: Variant in resources:
|
|
if typeof(raw_resource_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return false
|
|
var resource_id := StringName(raw_resource_id)
|
|
if resource_id.is_empty() or seen_resources.has(resource_id) \
|
|
or typeof(resources[raw_resource_id]) != TYPE_INT \
|
|
or int(resources[raw_resource_id]) < 0:
|
|
return false
|
|
seen_resources[resource_id] = true
|
|
for required_resource: StringName in [&"gold", &"mana", &"materials", &"faith"]:
|
|
if not seen_resources.has(required_resource):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _connect_path_performance_signal() -> void:
|
|
if path_service == null or not path_service.has_signal(&"path_query_measured"):
|
|
return
|
|
var callback := Callable(self, "_on_path_query_measured")
|
|
if not path_service.is_connected(&"path_query_measured", callback):
|
|
path_service.connect(&"path_query_measured", callback)
|
|
|
|
|
|
func _on_path_query_measured(duration_us: int) -> void:
|
|
_record_runtime_performance(&"path_query", duration_us)
|
|
|
|
func _initialize_rng_streams() -> void:
|
|
_rng_streams.clear()
|
|
for stream_id in RNG_STREAM_IDS:
|
|
_rng_streams[stream_id] = DeterministicRng.from_root_seed(world_seed, stream_id)
|
|
|
|
func _connect_construction_signals() -> void:
|
|
if construction_service == null:
|
|
return
|
|
var completed_callback := Callable(self, "_on_facility_completed")
|
|
var demolished_callback := Callable(self, "_on_facility_demolished")
|
|
if not construction_service.facility_completed.is_connected(completed_callback):
|
|
construction_service.facility_completed.connect(completed_callback)
|
|
if not construction_service.facility_demolished.is_connected(demolished_callback):
|
|
construction_service.facility_demolished.connect(demolished_callback)
|
|
|
|
|
|
func _initialize_infrastructure_services() -> bool:
|
|
spatial_subslot_service = SpatialSubslotService.new()
|
|
route_subslot_path_adapter = RouteSubslotPathAdapter.new()
|
|
if not route_subslot_path_adapter.configure(
|
|
spatial_subslot_service,
|
|
path_service,
|
|
world_service.get_walkable_cells()
|
|
):
|
|
return false
|
|
facility_infrastructure_projector = FacilityInfrastructureProjector.new()
|
|
if not facility_infrastructure_projector.configure(
|
|
route_subslot_path_adapter,
|
|
spatial_subslot_service,
|
|
path_service
|
|
):
|
|
return false
|
|
excavation_service = ExcavationService.new(world_service, construction_resource_state)
|
|
excavation_service.current_minute = _clock.absolute_minute
|
|
var excavation_callback := Callable(self, "_on_excavation_completed")
|
|
if not excavation_service.excavation_completed.is_connected(excavation_callback):
|
|
excavation_service.excavation_completed.connect(excavation_callback)
|
|
return true
|
|
|
|
func rebuild_path_graph_from_world() -> bool:
|
|
if path_service == null or world_service == null:
|
|
return false
|
|
var walkable_cells: Array[Vector3i] = world_service.get_walkable_cells()
|
|
var projected_walkable_cells: Array[Vector3i] = walkable_cells.duplicate()
|
|
if route_subslot_path_adapter != null \
|
|
and route_subslot_path_adapter.is_configured() \
|
|
and spatial_subslot_service != null:
|
|
var seen_projected: Dictionary = {}
|
|
projected_walkable_cells.clear()
|
|
for cell: Vector3i in walkable_cells:
|
|
if not seen_projected.has(cell):
|
|
seen_projected[cell] = true
|
|
projected_walkable_cells.append(cell)
|
|
for cell: Vector3i in spatial_subslot_service.get_route_cells():
|
|
if not seen_projected.has(cell):
|
|
seen_projected[cell] = true
|
|
projected_walkable_cells.append(cell)
|
|
var current_path_snapshot: Dictionary = path_service.create_snapshot()
|
|
var raw_actor_cells: Variant = current_path_snapshot.get(&"actor_cells", null)
|
|
var has_runtime_actor_state: bool = (
|
|
not _movement_credits.is_empty()
|
|
or (raw_actor_cells is Array and not raw_actor_cells.is_empty())
|
|
)
|
|
# PathService correctly owns its own graph, but Runtime owns the cross-system
|
|
# Actor/Path projection contract. Reject a topology change that would evict
|
|
# a live Guest/Staff projection instead of allowing PathService to prune it.
|
|
if has_runtime_actor_state:
|
|
if _clock == null or not _validate_runtime_path_crosslinks(
|
|
path_service,
|
|
guest_service,
|
|
staff_service,
|
|
_movement_credits,
|
|
_clock.absolute_minute
|
|
):
|
|
return false
|
|
var walkable_set: Dictionary = {}
|
|
for cell: Vector3i in projected_walkable_cells:
|
|
walkable_set[cell] = true
|
|
var expected_cells: Dictionary = _expected_runtime_actor_cells(
|
|
guest_service,
|
|
staff_service
|
|
)
|
|
for actor_id: StringName in _sorted_name_keys(expected_cells):
|
|
if not walkable_set.has(expected_cells[actor_id]):
|
|
return false
|
|
if route_subslot_path_adapter != null \
|
|
and route_subslot_path_adapter.is_configured():
|
|
var synchronized := route_subslot_path_adapter.synchronize_projection(
|
|
StringName(
|
|
"runtime.base_sync.%08d" % (route_subslot_path_adapter.projection_revision + 1)
|
|
),
|
|
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)
|
|
world_service.reset(bounds)
|
|
_seed_boundary_cells(bounds)
|
|
rebuild_path_graph_from_world()
|
|
path_service.add_connector(
|
|
&"starter.staff_door",
|
|
Vector3i(0, 0, WorldService.LAYER_FRONT),
|
|
Vector3i(0, 0, WorldService.LAYER_BACK),
|
|
PathService.DEFAULT_CONNECTOR_COST,
|
|
true,
|
|
&"staff"
|
|
)
|
|
|
|
func _initialize_mana_network() -> void:
|
|
mana_network_service.register_source(STARTER_MANA_SOURCE, STARTER_MANA_OUTPUT)
|
|
mana_network_service.register_connector(STARTER_MANA_BUS)
|
|
mana_network_service.connect_nodes(STARTER_MANA_SOURCE, STARTER_MANA_BUS)
|
|
|
|
func _initialize_economy() -> void:
|
|
economy_service = EconomyService.new({
|
|
PLAYER_CASH_ACCOUNT: {&"allow_negative": false},
|
|
CONSTRUCTION_EXPENSE_ACCOUNT: {&"allow_negative": false},
|
|
OPERATING_EXPENSE_ACCOUNT: {&"allow_negative": false},
|
|
AUTHORITY_FINE_ACCOUNT: {&"allow_negative": false},
|
|
WAGE_EXPENSE_ACCOUNT: {&"allow_negative": false},
|
|
})
|
|
economy_service.initialize_account(
|
|
PLAYER_CASH_ACCOUNT,
|
|
int(_state.get_resource(&"gold")),
|
|
_clock.absolute_minute,
|
|
&"runtime.initial_player_cash"
|
|
)
|
|
facility_cleanliness = FacilityCleanliness.new()
|
|
cleaning_policy = CleaningPolicy.new()
|
|
cleaning_policy.load_file()
|
|
weather = WeatherTimeline.new()
|
|
weather.initialize(&"clear", _clock.absolute_minute)
|
|
facility_upkeep = FacilityUpkeepAccrual.new()
|
|
upkeep_settlement = FacilityUpkeepSettlement.new()
|
|
FacilityUpkeepSettlement.prepare_accounts(economy_service)
|
|
facility_upkeep.start_at(_clock.absolute_minute)
|
|
construction_resource_state = _create_resource_adapter(
|
|
economy_service,
|
|
CONSTRUCTION_EXPENSE_ACCOUNT,
|
|
&"CONSTRUCTION",
|
|
&"CONSTRUCTION_REFUND",
|
|
&"construction_service"
|
|
)
|
|
operating_resource_state = _create_resource_adapter(
|
|
economy_service,
|
|
OPERATING_EXPENSE_ACCOUNT,
|
|
&"OPERATING_EXPENSE",
|
|
&"OPERATING_REFUND",
|
|
&"simulation_runtime"
|
|
)
|
|
_connect_economy_signals()
|
|
|
|
func _create_resource_adapter(
|
|
economy: EconomyService,
|
|
counter_account: StringName,
|
|
expense_category: StringName,
|
|
refund_category: StringName,
|
|
source_entity_id: StringName
|
|
) -> LedgerBackedResourceState:
|
|
return LedgerBackedResourceState.new(
|
|
_state,
|
|
economy,
|
|
counter_account,
|
|
expense_category,
|
|
refund_category,
|
|
source_entity_id,
|
|
func() -> int: return _runtime_event_minute()
|
|
)
|
|
|
|
func _connect_economy_signals() -> void:
|
|
if economy_service == null:
|
|
return
|
|
economy_service.transaction_posted.connect(Callable(self, "_on_ledger_transaction_posted"))
|
|
|
|
func _initialize_s2_operations() -> void:
|
|
inventory_service = InventoryService.new()
|
|
inventory_service.current_minute = _clock.absolute_minute
|
|
logistics_dispatcher = LogisticsDispatcher.new()
|
|
logistics_requests = {}
|
|
facility_service = FacilityService.new()
|
|
facility_service.current_minute = _clock.absolute_minute
|
|
_configure_facility_boundaries()
|
|
_connect_facility_signals()
|
|
auto_purchase_plans = AutoPurchasePlansType.new()
|
|
kitchen_production = KitchenProductionService.new()
|
|
kitchen_production.configure(inventory_service, facility_service, definition_registry, _clock.absolute_minute)
|
|
|
|
func _initialize_s3_actors() -> void:
|
|
guest_service = GuestService.new()
|
|
guest_service.current_minute = _clock.absolute_minute
|
|
guest_service.create_definition(&"traveler", {
|
|
&"tags": [&"general", &"traveler"],
|
|
&"wealth_min": 120,
|
|
&"wealth_max": 450,
|
|
&"base_patience": 45,
|
|
&"risk_tolerance": 50,
|
|
&"preference_weights": {&"dining": 8, &"lodging": 7, &"social": 3},
|
|
&"group_size_weights": {1: 70, 2: 25, 3: 5},
|
|
&"legal_sensitivity": 65,
|
|
&"quality_sensitivity": 50,
|
|
&"need_rates": {&"hunger": 1, &"sleep": 1, &"hygiene": 0, &"medical": 0, &"leisure": 1, &"intimacy": 0},
|
|
&"credit_limit_gold": 50,
|
|
&"minimum_quality": 25,
|
|
})
|
|
guest_service.create_definition(&"merchant", {
|
|
&"tags": [&"general", &"merchant", &"frugal"],
|
|
&"wealth_min": 300,
|
|
&"wealth_max": 1_200,
|
|
&"base_patience": 35,
|
|
&"risk_tolerance": 35,
|
|
&"preference_weights": {&"lodging": 9, &"quiet": 7, &"service": 4},
|
|
&"group_size_weights": {1: 60, 2: 40},
|
|
&"legal_sensitivity": 80,
|
|
&"quality_sensitivity": 70,
|
|
&"need_rates": {&"hunger": 1, &"sleep": 1, &"hygiene": 0, &"medical": 0, &"leisure": 1, &"intimacy": 0},
|
|
&"credit_limit_gold": 200,
|
|
&"minimum_quality": 40,
|
|
})
|
|
guest_service.create_definition(&"adventurer", {
|
|
&"tags": [&"adventurer", &"general", &"spendthrift"],
|
|
&"wealth_min": 180,
|
|
&"wealth_max": 800,
|
|
&"base_patience": 30,
|
|
&"risk_tolerance": 85,
|
|
&"preference_weights": {&"dining": 6, &"social": 8, &"service": 2},
|
|
&"group_size_weights": {1: 45, 2: 35, 3: 15, 4: 5},
|
|
&"legal_sensitivity": 35,
|
|
&"quality_sensitivity": 35,
|
|
&"adult_verified": true,
|
|
&"need_rates": {&"hunger": 2, &"sleep": 1, &"hygiene": 0, &"medical": 0, &"leisure": 1, &"intimacy": 0},
|
|
&"credit_limit_gold": 100,
|
|
&"minimum_quality": 20,
|
|
})
|
|
if bool(mvp_content_report.get(&"valid", false)):
|
|
for definition: GuestDefinition in mvp_content_catalog.build_all_guest_definitions():
|
|
if not guest_service.register_definition(definition):
|
|
mvp_content_report = mvp_content_report.duplicate(true)
|
|
mvp_content_report[&"valid"] = false
|
|
mvp_content_report[&"runtime_registration_error"] = guest_service.last_error
|
|
break
|
|
staff_service = StaffService.new()
|
|
staff_service.current_minute = _clock.absolute_minute
|
|
_configure_staff_boundaries()
|
|
_connect_guest_signals()
|
|
_connect_staff_signals()
|
|
|
|
func _configure_staff_boundaries() -> void:
|
|
if staff_service == null:
|
|
return
|
|
staff_service.configure_arrival_driver(Callable(self, "_handle_rescue_arrival"))
|
|
staff_service.configure_work_progress_driver(Callable(self, "_automatic_staff_work_units"))
|
|
staff_service.configure_callbacks(
|
|
Callable(self, "_reserve_staff_task_items"),
|
|
Callable(self, "_release_staff_task_items"),
|
|
Callable(self, "_estimate_staff_task_travel")
|
|
)
|
|
|
|
func _connect_guest_signals() -> void:
|
|
if guest_service == null:
|
|
return
|
|
guest_service.guest_spawned.connect(Callable(self, "_on_guest_spawned"))
|
|
guest_service.guest_state_changed.connect(Callable(self, "_on_guest_state_changed"))
|
|
guest_service.guest_queue_abandoned.connect(Callable(self, "_on_guest_queue_abandoned"))
|
|
guest_service.guest_recovery_started.connect(Callable(self, "_on_guest_recovery_started"))
|
|
guest_service.guest_despawned.connect(Callable(self, "_on_guest_despawned"))
|
|
|
|
func _connect_staff_signals() -> void:
|
|
if staff_service == null:
|
|
return
|
|
staff_service.staff_registered.connect(Callable(self, "_on_staff_registered"))
|
|
staff_service.staff_removed.connect(Callable(self, "_on_staff_removed"))
|
|
staff_service.shift_registered.connect(Callable(self, "_on_shift_registered"))
|
|
staff_service.shift_state_changed.connect(Callable(self, "_on_shift_state_changed"))
|
|
staff_service.task_registered.connect(Callable(self, "_on_staff_task_registered"))
|
|
staff_service.task_assigned.connect(Callable(self, "_on_staff_task_assigned"))
|
|
staff_service.task_reopened.connect(Callable(self, "_on_staff_task_reopened"))
|
|
staff_service.task_completed.connect(Callable(self, "_on_staff_task_completed"))
|
|
|
|
func _initialize_s4_relationship_events() -> void:
|
|
relationship_service = RelationshipService.new()
|
|
event_service = EventService.new()
|
|
event_service.create_definition(&"relationship.threshold", {
|
|
&"category": &"RELATIONSHIP",
|
|
&"priority": 80,
|
|
&"foreground": true,
|
|
&"expires_after": 1_440,
|
|
&"transaction_policy": &"ROLLBACK_ALL",
|
|
&"missing_actor_policy": &"CANCEL",
|
|
&"content_rating": &"GENERAL",
|
|
&"choices": [
|
|
{
|
|
&"choice_id": &"acknowledge",
|
|
&"text_key": &"event.relationship.threshold.acknowledge",
|
|
&"actions": [{
|
|
&"action_id": &"record_acknowledgement",
|
|
&"type": &"SET_FLAG",
|
|
&"payload": {&"flag_id": &"relationship_threshold_acknowledged"},
|
|
}],
|
|
},
|
|
{
|
|
&"choice_id": &"defer",
|
|
&"text_key": &"event.relationship.threshold.defer",
|
|
&"actions": [],
|
|
},
|
|
],
|
|
})
|
|
_configure_relationship_event_boundaries()
|
|
_connect_relationship_signals()
|
|
_connect_event_signals()
|
|
|
|
func _configure_relationship_event_boundaries() -> void:
|
|
if relationship_service == null or event_service == null:
|
|
return
|
|
relationship_service.configure_original_identity_validator(
|
|
func(payload: Dictionary) -> bool: return _original_relationship_identities_valid(payload, staff_service, mvp_content_catalog)
|
|
)
|
|
relationship_service.configure_candidate_callback(
|
|
Callable(self, "_on_relationship_threshold_candidate")
|
|
)
|
|
event_service.configure_relationship_service(relationship_service)
|
|
event_service.configure_candidate_eligibility_guard(Callable(self, "_event_candidate_placement_permitted"))
|
|
event_service.configure_callbacks(
|
|
Callable(self, "_event_actor_exists"),
|
|
Callable(self, "_execute_event_action_boundary"),
|
|
Callable(self, "_reserve_event_cost"),
|
|
Callable(self, "_release_event_cost"),
|
|
Callable(self, "_commit_event_cost"),
|
|
Callable(self, "_event_entity_exists"),
|
|
Callable(self, "_event_path_exists")
|
|
)
|
|
|
|
## Original 6.2/6.4: Tomari's placement prevents floor trouble. This does not
|
|
## choose a joining date, probability, defense score, or narrative content.
|
|
func is_floor_trouble_protected(floor_y: int) -> bool:
|
|
var tomari := staff_service.get_staff(&"heroine_tomari")
|
|
if tomari == null or tomari.employment_kind != StaffRuntime.EmploymentKind.UNIQUE:
|
|
return false
|
|
var assignment := staff_service.get_work_assignment(tomari.staff_id)
|
|
var facility := facility_service.get_instance(StringName(assignment.get(&"facility_id", &"")))
|
|
return facility != null and facility.origin.y == floor_y
|
|
|
|
|
|
func _event_candidate_placement_permitted(definition: EventDefinition, context: Dictionary) -> bool:
|
|
if definition.category != &"TROUBLE":
|
|
return true
|
|
# A business trouble identifies its existing target facility. Missing or
|
|
# conflicting location data cannot silently bypass placement protection.
|
|
var target: Variant = context.get(&"facility_id", definition.location_rule.get(&"facility_id", null))
|
|
if typeof(target) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return false
|
|
var declared: Variant = definition.location_rule.get(&"facility_id", target)
|
|
if typeof(declared) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(declared) != StringName(target):
|
|
return false
|
|
var facility := facility_service.get_instance(StringName(target))
|
|
return facility != null and not is_floor_trouble_protected(facility.origin.y)
|
|
|
|
|
|
func _connect_relationship_signals() -> void:
|
|
if relationship_service == null:
|
|
return
|
|
relationship_service.relationship_registered.connect(Callable(self, "_on_relationship_registered"))
|
|
relationship_service.relationship_value_changed.connect(Callable(self, "_on_relationship_value_changed"))
|
|
relationship_service.shield_changed.connect(Callable(self, "_on_relationship_shield_changed"))
|
|
relationship_service.route_lock_changed.connect(Callable(self, "_on_relationship_route_lock_changed"))
|
|
|
|
func _connect_event_signals() -> void:
|
|
if event_service == null:
|
|
return
|
|
event_service.event_enqueued.connect(Callable(self, "_on_event_enqueued"))
|
|
event_service.event_started.connect(Callable(self, "_on_event_started"))
|
|
event_service.event_finished.connect(Callable(self, "_on_event_finished"))
|
|
event_service.event_cancelled.connect(Callable(self, "_on_event_cancelled"))
|
|
event_service.choice_resolved.connect(Callable(self, "_on_event_choice_resolved"))
|
|
event_service.effect_transaction_started.connect(Callable(self, "_on_event_effect_transaction_started"))
|
|
event_service.effect_transaction_finished.connect(Callable(self, "_on_event_effect_transaction_finished"))
|
|
|
|
func _initialize_s5_authority() -> void:
|
|
authority_service = AuthorityService.new()
|
|
authority_service.current_minute = _clock.absolute_minute
|
|
_configure_authority_boundaries()
|
|
_connect_authority_signals()
|
|
|
|
func _configure_authority_boundaries() -> void:
|
|
if authority_service == null:
|
|
return
|
|
authority_service.configure_callbacks(
|
|
Callable(self, "_authority_line_of_sight"),
|
|
Callable(self, "_authority_path_query"),
|
|
Callable(self, "_apply_authority_resolution_boundary"),
|
|
Callable(self, "_roll_authority_inspection")
|
|
)
|
|
|
|
func _connect_authority_signals() -> void:
|
|
if authority_service == null:
|
|
return
|
|
authority_service.heat_changed.connect(Callable(self, "_on_authority_heat_changed"))
|
|
authority_service.incident_created.connect(Callable(self, "_on_authority_incident_created"))
|
|
authority_service.incident_detector_added.connect(Callable(self, "_on_authority_incident_detector_added"))
|
|
authority_service.evidence_registered.connect(Callable(self, "_on_authority_evidence_registered"))
|
|
authority_service.evidence_discovered.connect(Callable(self, "_on_authority_evidence_discovered"))
|
|
authority_service.inspection_scheduled.connect(Callable(self, "_on_authority_inspection_scheduled"))
|
|
authority_service.inspection_state_changed.connect(Callable(self, "_on_authority_inspection_state_changed"))
|
|
authority_service.resolution_applied.connect(Callable(self, "_on_authority_resolution_applied"))
|
|
|
|
func _initialize_s5_defense() -> void:
|
|
_reset_defense_round_attempts()
|
|
defense_service = DefenseService.new()
|
|
defense_service.current_minute = _clock.absolute_minute
|
|
_configure_defense_boundaries()
|
|
_connect_defense_signals()
|
|
|
|
func _defense_operation_minute() -> int:
|
|
return _active_command_minute if _active_command_minute >= 0 else _clock.absolute_minute
|
|
|
|
func _runtime_event_minute() -> int:
|
|
return _active_command_minute if _active_command_minute >= 0 else _clock.absolute_minute
|
|
|
|
func _configure_defense_boundaries() -> void:
|
|
if defense_service == null:
|
|
return
|
|
defense_service.configure_callbacks(
|
|
Callable(self, "_roll_defense_round"),
|
|
Callable(self, "_apply_defense_skill_boundary"),
|
|
Callable(self, "_apply_defense_outcome_boundary"),
|
|
Callable(self, "_apply_defense_rng_boundary")
|
|
)
|
|
|
|
func _connect_defense_signals() -> void:
|
|
if defense_service == null:
|
|
return
|
|
var bindings := [
|
|
[&"raid_created", Callable(self, "_on_defense_raid_created")],
|
|
[&"raid_state_changed", Callable(self, "_on_defense_raid_state_changed")],
|
|
[&"active_skill_applied", Callable(self, "_on_defense_active_skill_applied")],
|
|
[&"round_resolved", Callable(self, "_on_defense_round_resolved")],
|
|
[&"raid_resolved", Callable(self, "_on_defense_raid_resolved")],
|
|
]
|
|
for binding: Array in bindings:
|
|
var signal_name: StringName = binding[0]
|
|
var callback: Callable = binding[1]
|
|
if not defense_service.is_connected(signal_name, callback):
|
|
defense_service.connect(signal_name, callback)
|
|
|
|
func _detach_defense_service(service: DefenseService) -> void:
|
|
if service == null:
|
|
return
|
|
# A caller may retain a RefCounted aggregate across Runtime initialize/load.
|
|
# Retiring it must sever both transaction callbacks and event projection so
|
|
# stale objects cannot consume the new Runtime's RNG/resources or emit ghost
|
|
# DomainEvents after an otherwise atomic restore.
|
|
service.configure_callbacks()
|
|
var bindings := [
|
|
[&"raid_created", Callable(self, "_on_defense_raid_created")],
|
|
[&"raid_state_changed", Callable(self, "_on_defense_raid_state_changed")],
|
|
[&"active_skill_applied", Callable(self, "_on_defense_active_skill_applied")],
|
|
[&"round_resolved", Callable(self, "_on_defense_round_resolved")],
|
|
[&"raid_resolved", Callable(self, "_on_defense_raid_resolved")],
|
|
]
|
|
for binding: Array in bindings:
|
|
var signal_name: StringName = binding[0]
|
|
var callback: Callable = binding[1]
|
|
if service.is_connected(signal_name, callback):
|
|
service.disconnect(signal_name, callback)
|
|
|
|
func _configure_facility_boundaries() -> void:
|
|
if facility_service == null:
|
|
return
|
|
facility_service.configure_callbacks(
|
|
Callable(self, "_validate_service_start"),
|
|
Callable(self, "_complete_service_transaction")
|
|
)
|
|
|
|
func _connect_facility_signals() -> void:
|
|
if facility_service == null:
|
|
return
|
|
facility_service.instance_registered.connect(Callable(self, "_on_cleanliness_facility_registered"))
|
|
facility_service.instance_unregistered.connect(Callable(self, "_on_cleanliness_facility_unregistered"))
|
|
facility_service.facility_state_changed.connect(Callable(self, "_on_facility_state_changed"))
|
|
facility_service.service_started.connect(Callable(self, "_on_service_started"))
|
|
facility_service.service_interrupted.connect(Callable(self, "_on_service_interrupted"))
|
|
facility_service.service_completed.connect(Callable(self, "_on_service_completed"))
|
|
facility_service.service_failed.connect(Callable(self, "_on_service_failed"))
|
|
|
|
func _seed_boundary_cells(bounds: Dictionary) -> void:
|
|
for x in range(int(bounds[&"min_x"]), int(bounds[&"max_x"]) + 1):
|
|
for layer in [WorldService.LAYER_FRONT, WorldService.LAYER_BACK]:
|
|
var cell := Vector3i(x, 0, layer)
|
|
world_service.set_walkable(cell, true)
|
|
world_service.set_support(cell, 0, true)
|
|
world_service.consume_dirty_cells()
|
|
|
|
func _expand_world_to_rank(rank: int) -> bool:
|
|
var bounds := _bounds_for_rank(rank)
|
|
if not world_service.expand_bounds(bounds):
|
|
return false
|
|
_seed_boundary_cells(bounds)
|
|
return rebuild_path_graph_from_world()
|
|
|
|
func _bounds_for_rank(rank: int) -> Dictionary:
|
|
var source: Variant = ProgressionService.RANK_BOUNDS.get(clampi(rank, 1, 7), {})
|
|
return source.duplicate(true) if source is Dictionary else {}
|
|
|
|
|
|
func _sync_progression_live_metrics() -> bool:
|
|
return progression_service.update_metrics({
|
|
&"active_above_ground_facilities": _count_active_facilities(facility_service, false),
|
|
&"active_underground_facilities": _count_active_facilities(facility_service, true),
|
|
&"asset_gold": economy_service.get_balance(PLAYER_CASH_ACCOUNT),
|
|
})
|
|
|
|
|
|
func _count_active_facilities(service: FacilityService, underground: bool) -> int:
|
|
var count := 0
|
|
for instance: FacilityInstance in service.get_instances():
|
|
if instance.state not in [FacilityInstance.State.OPEN, FacilityInstance.State.DEGRADED]:
|
|
continue
|
|
if (instance.origin.y < 0) == underground:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
func _capture_rank_external_state() -> Dictionary:
|
|
return {
|
|
&"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(),
|
|
&"facility_infrastructure": facility_infrastructure_projector.create_snapshot(),
|
|
&"game_state": _state.create_snapshot(),
|
|
&"movement_credits": _movement_credits.duplicate(true),
|
|
&"domain_events": domain_events.create_snapshot(),
|
|
&"id_generator": id_generator.create_snapshot(),
|
|
}
|
|
|
|
|
|
func _apply_rank_external_state(target_rank: int, expected_bounds: Dictionary) -> bool:
|
|
if target_rank != int(_state.world_tree_rank) + 1 \
|
|
or not _strict_variants_equal(_bounds_for_rank(target_rank), expected_bounds):
|
|
return false
|
|
if not _expand_world_to_rank(target_rank):
|
|
return false
|
|
if not _state.set_world_tree_rank(target_rank):
|
|
return false
|
|
return (
|
|
int(_state.world_tree_rank) == target_rank
|
|
and _strict_variants_equal(world_service.get_bounds(), expected_bounds)
|
|
and _validate_infrastructure_crosslinks(
|
|
world_service,
|
|
path_service,
|
|
construction_service,
|
|
construction_resource_state,
|
|
excavation_service,
|
|
spatial_subslot_service,
|
|
route_subslot_path_adapter,
|
|
facility_infrastructure_projector
|
|
)
|
|
and _validate_runtime_path_crosslinks(
|
|
path_service,
|
|
guest_service,
|
|
staff_service,
|
|
_movement_credits,
|
|
_clock.absolute_minute
|
|
)
|
|
)
|
|
|
|
|
|
func _rollback_rank_external_state(snapshot: Dictionary) -> bool:
|
|
if not snapshot.get(&"world", null) is Dictionary \
|
|
or not snapshot.get(&"path", null) is Dictionary \
|
|
or not snapshot.get(&"excavation", null) is Dictionary \
|
|
or not snapshot.get(&"spatial_subslots", null) is Dictionary \
|
|
or not snapshot.get(&"route_projection", null) is Dictionary \
|
|
or not snapshot.get(&"facility_infrastructure", null) is Dictionary \
|
|
or not snapshot.get(&"game_state", null) is Dictionary \
|
|
or not snapshot.get(&"movement_credits", null) is Dictionary \
|
|
or not snapshot.get(&"domain_events", null) is Dictionary \
|
|
or not snapshot.get(&"id_generator", null) is Dictionary:
|
|
return false
|
|
var restored := world_service.restore_snapshot(snapshot[&"world"])
|
|
restored = spatial_subslot_service.restore_snapshot(snapshot[&"spatial_subslots"]) and restored
|
|
var route_snapshot: Dictionary = snapshot[&"route_projection"]
|
|
var projected_walkable: Variant = route_snapshot.get(&"projected_walkable_cells", null)
|
|
if not projected_walkable is Array:
|
|
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 = 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"]
|
|
) and restored
|
|
restored = excavation_service.restore_snapshot(snapshot[&"excavation"]) and restored
|
|
restored = _state.restore_snapshot(snapshot[&"game_state"]) 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
|
|
if not restored:
|
|
return false
|
|
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"]
|
|
)
|
|
and _strict_variants_equal(
|
|
spatial_subslot_service.create_snapshot(), snapshot[&"spatial_subslots"]
|
|
)
|
|
and _strict_variants_equal(
|
|
route_subslot_path_adapter.create_snapshot(), snapshot[&"route_projection"]
|
|
)
|
|
and _strict_variants_equal(
|
|
facility_infrastructure_projector.create_snapshot(),
|
|
snapshot[&"facility_infrastructure"]
|
|
)
|
|
and _strict_variants_equal(_state.create_snapshot(), snapshot[&"game_state"])
|
|
and _strict_variants_equal(_movement_credits, snapshot[&"movement_credits"])
|
|
and _strict_variants_equal(domain_events.create_snapshot(), snapshot[&"domain_events"])
|
|
and _strict_variants_equal(id_generator.create_snapshot(), snapshot[&"id_generator"])
|
|
and _validate_infrastructure_crosslinks(
|
|
world_service,
|
|
path_service,
|
|
construction_service,
|
|
construction_resource_state,
|
|
excavation_service,
|
|
spatial_subslot_service,
|
|
route_subslot_path_adapter,
|
|
facility_infrastructure_projector
|
|
)
|
|
and _validate_runtime_path_crosslinks(
|
|
path_service,
|
|
guest_service,
|
|
staff_service,
|
|
_movement_credits,
|
|
_clock.absolute_minute
|
|
)
|
|
)
|
|
|
|
func _process_commands(absolute_minute: int) -> bool:
|
|
var previous_command_minute := _active_command_minute
|
|
_active_command_minute = absolute_minute
|
|
var results := command_queue.execute_ready(absolute_minute, Callable(self, "_execute_command"))
|
|
_active_command_minute = previous_command_minute
|
|
_synchronize_operations_population()
|
|
for result in results:
|
|
var entity_ids: Array[StringName] = []
|
|
if not result.target_id.is_empty():
|
|
entity_ids.append(result.target_id)
|
|
var event := SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"),
|
|
&"COMMAND_COMPLETED" if result.is_success() else &"COMMAND_FAILED",
|
|
absolute_minute,
|
|
&"simulation_runtime",
|
|
entity_ids,
|
|
result.to_dictionary(),
|
|
result.command_id
|
|
)
|
|
_publish_domain_event(event)
|
|
command_completed.emit(result)
|
|
if _operations_resume_publish_pending:
|
|
_operations_resume_publish_pending = false
|
|
_publish_snapshot(absolute_minute)
|
|
_process_save_checkpoints(absolute_minute)
|
|
return true
|
|
return false
|
|
|
|
func _process_tick_commands(current_absolute_minute: int) -> void:
|
|
if _operations_enabled:
|
|
_operations_tick_blocked = _operations_should_block_tick()
|
|
if _operations_tick_blocked:
|
|
_clock.set_paused(true)
|
|
_clock.request_current_tick_abort()
|
|
return
|
|
_process_commands(current_absolute_minute + 1)
|
|
_synchronize_operations_population()
|
|
|
|
|
|
func _synchronize_operations_population() -> bool:
|
|
if not _operations_enabled or operations_coordinator == null \
|
|
or _operations_transaction_active:
|
|
return true
|
|
var active_count := guest_service.guest_count()
|
|
if operations_coordinator.get_population_count() == active_count:
|
|
return true
|
|
var synchronized := operations_coordinator.set_population_count(active_count)
|
|
if synchronized.is_success():
|
|
return true
|
|
_operations_runtime_failure_latched = true
|
|
_operations_runtime_failure_report = synchronized.to_dictionary()
|
|
_operations_tick_blocked = true
|
|
_clock.set_paused(true)
|
|
return false
|
|
|
|
func _execute_command(command: SimulationCommand) -> CommandResult:
|
|
if not _executors.has(command.command_type):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.INVALID_COMMAND,
|
|
command.command_id,
|
|
"No executor is registered for command type '%s'." % command.command_type
|
|
)
|
|
if command.command_type in DEFENSE_COMMAND_TYPES:
|
|
var advanced := defense_service.advance_time(_defense_operation_minute())
|
|
if not advanced.is_success():
|
|
advanced.command_id = command.command_id
|
|
return advanced
|
|
var executor: Callable = _executors[command.command_type]
|
|
var returned: Variant = executor.call(command)
|
|
if returned is CommandResult:
|
|
return returned
|
|
return CommandResult.failed(
|
|
CommandResult.Code.EXECUTOR_FAILED,
|
|
command.command_id,
|
|
"Command executor returned an invalid result."
|
|
)
|
|
|
|
func _execute_change_resources(command: SimulationCommand) -> CommandResult:
|
|
var deltas: Variant = command.payload.get(&"deltas", command.payload.get("deltas", null))
|
|
if not deltas is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "deltas must be a Dictionary.")
|
|
var state_before: Dictionary = _state.create_snapshot()
|
|
var economy_before := economy_service.create_snapshot()
|
|
var progression_before := progression_service.create_snapshot()
|
|
var events_before := domain_events.create_snapshot()
|
|
var ids_before := id_generator.create_snapshot()
|
|
if not _apply_command_resource_deltas(command, deltas):
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Resource balance would become negative.")
|
|
var faith_delta := 0
|
|
for raw_resource_id: Variant in deltas:
|
|
if StringName(raw_resource_id) == &"faith":
|
|
faith_delta += int(deltas[raw_resource_id])
|
|
if faith_delta > 0:
|
|
var next_faith_lifetime := int(progression_service.metrics[&"faith_lifetime"]) + faith_delta
|
|
if not progression_service.update_metrics({&"faith_lifetime": next_faith_lifetime}):
|
|
economy_service.restore_snapshot(economy_before)
|
|
_state.restore_snapshot(state_before)
|
|
progression_service.restore_snapshot(progression_before)
|
|
domain_events.restore_snapshot(events_before)
|
|
id_generator.restore_snapshot(ids_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"Progression lifetime Faith could not be updated atomically."
|
|
)
|
|
return CommandResult.succeeded(command.command_id, {
|
|
&"revision": _state.revision,
|
|
&"player_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT),
|
|
&"faith_lifetime": int(progression_service.metrics[&"faith_lifetime"]),
|
|
})
|
|
|
|
# Repay an existing attested liability through the ordinary command queue.
|
|
# No rate, issuance, eligibility or due-date values are supplied here.
|
|
func _execute_repay_loan_principal(command: SimulationCommand) -> CommandResult:
|
|
var loan_id: Variant = command.payload.get(&"loan_id", null)
|
|
if typeof(loan_id) not in [TYPE_STRING, TYPE_STRING_NAME] or String(loan_id).is_empty():
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "loan_id must be a nonempty String or StringName.")
|
|
var result := repay_loan_principal(
|
|
StringName(loan_id), command.payload.get(&"amount", null), command.idempotency_key
|
|
)
|
|
result.command_id = command.command_id
|
|
result.target_id = StringName(loan_id)
|
|
return result
|
|
|
|
|
|
func _execute_spend_resources(command: SimulationCommand) -> CommandResult:
|
|
var cost: Variant = command.payload.get(&"cost", command.payload.get("cost", null))
|
|
if not cost is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "cost must be a Dictionary.")
|
|
if not operating_resource_state.spend(cost):
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Resources are insufficient.")
|
|
return CommandResult.succeeded(command.command_id, {
|
|
&"revision": _state.revision,
|
|
&"player_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT),
|
|
})
|
|
|
|
func _apply_command_resource_deltas(command: SimulationCommand, deltas: Dictionary) -> bool:
|
|
var normalized: Dictionary = {}
|
|
for raw_name: Variant in deltas:
|
|
var resource_name := StringName(raw_name)
|
|
normalized[resource_name] = int(normalized.get(resource_name, 0)) + int(deltas[raw_name])
|
|
for resource_name: StringName in normalized:
|
|
var current := economy_service.get_balance(PLAYER_CASH_ACCOUNT) \
|
|
if resource_name == &"gold" else int(_state.get_resource(resource_name))
|
|
if current + int(normalized[resource_name]) < 0:
|
|
return false
|
|
|
|
var before_state: Dictionary = _state.create_snapshot()
|
|
var before_economy: Dictionary = economy_service.create_snapshot()
|
|
var non_gold := normalized.duplicate(true)
|
|
var gold_delta := int(non_gold.get(&"gold", 0))
|
|
non_gold.erase(&"gold")
|
|
if not non_gold.is_empty() and not _state.apply_resource_deltas(non_gold):
|
|
return false
|
|
if gold_delta != 0:
|
|
var ledger_result: CommandResult
|
|
var ledger_idempotency := StringName("runtime.resource.%08d" % command.command_id)
|
|
if gold_delta > 0:
|
|
ledger_result = economy_service.transfer(
|
|
EconomyService.SYSTEM_SOURCE,
|
|
PLAYER_CASH_ACCOUNT,
|
|
gold_delta,
|
|
_runtime_event_minute(),
|
|
&"GENERATION",
|
|
command.actor_id,
|
|
ledger_idempotency
|
|
)
|
|
else:
|
|
ledger_result = economy_service.transfer(
|
|
PLAYER_CASH_ACCOUNT,
|
|
EconomyService.SYSTEM_SINK,
|
|
-gold_delta,
|
|
_runtime_event_minute(),
|
|
&"DESTRUCTION",
|
|
command.actor_id,
|
|
ledger_idempotency
|
|
)
|
|
if not ledger_result.is_success():
|
|
_state.restore_snapshot(before_state)
|
|
economy_service.restore_snapshot(before_economy)
|
|
return false
|
|
_state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT))
|
|
return true
|
|
|
|
func _execute_add_inventory_stack(command: SimulationCommand) -> CommandResult:
|
|
var owner_id := StringName(command.payload.get(&"owner_inventory_id", command.payload.get("owner_inventory_id", "")))
|
|
var item_id := StringName(command.payload.get(&"item_id", command.payload.get("item_id", "")))
|
|
var quantity := int(command.payload.get(&"quantity", command.payload.get("quantity", 0)))
|
|
var result := inventory_service.add_stack(
|
|
owner_id,
|
|
item_id,
|
|
quantity,
|
|
int(command.payload.get(&"quality", command.payload.get("quality", InventoryService.DEFAULT_QUALITY))),
|
|
int(command.payload.get(&"expires_at", command.payload.get("expires_at", -1))),
|
|
_runtime_event_minute(),
|
|
int(command.payload.get(&"unit_weight", command.payload.get("unit_weight", InventoryService.DEFAULT_UNIT_WEIGHT))),
|
|
StringName(command.payload.get(&"stack_id", command.payload.get("stack_id", "")))
|
|
)
|
|
return _command_result_from_dictionary(command.command_id, result, "Inventory stack could not be added.")
|
|
|
|
func _execute_create_logistics_request(command: SimulationCommand) -> CommandResult:
|
|
var source_id := StringName(command.payload.get(&"source_inventory_id", command.payload.get("source_inventory_id", "")))
|
|
var destination_id := StringName(command.payload.get(&"destination_inventory_id", command.payload.get("destination_inventory_id", "")))
|
|
var raw_requirements: Variant = command.payload.get(&"item_requirements", command.payload.get("item_requirements", null))
|
|
if source_id.is_empty() or destination_id.is_empty() or source_id == destination_id \
|
|
or not raw_requirements is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Source, destination, and item requirements are required.")
|
|
var requirements: Dictionary = {}
|
|
for raw_item_id: Variant in raw_requirements:
|
|
var item_id := StringName(raw_item_id)
|
|
var quantity := int(raw_requirements[raw_item_id])
|
|
if item_id.is_empty() or quantity <= 0:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Logistics requirements must be positive.")
|
|
requirements[item_id] = quantity
|
|
if requirements.is_empty():
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "At least one item is required.")
|
|
|
|
var inventory_before := inventory_service.create_snapshot()
|
|
var id_before := id_generator.create_snapshot()
|
|
var request_id := StringName(command.payload.get(&"request_id", command.payload.get("request_id", "")))
|
|
if request_id.is_empty():
|
|
request_id = id_generator.next_id(&"logistics_request")
|
|
if logistics_requests.has(request_id):
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(CommandResult.Code.DUPLICATE_COMMAND_ID, command.command_id, "Logistics request id already exists.")
|
|
var reservation_ids: Array[StringName] = []
|
|
var stack_ids: Array[StringName] = []
|
|
var total_weight := 0
|
|
var deadline := int(command.payload.get(&"deadline", command.payload.get("deadline", -1)))
|
|
for item_id: StringName in _sorted_name_keys(requirements):
|
|
var reserve_result := inventory_service.reserve_item(
|
|
source_id,
|
|
item_id,
|
|
int(requirements[item_id]),
|
|
request_id,
|
|
&"logistics",
|
|
_runtime_event_minute(),
|
|
deadline,
|
|
StringName("logistics.reserve.%s.%s" % [request_id, item_id])
|
|
)
|
|
if not bool(reserve_result.get(&"ok", false)):
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
id_generator.restore_snapshot(id_before)
|
|
return _command_result_from_dictionary(command.command_id, reserve_result, "Logistics stock could not be reserved.")
|
|
for reservation_id: StringName in reserve_result.get(&"reservation_ids", []):
|
|
reservation_ids.append(reservation_id)
|
|
var reservation := inventory_service.get_reservation(reservation_id)
|
|
var stack := inventory_service.get_stack(reservation.stack_id)
|
|
stack_ids.append(reservation.stack_id)
|
|
total_weight += reservation.quantity * int(stack.get(&"unit_weight", 1))
|
|
var request := LogisticsRequest.new().configure(
|
|
request_id,
|
|
source_id,
|
|
destination_id,
|
|
requirements,
|
|
int(command.payload.get(&"priority", command.payload.get("priority", 50))),
|
|
_runtime_event_minute(),
|
|
deadline,
|
|
total_weight
|
|
)
|
|
if not request.mark_reserved(reservation_ids, stack_ids, _runtime_event_minute()):
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, "Logistics request could not enter RESERVED state.")
|
|
logistics_requests[request_id] = request
|
|
return CommandResult.succeeded(command.command_id, {
|
|
&"request_id": request_id,
|
|
&"reservation_ids": reservation_ids,
|
|
&"total_weight": total_weight,
|
|
})
|
|
|
|
func _execute_dispatch_logistics(command: SimulationCommand) -> CommandResult:
|
|
var carriers: Variant = command.payload.get(&"carriers", command.payload.get("carriers", null))
|
|
if not carriers is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "carriers must be a Dictionary.")
|
|
var manual_requests: Array[LogisticsRequest] = []
|
|
for request: LogisticsRequest in _sorted_logistics_requests():
|
|
if not String(request.request_id).begins_with(SupplyLogisticsBridge.REQUEST_PREFIX):
|
|
manual_requests.append(request)
|
|
var assignments := logistics_dispatcher.dispatch(manual_requests, carriers, _runtime_event_minute())
|
|
return CommandResult.succeeded(command.command_id, {
|
|
&"assignments": assignments,
|
|
&"report": logistics_dispatcher.last_report.duplicate(true),
|
|
})
|
|
|
|
func _execute_advance_logistics_request(command: SimulationCommand) -> CommandResult:
|
|
var request_id := StringName(command.payload.get(&"request_id", command.payload.get("request_id", "")))
|
|
if command.command_id > 0 and String(request_id).begins_with(SupplyLogisticsBridge.REQUEST_PREFIX):
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Supply deliveries advance only after the porter arrives.", {&"reason": &"SUPPLY_DRIVER_REQUIRED"})
|
|
if not logistics_requests.has(request_id):
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Logistics request was not found.")
|
|
var request: LogisticsRequest = logistics_requests[request_id]
|
|
var request_before := request.create_snapshot()
|
|
var inventory_before_command := inventory_service.create_snapshot()
|
|
var dispatcher_before := logistics_dispatcher.create_snapshot()
|
|
match request.state:
|
|
LogisticsRequest.State.ASSIGNED:
|
|
if not request.transition_to(LogisticsRequest.State.PICKING_UP, "", _runtime_event_minute()):
|
|
return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, "Logistics pickup could not begin.")
|
|
LogisticsRequest.State.PICKING_UP:
|
|
var carrier_id := request.assigned_carrier_id
|
|
var pickup := inventory_service.transfer_reserved(
|
|
request.reservation_ids,
|
|
StringName("cargo:%s" % carrier_id),
|
|
StringName("logistics.pickup.%s" % request_id),
|
|
_runtime_event_minute()
|
|
)
|
|
if not bool(pickup.get(&"ok", false)):
|
|
request.mark_blocked(String(pickup.get(&"code", &"PICKUP_FAILED")), _runtime_event_minute())
|
|
var released := logistics_dispatcher.release_assignment_for_request(
|
|
request_id, carrier_id, request.total_weight
|
|
)
|
|
if not released:
|
|
inventory_service.restore_snapshot(inventory_before_command)
|
|
logistics_dispatcher.restore_snapshot(dispatcher_before)
|
|
logistics_requests[request_id] = LogisticsRequest.from_snapshot(request_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"Logistics pickup failure could not release request ownership."
|
|
)
|
|
return _command_result_from_dictionary(command.command_id, pickup, "Logistics pickup failed.")
|
|
request.transition_to(LogisticsRequest.State.DELIVERING, "", _runtime_event_minute())
|
|
LogisticsRequest.State.DELIVERING:
|
|
var carrier_id := request.assigned_carrier_id
|
|
var cargo_id := StringName("cargo:%s" % carrier_id)
|
|
var inventory_before := inventory_service.create_snapshot()
|
|
for item_id: StringName in _sorted_name_keys(request.item_requirements):
|
|
var delivery := inventory_service.transfer_available(
|
|
cargo_id,
|
|
request.destination_inventory_id,
|
|
item_id,
|
|
int(request.item_requirements[item_id]),
|
|
StringName("logistics.delivery.%s.%s" % [request_id, item_id]),
|
|
_runtime_event_minute()
|
|
)
|
|
if not bool(delivery.get(&"ok", false)):
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
request.mark_blocked(String(delivery.get(&"code", &"DELIVERY_FAILED")), _runtime_event_minute())
|
|
var released := logistics_dispatcher.release_assignment_for_request(
|
|
request_id, carrier_id, request.total_weight
|
|
)
|
|
if not released:
|
|
inventory_service.restore_snapshot(inventory_before_command)
|
|
logistics_dispatcher.restore_snapshot(dispatcher_before)
|
|
logistics_requests[request_id] = LogisticsRequest.from_snapshot(request_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"Logistics delivery failure could not release request ownership."
|
|
)
|
|
return _command_result_from_dictionary(command.command_id, delivery, "Logistics delivery failed.")
|
|
request.transition_to(LogisticsRequest.State.COMPLETED, "", _runtime_event_minute())
|
|
var released := logistics_dispatcher.release_assignment_for_request(
|
|
request_id, carrier_id, request.total_weight
|
|
)
|
|
if not released:
|
|
inventory_service.restore_snapshot(inventory_before_command)
|
|
logistics_dispatcher.restore_snapshot(dispatcher_before)
|
|
logistics_requests[request_id] = LogisticsRequest.from_snapshot(request_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"Completed Logistics delivery could not release request ownership."
|
|
)
|
|
_:
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Logistics request cannot advance from its current state.")
|
|
return CommandResult.succeeded(command.command_id, {
|
|
&"request_id": request_id,
|
|
&"state": LogisticsRequest.State.keys()[request.state],
|
|
})
|
|
|
|
func _execute_register_guest_wallet(command: SimulationCommand) -> CommandResult:
|
|
var guest_id := StringName(command.payload.get(&"guest_id", command.payload.get("guest_id", "")))
|
|
var amount := int(command.payload.get(&"gold", command.payload.get("gold", 0)))
|
|
if guest_id.is_empty() or amount < 0:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "A guest id and non-negative Gold are required.")
|
|
var account_id := _guest_wallet_account(guest_id)
|
|
if economy_service.has_account(account_id):
|
|
return CommandResult.failed(CommandResult.Code.DUPLICATE_COMMAND_ID, command.command_id, "Guest wallet already exists.")
|
|
var before := economy_service.create_snapshot()
|
|
if not economy_service.register_account(account_id):
|
|
return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, "Guest wallet account could not be registered.")
|
|
if amount > 0:
|
|
var initialized := economy_service.initialize_account(
|
|
account_id,
|
|
amount,
|
|
_runtime_event_minute(),
|
|
StringName("guest.wallet.%s" % guest_id)
|
|
)
|
|
if not initialized.is_success():
|
|
economy_service.restore_snapshot(before)
|
|
return initialized
|
|
return CommandResult.succeeded(command.command_id, {&"account_id": account_id, &"balance": amount})
|
|
|
|
func _execute_spawn_guest(command: SimulationCommand) -> CommandResult:
|
|
if _operations_enabled and operations_coordinator != null:
|
|
if operations_coordinator.is_terminal() \
|
|
or operations_coordinator.is_external_quarantined():
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"Operations currently block new Guest arrivals."
|
|
)
|
|
if guest_service.guest_count() >= int(
|
|
operations_coordinator.get_config().get(&"population_cap", 0)
|
|
):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"Operations Guest population capacity is full."
|
|
)
|
|
var guest_id := StringName(command.payload.get(&"guest_id", command.payload.get("guest_id", "")))
|
|
var id_before := id_generator.create_snapshot()
|
|
if guest_id.is_empty():
|
|
guest_id = id_generator.next_id(&"guest")
|
|
if String(guest_id).begins_with("guest-auto-") \
|
|
and not (_operations_transaction_active \
|
|
and operations_coordinator != null \
|
|
and command.actor_id == &"operations_coordinator"):
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"guest-auto IDs are reserved for the OperationsCoordinator."
|
|
)
|
|
var guest_type_id := StringName(command.payload.get(&"guest_type_id", command.payload.get("guest_type_id", "traveler")))
|
|
var definition := guest_service.get_definition(guest_type_id)
|
|
if definition == null:
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Guest definition was not found.")
|
|
if guest_service.has_guest(guest_id):
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(CommandResult.Code.DUPLICATE_COMMAND_ID, command.command_id, "Guest already exists.")
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
var options: Dictionary = raw_options.duplicate(true) if raw_options is Dictionary else {}
|
|
var initial_cell_value: Variant = _parse_world_cell(
|
|
options.get(&"current_cell", options.get("current_cell", Vector3i.ZERO))
|
|
)
|
|
if initial_cell_value == null:
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.INVALID_PAYLOAD,
|
|
command.command_id,
|
|
"Guest current_cell must be Vector3i or [x, y, layer]."
|
|
)
|
|
var initial_cell: Vector3i = initial_cell_value
|
|
var initial_traffic := path_service.get_cell_traffic(initial_cell)
|
|
var path_actor_id := _guest_path_actor_id(guest_id)
|
|
if initial_traffic.is_empty() or bool(initial_traffic.get(&"temporary_block", false)) \
|
|
or path_service.has_actor_projection(path_actor_id):
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"Guest current_cell must be an unblocked walkable cell with a free Path actor id."
|
|
)
|
|
options[&"current_cell"] = initial_cell
|
|
var requested_gold := int(command.payload.get(
|
|
&"gold",
|
|
command.payload.get("gold", options.get(&"wallet_gold", options.get("wallet_gold", definition.wealth_min)))
|
|
))
|
|
if requested_gold < 0:
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Guest Gold cannot be negative.")
|
|
var defer_spawn_notification := _operations_transaction_active \
|
|
and operations_coordinator != null \
|
|
and command.actor_id == &"operations_coordinator"
|
|
var account_id := _guest_wallet_account(guest_id)
|
|
var economy_before := economy_service.create_snapshot()
|
|
var guest_before := guest_service.create_snapshot()
|
|
var path_before := path_service.create_snapshot()
|
|
var movement_credits_before := _movement_credits.duplicate(true)
|
|
var events_before := domain_events.create_snapshot()
|
|
var progression_before := progression_service.create_snapshot()
|
|
if economy_service.has_account(account_id):
|
|
requested_gold = economy_service.get_balance(account_id)
|
|
else:
|
|
if not economy_service.register_account(account_id):
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, "Guest wallet account could not be registered.")
|
|
if requested_gold > 0:
|
|
var initialized := economy_service.initialize_account(
|
|
account_id,
|
|
requested_gold,
|
|
_runtime_event_minute(),
|
|
StringName("guest.wallet.%s" % guest_id),
|
|
not defer_spawn_notification
|
|
)
|
|
if not initialized.is_success():
|
|
economy_service.restore_snapshot(economy_before)
|
|
id_generator.restore_snapshot(id_before)
|
|
initialized.command_id = command.command_id
|
|
return initialized
|
|
if defer_spawn_notification \
|
|
and not _stage_operations_economy_notification(initialized):
|
|
economy_service.restore_snapshot(economy_before)
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"Guest wallet notification could not be staged."
|
|
)
|
|
options[&"wallet_gold"] = requested_gold
|
|
var guest := guest_service.spawn_guest(
|
|
guest_id,
|
|
guest_type_id,
|
|
_runtime_event_minute(),
|
|
options,
|
|
not defer_spawn_notification
|
|
)
|
|
if guest != null and defer_spawn_notification:
|
|
# Runtime's Path projection and DomainEvent are part of the external
|
|
# transaction, while the observable GuestService signal is held until the
|
|
# Coordinator minute and DomainEvent batch have both committed.
|
|
_on_guest_spawned(guest)
|
|
if guest == null or not path_service.has_actor_projection(path_actor_id) \
|
|
or path_service.get_actor_projected_cell(path_actor_id) != guest.current_cell \
|
|
or not _movement_credits.has(path_actor_id):
|
|
economy_service.restore_snapshot(economy_before)
|
|
guest_service.restore_snapshot(guest_before)
|
|
path_service.restore_snapshot(path_before)
|
|
_movement_credits = movement_credits_before
|
|
domain_events.restore_snapshot(events_before)
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
guest_service.last_error if guest == null else "Guest Path projection failed."
|
|
)
|
|
var next_guest_lifetime := int(progression_service.metrics[&"guest_lifetime"]) + 1
|
|
if not progression_service.update_metrics({&"guest_lifetime": next_guest_lifetime}):
|
|
economy_service.restore_snapshot(economy_before)
|
|
guest_service.restore_snapshot(guest_before)
|
|
path_service.restore_snapshot(path_before)
|
|
_movement_credits = movement_credits_before
|
|
domain_events.restore_snapshot(events_before)
|
|
id_generator.restore_snapshot(id_before)
|
|
progression_service.restore_snapshot(progression_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"Progression lifetime Guest count could not be updated atomically."
|
|
)
|
|
if defer_spawn_notification:
|
|
_operations_queued_guest_spawn_notifications.append(guest_id)
|
|
return CommandResult.succeeded(command.command_id, {
|
|
&"guest_id": guest_id,
|
|
&"guest_type_id": guest_type_id,
|
|
&"wallet_account": account_id,
|
|
&"wallet_gold": requested_gold,
|
|
&"guest_lifetime": next_guest_lifetime,
|
|
})
|
|
|
|
func _execute_transition_guest(command: SimulationCommand) -> CommandResult:
|
|
var guest_id := StringName(command.payload.get(&"guest_id", command.payload.get("guest_id", "")))
|
|
var state_index := _guest_state_index(command.payload.get(&"state", command.payload.get("state", -1)))
|
|
if guest_id.is_empty() or state_index < 0:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Guest id and valid state are required.")
|
|
var guest := guest_service.get_guest(guest_id)
|
|
if guest == null:
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Guest was not found.")
|
|
if not guest.queue_request_id.is_empty() or not guest.current_service_id.is_empty():
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"A queued or serviced Guest must transition through the owning Facility transaction."
|
|
)
|
|
var result := guest_service.transition_guest(
|
|
guest_id,
|
|
state_index,
|
|
_runtime_event_minute(),
|
|
StringName(command.payload.get(&"reason", command.payload.get("reason", "PLAYER_COMMAND")))
|
|
)
|
|
return _command_result_from_dictionary(command.command_id, result, "Guest transition failed.")
|
|
|
|
func _execute_decide_guest(command: SimulationCommand) -> CommandResult:
|
|
var guest_id := StringName(command.payload.get(&"guest_id", command.payload.get("guest_id", "")))
|
|
var guest := guest_service.get_guest(guest_id)
|
|
if guest == null:
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Guest was not found.")
|
|
var wallet_account := _guest_wallet_account(guest_id)
|
|
if economy_service.has_account(wallet_account):
|
|
guest.wallet_gold = economy_service.get_balance(wallet_account)
|
|
var raw_candidates: Variant = command.payload.get(&"candidates", command.payload.get("candidates", []))
|
|
var candidates: Array = raw_candidates.duplicate(true) if raw_candidates is Array else []
|
|
if candidates.is_empty():
|
|
candidates = _project_guest_candidates(guest_id)
|
|
var raw_context: Variant = command.payload.get(&"context", command.payload.get("context", {}))
|
|
var context: Dictionary = raw_context.duplicate(true) if raw_context is Dictionary else {}
|
|
context[&"absolute_minute"] = _runtime_event_minute()
|
|
var result := _decide_guest_with_log(guest_id, candidates, _runtime_event_minute(), context)
|
|
return _command_result_from_dictionary(command.command_id, result, "Guest decision failed.")
|
|
|
|
func _execute_register_staff(command: SimulationCommand) -> CommandResult:
|
|
var staff_id := StringName(command.payload.get(&"staff_id", command.payload.get("staff_id", "")))
|
|
var role_id := StringName(command.payload.get(&"role_id", command.payload.get("role_id", "")))
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
if staff_id.is_empty() or role_id.is_empty() or not raw_options is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Staff id, role id and options are required.")
|
|
if _is_mvp_staff_candidate_id(staff_id) or mvp_content_catalog.get_unique_character_ids().has(staff_id):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"Catalog identities must be created through their validated recruitment flow."
|
|
)
|
|
var options: Dictionary = raw_options.duplicate(true)
|
|
var initial_cell_value: Variant = _parse_world_cell(
|
|
options.get(&"current_cell", options.get("current_cell", Vector3i.ZERO))
|
|
)
|
|
if initial_cell_value == null:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.INVALID_PAYLOAD,
|
|
command.command_id,
|
|
"Staff current_cell must be Vector3i or [x, y, layer]."
|
|
)
|
|
var initial_cell: Vector3i = initial_cell_value
|
|
var initial_traffic := path_service.get_cell_traffic(initial_cell)
|
|
var path_actor_id := _staff_path_actor_id(staff_id)
|
|
if initial_traffic.is_empty() or bool(initial_traffic.get(&"temporary_block", false)) \
|
|
or path_service.has_actor_projection(path_actor_id):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"Staff current_cell must be an unblocked walkable cell with a free Path actor id."
|
|
)
|
|
options[&"current_cell"] = initial_cell
|
|
var staff_before := staff_service.create_snapshot()
|
|
var path_before := path_service.create_snapshot()
|
|
var credits_before := _movement_credits.duplicate(true)
|
|
var events_before := domain_events.create_snapshot()
|
|
var ids_before := id_generator.create_snapshot()
|
|
options[&"employment_effective_minute"] = _runtime_event_minute()
|
|
var result := staff_service.create_staff(staff_id, role_id, options)
|
|
if result.is_success() and (
|
|
not path_service.has_actor_projection(path_actor_id)
|
|
or path_service.get_actor_projected_cell(path_actor_id) != initial_cell
|
|
or not _movement_credits.has(path_actor_id)
|
|
):
|
|
staff_service.restore_snapshot(staff_before)
|
|
path_service.restore_snapshot(path_before)
|
|
_movement_credits = credits_before
|
|
domain_events.restore_snapshot(events_before)
|
|
id_generator.restore_snapshot(ids_before)
|
|
result = CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"Staff Path projection failed."
|
|
)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
|
|
# Original 6.1 plus the approved Anri staff-screen choice. Other unique
|
|
# characters still have no approved joining timing. Zero signing cost and both
|
|
# canonical wage fields must be actual integers; payload aliases cannot qualify.
|
|
func _is_approved_free_unique_hire(candidate_id: StringName, canonical: Dictionary) -> bool:
|
|
if candidate_id != &"heroine_anri" or not mvp_content_catalog.is_valid():
|
|
return false
|
|
var staff_id_value: Variant = canonical.get(&"staff_id", null)
|
|
var role_value: Variant = canonical.get(&"role_id", null)
|
|
var options_value: Variant = canonical.get(&"options", null)
|
|
if typeof(role_value) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(role_value) != &"innkeeper":
|
|
return false
|
|
if typeof(staff_id_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or StringName(staff_id_value) != candidate_id or not options_value is Dictionary:
|
|
return false
|
|
var options: Dictionary = options_value
|
|
var employment_value: Variant = options.get(&"employment_kind", null)
|
|
if typeof(employment_value) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
|
or StringName(employment_value) != &"UNIQUE" \
|
|
or typeof(canonical.get(&"adult_verified", null)) != TYPE_BOOL \
|
|
or not bool(canonical[&"adult_verified"]):
|
|
return false
|
|
for value: Variant in [canonical.get(&"signing_cost", null), canonical.get(&"daily_wage", null), options.get(&"daily_wage", null)]:
|
|
if typeof(value) != TYPE_INT or int(value) != 0:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _execute_hire_staff_candidate(command: SimulationCommand) -> CommandResult:
|
|
# Ordinary contracts retain the investment gate and its existing error
|
|
# priority. Only Anri has approved free recruitment; classify that exception
|
|
# from validated canonical data, never caller-supplied costs or employment.
|
|
var operations_gate := _operations_construction_investment_gate(command.command_id)
|
|
var raw_candidate_id: Variant = command.payload.get(
|
|
&"candidate_id", command.payload.get("candidate_id", null)
|
|
)
|
|
if operations_gate != null and (
|
|
typeof(raw_candidate_id) not in [TYPE_STRING, TYPE_STRING_NAME]
|
|
or StringName(raw_candidate_id) != &"heroine_anri"
|
|
):
|
|
return operations_gate
|
|
if typeof(raw_candidate_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.INVALID_PAYLOAD,
|
|
command.command_id,
|
|
"A catalog Staff candidate id is required."
|
|
)
|
|
var candidate_id := StringName(raw_candidate_id)
|
|
var canonical := mvp_content_catalog.staff_hire_payload(candidate_id)
|
|
if canonical.is_empty():
|
|
return CommandResult.failed(
|
|
CommandResult.Code.NOT_FOUND,
|
|
command.command_id,
|
|
"Staff candidate was not found in the validated MVP catalog."
|
|
)
|
|
if not bool(canonical.get(&"adult_verified", false)):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"Staff candidate does not have verified adult eligibility."
|
|
)
|
|
if operations_gate != null:
|
|
var reason := StringName(operations_gate.details.get(&"reason", &""))
|
|
var can_join_without_investment := _is_approved_free_unique_hire(candidate_id, canonical) \
|
|
and reason in [&"ARREARS_REMAIN", &"UNPAID_EXPENSES"] \
|
|
and not _operations_should_block_tick()
|
|
if not can_join_without_investment:
|
|
return operations_gate
|
|
if _hired_staff_candidate_ids.has(candidate_id) or staff_service.has_staff(candidate_id):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.DUPLICATE_COMMAND_ID,
|
|
command.command_id,
|
|
"Staff candidate has already been hired."
|
|
)
|
|
var initial_cell_value: Variant = _parse_world_cell(
|
|
command.payload.get(&"current_cell", command.payload.get("current_cell", Vector3i.ZERO))
|
|
)
|
|
if initial_cell_value == null:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.INVALID_PAYLOAD,
|
|
command.command_id,
|
|
"Staff current_cell must be Vector3i or [x, y, layer]."
|
|
)
|
|
var initial_cell: Vector3i = initial_cell_value
|
|
var path_actor_id := _staff_path_actor_id(candidate_id)
|
|
var traffic := path_service.get_cell_traffic(initial_cell)
|
|
if traffic.is_empty() or bool(traffic.get(&"temporary_block", false)) \
|
|
or path_service.has_actor_projection(path_actor_id):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"Staff current_cell must be an unblocked walkable cell with a free Path actor id."
|
|
)
|
|
var assigned_facility_id := StringName(command.payload.get(
|
|
&"facility_id", command.payload.get("facility_id", "")
|
|
))
|
|
if not assigned_facility_id.is_empty() \
|
|
and not facility_service.has_instance(assigned_facility_id):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.NOT_FOUND,
|
|
command.command_id,
|
|
"Assigned Staff facility was not found."
|
|
)
|
|
|
|
var before := {
|
|
&"economy": economy_service.create_snapshot(),
|
|
&"facility_upkeep": facility_upkeep.create_snapshot(),
|
|
&"upkeep_settlement": upkeep_settlement.create_snapshot(),
|
|
&"game_state": _state.create_snapshot(),
|
|
&"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(),
|
|
&"hired_ids": _hired_staff_candidate_ids.duplicate(true),
|
|
&"staffing_projection_enabled": _staffing_projection_enabled,
|
|
}
|
|
var signing_cost := int(canonical[&"signing_cost"])
|
|
if signing_cost > 0:
|
|
var payment := economy_service.transfer(
|
|
PLAYER_CASH_ACCOUNT,
|
|
OPERATING_EXPENSE_ACCOUNT,
|
|
signing_cost,
|
|
_runtime_event_minute(),
|
|
&"STAFF_SIGNING_COST",
|
|
candidate_id,
|
|
StringName("staff_signing_%s" % candidate_id),
|
|
&"staff.signing_cost"
|
|
)
|
|
if not payment.is_success():
|
|
payment.command_id = command.command_id
|
|
return payment
|
|
|
|
var options: Dictionary = (canonical[&"options"] as Dictionary).duplicate(true)
|
|
options[&"current_cell"] = initial_cell
|
|
options[&"assigned_facility_id"] = assigned_facility_id
|
|
options[&"employment_effective_minute"] = _runtime_event_minute()
|
|
var staff_result := staff_service.create_staff(
|
|
candidate_id,
|
|
StringName(canonical[&"role_id"]),
|
|
options
|
|
)
|
|
if not staff_result.is_success():
|
|
_rollback_staff_candidate_hire(before)
|
|
staff_result.command_id = command.command_id
|
|
return staff_result
|
|
var preferred_shift := StringName(canonical[&"preferred_shift"])
|
|
var shift_day := _candidate_shift_start_day(preferred_shift, _runtime_event_minute())
|
|
var shift_result: CommandResult
|
|
if preferred_shift in [&"day", &"night"]:
|
|
shift_result = staff_service.create_standard_shift(
|
|
candidate_id,
|
|
preferred_shift,
|
|
shift_day,
|
|
assigned_facility_id
|
|
)
|
|
else:
|
|
shift_result = staff_service.create_flex_shift(
|
|
candidate_id,
|
|
shift_day,
|
|
ShiftInstance.DAY_START,
|
|
ShiftInstance.DAY_END,
|
|
assigned_facility_id
|
|
)
|
|
var shift_id := StringName(shift_result.details.get(&"shift_id", &""))
|
|
var hired_staff := staff_service.get_staff(candidate_id)
|
|
var hired_shift := staff_service.get_shift(shift_id)
|
|
if not shift_result.is_success() or hired_staff == null or hired_shift == null \
|
|
or hired_staff.daily_wage != int(canonical[&"daily_wage"]) \
|
|
or hired_staff.get_employment_kind_name() != StringName(canonical[&"options"][&"employment_kind"]) \
|
|
or not path_service.has_actor_projection(path_actor_id) \
|
|
or path_service.get_actor_projected_cell(path_actor_id) != initial_cell \
|
|
or not _movement_credits.has(path_actor_id):
|
|
_rollback_staff_candidate_hire(before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"Staff hire could not commit its employee, Shift, finance, and Path state atomically."
|
|
)
|
|
_hired_staff_candidate_ids[candidate_id] = true
|
|
_publish_actor_event(
|
|
&"STAFF_CANDIDATE_HIRED",
|
|
&"simulation_runtime",
|
|
[candidate_id, shift_id],
|
|
{
|
|
&"candidate_id": candidate_id,
|
|
&"role_id": StringName(canonical[&"role_id"]),
|
|
&"daily_wage": int(canonical[&"daily_wage"]),
|
|
&"employment_kind": hired_staff.get_employment_kind_name(),
|
|
&"signing_cost": signing_cost,
|
|
&"shift_id": shift_id,
|
|
&"preferred_shift": preferred_shift,
|
|
}
|
|
)
|
|
return CommandResult.succeeded(command.command_id, {
|
|
&"candidate_id": candidate_id,
|
|
&"staff_id": candidate_id,
|
|
&"role_id": StringName(canonical[&"role_id"]),
|
|
&"daily_wage": int(canonical[&"daily_wage"]),
|
|
&"employment_kind": hired_staff.get_employment_kind_name(),
|
|
&"signing_cost": signing_cost,
|
|
&"shift_id": shift_id,
|
|
&"player_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT),
|
|
})
|
|
|
|
func _execute_create_staff_shift(command: SimulationCommand) -> CommandResult:
|
|
var staff_id := StringName(command.payload.get(&"staff_id", command.payload.get("staff_id", "")))
|
|
var shift_id := StringName(command.payload.get(&"shift_id", command.payload.get("shift_id", "")))
|
|
var start_day := int(command.payload.get(&"start_day", command.payload.get("start_day", _runtime_event_minute() / 1_440)))
|
|
var facility_id := StringName(command.payload.get(&"facility_id", command.payload.get("facility_id", "")))
|
|
var template_id := StringName(command.payload.get(&"template_id", command.payload.get("template_id", "FLEX")))
|
|
var result: CommandResult
|
|
if String(template_id).to_upper() in ["DAY", "NIGHT"]:
|
|
result = staff_service.create_standard_shift(staff_id, template_id, start_day, facility_id, shift_id)
|
|
else:
|
|
var start_minute := int(command.payload.get(&"start_minute_of_day", command.payload.get("start_minute_of_day", posmod(_runtime_event_minute(), 1_440))))
|
|
var end_minute := int(command.payload.get(&"end_minute_of_day", command.payload.get("end_minute_of_day", posmod(start_minute + 480, 1_440))))
|
|
result = staff_service.create_flex_shift(staff_id, start_day, start_minute, end_minute, facility_id, shift_id)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
|
|
func _candidate_shift_start_day(preferred_shift: StringName, absolute_minute: int) -> int:
|
|
var day := absolute_minute / ShiftInstance.MINUTES_PER_DAY
|
|
var minute_of_day := posmod(absolute_minute, ShiftInstance.MINUTES_PER_DAY)
|
|
# A NIGHT record starts at 18:00 of its named day and remains meaningful even
|
|
# when hired after that start. DAY/FLEX records move to tomorrow once today's
|
|
# daytime window has ended.
|
|
if preferred_shift != &"night" and minute_of_day >= ShiftInstance.DAY_END:
|
|
day += 1
|
|
return day
|
|
|
|
|
|
func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool:
|
|
var restored := economy_service.restore_snapshot(snapshot[&"economy"])
|
|
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
|
|
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
|
|
_hired_staff_candidate_ids = (snapshot[&"hired_ids"] as Dictionary).duplicate(true)
|
|
_staffing_projection_enabled = bool(snapshot[&"staffing_projection_enabled"])
|
|
return restored and _validate_runtime_path_crosslinks(
|
|
path_service,
|
|
guest_service,
|
|
staff_service,
|
|
_movement_credits,
|
|
_runtime_event_minute()
|
|
)
|
|
|
|
func _staff_role_matches_facility(role_id: StringName, definition: FacilityDefinition) -> bool:
|
|
if definition.required_roles.is_empty() or role_id in definition.required_roles:
|
|
return true
|
|
# Existing public-bath data uses a legacy job ID; the paid roster exposes
|
|
# the canonical attendant. The other aliases likewise retain saved job IDs.
|
|
if definition.id == &"public_bath" and role_id == &"attendant" and &"bath_attendant" in definition.required_roles:
|
|
return true
|
|
if definition.id == &"massage_room" and role_id == &"masseur" and &"therapist" in definition.required_roles:
|
|
return true
|
|
return definition.id == &"security_station" and role_id == &"guard" and &"security" in definition.required_roles
|
|
|
|
func _execute_set_staff_work_assignment(command: SimulationCommand) -> CommandResult:
|
|
for key: StringName in [&"staff_id", &"facility_id", &"template_id"]:
|
|
if typeof(command.payload.get(key, null)) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return _staff_assignment_failure(command.command_id, &"INVALID_PAYLOAD", "Staff, facility, and shift IDs must be strings.")
|
|
var staff_id := StringName(command.payload[&"staff_id"])
|
|
var facility_id := StringName(command.payload[&"facility_id"])
|
|
var template_id := StringName(command.payload[&"template_id"])
|
|
var staff := staff_service.get_staff(staff_id)
|
|
if staff == null:
|
|
return _staff_assignment_failure(command.command_id, &"STAFF_NOT_FOUND", "Staff was not found.")
|
|
if template_id not in [&"DAY", &"NIGHT"]:
|
|
return _staff_assignment_failure(command.command_id, &"INVALID_TEMPLATE", "Choose DAY or NIGHT.")
|
|
var block_reason := _staff_assignment_block_reason(staff)
|
|
if not block_reason.is_empty():
|
|
var messages := {
|
|
&"DEFENSE_LOCKED": "Staff is participating in a defense operation.",
|
|
&"EVENT_LOCKED": "Staff is participating in an event.",
|
|
&"STAFF_BUSY": "Finish the current task before changing assignment.",
|
|
&"STAFF_UNAVAILABLE": "Staff cannot change assignment in the current state.",
|
|
}
|
|
return _staff_assignment_failure(command.command_id, block_reason, messages[block_reason])
|
|
if not facility_id.is_empty():
|
|
var facility := facility_service.get_instance(facility_id)
|
|
if facility == null:
|
|
return _staff_assignment_failure(command.command_id, &"FACILITY_NOT_FOUND", "Facility was not found.")
|
|
var definition := definition_registry.get_facility(facility.definition_id)
|
|
if definition == null or definition.staff_max <= 0:
|
|
return _staff_assignment_failure(command.command_id, &"FACILITY_UNSTAFFED", "This facility does not accept Staff assignments.")
|
|
if not _staff_role_matches_facility(staff.role_id, definition) and not _is_anri_manager(staff):
|
|
return _staff_assignment_failure(command.command_id, &"ROLE_MISMATCH", "Staff role does not match this facility.")
|
|
if _staff_assignment_count(facility_id, template_id, staff_id) >= definition.staff_max:
|
|
return _staff_assignment_failure(command.command_id, &"FACILITY_FULL", "This shift has reached the facility Staff limit.")
|
|
if not _staff_can_reach_facility(staff, facility):
|
|
return _staff_assignment_failure(command.command_id, &"NO_ROUTE", "Staff cannot reach this facility through an accessible route.")
|
|
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":
|
|
_publish_actor_event(&"STAFF_WORK_ASSIGNMENT_CHANGED", &"staff_service", [staff_id], result.details)
|
|
return result
|
|
|
|
|
|
## Shared by the command and presentation; always derived from live state.
|
|
func _staff_assignment_block_reason(staff: StaffRuntime) -> StringName:
|
|
if not _defense_lock_owner(staff.staff_id, &"DEFENDER").is_empty():
|
|
return &"DEFENSE_LOCKED"
|
|
if event_service.get_actor_locks().has(staff.staff_id):
|
|
return &"EVENT_LOCKED"
|
|
if not staff.current_task_id.is_empty():
|
|
return &"STAFF_BUSY"
|
|
if not staff.can_take_shift() or staff.state not in [StaffRuntime.State.OFF_DUTY, StaffRuntime.State.COMMUTING, StaffRuntime.State.AVAILABLE]:
|
|
return &"STAFF_UNAVAILABLE"
|
|
return &""
|
|
|
|
|
|
func _staff_assignment_failure(command_id: int, reason: StringName, message: String) -> CommandResult:
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command_id, message, {&"reason": reason})
|
|
|
|
|
|
func _staff_assignment_count(facility_id: StringName, template_id: StringName, excluded_staff: StringName) -> int:
|
|
var counted: Dictionary = {}
|
|
for row: Dictionary in staff_service.get_work_assignments():
|
|
var staff_id := StringName(row[&"staff_id"])
|
|
if staff_id != excluded_staff and StringName(row[&"facility_id"]) == facility_id \
|
|
and StringName(row[&"template_id"]) == template_id:
|
|
counted[staff_id] = true
|
|
var interval := StaffService._work_interval(template_id, _runtime_event_minute())
|
|
for shift: ShiftInstance in staff_service.get_shifts():
|
|
if shift.staff_id != excluded_staff and not shift.cancelled \
|
|
and shift.assigned_facility_id == facility_id \
|
|
and shift.end_at > maxi(interval.x, _runtime_event_minute()) and shift.start_at < interval.y:
|
|
counted[shift.staff_id] = true
|
|
return counted.size()
|
|
|
|
|
|
func _staff_can_reach_facility(staff: StaffRuntime, facility: FacilityInstance) -> bool:
|
|
var occupied: Array[Vector3i] = facility.occupied_cells.duplicate()
|
|
if occupied.is_empty():
|
|
occupied.append(facility.origin)
|
|
var candidates: Dictionary = {}
|
|
for cell: Vector3i in occupied:
|
|
for offset: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]:
|
|
var candidate := cell + offset
|
|
if path_service.has_cell(candidate):
|
|
candidates[candidate] = true
|
|
for candidate: Vector3i in candidates:
|
|
if not path_service.find_path(staff.current_cell, candidate, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"]).is_empty():
|
|
return true
|
|
return false
|
|
|
|
|
|
func _execute_set_staff_schedule_override(command: SimulationCommand) -> CommandResult:
|
|
var staff_id := StringName(command.payload.get(&"staff_id", command.payload.get("staff_id", "")))
|
|
var start_day := int(command.payload.get(&"start_day", command.payload.get("start_day", -1)))
|
|
var patch_value: Variant = command.payload.get(&"patch", command.payload.get("patch", null))
|
|
if not patch_value is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Schedule override patch must be a Dictionary.")
|
|
var result := staff_service.set_schedule_override(staff_id, start_day, patch_value)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_create_staff_task(command: SimulationCommand) -> CommandResult:
|
|
var task_type := StringName(command.payload.get(&"task_type", command.payload.get("task_type", "")))
|
|
var priority := int(command.payload.get(&"priority", command.payload.get("priority", 50)))
|
|
var task_id := StringName(command.payload.get(&"task_id", command.payload.get("task_id", "")))
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
if task_type == CleaningBridge.TASK_TYPE:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Cleaning jobs are scheduled from actual facility dirt.")
|
|
if task_type.is_empty() or not raw_options is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Task type and options are required.")
|
|
var result := staff_service.create_task(task_type, priority, _runtime_event_minute(), raw_options, task_id)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_begin_staff_task(command: SimulationCommand) -> CommandResult:
|
|
var task_id := StringName(command.payload.get(&"task_id", command.payload.get("task_id", "")))
|
|
var result := staff_service.begin_task(task_id, _runtime_event_minute())
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_progress_staff_task(command: SimulationCommand) -> CommandResult:
|
|
var task_id := StringName(command.payload.get(&"task_id", command.payload.get("task_id", "")))
|
|
var task := staff_service.get_task(task_id)
|
|
if task != null and task.task_type == CleaningBridge.TASK_TYPE:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Cleaning work advances only at its physical destination with game time.")
|
|
var minutes := int(command.payload.get(&"minutes", command.payload.get("minutes", 1)))
|
|
var result := staff_service.progress_task(task_id, minutes, _runtime_event_minute())
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _original_relationship_identities_valid(payload: Dictionary, staff_registry: StaffService, catalog: MvpContentCatalog) -> bool:
|
|
if payload.get("protagonist_id") != "player" or staff_registry == null:
|
|
return false
|
|
var subject := StringName(payload.get("subject_id", ""))
|
|
var rival := StringName(payload.get("rival_id", ""))
|
|
if subject == &"player" or rival == &"player" or subject == rival or not staff_registry.has_staff(subject):
|
|
return false
|
|
var heroine_definition := catalog.get_entry(&"unique_characters", subject)
|
|
var rival_definition := catalog.get_entry(&"unique_characters", rival)
|
|
var subject_staff := staff_registry.get_staff(subject)
|
|
return subject_staff.employment_kind == StaffRuntime.EmploymentKind.UNIQUE \
|
|
and heroine_definition.get(&"role_id") == String(subject_staff.role_id) \
|
|
and heroine_definition.get(&"character_kind") == "heroine" \
|
|
and rival_definition.get(&"character_kind") == "rival" \
|
|
and heroine_definition.get(&"counterpart_id") == String(rival) \
|
|
and rival_definition.get(&"counterpart_id") == String(subject)
|
|
|
|
|
|
func _execute_register_original_relationship(command: SimulationCommand) -> CommandResult:
|
|
var result := relationship_service.register_original_relationship(command.payload)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
|
|
func _execute_create_relationship(command: SimulationCommand) -> CommandResult:
|
|
var actor_a_id := StringName(command.payload.get(&"actor_a_id", command.payload.get("actor_a_id", "")))
|
|
var actor_b_id := StringName(command.payload.get(&"actor_b_id", command.payload.get("actor_b_id", "")))
|
|
if not _event_actor_exists(actor_a_id) or not _event_actor_exists(actor_b_id):
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Both relationship actors must exist.")
|
|
var relationship_id := StringName(command.payload.get(&"relationship_id", command.payload.get("relationship_id", "")))
|
|
var id_before := id_generator.create_snapshot()
|
|
if relationship_id.is_empty():
|
|
relationship_id = id_generator.next_id(&"relationship")
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
var options: Dictionary = raw_options.duplicate(true) if raw_options is Dictionary else {}
|
|
var result := relationship_service.create_relationship(relationship_id, actor_a_id, actor_b_id, options)
|
|
if not result.is_success():
|
|
id_generator.restore_snapshot(id_before)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_change_relationship(command: SimulationCommand) -> CommandResult:
|
|
var relationship_id := StringName(command.payload.get(&"relationship_id", command.payload.get("relationship_id", "")))
|
|
var metric_id := StringName(command.payload.get(&"metric_id", command.payload.get("metric_id", "")))
|
|
var delta := int(command.payload.get(&"delta", command.payload.get("delta", 0)))
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
var options: Dictionary = raw_options.duplicate(true) if raw_options is Dictionary else {}
|
|
var result := relationship_service.apply_value_change(
|
|
relationship_id, metric_id, delta, _runtime_event_minute(), options
|
|
)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_attack_relationship_shield(command: SimulationCommand) -> CommandResult:
|
|
var relationship_id := StringName(command.payload.get(&"relationship_id", command.payload.get("relationship_id", "")))
|
|
var action_id := StringName(command.payload.get(&"action_id", command.payload.get("action_id", "")))
|
|
var raw_terms: Variant = command.payload.get(&"terms", command.payload.get("terms", {}))
|
|
if not raw_terms is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Shield attack terms must be a Dictionary.")
|
|
var result := relationship_service.apply_shield_attack(
|
|
relationship_id, action_id, _runtime_event_minute(), raw_terms
|
|
)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_request_route_lock(command: SimulationCommand) -> CommandResult:
|
|
var relationship_id := StringName(command.payload.get(&"relationship_id", command.payload.get("relationship_id", "")))
|
|
var route_id := StringName(command.payload.get(&"route_id", command.payload.get("route_id", "")))
|
|
var lock_index := _route_lock_index(command.payload.get(&"route_lock", command.payload.get("route_lock", -1)))
|
|
if lock_index < 0:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "A valid Route Lock state is required.")
|
|
var result := relationship_service.request_route_lock(
|
|
relationship_id,
|
|
lock_index,
|
|
route_id,
|
|
bool(command.payload.get(&"irreversible_event", command.payload.get("irreversible_event", false)))
|
|
)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_confirm_route_lock(command: SimulationCommand) -> CommandResult:
|
|
var relationship_id := StringName(command.payload.get(&"relationship_id", command.payload.get("relationship_id", "")))
|
|
var route_id := StringName(command.payload.get(&"route_id", command.payload.get("route_id", "")))
|
|
var result := relationship_service.confirm_route_lock(relationship_id, route_id)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_register_event_definition(command: SimulationCommand) -> CommandResult:
|
|
var raw_definition: Variant = command.payload.get(&"definition", command.payload.get("definition", null))
|
|
var definition := EventDefinition.from_variant(raw_definition)
|
|
if definition == null:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Event definition is malformed.")
|
|
var result := event_service.register_definition(definition)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_enqueue_event(command: SimulationCommand) -> CommandResult:
|
|
var event_id := StringName(command.payload.get(&"event_id", command.payload.get("event_id", "")))
|
|
var actor_ids := _name_array(command.payload.get(&"actor_ids", command.payload.get("actor_ids", [])))
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
var options: Dictionary = raw_options.duplicate(true) if raw_options is Dictionary else {}
|
|
var result := event_service.enqueue_candidate(event_id, actor_ids, _runtime_event_minute(), options)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_reserve_event_choice_cost(command: SimulationCommand) -> CommandResult:
|
|
var instance_id := StringName(command.payload.get(&"event_instance_id", command.payload.get("event_instance_id", "")))
|
|
var choice_id := StringName(command.payload.get(&"choice_id", command.payload.get("choice_id", "")))
|
|
var result := event_service.reserve_choice_cost(instance_id, choice_id, _runtime_event_minute())
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_submit_event_choice(command: SimulationCommand) -> CommandResult:
|
|
var instance_id := StringName(command.payload.get(&"event_instance_id", command.payload.get("event_instance_id", "")))
|
|
var choice_instance_id := StringName(command.payload.get(&"choice_instance_id", command.payload.get("choice_instance_id", "")))
|
|
var choice_id := StringName(command.payload.get(&"choice_id", command.payload.get("choice_id", "")))
|
|
var result := event_service.submit_choice(
|
|
instance_id, choice_instance_id, choice_id, _runtime_event_minute()
|
|
)
|
|
if result.is_success() and event_service.get_foreground_instance() == null:
|
|
_clock.set_paused(false)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_report_authority_incident(command: SimulationCommand) -> CommandResult:
|
|
var incident_id := StringName(command.payload.get(&"incident_id", command.payload.get("incident_id", "")))
|
|
var incident_type := StringName(command.payload.get(&"incident_type", command.payload.get("incident_type", "")))
|
|
var detector_id := StringName(command.payload.get(&"detector_id", command.payload.get("detector_id", command.actor_id)))
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
if not raw_options is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Incident options must be a Dictionary.")
|
|
var id_before := id_generator.create_snapshot()
|
|
if incident_id.is_empty():
|
|
incident_id = id_generator.next_id(&"incident")
|
|
var result := authority_service.create_incident(
|
|
incident_id,
|
|
incident_type,
|
|
_runtime_event_minute(),
|
|
detector_id,
|
|
(raw_options as Dictionary).duplicate(true)
|
|
)
|
|
if not result.is_success():
|
|
id_generator.restore_snapshot(id_before)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_register_authority_evidence(command: SimulationCommand) -> CommandResult:
|
|
var evidence_id := StringName(command.payload.get(&"evidence_id", command.payload.get("evidence_id", "")))
|
|
var source := StringName(command.payload.get(&"source", command.payload.get("source", "")))
|
|
var severity := int(command.payload.get(&"severity", command.payload.get("severity", -1)))
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
if not raw_options is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Evidence options must be a Dictionary.")
|
|
var options: Dictionary = (raw_options as Dictionary).duplicate(true)
|
|
if not options.has(&"created_at") and not options.has("created_at"):
|
|
options[&"created_at"] = _runtime_event_minute()
|
|
var id_before := id_generator.create_snapshot()
|
|
if evidence_id.is_empty():
|
|
evidence_id = id_generator.next_id(&"evidence")
|
|
var result := authority_service.create_evidence(evidence_id, source, severity, options)
|
|
if not result.is_success():
|
|
id_generator.restore_snapshot(id_before)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_discover_authority_evidence(command: SimulationCommand) -> CommandResult:
|
|
var evidence_id := StringName(command.payload.get(&"evidence_id", command.payload.get("evidence_id", "")))
|
|
var inspector_id := StringName(command.payload.get(&"inspector_id", command.payload.get("inspector_id", command.actor_id)))
|
|
var parsed_cell: Variant = _parse_world_cell(command.payload.get(&"inspector_cell", command.payload.get("inspector_cell", null)))
|
|
var raw_context: Variant = command.payload.get(&"context", command.payload.get("context", {}))
|
|
if parsed_cell == null or not raw_context is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Evidence discovery requires inspector_cell and Dictionary context.")
|
|
var result := authority_service.attempt_discover_evidence(
|
|
evidence_id,
|
|
inspector_id,
|
|
parsed_cell,
|
|
_runtime_event_minute(),
|
|
(raw_context as Dictionary).duplicate(true)
|
|
)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_evaluate_authority_inspection(command: SimulationCommand) -> CommandResult:
|
|
var raw_terms: Variant = command.payload.get(&"terms", command.payload.get("terms", {}))
|
|
if not raw_terms is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection evaluation terms must be a Dictionary.")
|
|
var result := authority_service.evaluate_inspection(
|
|
_runtime_event_minute(),
|
|
(raw_terms as Dictionary).duplicate(true)
|
|
)
|
|
if result.is_success() and not result.is_replay:
|
|
_publish_authority_evaluation(result.details)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_schedule_authority_inspection(command: SimulationCommand) -> CommandResult:
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
if not raw_options is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection options must be a Dictionary.")
|
|
var scheduled_at := int(command.payload.get(&"scheduled_at", command.payload.get("scheduled_at", _runtime_event_minute())))
|
|
var result := authority_service.schedule_inspection(
|
|
scheduled_at,
|
|
(raw_options as Dictionary).duplicate(true)
|
|
)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_start_authority_inspection(command: SimulationCommand) -> CommandResult:
|
|
var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", "")))
|
|
var result := authority_service.start_inspection(inspection_id, _runtime_event_minute())
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_arrive_authority_inspection(command: SimulationCommand) -> CommandResult:
|
|
var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", "")))
|
|
var result := authority_service.mark_inspector_arrived(inspection_id, _runtime_event_minute())
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_plan_authority_inspection(command: SimulationCommand) -> CommandResult:
|
|
var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", "")))
|
|
var raw_candidates: Variant = command.payload.get(&"candidates", command.payload.get("candidates", null))
|
|
if not raw_candidates is Array:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection patrol candidates must be an Array.")
|
|
var time_budget := int(command.payload.get(&"time_budget_minutes", command.payload.get("time_budget_minutes", 120)))
|
|
var result := authority_service.plan_patrol(
|
|
inspection_id,
|
|
raw_candidates,
|
|
_runtime_event_minute(),
|
|
time_budget
|
|
)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_advance_authority_inspection(command: SimulationCommand) -> CommandResult:
|
|
var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", "")))
|
|
var raw_context: Variant = command.payload.get(&"context", command.payload.get("context", {}))
|
|
if not raw_context is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection path context must be a Dictionary.")
|
|
var result := authority_service.advance_patrol(
|
|
inspection_id,
|
|
_runtime_event_minute(),
|
|
(raw_context as Dictionary).duplicate(true)
|
|
)
|
|
if result.is_success() and result.details.has(&"reachable"):
|
|
var event_type := &"INSPECTION_WAYPOINT_REACHED" if bool(result.details[&"reachable"]) else &"INSPECTION_PATH_BLOCKED"
|
|
_publish_actor_event(
|
|
event_type,
|
|
&"authority_service",
|
|
[inspection_id, StringName(result.details.get(&"waypoint_id", ""))],
|
|
result.details.duplicate(true)
|
|
)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_inspect_authority_waypoint(command: SimulationCommand) -> CommandResult:
|
|
var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", "")))
|
|
var evidence_ids := _name_array(command.payload.get(&"evidence_ids", command.payload.get("evidence_ids", [])))
|
|
var raw_context: Variant = command.payload.get(&"context", command.payload.get("context", {}))
|
|
if not raw_context is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection evidence context must be a Dictionary.")
|
|
var result := authority_service.inspect_current_waypoint(
|
|
inspection_id,
|
|
evidence_ids,
|
|
_runtime_event_minute(),
|
|
(raw_context as Dictionary).duplicate(true)
|
|
)
|
|
if result.is_success():
|
|
_publish_actor_event(
|
|
&"INSPECTION_WAYPOINT_INSPECTED",
|
|
&"authority_service",
|
|
[inspection_id],
|
|
result.details.duplicate(true)
|
|
)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_resolve_authority_inspection(command: SimulationCommand) -> CommandResult:
|
|
var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", "")))
|
|
var resolution_id := StringName(command.payload.get(&"resolution_id", command.payload.get("resolution_id", "")))
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
if not raw_options is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Inspection resolution options must be a Dictionary.")
|
|
var id_before := id_generator.create_snapshot()
|
|
if resolution_id.is_empty():
|
|
resolution_id = id_generator.next_id(&"resolution")
|
|
var result := authority_service.resolve_inspection(
|
|
inspection_id,
|
|
resolution_id,
|
|
_runtime_event_minute(),
|
|
(raw_options as Dictionary).duplicate(true)
|
|
)
|
|
if not result.is_success():
|
|
id_generator.restore_snapshot(id_before)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_depart_authority_inspection(command: SimulationCommand) -> CommandResult:
|
|
var inspection_id := StringName(command.payload.get(&"inspection_id", command.payload.get("inspection_id", "")))
|
|
var result := authority_service.depart_inspection(inspection_id, _runtime_event_minute())
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
## Existing Defense commands remain authoritative. This report only explains
|
|
## why a live employee or target can be registered; no balance data is invented.
|
|
func get_defense_management_report(selected_raid_id: StringName = &"") -> Dictionary:
|
|
var raids: Array[Dictionary] = defense_service.get_management_rows()
|
|
var selected: Dictionary = {}
|
|
for row: Dictionary in raids:
|
|
if row[&"raid_id"] == selected_raid_id:
|
|
selected = row
|
|
break
|
|
if selected.is_empty() and not raids.is_empty():
|
|
selected = raids[0]
|
|
for row: Dictionary in raids:
|
|
if int(row[&"state"]) not in DefenseService.TERMINAL_STATES:
|
|
selected = row
|
|
break
|
|
var raid_id := StringName(selected.get(&"raid_id", &""))
|
|
var detail := defense_service.get_management_detail(raid_id)
|
|
var defenders: Array = detail.get(&"defenders", [])
|
|
var assets: Array = detail.get(&"assets", [])
|
|
var defender_owners: Dictionary = {}
|
|
var asset_owners: Dictionary = {}
|
|
for row: Dictionary in raids:
|
|
if int(row[&"state"]) in DefenseService.TERMINAL_STATES: continue
|
|
for actor_id: StringName in row[&"defender_ids"]: defender_owners[actor_id] = row[&"raid_id"]
|
|
for asset_id: StringName in row[&"asset_ids"]: asset_owners[asset_id] = row[&"raid_id"]
|
|
var preparing := int(selected.get(&"state", -1)) == DefenseService.RaidState.PREPARING
|
|
var staff_rows: Array[Dictionary] = []
|
|
for staff: StaffRuntime in staff_service.get_staff_members():
|
|
var entry := mvp_content_catalog.get_recruitment_entry(staff.staff_id)
|
|
var registered := false
|
|
for actor: Dictionary in defenders:
|
|
registered = registered or actor[&"actor_id"] == staff.staff_id
|
|
var reason := ""
|
|
if not preparing: reason = "準備中だけ参加者を追加できます。"
|
|
elif registered: reason = "この防衛戦に参加済みです。"
|
|
elif defender_owners.has(staff.staff_id) and defender_owners[staff.staff_id] != raid_id: reason = "別の防衛戦に参加しています。"
|
|
elif not staff.can_take_shift(): reason = "現在の体調や状態では参加できません。"
|
|
elif not staff.current_task_id.is_empty(): reason = "担当中の業務が完了するまで参加できません。"
|
|
staff_rows.append({
|
|
&"staff_id": staff.staff_id, &"display_name": String(entry.get(&"display_name", staff.staff_id)),
|
|
&"combat": int(staff.skills.get(&"combat", 0)), &"health": staff.health,
|
|
&"fatigue": staff.fatigue, &"registered": registered,
|
|
&"can_register": reason.is_empty(), &"reason": reason,
|
|
})
|
|
var objective_rows: Array[Dictionary] = []
|
|
for objective_id: StringName in selected.get(&"objective_ids", []):
|
|
var facility := facility_service.get_instance(objective_id)
|
|
var definition: Variant = definition_registry.get_facility(facility.definition_id) if facility != null else null
|
|
var registered := false
|
|
for asset: Dictionary in assets:
|
|
registered = registered or asset[&"asset_id"] == objective_id
|
|
var reason := ""
|
|
if not preparing: reason = "準備中だけ防衛対象を登録できます。"
|
|
elif registered: reason = "登録済みです。"
|
|
elif facility == null: reason = "指定された施設が見つかりません。"
|
|
elif facility.durability <= 0: reason = "破壊された施設は登録できません。"
|
|
elif not facility.queue_entries.is_empty() or not facility.active_services.is_empty(): reason = "利用中または待機中の顧客がいます。"
|
|
elif asset_owners.has(objective_id) and asset_owners[objective_id] != raid_id: reason = "別の防衛戦の対象です。"
|
|
objective_rows.append({
|
|
&"facility_id": objective_id, &"display_name": String(definition.display_name) if definition != null else String(objective_id),
|
|
&"durability": facility.durability if facility != null else 0,
|
|
&"registered": registered, &"can_register": reason.is_empty(), &"reason": reason,
|
|
})
|
|
return {&"raids": raids, &"selected": selected, &"staff": staff_rows,
|
|
&"objectives": objective_rows, &"defenders": defenders, &"assets": assets,
|
|
&"can_control": not (_operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal())}
|
|
|
|
|
|
func _execute_create_defense_raid(command: SimulationCommand) -> CommandResult:
|
|
var raw_definition: Variant = command.payload.get(&"definition", command.payload.get("definition", null))
|
|
if not raw_definition is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Defense Raid definition must be a Dictionary.")
|
|
var event_source_value: Variant = raw_definition.get(&"event_source", raw_definition.get("event_source", {}))
|
|
if not event_source_value is Dictionary or not event_source_value.is_empty():
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Event threat provenance is reserved to the configured Event boundary.")
|
|
var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id)))
|
|
var id_before := id_generator.create_snapshot()
|
|
if raid_id.is_empty():
|
|
raid_id = id_generator.next_id(&"defense_raid")
|
|
var definition: Dictionary = (raw_definition as Dictionary).duplicate(true)
|
|
if not definition.has(&"source_id") and not definition.has("source_id"):
|
|
definition[&"source_id"] = command.actor_id
|
|
var result := defense_service.create_raid(raid_id, _defense_operation_minute(), definition)
|
|
if not result.is_success():
|
|
id_generator.restore_snapshot(id_before)
|
|
result.command_id = command.command_id
|
|
result.target_id = raid_id
|
|
return result
|
|
|
|
func _execute_register_defense_combatant(command: SimulationCommand) -> CommandResult:
|
|
var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id)))
|
|
var actor_id := StringName(command.payload.get(&"actor_id", command.payload.get("actor_id", command.payload.get(&"staff_id", command.payload.get("staff_id", "")))))
|
|
var side := StringName(String(command.payload.get(&"side", command.payload.get("side", ""))).to_upper())
|
|
var combat := int(command.payload.get(&"combat", command.payload.get("combat", 0)))
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
if raid_id.is_empty() or actor_id.is_empty() or not raw_options is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Defense combatant requires raid_id, actor_id and Dictionary options.")
|
|
if not _validate_live_defense_crosslinks():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before combatant registration.")
|
|
var options: Dictionary = (raw_options as Dictionary).duplicate(true)
|
|
var max_health := int(command.payload.get(&"max_health", command.payload.get("max_health", 100)))
|
|
if side == &"DEFENDER":
|
|
var staff := staff_service.get_staff(actor_id)
|
|
if staff == null:
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Defense defender must reference existing Staff.")
|
|
if not staff.can_take_shift() or not staff.current_task_id.is_empty():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense defender must be healthy enough to participate and free of Staff tasks.")
|
|
var lock_owner: StringName = _defense_lock_owner(actor_id, &"DEFENDER", raid_id)
|
|
if not lock_owner.is_empty():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Staff is already assigned to another non-terminal Raid.", {&"raid_id": lock_owner})
|
|
if max_health != StaffRuntime.MAX_PSYCHOLOGY_VALUE \
|
|
or ((options.has(&"health") or options.has("health")) and int(options.get(&"health", options.get("health", -1))) != staff.health):
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defender health must use the current Staff health on a 100-point baseline.")
|
|
max_health = StaffRuntime.MAX_PSYCHOLOGY_VALUE
|
|
options[&"health"] = staff.health
|
|
options[&"fatigue"] = staff.fatigue
|
|
var staff_before: Dictionary = staff_service.create_snapshot()
|
|
staff.transition_to(StaffRuntime.State.UNAVAILABLE, &"DEFENSE_ASSIGNED", _defense_operation_minute())
|
|
var defender_result := defense_service.register_combatant(
|
|
raid_id, actor_id, side, combat, max_health, options
|
|
)
|
|
if not defender_result.is_success():
|
|
staff_service.restore_snapshot(staff_before)
|
|
defender_result.command_id = command.command_id
|
|
defender_result.target_id = raid_id
|
|
return defender_result
|
|
elif side != &"ATTACKER":
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Defense combatant side must be DEFENDER or ATTACKER.")
|
|
var result := defense_service.register_combatant(
|
|
raid_id, actor_id, side, combat, max_health, options
|
|
)
|
|
result.command_id = command.command_id
|
|
result.target_id = raid_id
|
|
return result
|
|
|
|
func _execute_register_defense_asset(command: SimulationCommand) -> CommandResult:
|
|
var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id)))
|
|
var asset_id := StringName(command.payload.get(&"asset_id", command.payload.get("asset_id", command.payload.get(&"facility_id", command.payload.get("facility_id", "")))))
|
|
var kind := StringName(String(command.payload.get(&"kind", command.payload.get("kind", ""))).to_upper())
|
|
var defense := int(command.payload.get(&"defense", command.payload.get("defense", 0)))
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
if raid_id.is_empty() or asset_id.is_empty() or not raw_options is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Defense asset requires raid_id, facility asset_id and Dictionary options.")
|
|
if not _validate_live_defense_crosslinks():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before asset registration.")
|
|
var facility := facility_service.get_instance(asset_id)
|
|
if facility == null:
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Defense asset must reference an existing Facility.")
|
|
if not facility.queue_entries.is_empty() or not facility.active_services.is_empty():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "A busy Facility cannot become a Defense asset.")
|
|
var lock_owner: StringName = _defense_lock_owner(asset_id, &"ASSET", raid_id)
|
|
if not lock_owner.is_empty():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Facility is already assigned to another non-terminal Raid.", {&"raid_id": lock_owner})
|
|
var supplied_maximum := int(command.payload.get(&"max_integrity", command.payload.get("max_integrity", FacilityInstance.DEFAULT_DURABILITY)))
|
|
var options: Dictionary = (raw_options as Dictionary).duplicate(true)
|
|
if supplied_maximum != FacilityInstance.DEFAULT_DURABILITY \
|
|
or ((options.has(&"integrity") or options.has("integrity")) and int(options.get(&"integrity", options.get("integrity", -1))) != facility.durability):
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense asset integrity must use the current Facility durability on a 10000-point baseline.")
|
|
options[&"integrity"] = facility.durability
|
|
var result := defense_service.register_asset(
|
|
raid_id,
|
|
asset_id,
|
|
kind,
|
|
FacilityInstance.DEFAULT_DURABILITY,
|
|
defense,
|
|
options
|
|
)
|
|
result.command_id = command.command_id
|
|
result.target_id = raid_id
|
|
return result
|
|
|
|
func _execute_start_defense_raid(command: SimulationCommand) -> CommandResult:
|
|
var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id)))
|
|
if not _validate_live_defense_crosslinks():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before Raid start.")
|
|
var result := defense_service.start_raid(raid_id, _defense_operation_minute())
|
|
result.command_id = command.command_id
|
|
result.target_id = raid_id
|
|
return result
|
|
|
|
func _execute_set_defense_retreat_policy(command: SimulationCommand) -> CommandResult:
|
|
var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id)))
|
|
var policy := StringName(String(command.payload.get(&"policy", command.payload.get("policy", ""))).to_upper())
|
|
var threshold := int(command.payload.get(&"threshold", command.payload.get("threshold", -1)))
|
|
if not _validate_live_defense_crosslinks():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before retreat-policy update.")
|
|
var result := defense_service.set_retreat_policy(raid_id, policy, threshold)
|
|
result.command_id = command.command_id
|
|
result.target_id = raid_id
|
|
return result
|
|
|
|
func _execute_queue_defense_skill(command: SimulationCommand) -> CommandResult:
|
|
var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id)))
|
|
var action_id := StringName(command.payload.get(&"action_id", command.payload.get("action_id", "")))
|
|
var actor_id := StringName(command.payload.get(&"actor_id", command.payload.get("actor_id", command.actor_id)))
|
|
var kind := StringName(String(command.payload.get(&"kind", command.payload.get("kind", ""))).to_upper())
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
if not raw_options is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Defense skill options must be a Dictionary.")
|
|
if not _validate_live_defense_crosslinks():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before skill queueing.")
|
|
var id_before := id_generator.create_snapshot()
|
|
if action_id.is_empty():
|
|
action_id = id_generator.next_id(&"defense_skill_action")
|
|
var result := defense_service.queue_active_skill(
|
|
raid_id, action_id, actor_id, kind, (raw_options as Dictionary).duplicate(true)
|
|
)
|
|
if not result.is_success():
|
|
id_generator.restore_snapshot(id_before)
|
|
result.command_id = command.command_id
|
|
result.target_id = raid_id
|
|
return result
|
|
|
|
func _execute_resolve_defense_round(command: SimulationCommand) -> CommandResult:
|
|
var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id)))
|
|
var resolution_id := StringName(command.payload.get(&"resolution_id", command.payload.get("resolution_id", "")))
|
|
var result := _resolve_defense_round_internal(raid_id, resolution_id, _defense_operation_minute())
|
|
result.command_id = command.command_id
|
|
result.target_id = raid_id
|
|
return result
|
|
|
|
|
|
## Both the explicit command and ordinary clock progression use the same
|
|
## transaction, generated-ID namespace and cross-domain callback boundaries.
|
|
func _resolve_defense_round_internal(
|
|
raid_id: StringName,
|
|
resolution_id: StringName,
|
|
absolute_minute: int
|
|
) -> CommandResult:
|
|
_prepare_defense_round_attempts(absolute_minute)
|
|
var already_attempted := _defense_round_attempted_raids.has(raid_id)
|
|
_defense_round_attempted_raids[raid_id] = true
|
|
var previous_command_minute := _active_command_minute
|
|
_active_command_minute = absolute_minute
|
|
if not _validate_live_defense_crosslinks():
|
|
_active_command_minute = previous_command_minute
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Defense cross-domain links are invalid before round resolution.")
|
|
var id_before := id_generator.create_snapshot()
|
|
if resolution_id.is_empty():
|
|
# Existing explicit IDs can occupy this namespace without advancing the
|
|
# allocator. Skip those IDs instead of replaying history as a new attack.
|
|
while id_generator.get_counter(&"defense_round_resolution") < StableIdGenerator.MAX_COUNTER:
|
|
resolution_id = id_generator.next_id(&"defense_round_resolution")
|
|
if not defense_service.has_resolution(raid_id, resolution_id):
|
|
break
|
|
if resolution_id.is_empty() or defense_service.has_resolution(raid_id, resolution_id):
|
|
id_generator.restore_snapshot(id_before)
|
|
_active_command_minute = previous_command_minute
|
|
var exhausted := CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Defense round ID namespace is exhausted.")
|
|
exhausted.target_id = raid_id
|
|
return exhausted
|
|
var result := defense_service.resolve_next_round(raid_id, resolution_id, absolute_minute)
|
|
# Reading an old canonical result is not a new round attempt. Restore only
|
|
# the guard introduced by this replay; an earlier real failure in this same
|
|
# minute must still prevent automatic progression from hiding that failure.
|
|
if result.is_replay and not already_attempted:
|
|
_defense_round_attempted_raids.erase(raid_id)
|
|
if not result.is_success():
|
|
id_generator.restore_snapshot(id_before)
|
|
_active_command_minute = previous_command_minute
|
|
result.target_id = raid_id
|
|
return result
|
|
|
|
|
|
func _prepare_defense_round_attempts(absolute_minute: int) -> void:
|
|
if _defense_round_attempt_minute == absolute_minute:
|
|
return
|
|
_defense_round_attempt_minute = absolute_minute
|
|
_defense_round_attempted_raids.clear()
|
|
_defense_automatic_failed_minute = -1
|
|
|
|
|
|
func _reset_defense_round_attempts() -> void:
|
|
_defense_round_attempt_minute = -1
|
|
_defense_round_attempted_raids.clear()
|
|
_defense_automatic_failed_minute = -1
|
|
|
|
|
|
func _execute_begin_next_defense_wave(command: SimulationCommand) -> CommandResult:
|
|
var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id)))
|
|
if not _validate_live_defense_crosslinks():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before the next Wave.")
|
|
var result := defense_service.begin_next_wave(raid_id, _defense_operation_minute())
|
|
result.command_id = command.command_id
|
|
result.target_id = raid_id
|
|
return result
|
|
|
|
func _execute_retreat_defenders(command: SimulationCommand) -> CommandResult:
|
|
var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id)))
|
|
var resolution_id := StringName(command.payload.get(&"resolution_id", command.payload.get("resolution_id", "")))
|
|
var reason := StringName(command.payload.get(&"reason", command.payload.get("reason", &"PLAYER_RETREAT")))
|
|
if not _validate_live_defense_crosslinks():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before defender retreat.")
|
|
var id_before := id_generator.create_snapshot()
|
|
if resolution_id.is_empty():
|
|
resolution_id = id_generator.next_id(&"defense_retreat_resolution")
|
|
var result := defense_service.retreat_defenders(raid_id, resolution_id, _defense_operation_minute(), reason)
|
|
if not result.is_success():
|
|
id_generator.restore_snapshot(id_before)
|
|
result.command_id = command.command_id
|
|
result.target_id = raid_id
|
|
return result
|
|
|
|
func _execute_cancel_defense_raid(command: SimulationCommand) -> CommandResult:
|
|
var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id)))
|
|
var resolution_id := StringName(command.payload.get(&"resolution_id", command.payload.get("resolution_id", "")))
|
|
var reason := StringName(command.payload.get(&"reason", command.payload.get("reason", &"CANCELLED")))
|
|
if not _validate_live_defense_crosslinks():
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before Raid cancellation.")
|
|
var id_before := id_generator.create_snapshot()
|
|
if resolution_id.is_empty():
|
|
resolution_id = id_generator.next_id(&"defense_cancel_resolution")
|
|
var result := defense_service.cancel_raid(raid_id, _defense_operation_minute(), reason, resolution_id)
|
|
if not result.is_success():
|
|
id_generator.restore_snapshot(id_before)
|
|
result.command_id = command.command_id
|
|
result.target_id = raid_id
|
|
return result
|
|
|
|
func _execute_create_service_request(command: SimulationCommand) -> CommandResult:
|
|
var facility_id := StringName(command.payload.get(&"facility_id", command.payload.get("facility_id", "")))
|
|
var instance := facility_service.get_instance(facility_id)
|
|
if instance == null:
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Facility was not found.")
|
|
var defense_owner: StringName = _defense_lock_owner(facility_id, &"ASSET")
|
|
if not defense_owner.is_empty():
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"A Facility assigned to a non-terminal Defense Raid cannot accept service requests.",
|
|
{&"raid_id": defense_owner}
|
|
)
|
|
var guest_ids := _name_array(command.payload.get(&"guest_ids", command.payload.get("guest_ids", [])))
|
|
if guest_ids.is_empty():
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "At least one guest id is required.")
|
|
for guest_id: StringName in guest_ids:
|
|
if not economy_service.has_account(_guest_wallet_account(guest_id)):
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Every guest needs a registered wallet.")
|
|
var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {}))
|
|
if not raw_options is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Service request options must be a Dictionary.")
|
|
var options: Dictionary = raw_options.duplicate(true)
|
|
var quoted_price := maxi(
|
|
int(options.get(&"quoted_price", options.get("quoted_price", get_facility_business_price(instance)))),
|
|
0
|
|
)
|
|
# Freeze the same price used by candidate selection and the business panel.
|
|
options[&"quoted_price"] = quoted_price
|
|
if not instance.operator_open or not instance.is_within_opening_hours(_runtime_event_minute()):
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "This facility is closed to new service requests.", {&"reason": &"FACILITY_CLOSED"})
|
|
if instance.requires_operating_kitchen() and not bool(facility_service.get_required_kitchen_report(facility_id).get(&"operating", false)):
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "This dining facility requires an operating BACK kitchen.", {&"reason": &"FACILITY_CLOSED", &"dependency_reason": facility_service.get_required_kitchen_report(facility_id).get(&"reason", &"")})
|
|
var payer_account := _guest_wallet_account(guest_ids[0])
|
|
var committed_price := _outstanding_service_commitment(guest_ids[0])
|
|
if not economy_service.account_allows_negative(payer_account) \
|
|
and economy_service.get_balance(payer_account) < committed_price + quoted_price:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"The guest cannot fund this service together with existing commitments.",
|
|
{
|
|
&"available_gold": economy_service.get_balance(payer_account),
|
|
&"committed_gold": committed_price,
|
|
&"quoted_price": quoted_price,
|
|
}
|
|
)
|
|
|
|
var inventory_before := inventory_service.create_snapshot()
|
|
var facility_before := facility_service.create_snapshot()
|
|
var guest_before := guest_service.create_snapshot()
|
|
var events_before := domain_events.create_snapshot()
|
|
var id_before := id_generator.create_snapshot()
|
|
var request_id := StringName(command.payload.get(&"request_id", command.payload.get("request_id", "")))
|
|
if request_id.is_empty():
|
|
request_id = id_generator.next_id(&"service_request")
|
|
var definition := definition_registry.get_facility(instance.definition_id)
|
|
if definition == null:
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Facility definition was not found.")
|
|
var reservation_ids: Array[StringName] = []
|
|
for item_id: StringName in _sorted_name_keys(definition.inventory_inputs):
|
|
var reserve_result := inventory_service.reserve_item(
|
|
instance.local_inventory_id,
|
|
item_id,
|
|
int(definition.inventory_inputs[item_id]),
|
|
request_id,
|
|
&"facility_service",
|
|
_runtime_event_minute(),
|
|
_runtime_event_minute() + 240,
|
|
StringName("service.reserve.%s.%s" % [request_id, item_id])
|
|
)
|
|
if not bool(reserve_result.get(&"ok", false)):
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
id_generator.restore_snapshot(id_before)
|
|
return _command_result_from_dictionary(command.command_id, reserve_result, "Facility input could not be reserved.")
|
|
for reservation_id: StringName in reserve_result.get(&"reservation_ids", []):
|
|
reservation_ids.append(reservation_id)
|
|
options[&"reserved_inventory_ids"] = reservation_ids
|
|
var request := facility_service.create_request(
|
|
request_id,
|
|
facility_id,
|
|
guest_ids,
|
|
StringName(command.payload.get(&"service_type", command.payload.get("service_type", "default"))),
|
|
_runtime_event_minute(),
|
|
options
|
|
)
|
|
var enqueue_result: CommandResult
|
|
var runtime_guests: Array[StringName] = []
|
|
for guest_id: StringName in guest_ids:
|
|
if guest_service.has_guest(guest_id):
|
|
runtime_guests.append(guest_id)
|
|
if runtime_guests.is_empty():
|
|
enqueue_result = facility_service.enqueue_request(request, _runtime_event_minute(), not (_operations_enabled and _active_command_minute == _clock.absolute_minute + 1))
|
|
else:
|
|
var first_guest := runtime_guests[0]
|
|
var queue_result := guest_service.enter_queue(
|
|
first_guest,
|
|
facility_id,
|
|
request_id,
|
|
_runtime_event_minute(),
|
|
int(options.get(&"initial_patience", options.get("initial_patience", -1))),
|
|
func(_guest_id: StringName, _facility_id: StringName, _request_id: StringName) -> CommandResult:
|
|
return facility_service.enqueue_request(request, _runtime_event_minute(), not (_operations_enabled and _active_command_minute == _clock.absolute_minute + 1))
|
|
)
|
|
if bool(queue_result.get(&"ok", false)):
|
|
for guest_index in range(1, runtime_guests.size()):
|
|
var joined := guest_service.enter_queue(
|
|
runtime_guests[guest_index], facility_id, request_id, _runtime_event_minute(),
|
|
int(options.get(&"initial_patience", options.get("initial_patience", -1)))
|
|
)
|
|
if not bool(joined.get(&"ok", false)):
|
|
queue_result = joined
|
|
break
|
|
enqueue_result = CommandResult.succeeded(0, {
|
|
&"expected_wait": request.expected_wait,
|
|
}) if bool(queue_result.get(&"ok", false)) else CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED, 0,
|
|
String(queue_result.get(&"message", "Guest could not enter the facility queue.")),
|
|
queue_result
|
|
)
|
|
if not enqueue_result.is_success():
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
facility_service.restore_snapshot(facility_before)
|
|
guest_service.restore_snapshot(guest_before)
|
|
domain_events.restore_snapshot(events_before)
|
|
id_generator.restore_snapshot(id_before)
|
|
enqueue_result.command_id = command.command_id
|
|
return enqueue_result
|
|
return CommandResult.succeeded(command.command_id, {
|
|
&"request_id": request_id,
|
|
&"reservation_ids": reservation_ids,
|
|
&"expected_wait": enqueue_result.details.get(&"expected_wait", 0),
|
|
})
|
|
|
|
func _execute_set_facility_conditions(command: SimulationCommand) -> CommandResult:
|
|
var facility_id := StringName(command.payload.get(&"facility_id", command.payload.get("facility_id", "")))
|
|
var conditions: Variant = command.payload.get(&"conditions", command.payload.get("conditions", null))
|
|
if not conditions is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "conditions must be a Dictionary.")
|
|
var defense_owner: StringName = _defense_lock_owner(facility_id, &"ASSET")
|
|
if not defense_owner.is_empty() and (conditions.has(&"durability") or conditions.has("durability")):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"Defense-owned Facility durability can only change through its Raid.",
|
|
{&"raid_id": defense_owner}
|
|
)
|
|
var result := facility_service.set_operational_status(facility_id, conditions, _runtime_event_minute())
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_set_facility_policy(command: SimulationCommand) -> CommandResult:
|
|
var facility_id := StringName(command.payload.get(&"facility_id", command.payload.get("facility_id", "")))
|
|
var patch: Variant = command.payload.get(&"patch", command.payload.get("patch", null))
|
|
if not patch is Dictionary:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "patch must be a Dictionary.")
|
|
var effective_at := int(command.payload.get(&"effective_at", command.payload.get("effective_at", -1)))
|
|
var result := facility_service.set_policy(facility_id, patch, effective_at)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_leave_facility_queue(command: SimulationCommand) -> CommandResult:
|
|
var facility_id := StringName(command.payload.get(&"facility_id", command.payload.get("facility_id", "")))
|
|
var request_id := StringName(command.payload.get(&"request_id", command.payload.get("request_id", "")))
|
|
var request := facility_service.get_request(request_id)
|
|
if request == null:
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Queued service request was not found.")
|
|
var inventory_before := inventory_service.create_snapshot()
|
|
var facility_before := facility_service.create_snapshot()
|
|
var guest_before := guest_service.create_snapshot()
|
|
var events_before := domain_events.create_snapshot()
|
|
var id_before := id_generator.create_snapshot()
|
|
var external_leave := func(_guest_id: StringName = &"", _facility_id: StringName = &"", _request_id: StringName = &"") -> Dictionary:
|
|
for reservation_id: StringName in request.reserved_inventory_ids:
|
|
var released := inventory_service.release_reservation(
|
|
reservation_id,
|
|
StringName("service.release.%s.%s" % [request_id, reservation_id]),
|
|
_runtime_event_minute()
|
|
)
|
|
if not bool(released.get(&"ok", false)):
|
|
return released
|
|
var leave_result := facility_service.leave_queue(facility_id, request_id)
|
|
return {&"ok": leave_result.is_success(), &"message": leave_result.message}
|
|
var runtime_guests: Array[StringName] = []
|
|
for guest_id: StringName in request.guest_ids:
|
|
if guest_service.has_guest(guest_id):
|
|
runtime_guests.append(guest_id)
|
|
var successful := true
|
|
var failure_message := "Facility queue removal failed."
|
|
if runtime_guests.is_empty():
|
|
var external_result: Dictionary = external_leave.call()
|
|
successful = bool(external_result.get(&"ok", false))
|
|
failure_message = String(external_result.get(&"message", failure_message))
|
|
else:
|
|
for guest_index in range(runtime_guests.size()):
|
|
var queued_guest := guest_service.get_guest(runtime_guests[guest_index])
|
|
var abandon := guest_service.tick_queue_patience(
|
|
queued_guest.guest_id,
|
|
_runtime_event_minute(),
|
|
-queued_guest.patience_remaining,
|
|
external_leave if guest_index == 0 else Callable()
|
|
)
|
|
if not bool(abandon.get(&"ok", false)):
|
|
successful = false
|
|
failure_message = String(abandon.get(&"message", failure_message))
|
|
break
|
|
if not successful:
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
facility_service.restore_snapshot(facility_before)
|
|
guest_service.restore_snapshot(guest_before)
|
|
domain_events.restore_snapshot(events_before)
|
|
id_generator.restore_snapshot(id_before)
|
|
return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, failure_message)
|
|
return CommandResult.succeeded(command.command_id, {&"request_id": request_id, &"reason": &"GUEST_LEFT"})
|
|
|
|
func preview_build_facility(definition_id: StringName, origin: Vector3i, rotation: int) -> Dictionary:
|
|
var definition := definition_registry.get_facility(definition_id)
|
|
var failure := _build_facility_precheck(0, {&"definition_id": definition_id, &"origin": origin, &"rotation": rotation})
|
|
if failure != null and definition == null:
|
|
var code := String(failure.details.get(&"reason", CommandResult.Code.keys()[failure.code]))
|
|
return {&"can_build": false, &"error_codes": [code],
|
|
&"reasons": [{&"code": code, &"message": failure.message, &"cells": [], &"details": failure.details.duplicate(true)}],
|
|
&"cells": []}
|
|
var report: Dictionary = construction_service.validate_placement(definition, origin, rotation).to_dictionary()
|
|
if failure != null:
|
|
report[&"can_build"] = false
|
|
var code := String(failure.details.get(&"reason", CommandResult.Code.keys()[failure.code]))
|
|
var codes: Array = Array(report.get(&"error_codes", []))
|
|
var reasons: Array = Array(report.get(&"reasons", []))
|
|
codes.push_front(code)
|
|
reasons.push_front({&"code": code, &"message": failure.message, &"cells": [], &"details": failure.details.duplicate(true)})
|
|
report[&"error_codes"] = codes
|
|
report[&"reasons"] = reasons
|
|
return report
|
|
|
|
func _build_facility_precheck(command_id: int, payload: Dictionary) -> CommandResult:
|
|
var operations_gate := _operations_construction_investment_gate(command_id)
|
|
if operations_gate != null:
|
|
return operations_gate
|
|
var definition_id := StringName(payload.get(&"definition_id", payload.get("definition_id", "")))
|
|
var definition := definition_registry.get_facility(definition_id)
|
|
if definition == null:
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, command_id, "Facility definition was not found.")
|
|
if _facility_definition_is_unsupported_vertical_proxy(definition):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command_id,
|
|
"Vertical infrastructure topology is not supported.",
|
|
{
|
|
&"reason": FacilityInfrastructureProjector.REASON_UNSUPPORTED_VERTICAL_TOPOLOGY,
|
|
&"definition_id": definition_id,
|
|
&"unchanged": true,
|
|
}
|
|
)
|
|
var origin_value: Variant = _parse_world_cell(payload.get(&"origin", payload.get("origin", null)))
|
|
if origin_value == null:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, 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_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_id, &"ELEVATOR_BUSY", "Wait until the shaft is idle before extending it.")
|
|
var rotation := int(payload.get(&"rotation", payload.get("rotation", 0)))
|
|
if rotation not in definition.allowed_rotations:
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command_id, "The selected rotation is not allowed.")
|
|
return null
|
|
|
|
func _execute_build_facility(command: SimulationCommand) -> CommandResult:
|
|
var failure := _build_facility_precheck(command.command_id, command.payload)
|
|
if failure != null:
|
|
return failure
|
|
var definition_id := StringName(command.payload.get(&"definition_id", command.payload.get("definition_id", "")))
|
|
var definition := definition_registry.get_facility(definition_id)
|
|
var origin: Vector3i = _parse_world_cell(command.payload.get(&"origin", command.payload.get("origin", null)))
|
|
var rotation := int(command.payload.get(&"rotation", command.payload.get("rotation", 0)))
|
|
var result := construction_service.start_construction(
|
|
definition,
|
|
origin,
|
|
rotation,
|
|
definition.build_minutes,
|
|
_runtime_event_minute()
|
|
)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
func _execute_cancel_construction(command: SimulationCommand) -> CommandResult:
|
|
var job_id := StringName(command.payload.get(&"job_id", command.payload.get("job_id", "")))
|
|
if job_id.is_empty():
|
|
return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "job_id is required.")
|
|
var result := construction_service.cancel_job(job_id, _runtime_event_minute())
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
|
|
func _execute_start_excavation(command: SimulationCommand) -> CommandResult:
|
|
var operations_gate := _operations_construction_investment_gate(command.command_id)
|
|
if operations_gate != null:
|
|
return operations_gate
|
|
if progression_service.current_rank < 2:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"Excavation requires World Tree Rank 2.",
|
|
{&"required_rank": 2, &"current_rank": progression_service.current_rank}
|
|
)
|
|
var parsed_cell: Variant = _parse_world_cell(
|
|
command.payload.get(&"cell", command.payload.get("cell", null))
|
|
)
|
|
if parsed_cell == null:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.INVALID_PAYLOAD,
|
|
command.command_id,
|
|
"cell must be Vector3i or [x, y, layer]."
|
|
)
|
|
var result := excavation_service.start_excavation(parsed_cell, _runtime_event_minute())
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
|
|
func _execute_pause_excavation(command: SimulationCommand) -> CommandResult:
|
|
var job_id := StringName(command.payload.get(&"job_id", command.payload.get("job_id", "")))
|
|
if job_id.is_empty():
|
|
return CommandResult.failed(
|
|
CommandResult.Code.INVALID_PAYLOAD,
|
|
command.command_id,
|
|
"job_id is required."
|
|
)
|
|
var result := excavation_service.pause_job(job_id)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
|
|
func _execute_resume_excavation(command: SimulationCommand) -> CommandResult:
|
|
var job_id := StringName(command.payload.get(&"job_id", command.payload.get("job_id", "")))
|
|
if job_id.is_empty():
|
|
return CommandResult.failed(
|
|
CommandResult.Code.INVALID_PAYLOAD,
|
|
command.command_id,
|
|
"job_id is required."
|
|
)
|
|
var result := excavation_service.resume_job(job_id)
|
|
result.command_id = command.command_id
|
|
return result
|
|
|
|
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 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(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"A Facility assigned to a non-terminal Defense Raid cannot be demolished.",
|
|
{&"raid_id": defense_owner}
|
|
)
|
|
var supply_guard: Dictionary = supply_management.validate_demolition(self, instance_id)
|
|
if not bool(supply_guard.get(&"ok", false)):
|
|
return _staff_assignment_failure(command.command_id, StringName(supply_guard.get(&"reason", &"SUPPLY_IN_USE")), "Facility still owns supply stock, a production plan, or an active delivery.")
|
|
for assignment: Dictionary in staff_service.get_work_assignments():
|
|
if StringName(assignment[&"facility_id"]) == instance_id:
|
|
return _staff_assignment_failure(command.command_id, &"STAFF_ASSIGNED", "Unassign scheduled Staff before demolishing this facility.")
|
|
var operational_instance := facility_service.get_instance(instance_id)
|
|
if operational_instance != null \
|
|
and (not operational_instance.queue_entries.is_empty() or not operational_instance.active_services.is_empty()):
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "A busy facility cannot be demolished.")
|
|
if facility_infrastructure_projector.has_projection(instance_id):
|
|
var boundary_before := _capture_infrastructure_demolition_state()
|
|
if boundary_before.is_empty():
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_CLOSED,
|
|
command.command_id,
|
|
"Infrastructure demolition state could not be captured."
|
|
)
|
|
var unprojected := facility_infrastructure_projector.unproject_facility(
|
|
StringName("runtime.demolish:%s" % instance_id),
|
|
instance_id
|
|
)
|
|
if not unprojected.is_success():
|
|
if not _infrastructure_demolition_state_matches(boundary_before):
|
|
_restore_infrastructure_demolition_state(boundary_before)
|
|
var rejection_details := unprojected.details.duplicate(true)
|
|
rejection_details[&"reason"] = &"INFRASTRUCTURE_UNPROJECT_FAILED"
|
|
rejection_details[&"instance_id"] = instance_id
|
|
return CommandResult.failed(
|
|
unprojected.code,
|
|
command.command_id,
|
|
unprojected.message,
|
|
rejection_details
|
|
)
|
|
var demolished := construction_service.demolish_facility(instance_id)
|
|
demolished.command_id = command.command_id
|
|
var transport_ok := false
|
|
if demolished.is_success():
|
|
transport_ok = bool(_synchronize_elevators(_runtime_event_minute()).get(&"ok", false)) and _prune_legacy_stairs_and_movement()
|
|
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() \
|
|
or not _validate_infrastructure_crosslinks(
|
|
world_service,
|
|
path_service,
|
|
construction_service,
|
|
construction_resource_state,
|
|
excavation_service,
|
|
spatial_subslot_service,
|
|
route_subslot_path_adapter,
|
|
facility_infrastructure_projector
|
|
):
|
|
var rolled_back := _restore_infrastructure_demolition_state(boundary_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"Infrastructure demolition failed and was rolled back.",
|
|
{
|
|
&"instance_id": instance_id,
|
|
&"rolled_back": rolled_back,
|
|
&"cause": demolished.to_dictionary(),
|
|
}
|
|
)
|
|
return demolished
|
|
var result := construction_service.demolish_facility(instance_id)
|
|
result.command_id = command.command_id
|
|
if result.is_success():
|
|
kitchen_production.remove_plan(instance_id)
|
|
auto_purchase_plans.remove_warehouse(instance_id)
|
|
return result
|
|
|
|
|
|
func _capture_infrastructure_demolition_state() -> Dictionary:
|
|
return {
|
|
&"game_state": _state.create_snapshot(),
|
|
&"economy": economy_service.create_snapshot(),
|
|
&"facility_upkeep": facility_upkeep.create_snapshot(),
|
|
&"upkeep_settlement": upkeep_settlement.create_snapshot(),
|
|
&"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(),
|
|
&"route_projection": route_subslot_path_adapter.create_snapshot(),
|
|
&"facility_infrastructure": facility_infrastructure_projector.create_snapshot(),
|
|
&"facilities": facility_service.create_snapshot(),
|
|
&"facility_cleanliness": facility_cleanliness.create_snapshot(),
|
|
&"mana": mana_network_service.create_snapshot(),
|
|
&"movement_credits": _movement_credits.duplicate(true),
|
|
&"domain_events": domain_events.create_snapshot(),
|
|
&"id_generator": id_generator.create_snapshot(),
|
|
}
|
|
|
|
|
|
func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool:
|
|
for key: StringName in [
|
|
&"game_state", &"economy", &"facility_upkeep", &"upkeep_settlement",
|
|
&"world", &"path", &"elevators", &"legacy_stairs", &"construction",
|
|
&"excavation", &"spatial_subslots", &"route_projection",
|
|
&"facility_infrastructure", &"facilities", &"facility_cleanliness", &"mana", &"movement_credits",
|
|
&"domain_events", &"id_generator",
|
|
]:
|
|
if not snapshot.get(key, null) is Dictionary:
|
|
return false
|
|
var restored: bool = bool(_state.restore_snapshot(snapshot[&"game_state"]))
|
|
restored = economy_service.restore_snapshot(snapshot[&"economy"]) and restored
|
|
restored = facility_upkeep.restore_snapshot(snapshot[&"facility_upkeep"]) and restored
|
|
restored = upkeep_settlement.restore_snapshot(snapshot[&"upkeep_settlement"], economy_service) and restored
|
|
restored = world_service.restore_snapshot(snapshot[&"world"]) and restored
|
|
restored = spatial_subslot_service.restore_snapshot(snapshot[&"spatial_subslots"]) and restored
|
|
var route_snapshot: Dictionary = snapshot[&"route_projection"]
|
|
var projected_walkable: Variant = route_snapshot.get(&"projected_walkable_cells", null)
|
|
if not projected_walkable is Array:
|
|
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 = 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"]
|
|
) and restored
|
|
restored = construction_service.restore_snapshot(snapshot[&"construction"]) and restored
|
|
restored = excavation_service.restore_snapshot(snapshot[&"excavation"]) and restored
|
|
restored = facility_service.restore_snapshot(snapshot[&"facilities"]) and restored
|
|
restored = facility_cleanliness.restore_snapshot(snapshot.get(&"facility_cleanliness", {})) and restored
|
|
restored = mana_network_service.restore_snapshot(snapshot[&"mana"]) 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
|
|
return restored and _infrastructure_demolition_state_matches(snapshot) \
|
|
and _validate_infrastructure_crosslinks(
|
|
world_service,
|
|
path_service,
|
|
construction_service,
|
|
construction_resource_state,
|
|
excavation_service,
|
|
spatial_subslot_service,
|
|
route_subslot_path_adapter,
|
|
facility_infrastructure_projector
|
|
)
|
|
|
|
|
|
func _infrastructure_demolition_state_matches(snapshot: Dictionary) -> bool:
|
|
return _strict_variants_equal(_state.create_snapshot(), snapshot.get(&"game_state", {})) \
|
|
and _strict_variants_equal(economy_service.create_snapshot(), snapshot.get(&"economy", {})) \
|
|
and _strict_variants_equal(facility_upkeep.create_snapshot(), snapshot.get(&"facility_upkeep", {})) \
|
|
and _strict_variants_equal(upkeep_settlement.create_snapshot(), snapshot.get(&"upkeep_settlement", {})) \
|
|
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", {})) \
|
|
and _strict_variants_equal(route_subslot_path_adapter.create_snapshot(), snapshot.get(&"route_projection", {})) \
|
|
and _strict_variants_equal(facility_infrastructure_projector.create_snapshot(), snapshot.get(&"facility_infrastructure", {})) \
|
|
and _strict_variants_equal(facility_service.create_snapshot(), snapshot.get(&"facilities", {})) \
|
|
and _strict_variants_equal(facility_cleanliness.create_snapshot(), snapshot.get(&"facility_cleanliness", {})) \
|
|
and _strict_variants_equal(mana_network_service.create_snapshot(), snapshot.get(&"mana", {})) \
|
|
and _strict_variants_equal(_movement_credits, snapshot.get(&"movement_credits", {})) \
|
|
and _strict_variants_equal(domain_events.create_snapshot(), snapshot.get(&"domain_events", {})) \
|
|
and _strict_variants_equal(id_generator.create_snapshot(), snapshot.get(&"id_generator", {}))
|
|
|
|
func _execute_set_world_tree_rank(command: SimulationCommand) -> CommandResult:
|
|
var operations_gate := _operations_construction_investment_gate(command.command_id)
|
|
if operations_gate != null:
|
|
return operations_gate
|
|
var rank := int(command.payload.get(&"rank", command.payload.get("rank", 0)))
|
|
var request_id := StringName(command.payload.get(
|
|
&"request_id",
|
|
command.payload.get("request_id", "rank_up_%d" % rank)
|
|
))
|
|
var progression_before := progression_service.create_snapshot()
|
|
if not _sync_progression_live_metrics():
|
|
progression_service.restore_snapshot(progression_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"Live progression facts could not be synchronized."
|
|
)
|
|
var external_before := _capture_rank_external_state()
|
|
var result := progression_service.request_rank_up(
|
|
request_id,
|
|
rank,
|
|
func(_current_rank: int, _target_rank: int) -> Dictionary:
|
|
return {&"ok": true, &"snapshot": external_before.duplicate(true)},
|
|
func(target_rank: int, expected_bounds: Dictionary) -> Dictionary:
|
|
return {&"ok": _apply_rank_external_state(target_rank, expected_bounds)},
|
|
func(snapshot: Dictionary) -> Dictionary:
|
|
return {&"ok": _rollback_rank_external_state(snapshot)},
|
|
)
|
|
result.command_id = command.command_id
|
|
if not result.is_success():
|
|
progression_service.restore_snapshot(progression_before)
|
|
return result
|
|
if result.is_replay:
|
|
# Progression replays are read-only. The live-fact synchronization above is
|
|
# deliberately rolled back so a resent command reproduces the first result
|
|
# without silently changing any metric.
|
|
progression_service.restore_snapshot(progression_before)
|
|
return result
|
|
var event := SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"),
|
|
&"WORLD_TREE_RANK_ADVANCED",
|
|
_runtime_event_minute(),
|
|
&"progression_service",
|
|
[StringName("world_tree_rank_%d" % rank)],
|
|
result.details.duplicate(true),
|
|
command.command_id
|
|
)
|
|
if not _publish_domain_event(event):
|
|
_rollback_rank_external_state(external_before)
|
|
progression_service.restore_snapshot(progression_before)
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
command.command_id,
|
|
"World Tree promotion event could not be published; the promotion was rolled back."
|
|
)
|
|
return result
|
|
|
|
|
|
func _execute_update_progression_metrics(command: SimulationCommand) -> CommandResult:
|
|
var patch: Variant = command.payload.get(&"metrics", command.payload.get("metrics", null))
|
|
if not patch is Dictionary:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.INVALID_PAYLOAD,
|
|
command.command_id,
|
|
"Progression metrics must be a Dictionary."
|
|
)
|
|
if patch.has(&"guest_lifetime") or patch.has("guest_lifetime"):
|
|
var requested_value: Variant = patch.get(
|
|
&"guest_lifetime", patch.get("guest_lifetime", null)
|
|
)
|
|
var guest_snapshot := guest_service.create_snapshot()
|
|
var records_value: Variant = guest_snapshot.get(&"persistent_records", null)
|
|
var derived: int = guest_service.guest_count() \
|
|
+ (records_value.size() if records_value is Array else -1)
|
|
if typeof(requested_value) != TYPE_INT or derived < 0 \
|
|
or int(requested_value) != derived:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
"guest_lifetime is an authoritative Guest aggregate fact."
|
|
)
|
|
if not progression_service.update_metrics(patch):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
progression_service.last_error
|
|
)
|
|
return CommandResult.succeeded(command.command_id, {
|
|
&"metrics": progression_service.metrics.duplicate(true),
|
|
})
|
|
|
|
|
|
func _execute_complete_progression_quest(command: SimulationCommand) -> CommandResult:
|
|
var raw_quest_id: Variant = command.payload.get(&"quest_id", command.payload.get("quest_id", null))
|
|
if typeof(raw_quest_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.INVALID_PAYLOAD,
|
|
command.command_id,
|
|
"A progression quest id is required."
|
|
)
|
|
var quest_id := StringName(raw_quest_id)
|
|
if not progression_service.complete_quest(quest_id):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
progression_service.last_error
|
|
)
|
|
return CommandResult.succeeded(command.command_id, {&"quest_id": quest_id})
|
|
|
|
|
|
func _execute_acknowledge_operations_pause(command: SimulationCommand) -> CommandResult:
|
|
var acknowledged := acknowledge_operations_pause()
|
|
if not bool(acknowledged.get(&"ok", false)):
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command.command_id,
|
|
String(acknowledged.get(&"message", "Operations acknowledgement failed.")),
|
|
acknowledged.get(&"details", {})
|
|
)
|
|
return CommandResult.succeeded(command.command_id, acknowledged)
|
|
|
|
|
|
func _operations_construction_investment_gate(command_id: int) -> CommandResult:
|
|
if not _operations_enabled or operations_coordinator == null:
|
|
return null
|
|
var gate := operations_coordinator.can_start_construction_or_investment()
|
|
if bool(gate.get(&"allowed", false)) and _operations_total_arrears() > 0:
|
|
gate = {&"allowed": false, &"reason": &"UNPAID_EXPENSES", &"arrears_total": _operations_total_arrears()}
|
|
if bool(gate.get(&"allowed", false)):
|
|
return null
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command_id,
|
|
"Operations currently block construction and investment.",
|
|
gate
|
|
)
|
|
|
|
func _advance_construction(absolute_minute: int) -> void:
|
|
if _operations_tick_blocked:
|
|
return
|
|
var preflight := _preflight_proxy_completions(absolute_minute)
|
|
if not bool(preflight.get(&"ok", false)):
|
|
_latch_infrastructure_failure(
|
|
StringName(preflight.get(&"code", &"INFRASTRUCTURE_PREFLIGHT_FAILED")),
|
|
absolute_minute,
|
|
String(preflight.get(&"reason", "Infrastructure completion preflight failed.")),
|
|
preflight.get(&"details", {})
|
|
)
|
|
return
|
|
# The expensive composed rollback token is only needed when Construction can
|
|
# cross the completion boundary this minute. Idle ticks and ordinary progress
|
|
# retain ConstructionService's direct job_changed notification semantics.
|
|
if not _construction_completion_is_due_this_tick():
|
|
var fast_results: Array[CommandResult] = construction_service.advance_one_minute(
|
|
absolute_minute
|
|
)
|
|
for fast_result: CommandResult in fast_results:
|
|
if fast_result == null or not fast_result.is_success():
|
|
_latch_infrastructure_failure(
|
|
&"CONSTRUCTION_ADVANCE_FAILED",
|
|
absolute_minute,
|
|
(
|
|
fast_result.message
|
|
if fast_result != null else "Construction returned an invalid result."
|
|
),
|
|
(
|
|
fast_result.to_dictionary()
|
|
if fast_result != null else {}
|
|
)
|
|
)
|
|
return
|
|
excavation_service.advance_one_minute(absolute_minute)
|
|
if _infrastructure_failure_latched:
|
|
return
|
|
if not _validate_infrastructure_crosslinks(
|
|
world_service,
|
|
path_service,
|
|
construction_service,
|
|
construction_resource_state,
|
|
excavation_service,
|
|
spatial_subslot_service,
|
|
route_subslot_path_adapter,
|
|
facility_infrastructure_projector
|
|
):
|
|
_latch_infrastructure_failure(
|
|
&"INFRASTRUCTURE_POSTCONDITION_FAILED",
|
|
absolute_minute,
|
|
"Construction and Excavation ended the minute with invalid infrastructure cross-links."
|
|
)
|
|
return
|
|
var boundary_before := _capture_infrastructure_demolition_state()
|
|
if boundary_before.is_empty():
|
|
_latch_infrastructure_failure(
|
|
&"INFRASTRUCTURE_CAPTURE_FAILED",
|
|
absolute_minute,
|
|
"The Construction completion boundary could not be captured."
|
|
)
|
|
return
|
|
var previous_facility_ids: Dictionary = {}
|
|
for instance: Dictionary in construction_service.get_facilities():
|
|
previous_facility_ids[StringName(instance.get(&"instance_id", &""))] = true
|
|
|
|
_construction_completion_transaction_active = true
|
|
_construction_queued_domain_events.clear()
|
|
var progress_results: Array[CommandResult] = construction_service.advance_one_minute(
|
|
absolute_minute, {}, true
|
|
)
|
|
var apply_failure: Dictionary = {}
|
|
for progress_result: CommandResult in progress_results:
|
|
if progress_result == null or not progress_result.is_success():
|
|
apply_failure = {
|
|
&"code": &"CONSTRUCTION_ADVANCE_FAILED",
|
|
&"reason": (
|
|
progress_result.message
|
|
if progress_result != null else "Construction returned an invalid result."
|
|
),
|
|
&"details": (
|
|
progress_result.to_dictionary()
|
|
if progress_result != null else {}
|
|
),
|
|
}
|
|
break
|
|
if apply_failure.is_empty():
|
|
for instance: Dictionary in construction_service.get_facilities():
|
|
var instance_id := StringName(instance.get(&"instance_id", &""))
|
|
if previous_facility_ids.has(instance_id):
|
|
continue
|
|
var applied := _apply_completed_facility_internal(instance, absolute_minute)
|
|
if not bool(applied.get(&"ok", false)):
|
|
apply_failure = applied
|
|
break
|
|
if apply_failure.is_empty() and not _construction_completion_crosslinks_are_valid(
|
|
absolute_minute
|
|
):
|
|
apply_failure = {
|
|
&"code": &"CONSTRUCTION_COMPLETION_CROSSLINK_FAILED",
|
|
&"reason": "Construction completion produced invalid Runtime cross-links.",
|
|
&"details": {},
|
|
}
|
|
if not apply_failure.is_empty():
|
|
_fail_construction_completion_boundary(
|
|
boundary_before,
|
|
StringName(apply_failure.get(&"code", &"CONSTRUCTION_COMPLETION_FAILED")),
|
|
absolute_minute,
|
|
String(apply_failure.get(&"reason", "Construction completion failed.")),
|
|
apply_failure.get(&"details", {})
|
|
)
|
|
return
|
|
|
|
# DomainEventStream validates and commits the whole batch before notifying
|
|
# subscribers. Construction's own raw signals are released only after the
|
|
# authoritative Runtime state and event batch have both committed.
|
|
var queued_events: Array[SimulationDomainEvent] = []
|
|
for event: SimulationDomainEvent in _construction_queued_domain_events:
|
|
queued_events.append(event.copy())
|
|
_construction_completion_transaction_active = false
|
|
_construction_queued_domain_events.clear()
|
|
if not domain_events.publish_batch(queued_events):
|
|
_fail_construction_completion_boundary(
|
|
boundary_before,
|
|
&"CONSTRUCTION_EVENT_COMMIT_FAILED",
|
|
absolute_minute,
|
|
"Construction completion DomainEvents could not be committed."
|
|
)
|
|
return
|
|
_construction_committing_notifications = true
|
|
var notifications_committed := construction_service.commit_deferred_notifications()
|
|
_construction_committing_notifications = false
|
|
if not notifications_committed:
|
|
# Service-created deferred rows are prevalidated by ConstructionService,
|
|
# so this branch represents an unrecoverable notification boundary fault.
|
|
_latch_infrastructure_failure(
|
|
&"CONSTRUCTION_NOTIFICATION_COMMIT_FAILED",
|
|
absolute_minute,
|
|
"Committed Construction state could not release its deferred notifications.",
|
|
{&"rolled_back": false}
|
|
)
|
|
return
|
|
|
|
# Construction is authoritative first. Excavation then advances to the same
|
|
# absolute minute, preventing an excavation completion signal from escaping
|
|
# when the Construction boundary must be rolled back.
|
|
excavation_service.advance_one_minute(absolute_minute)
|
|
if _infrastructure_failure_latched:
|
|
return
|
|
if not _validate_infrastructure_crosslinks(
|
|
world_service,
|
|
path_service,
|
|
construction_service,
|
|
construction_resource_state,
|
|
excavation_service,
|
|
spatial_subslot_service,
|
|
route_subslot_path_adapter,
|
|
facility_infrastructure_projector
|
|
):
|
|
_latch_infrastructure_failure(
|
|
&"INFRASTRUCTURE_POSTCONDITION_FAILED",
|
|
absolute_minute,
|
|
"Construction and Excavation ended the minute with invalid infrastructure cross-links."
|
|
)
|
|
|
|
|
|
func _construction_completion_is_due_this_tick() -> bool:
|
|
for job: ConstructionJob in construction_service.get_jobs():
|
|
if job == null or job.is_terminal():
|
|
continue
|
|
if job.work_units_done + ConstructionJob.NORMAL_WORK_RATE \
|
|
>= job.build_minutes * ConstructionJob.MAX_PROGRESS:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _apply_completed_facility_internal(instance: Dictionary, absolute_minute: int) -> Dictionary:
|
|
var instance_id := StringName(instance.get(&"instance_id", &""))
|
|
var definition_id := StringName(instance.get(&"definition_id", &""))
|
|
var definition := definition_registry.get_facility(definition_id)
|
|
if instance_id.is_empty() or definition == null:
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"INVALID_COMPLETED_FACILITY",
|
|
&"reason": "Construction produced an invalid completed Facility.",
|
|
&"details": {&"instance_id": instance_id, &"definition_id": definition_id},
|
|
}
|
|
if _facility_definition_is_schema_proxy(definition):
|
|
if not _facility_definition_is_supported_infrastructure_proxy(definition):
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"UNSUPPORTED_VERTICAL_TOPOLOGY",
|
|
&"reason": "An unsupported vertical proxy reached Construction completion.",
|
|
&"details": {&"instance_id": instance_id, &"definition_id": definition.id},
|
|
}
|
|
if not facility_infrastructure_projector.has_projection(instance_id):
|
|
var projection_request_id := StringName(
|
|
"runtime.complete:%s" % String(instance.get(&"source_job_id", instance_id))
|
|
)
|
|
var projected := facility_infrastructure_projector.project_completed_facility(
|
|
projection_request_id, definition, instance
|
|
)
|
|
if not projected.is_success():
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"FACILITY_INFRASTRUCTURE_PROJECTION_FAILED",
|
|
&"reason": projected.message,
|
|
&"details": projected.details.duplicate(true),
|
|
}
|
|
if not _publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"),
|
|
&"FACILITY_INFRASTRUCTURE_PROJECTED",
|
|
absolute_minute,
|
|
&"facility_infrastructure_projector",
|
|
[instance_id],
|
|
projected.details.duplicate(true),
|
|
0
|
|
)):
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"FACILITY_INFRASTRUCTURE_EVENT_FAILED",
|
|
&"reason": "The infrastructure projection event could not be staged.",
|
|
&"details": {&"instance_id": instance_id},
|
|
}
|
|
else:
|
|
if not facility_service.has_instance(instance_id):
|
|
_register_facility_mana_consumer(instance)
|
|
_register_operational_facility(instance)
|
|
if not facility_service.has_instance(instance_id) \
|
|
or not economy_service.has_account(_facility_revenue_account(instance_id)):
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"OPERATIONAL_FACILITY_REGISTRATION_FAILED",
|
|
&"reason": "The completed Facility could not be registered operationally.",
|
|
&"details": {&"instance_id": instance_id},
|
|
}
|
|
if definition.maintenance_mana > 0 \
|
|
and mana_network_service.get_node(instance_id).is_empty():
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"FACILITY_MANA_REGISTRATION_FAILED",
|
|
&"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 _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",
|
|
absolute_minute,
|
|
&"construction_service",
|
|
[instance_id],
|
|
instance.duplicate(true),
|
|
0
|
|
)):
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"FACILITY_COMPLETION_EVENT_FAILED",
|
|
&"reason": "The Facility completion event could not be staged.",
|
|
&"details": {&"instance_id": instance_id},
|
|
}
|
|
return {&"ok": true}
|
|
|
|
|
|
func _construction_completion_crosslinks_are_valid(absolute_minute: int) -> bool:
|
|
return construction_service.current_minute == absolute_minute \
|
|
and bool(mana_network_service.validate_invariants().get(&"valid", false)) \
|
|
and _validate_construction_facility_crosslinks(
|
|
world_service,
|
|
construction_service,
|
|
facility_service,
|
|
facility_infrastructure_projector
|
|
) \
|
|
and _validate_infrastructure_crosslinks(
|
|
world_service,
|
|
path_service,
|
|
construction_service,
|
|
construction_resource_state,
|
|
excavation_service,
|
|
spatial_subslot_service,
|
|
route_subslot_path_adapter,
|
|
facility_infrastructure_projector,
|
|
false
|
|
)
|
|
|
|
|
|
func _fail_construction_completion_boundary(
|
|
boundary_before: Dictionary,
|
|
code: StringName,
|
|
absolute_minute: int,
|
|
reason: String,
|
|
details: Dictionary = {}
|
|
) -> void:
|
|
_construction_completion_transaction_active = false
|
|
_construction_committing_notifications = false
|
|
_construction_queued_domain_events.clear()
|
|
var notifications_discarded := construction_service.discard_deferred_notifications()
|
|
var rolled_back := notifications_discarded \
|
|
and _restore_infrastructure_demolition_state(boundary_before)
|
|
var failure_details := details.duplicate(true)
|
|
failure_details[&"notifications_discarded"] = notifications_discarded
|
|
failure_details[&"rolled_back"] = rolled_back
|
|
_latch_infrastructure_failure(
|
|
code if rolled_back else &"INFRASTRUCTURE_COMPLETION_ROLLBACK_FAILED",
|
|
absolute_minute,
|
|
reason,
|
|
failure_details
|
|
)
|
|
|
|
|
|
func _preflight_proxy_completions(absolute_minute: int) -> Dictionary:
|
|
if construction_service == null or facility_infrastructure_projector == null \
|
|
or route_subslot_path_adapter == null or spatial_subslot_service == null:
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"INFRASTRUCTURE_UNAVAILABLE",
|
|
&"reason": "Infrastructure services are unavailable.",
|
|
}
|
|
var due_proxy_ids: Array[StringName] = []
|
|
for job: ConstructionJob in construction_service.get_jobs():
|
|
if job == null or job.is_terminal():
|
|
continue
|
|
var definition := definition_registry.get_facility(job.definition_id)
|
|
if definition == null:
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"MISSING_FACILITY_DEFINITION",
|
|
&"reason": "A Construction job references a missing Facility definition.",
|
|
&"details": {&"job_id": job.job_id, &"definition_id": job.definition_id},
|
|
}
|
|
if not _facility_definition_is_supported_infrastructure_proxy(definition):
|
|
continue
|
|
if job.work_units_done + ConstructionJob.NORMAL_WORK_RATE \
|
|
>= job.build_minutes * ConstructionJob.MAX_PROGRESS:
|
|
due_proxy_ids.append(job.job_id)
|
|
if due_proxy_ids.is_empty():
|
|
return {&"ok": true}
|
|
if route_subslot_path_adapter.is_quarantined() \
|
|
or route_subslot_path_adapter.is_projection_stale() \
|
|
or facility_infrastructure_projector.is_quarantined() \
|
|
or facility_infrastructure_projector.is_projection_stale() \
|
|
or not bool(spatial_subslot_service.validate_invariants().get(&"ok", false)) \
|
|
or not bool(route_subslot_path_adapter.validate_invariants().get(&"ok", false)) \
|
|
or not bool(facility_infrastructure_projector.validate_invariants().get(&"ok", false)):
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"INFRASTRUCTURE_DEPENDENCY_STALE",
|
|
&"reason": "Infrastructure dependencies are stale or quarantined.",
|
|
&"details": {&"job_ids": due_proxy_ids.duplicate()},
|
|
}
|
|
var projector_snapshot := facility_infrastructure_projector.create_snapshot()
|
|
if int((projector_snapshot.get(&"projections", []) as Array).size()) \
|
|
> FacilityInfrastructureProjector.MAX_PROJECTIONS - due_proxy_ids.size() \
|
|
or int((projector_snapshot.get(&"processed_requests", []) as Array).size()) \
|
|
> FacilityInfrastructureProjector.MAX_PROCESSED_REQUESTS - due_proxy_ids.size():
|
|
return {
|
|
&"ok": false,
|
|
&"code": &"INFRASTRUCTURE_CAPACITY_EXHAUSTED",
|
|
&"reason": "Infrastructure projection capacity is exhausted.",
|
|
&"details": {&"job_ids": due_proxy_ids.duplicate()},
|
|
}
|
|
return {&"ok": true}
|
|
|
|
|
|
func _latch_infrastructure_failure(
|
|
code: StringName,
|
|
absolute_minute: int,
|
|
reason: String,
|
|
details: Dictionary = {}
|
|
) -> void:
|
|
_infrastructure_failure_latched = true
|
|
_infrastructure_failure_report = {
|
|
&"code": code,
|
|
&"minute": absolute_minute,
|
|
&"reason": reason,
|
|
&"details": details.duplicate(true),
|
|
}
|
|
_operations_tick_blocked = true
|
|
if _clock != null:
|
|
_clock.set_paused(true)
|
|
|
|
|
|
func get_infrastructure_report() -> Dictionary:
|
|
return {
|
|
&"failure_latched": _infrastructure_failure_latched,
|
|
&"failure_report": _infrastructure_failure_report.duplicate(true),
|
|
&"excavation": (
|
|
excavation_service.validate_invariants().duplicate(true)
|
|
if excavation_service != null else {&"ok": false}
|
|
),
|
|
&"spatial_subslots": (
|
|
spatial_subslot_service.validate_invariants().duplicate(true)
|
|
if spatial_subslot_service != null else {&"ok": false}
|
|
),
|
|
&"route_projection": (
|
|
route_subslot_path_adapter.get_projection_status().duplicate(true)
|
|
if route_subslot_path_adapter != null else {&"configured": false}
|
|
),
|
|
&"facility_infrastructure": (
|
|
facility_infrastructure_projector.get_status().duplicate(true)
|
|
if facility_infrastructure_projector != null else {&"configured": false}
|
|
),
|
|
}.duplicate(true)
|
|
|
|
func _advance_inventory_and_logistics(absolute_minute: int) -> void:
|
|
if _operations_tick_blocked:
|
|
return
|
|
inventory_service.current_minute = absolute_minute
|
|
inventory_service.process_expired(absolute_minute, &"inventory.waste")
|
|
if int(_state.get_resource(&"mana")) > 0:
|
|
supply_management.advance_auto_purchases(self, absolute_minute)
|
|
supply_management.ensure_deliveries(self, absolute_minute)
|
|
supply_logistics.tick(absolute_minute)
|
|
|
|
func _advance_facilities(absolute_minute: int) -> void:
|
|
if _operations_tick_blocked:
|
|
return
|
|
facility_service.advance_one_minute(absolute_minute)
|
|
kitchen_production.advance_one_minute(absolute_minute)
|
|
|
|
func _advance_guest_vitals(absolute_minute: int) -> void:
|
|
if _operations_tick_blocked:
|
|
return
|
|
var minute_of_day := posmod(absolute_minute, 1_440)
|
|
guest_service.advance_one_minute(absolute_minute, {
|
|
&"default": {
|
|
&"unstimulated": true,
|
|
&"solo_guest": true,
|
|
&"is_night": minute_of_day < 360 or minute_of_day >= 1_080,
|
|
&"intimacy_day": 3,
|
|
&"intimacy_night": 7,
|
|
}
|
|
})
|
|
|
|
func _advance_guest_queues(absolute_minute: int) -> void:
|
|
if _operations_tick_blocked:
|
|
return
|
|
_release_closed_business_queues(absolute_minute)
|
|
var guests_by_request: Dictionary = {}
|
|
for guest: GuestRuntime in guest_service.get_guests():
|
|
if guest.state != GuestRuntime.State.QUEUING:
|
|
continue
|
|
if not guests_by_request.has(guest.queue_request_id):
|
|
guests_by_request[guest.queue_request_id] = []
|
|
(guests_by_request[guest.queue_request_id] as Array).append(guest)
|
|
for request_id: StringName in _sorted_name_keys(guests_by_request):
|
|
var grouped_guests: Array = guests_by_request[request_id]
|
|
var queued_facility := facility_service.get_instance(grouped_guests[0].target_facility_id)
|
|
if queued_facility != null and (not queued_facility.operator_open or not queued_facility.is_within_opening_hours(absolute_minute)):
|
|
continue # A deferred closure must not spend patience while retrying.
|
|
var abandon_group := false
|
|
for guest: GuestRuntime in grouped_guests:
|
|
if guest.patience_remaining <= 1:
|
|
abandon_group = true
|
|
break
|
|
if not abandon_group:
|
|
for guest: GuestRuntime in grouped_guests:
|
|
guest_service.tick_queue_patience(guest.guest_id, absolute_minute)
|
|
continue
|
|
var inventory_before := inventory_service.create_snapshot()
|
|
var facility_before := facility_service.create_snapshot()
|
|
var guest_before := guest_service.create_snapshot()
|
|
var events_before := domain_events.create_snapshot()
|
|
var id_before := id_generator.create_snapshot()
|
|
var group_succeeded := true
|
|
for guest_index in range(grouped_guests.size()):
|
|
var guest: GuestRuntime = grouped_guests[guest_index]
|
|
var abandoned := guest_service.tick_queue_patience(
|
|
guest.guest_id,
|
|
absolute_minute,
|
|
-guest.patience_remaining,
|
|
Callable(self, "_leave_guest_queue_boundary") if guest_index == 0 else Callable()
|
|
)
|
|
if not bool(abandoned.get(&"ok", false)):
|
|
group_succeeded = false
|
|
break
|
|
if not group_succeeded:
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
facility_service.restore_snapshot(facility_before)
|
|
guest_service.restore_snapshot(guest_before)
|
|
domain_events.restore_snapshot(events_before)
|
|
id_generator.restore_snapshot(id_before)
|
|
|
|
func _advance_authority_incidents(absolute_minute: int) -> void:
|
|
if _operations_tick_blocked:
|
|
return
|
|
if _operations_morning_already_processed(absolute_minute):
|
|
return
|
|
var advanced := authority_service.advance_time(absolute_minute)
|
|
if not advanced.is_success():
|
|
_clock.set_paused(true)
|
|
_publish_actor_event(&"AUTHORITY_ADVANCE_FAILED", &"authority_service", [], {
|
|
&"message": advanced.message,
|
|
&"details": advanced.details.duplicate(true),
|
|
})
|
|
return
|
|
if posmod(absolute_minute, 1_440) != AuthorityService.INSPECTION_EVALUATION_MINUTE:
|
|
return
|
|
var evaluated := authority_service.evaluate_inspection(absolute_minute)
|
|
if not evaluated.is_success():
|
|
_clock.set_paused(true)
|
|
_publish_actor_event(&"AUTHORITY_INSPECTION_EVALUATION_FAILED", &"authority_service", [], {
|
|
&"message": evaluated.message,
|
|
&"details": evaluated.details.duplicate(true),
|
|
})
|
|
return
|
|
if not evaluated.is_replay:
|
|
_publish_authority_evaluation(evaluated.details)
|
|
|
|
func _advance_defense(absolute_minute: int) -> void:
|
|
if _operations_tick_blocked:
|
|
return
|
|
_prepare_defense_round_attempts(absolute_minute)
|
|
# A deferred operations suffix must not reattempt the same failed automatic
|
|
# batch. Resume on a later game minute, after the player resumes the clock.
|
|
if _defense_automatic_failed_minute == absolute_minute:
|
|
return
|
|
var advanced := defense_service.advance_time(absolute_minute)
|
|
if not advanced.is_success():
|
|
_defense_automatic_failed_minute = absolute_minute
|
|
_clock.set_paused(true)
|
|
_publish_actor_event(&"DEFENSE_ADVANCE_FAILED", &"defense_service", [], {
|
|
&"message": advanced.message,
|
|
&"details": advanced.details.duplicate(true),
|
|
})
|
|
return
|
|
# One attempt per due raid, in stable raid-ID order. No catch-up while-loop,
|
|
# invented deadline, offline replay, PREPARING start or BETWEEN_WAVES advance.
|
|
for due: Dictionary in defense_service.get_due_rounds(absolute_minute):
|
|
if _defense_automatic_failed_minute == absolute_minute:
|
|
return
|
|
var raid_id := StringName(due[&"raid_id"])
|
|
if _defense_round_attempted_raids.has(raid_id):
|
|
continue
|
|
var resolved := _resolve_defense_round_internal(raid_id, &"", absolute_minute)
|
|
if not resolved.is_success():
|
|
_defense_automatic_failed_minute = absolute_minute
|
|
_clock.set_paused(true)
|
|
_publish_actor_event(&"DEFENSE_AUTOMATIC_ROUND_FAILED", &"defense_service", [raid_id], {
|
|
&"round_number": due[&"round_number"],
|
|
&"earliest_minute": due[&"earliest_minute"],
|
|
&"message": resolved.message,
|
|
&"details": resolved.details.duplicate(true),
|
|
})
|
|
return
|
|
|
|
|
|
func _advance_relationship_events(absolute_minute: int) -> void:
|
|
if _operations_tick_blocked:
|
|
return
|
|
if _operations_morning_already_processed(absolute_minute):
|
|
return
|
|
event_service.process_expired(absolute_minute)
|
|
event_service.audit_actor_availability(absolute_minute)
|
|
_enqueue_pending_relationship_candidates()
|
|
if event_service.get_foreground_instance() == null:
|
|
var started := event_service.start_next_event(
|
|
absolute_minute,
|
|
_build_event_context(absolute_minute)
|
|
)
|
|
if started.is_success() and bool(started.details.get(&"waiting_for_choice", false)):
|
|
_clock.set_paused(true)
|
|
if event_service.pause_requested:
|
|
_clock.set_paused(true)
|
|
|
|
|
|
func _operations_morning_already_processed(absolute_minute: int) -> bool:
|
|
if not _operations_enabled or operations_coordinator == null \
|
|
or posmod(absolute_minute, SimulationKernel.MINUTES_PER_DAY) \
|
|
!= OPERATIONS_START_MINUTE:
|
|
return false
|
|
var day := absolute_minute / SimulationKernel.MINUTES_PER_DAY + 1
|
|
var report := operations_coordinator.get_morning_report(day)
|
|
return not report.is_empty() and int(report.get(&"minute", -1)) == absolute_minute
|
|
|
|
func _enqueue_pending_relationship_candidates() -> void:
|
|
for candidate: Dictionary in relationship_service.get_threshold_candidates():
|
|
_on_relationship_threshold_candidate(candidate)
|
|
|
|
func _build_event_context(absolute_minute: int) -> Dictionary:
|
|
return {
|
|
&"absolute_minute": absolute_minute,
|
|
&"day": absolute_minute / 1_440 + 1,
|
|
&"minute_of_day": posmod(absolute_minute, 1_440),
|
|
&"player": {
|
|
&"gold": economy_service.get_balance(PLAYER_CASH_ACCOUNT),
|
|
&"world_tree_rank": int(_state.world_tree_rank),
|
|
},
|
|
}
|
|
|
|
func _advance_staff_ai(absolute_minute: int) -> void:
|
|
if _operations_tick_blocked:
|
|
return
|
|
_prune_expired_staff_elevator_waits(absolute_minute)
|
|
_advance_elevators(absolute_minute)
|
|
_advance_staff_rescue(absolute_minute)
|
|
staff_service.advance_one_minute(absolute_minute)
|
|
_prune_expired_staff_elevator_waits(absolute_minute)
|
|
staff_commute.tick(absolute_minute)
|
|
_sync_facility_staffing(absolute_minute)
|
|
cleaning_bridge.tick(absolute_minute)
|
|
for task: StaffTask in staff_service.get_tasks():
|
|
if task.state == StaffTask.State.RESERVED:
|
|
if task.state_changed_at < absolute_minute:
|
|
task.begin_moving(task.state_changed_at)
|
|
elif task.state == StaffTask.State.WORKING and task.task_type == CleaningBridge.TASK_TYPE:
|
|
# Only cleaning is deferred from StaffService to these attendance and
|
|
# dependency checks. Other tasks already progressed once this minute.
|
|
if task.state_changed_at >= absolute_minute or not cleaning_bridge.task_can_work(task, staff_service.get_staff(task.assigned_staff_id)):
|
|
continue
|
|
var work_units := CleaningBridge.work_units_per_minute(staff_service.get_staff(task.assigned_staff_id))
|
|
staff_service.progress_task(task.task_id, work_units, absolute_minute)
|
|
for staff: StaffRuntime in staff_service.get_staff_members():
|
|
if absolute_minute % 30 != 0:
|
|
continue
|
|
if staff.state == StaffRuntime.State.WORKING or (staff.state == StaffRuntime.State.AVAILABLE and staff_commute.is_present(staff, facility_service.get_instance(staff.assigned_facility_id))):
|
|
staff.add_fatigue(1)
|
|
if _operations_enabled:
|
|
staff.health = maxi(staff.health - 1, 0)
|
|
elif staff.state == StaffRuntime.State.OFF_DUTY:
|
|
var resting := get_staff_rest_report(staff.staff_id)
|
|
if not _operations_enabled or resting.get(&"resting", false):
|
|
staff.add_fatigue(-1)
|
|
if _operations_enabled and staff.health > 0:
|
|
staff.health = mini(staff.health + 1, 100)
|
|
_advance_staff_rescue(absolute_minute)
|
|
_sync_facility_staffing(absolute_minute)
|
|
_advance_guest_ai_decisions(absolute_minute)
|
|
|
|
## Record the actual completed choice, not a fresh prediction made by the UI.
|
|
func _decide_guest_with_log(guest_id: StringName, candidates: Array, minute: int, context: Dictionary) -> Dictionary:
|
|
var observation := guest_service.get_observation(guest_id)
|
|
var result := guest_service.decide(guest_id, candidates, minute, context)
|
|
if not bool(result.get(&"ok", false)):
|
|
return result
|
|
var candidate: Dictionary = result.get(&"candidate", {})
|
|
var rejections := guest_service.get_last_candidate_rejections(guest_id)
|
|
var reason_counts: Dictionary = {}
|
|
for rejection: Dictionary in rejections:
|
|
var reason := String(rejection.get(&"reason", ""))
|
|
reason_counts[reason] = int(reason_counts.get(reason, 0)) + 1
|
|
var payload := {
|
|
&"rejection_counts": reason_counts,
|
|
&"rejection_examples": rejections.slice(0, 8),
|
|
&"rejection_count": rejections.size(),
|
|
&"action": result[&"action"],
|
|
&"needs": observation.get(&"needs", {}).duplicate(true),
|
|
&"primary_needs": observation.get(&"primary_needs", []).duplicate(),
|
|
&"facility_id": candidate.get(&"facility_id", &""),
|
|
&"need_priority": candidate.get(&"need_priority", 0),
|
|
&"utility": candidate.get(&"utility", 0),
|
|
&"travel_minutes": candidate.get(&"travel_minutes", 0),
|
|
&"wait_minutes": candidate.get(&"utility_terms", {}).get(&"wait_minutes", 0),
|
|
&"estimated_total_gold": maxi(int(candidate.get(&"estimated_total_gold", candidate.get(&"price_gold", candidate.get(&"quoted_price", 0)))), 0),
|
|
}
|
|
_publish_actor_event(&"GUEST_DECISION_RECORDED", &"guest_service", [guest_id], payload)
|
|
return result
|
|
|
|
|
|
func get_guest_decision_record(guest_id: StringName) -> Dictionary:
|
|
var events := domain_events.recent_events()
|
|
for index in range(events.size() - 1, -1, -1):
|
|
var event: SimulationDomainEvent = events[index]
|
|
if event.event_type == &"GUEST_DECISION_RECORDED" and guest_id in event.entity_ids:
|
|
var report := event.payload.duplicate(true)
|
|
report[&"absolute_minute"] = event.absolute_minute
|
|
return report
|
|
return {}
|
|
|
|
|
|
func _advance_guest_ai_decisions(absolute_minute: int) -> void:
|
|
for guest: GuestRuntime in guest_service.get_guests():
|
|
match guest.state:
|
|
GuestRuntime.State.SPAWNING:
|
|
guest_service.transition_guest(guest.guest_id, GuestRuntime.State.ENTERING, absolute_minute, &"ENTRY_STARTED")
|
|
GuestRuntime.State.ENTERING:
|
|
guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, absolute_minute, &"ENTRY_COMPLETED")
|
|
GuestRuntime.State.DECIDING:
|
|
if guest.planned_exit_at <= absolute_minute:
|
|
guest_service.begin_leaving(guest.guest_id, absolute_minute, &"PLANNED_EXIT")
|
|
continue
|
|
guest.wallet_gold = economy_service.get_balance(_guest_wallet_account(guest.guest_id))
|
|
_decide_guest_with_log(
|
|
guest.guest_id,
|
|
_project_guest_candidates(guest.guest_id),
|
|
absolute_minute,
|
|
{&"absolute_minute": absolute_minute}
|
|
)
|
|
GuestRuntime.State.MOVING:
|
|
pass
|
|
GuestRuntime.State.REPLANNING, GuestRuntime.State.ABANDONING, GuestRuntime.State.INTERRUPTED:
|
|
guest_service.transition_guest(guest.guest_id, GuestRuntime.State.DECIDING, absolute_minute, &"REPLAN_AFTER_INTERRUPTION")
|
|
GuestRuntime.State.LEAVING:
|
|
if guest.state_entered_at < absolute_minute:
|
|
guest_service.despawn_guest(guest.guest_id, absolute_minute)
|
|
_advance_runtime_path_traffic(absolute_minute)
|
|
|
|
|
|
func _advance_runtime_path_traffic(absolute_minute: int) -> void:
|
|
var actors: Array[Dictionary] = []
|
|
for guest: GuestRuntime in guest_service.get_guests():
|
|
actors.append({
|
|
&"actor_id": _guest_path_actor_id(guest.guest_id),
|
|
&"kind": &"guest",
|
|
&"entity_id": guest.guest_id,
|
|
})
|
|
for staff: StaffRuntime in staff_service.get_staff_members():
|
|
actors.append({
|
|
&"actor_id": _staff_path_actor_id(staff.staff_id),
|
|
&"kind": &"staff",
|
|
&"entity_id": staff.staff_id,
|
|
})
|
|
actors.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
|
|
return String(left[&"actor_id"]) < String(right[&"actor_id"])
|
|
)
|
|
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":
|
|
var guest := guest_service.get_guest(entity_id)
|
|
if guest == null:
|
|
continue
|
|
if not _path_projection_matches(actor_id, guest.current_cell):
|
|
_report_path_integration_failure(actor_id, entity_id, kind, guest.current_cell)
|
|
continue
|
|
if guest.state == GuestRuntime.State.MOVING \
|
|
and guest.state_entered_at < absolute_minute:
|
|
_advance_guest_path_travel(guest, actor_id, absolute_minute)
|
|
else:
|
|
_record_expected_actor_wait(actor_id, guest.current_cell, absolute_minute)
|
|
continue
|
|
var staff := staff_service.get_staff(entity_id)
|
|
if staff == null:
|
|
continue
|
|
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
|
|
if _operations_enabled and staff.state == StaffRuntime.State.OFF_DUTY and staff.current_task_id.is_empty():
|
|
var rest := get_staff_rest_report(staff.staff_id)
|
|
if rest.get(&"reachable", false):
|
|
advance_staff_commute(staff.staff_id, rest[&"cell"], absolute_minute)
|
|
else:
|
|
_record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute)
|
|
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 \
|
|
and supply_logistics.can_move_task(task.task_id):
|
|
_advance_staff_task_travel(task, actor_id, absolute_minute)
|
|
else:
|
|
_record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute)
|
|
|
|
|
|
func _path_projection_matches(actor_id: StringName, cell: Vector3i) -> bool:
|
|
return path_service.has_actor_projection(actor_id) \
|
|
and path_service.get_actor_projected_cell(actor_id) == cell \
|
|
and _movement_credits.has(actor_id)
|
|
|
|
|
|
func _report_path_integration_failure(
|
|
actor_id: StringName,
|
|
entity_id: StringName,
|
|
kind: StringName,
|
|
cell: Vector3i
|
|
) -> void:
|
|
_clock.set_paused(true)
|
|
_publish_actor_event(&"PATH_ACTOR_CROSSLINK_FAILED", &"simulation_runtime", [actor_id, entity_id], {
|
|
&"actor_kind": kind,
|
|
&"actor_cell": cell,
|
|
&"projected_cell": path_service.get_actor_projected_cell(actor_id),
|
|
&"has_projection": path_service.has_actor_projection(actor_id),
|
|
&"has_movement_credit": _movement_credits.has(actor_id),
|
|
})
|
|
|
|
|
|
func _record_expected_actor_wait(
|
|
actor_id: StringName,
|
|
cell: Vector3i,
|
|
absolute_minute: int
|
|
) -> void:
|
|
# Intentional queue, service, decision, and idle waits are valid progress. Reset
|
|
# the stuck baseline so a later movement does not inherit intentional waiting.
|
|
path_service.clear_actor_progress(actor_id)
|
|
var recorded := path_service.record_actor_progress(actor_id, cell, absolute_minute)
|
|
if not bool(recorded.get(&"ok", false)):
|
|
_report_path_integration_failure(actor_id, actor_id, &"unknown", cell)
|
|
return
|
|
_movement_credits[actor_id] = {&"credit": 0, &"updated_at": absolute_minute}
|
|
|
|
|
|
func _record_blocked_actor_wait(
|
|
actor_id: StringName,
|
|
entity_id: StringName,
|
|
kind: StringName,
|
|
cell: Vector3i,
|
|
absolute_minute: int,
|
|
credit: int
|
|
) -> void:
|
|
var recorded := path_service.record_actor_progress(actor_id, cell, absolute_minute)
|
|
if not bool(recorded.get(&"ok", false)):
|
|
_report_path_integration_failure(actor_id, entity_id, kind, cell)
|
|
return
|
|
_movement_credits[actor_id] = {
|
|
&"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:
|
|
_recover_stuck_actor(actor_id, entity_id, kind, cell, absolute_minute)
|
|
|
|
|
|
func _movement_credit_after_one_minute(
|
|
actor_id: StringName,
|
|
cell: Vector3i,
|
|
absolute_minute: int
|
|
) -> int:
|
|
var current: Dictionary = _movement_credits.get(actor_id, {
|
|
&"credit": 0,
|
|
&"updated_at": absolute_minute - 1,
|
|
})
|
|
var carried := int(current.get(&"credit", 0))
|
|
if int(current.get(&"updated_at", -1)) >= absolute_minute:
|
|
return carried
|
|
var speed_credit := clampi(
|
|
roundi(path_service.get_speed_multiplier(cell) * MOVEMENT_CREDIT_SCALE),
|
|
MIN_MOVEMENT_CREDIT_PER_MINUTE,
|
|
MOVEMENT_CREDIT_SCALE
|
|
)
|
|
return carried + speed_credit
|
|
|
|
|
|
func _advance_guest_path_travel(
|
|
guest: GuestRuntime,
|
|
actor_id: StringName,
|
|
absolute_minute: int
|
|
) -> void:
|
|
var target_cell := _path_handle_target(guest.path_handle, guest.current_cell)
|
|
if guest.current_cell == target_cell:
|
|
_record_expected_actor_wait(actor_id, guest.current_cell, absolute_minute)
|
|
_queue_guest_at_target(guest, absolute_minute)
|
|
return
|
|
var path := path_service.find_path(guest.current_cell, target_cell)
|
|
if path.size() <= 1:
|
|
_record_blocked_actor_wait(
|
|
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 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
|
|
)
|
|
accumulated = mini(accumulated, required_credit + MOVEMENT_CREDIT_SCALE - 1)
|
|
if accumulated < required_credit:
|
|
_record_blocked_actor_wait(
|
|
actor_id,
|
|
guest.guest_id,
|
|
&"guest",
|
|
guest.current_cell,
|
|
absolute_minute,
|
|
accumulated
|
|
)
|
|
return
|
|
var previous_cell := guest.current_cell
|
|
var previous_progress_cell := guest.last_progress_cell
|
|
var previous_progress_at := guest.last_progress_at
|
|
var previous_failure_count := guest.path_failure_count
|
|
var previous_recovery_kind := guest.recovery_kind
|
|
if not guest_service.record_path_success(guest.guest_id, path[1], absolute_minute) \
|
|
or not path_service.project_actor_position(actor_id, path[1], absolute_minute):
|
|
guest.current_cell = previous_cell
|
|
guest.last_progress_cell = previous_progress_cell
|
|
guest.last_progress_at = previous_progress_at
|
|
guest.path_failure_count = previous_failure_count
|
|
guest.recovery_kind = previous_recovery_kind
|
|
_report_path_integration_failure(actor_id, guest.guest_id, &"guest", previous_cell)
|
|
return
|
|
var remaining_credit := accumulated - required_credit
|
|
_movement_credits[actor_id] = {
|
|
&"credit": remaining_credit,
|
|
&"updated_at": absolute_minute,
|
|
}
|
|
_publish_actor_event(&"GUEST_MOVED", &"path_service", [guest.guest_id, actor_id], {
|
|
&"from_cell": previous_cell,
|
|
&"cell": guest.current_cell,
|
|
&"target_cell": target_cell,
|
|
&"movement_credit": remaining_credit,
|
|
})
|
|
if guest.current_cell == target_cell:
|
|
_queue_guest_at_target(guest, absolute_minute)
|
|
|
|
func _queue_guest_at_target(guest: GuestRuntime, absolute_minute: int) -> void:
|
|
var operation_key := StringName(
|
|
"guest.auto_queue.%s.%d" % [guest.guest_id, guest.state_entered_at]
|
|
)
|
|
var result := submit_command(
|
|
&"create_service_request",
|
|
{
|
|
&"facility_id": guest.target_facility_id,
|
|
&"guest_ids": [guest.guest_id],
|
|
&"service_type": &"default",
|
|
},
|
|
guest.guest_id,
|
|
guest.target_facility_id,
|
|
0,
|
|
absolute_minute,
|
|
operation_key
|
|
)
|
|
if not result.is_success() and guest.state == GuestRuntime.State.MOVING:
|
|
guest_service.transition_guest(
|
|
guest.guest_id,
|
|
GuestRuntime.State.DECIDING,
|
|
absolute_minute,
|
|
&"QUEUE_REQUEST_FAILED"
|
|
)
|
|
|
|
func _guest_recovery_targets(current_cell: Vector3i) -> Array:
|
|
return [{
|
|
&"kind": &"emergency_exit",
|
|
&"cell": current_cell,
|
|
&"path_handle": {&"recovery": true, &"target_cell": current_cell},
|
|
}]
|
|
|
|
func _guest_recovery_path_exists(
|
|
from_cell: Vector3i,
|
|
to_cell: Vector3i,
|
|
_guest_id: StringName,
|
|
_target: Dictionary
|
|
) -> bool:
|
|
return not path_service.find_path(from_cell, to_cell).is_empty()
|
|
|
|
func _advance_staff_task_travel(
|
|
task: StaffTask,
|
|
actor_id: StringName,
|
|
absolute_minute: int
|
|
) -> void:
|
|
var staff := staff_service.get_staff(task.assigned_staff_id)
|
|
if staff == null or staff.current_task_id != task.task_id:
|
|
return
|
|
if task.task_type == CleaningBridge.TASK_TYPE and not cleaning_bridge.prepare_route(task, staff):
|
|
_record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute)
|
|
return
|
|
if staff.current_cell == task.target_cell:
|
|
_record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute)
|
|
staff_service.begin_task(task.task_id, absolute_minute)
|
|
return
|
|
var path := path_service.find_path(
|
|
staff.current_cell,
|
|
task.target_cell,
|
|
[WorldService.LAYER_FRONT, WorldService.LAYER_BACK],
|
|
[&"staff"]
|
|
)
|
|
if task.task_type in [&"rescue_pickup", &"rescue_transport"]:
|
|
path = path_service.find_backyard_path(staff.current_cell, task.target_cell, true)
|
|
if path.size() <= 1:
|
|
if task.task_type in [&"rescue_pickup", &"rescue_transport"]:
|
|
_record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute)
|
|
return
|
|
_record_blocked_actor_wait(
|
|
actor_id, staff.staff_id, &"staff", staff.current_cell, absolute_minute, 0
|
|
)
|
|
return
|
|
if task.task_type == &"rescue_transport" and elevators.owns_connector(path_service.get_step_connector(path[0], path[1], [&"staff"])):
|
|
var patient_actor := _staff_path_actor_id(task.target_entity_id)
|
|
var connector := path_service.get_step_connector(path[0], path[1], [&"staff"])
|
|
var pair := {}
|
|
if not _elevator_extension_pending(elevators.get_shaft_for_connector(connector)):
|
|
pair = elevators.request_pair(actor_id, patient_actor, path[0], path[1], absolute_minute, [&"staff"])
|
|
if pair.get(&"ok", false) and not pair.get(&"replay", false):
|
|
_publish_actor_event(&"ELEVATOR_RESCUE_REQUESTED", &"elevators", [actor_id, patient_actor], pair[&"trip"])
|
|
_record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute)
|
|
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
|
|
)
|
|
accumulated = mini(accumulated, required_credit + MOVEMENT_CREDIT_SCALE - 1)
|
|
if accumulated < required_credit:
|
|
_record_blocked_actor_wait(
|
|
actor_id,
|
|
staff.staff_id,
|
|
&"staff",
|
|
staff.current_cell,
|
|
absolute_minute,
|
|
accumulated
|
|
)
|
|
return
|
|
var previous_cell := staff.current_cell
|
|
staff.current_cell = path[1]
|
|
if not path_service.project_actor_position(actor_id, staff.current_cell, absolute_minute):
|
|
staff.current_cell = previous_cell
|
|
_report_path_integration_failure(actor_id, staff.staff_id, &"staff", previous_cell)
|
|
return
|
|
if task.task_type == &"rescue_transport":
|
|
var patient := staff_service.get_staff(task.target_entity_id)
|
|
if patient == null or not path_service.project_actor_position(_staff_path_actor_id(patient.staff_id), staff.current_cell, absolute_minute):
|
|
staff.current_cell = previous_cell
|
|
path_service.project_actor_position(actor_id, previous_cell, absolute_minute)
|
|
return
|
|
patient.current_cell = staff.current_cell
|
|
_record_expected_actor_wait(_staff_path_actor_id(patient.staff_id), patient.current_cell, absolute_minute)
|
|
var remaining_credit := accumulated - required_credit
|
|
_movement_credits[actor_id] = {
|
|
&"credit": remaining_credit,
|
|
&"updated_at": absolute_minute,
|
|
}
|
|
_publish_actor_event(&"STAFF_MOVED", &"staff_service", [staff.staff_id, task.task_id], {
|
|
&"path_actor_id": actor_id,
|
|
&"from_cell": previous_cell,
|
|
&"cell": staff.current_cell,
|
|
&"target_cell": task.target_cell,
|
|
&"movement_credit": remaining_credit,
|
|
})
|
|
if staff.current_cell == task.target_cell:
|
|
staff_service.begin_task(task.task_id, absolute_minute)
|
|
|
|
|
|
func _recover_stuck_actor(
|
|
actor_id: StringName,
|
|
entity_id: StringName,
|
|
kind: StringName,
|
|
cell: Vector3i,
|
|
absolute_minute: int
|
|
) -> void:
|
|
var path_before := path_service.create_snapshot()
|
|
var guests_before := guest_service.create_snapshot()
|
|
var staff_before := staff_service.create_snapshot()
|
|
var inventory_before := inventory_service.create_snapshot()
|
|
var events_before := domain_events.create_snapshot()
|
|
var ids_before := id_generator.create_snapshot()
|
|
var credits_before := _movement_credits.duplicate(true)
|
|
var report := path_service.get_actor_stuck_report(
|
|
actor_id,
|
|
absolute_minute,
|
|
[WorldService.LAYER_FRONT, WorldService.LAYER_BACK]
|
|
)
|
|
if not bool(report.get(&"ok", false)) or not bool(report.get(&"should_recover", false)):
|
|
return
|
|
var action := &"NONE"
|
|
var action_ok := false
|
|
if kind == &"guest":
|
|
var guest := guest_service.get_guest(entity_id)
|
|
if guest != null:
|
|
var recovery_targets: Array = []
|
|
var recovery_value: Variant = report.get(&"recovery", {})
|
|
if recovery_value is Dictionary and bool(recovery_value.get(&"found", false)):
|
|
var recovery_cell: Vector3i = recovery_value[&"cell"]
|
|
recovery_targets.append({
|
|
&"kind": &"guidance",
|
|
&"cell": recovery_cell,
|
|
&"path_handle": {
|
|
&"recovery": true,
|
|
&"target_cell": recovery_cell,
|
|
},
|
|
})
|
|
var recovery_result: Dictionary = {}
|
|
for _attempt in range(GuestService.PATH_FAILURE_LIMIT):
|
|
recovery_result = guest_service.record_path_failure(
|
|
entity_id,
|
|
absolute_minute,
|
|
recovery_targets,
|
|
Callable(self, "_guest_recovery_path_exists")
|
|
)
|
|
if not bool(recovery_result.get(&"ok", false)) \
|
|
or bool(recovery_result.get(&"recovery_started", false)):
|
|
break
|
|
action_ok = bool(recovery_result.get(&"ok", false)) \
|
|
and bool(recovery_result.get(&"recovery_started", false))
|
|
action = &"GUEST_SAFE_RECOVERY" if action_ok else &"GUEST_RECOVERY_FAILED"
|
|
elif kind == &"staff":
|
|
var staff := staff_service.get_staff(entity_id)
|
|
if staff != null and not staff.current_task_id.is_empty():
|
|
if supply_logistics.owns_task(staff.current_task_id):
|
|
var recovery: Dictionary = supply_logistics.handle_path_stuck(staff.current_task_id, absolute_minute)
|
|
action_ok = bool(recovery.get(&"ok", false))
|
|
action = StringName(recovery.get(&"action", &"SUPPLY_RECOVERY_FAILED"))
|
|
else:
|
|
var cancelled := staff_service.cancel_task(staff.current_task_id, &"PATH_STUCK", absolute_minute)
|
|
action_ok = cancelled.is_success()
|
|
action = &"STAFF_TASK_CANCELLED" if action_ok else &"STAFF_TASK_CANCEL_FAILED"
|
|
if not action_ok:
|
|
path_service.restore_snapshot(path_before)
|
|
guest_service.restore_snapshot(guests_before)
|
|
staff_service.restore_snapshot(staff_before)
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
domain_events.restore_snapshot(events_before)
|
|
id_generator.restore_snapshot(ids_before)
|
|
_movement_credits = credits_before
|
|
return
|
|
_movement_credits[actor_id] = {&"credit": 0, &"updated_at": absolute_minute}
|
|
_publish_actor_event(&"PATH_ACTOR_STUCK", &"path_service", [actor_id, entity_id], {
|
|
&"actor_kind": kind,
|
|
&"cell": cell,
|
|
&"minutes_without_progress": int(report[&"minutes_without_progress"]),
|
|
&"threshold_minutes": int(report[&"threshold_minutes"]),
|
|
&"recovery": (report[&"recovery"] as Dictionary).duplicate(true),
|
|
&"action": action,
|
|
&"implicit_move_applied": false,
|
|
})
|
|
|
|
func _leave_guest_queue_boundary(
|
|
_guest_id: StringName,
|
|
facility_id: StringName,
|
|
request_id: StringName
|
|
) -> Dictionary:
|
|
var request := facility_service.get_request(request_id)
|
|
if request == null:
|
|
return {&"ok": true, &"orphan_repaired": true}
|
|
var inventory_before := inventory_service.create_snapshot()
|
|
for reservation_id: StringName in request.reserved_inventory_ids:
|
|
var released := inventory_service.release_reservation(
|
|
reservation_id,
|
|
StringName("guest.abandon.%s.%s" % [request_id, reservation_id]),
|
|
_clock.absolute_minute
|
|
)
|
|
if not bool(released.get(&"ok", false)):
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
return released
|
|
var leave_result := facility_service.leave_queue(facility_id, request_id, &"PATIENCE_EXHAUSTED")
|
|
if not leave_result.is_success():
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
return {&"ok": false, &"message": leave_result.message}
|
|
return {&"ok": true}
|
|
|
|
func _reserve_staff_task_items(task: StaffTask, absolute_minute: int) -> Dictionary:
|
|
var inventory_id := &"staff.supplies"
|
|
var facility := facility_service.get_instance(task.target_entity_id)
|
|
if facility != null:
|
|
inventory_id = facility.local_inventory_id
|
|
var before := inventory_service.create_snapshot()
|
|
var reservation_ids: Array[StringName] = []
|
|
for item_id: StringName in _sorted_name_keys(task.required_items):
|
|
var result := inventory_service.reserve_item(
|
|
inventory_id,
|
|
item_id,
|
|
int(task.required_items[item_id]),
|
|
task.task_id,
|
|
&"staff_task",
|
|
absolute_minute,
|
|
absolute_minute + 480,
|
|
StringName("staff.task.reserve.%s.%s" % [task.task_id, item_id])
|
|
)
|
|
if not bool(result.get(&"ok", false)):
|
|
inventory_service.restore_snapshot(before)
|
|
return {
|
|
&"ok": false,
|
|
&"reason": StringName(result.get(&"code", &"ITEM_RESERVATION_FAILED")),
|
|
}
|
|
for reservation_id: StringName in result.get(&"reservation_ids", []):
|
|
reservation_ids.append(reservation_id)
|
|
return {&"ok": true, &"reservation_ids": reservation_ids}
|
|
|
|
func _release_staff_task_items(
|
|
reservation_ids: Array[StringName],
|
|
task: StaffTask,
|
|
absolute_minute: int
|
|
) -> Dictionary:
|
|
var before := inventory_service.create_snapshot()
|
|
for reservation_id: StringName in reservation_ids:
|
|
var result := inventory_service.release_reservation(
|
|
reservation_id,
|
|
StringName("staff.task.release.%s.%s" % [task.task_id, reservation_id]),
|
|
absolute_minute
|
|
)
|
|
if not bool(result.get(&"ok", false)):
|
|
inventory_service.restore_snapshot(before)
|
|
return result
|
|
return {&"ok": true}
|
|
|
|
func _estimate_staff_task_travel(staff: StaffRuntime, task: StaffTask, _absolute_minute: int) -> int:
|
|
if task.task_type == CleaningBridge.TASK_TYPE and not cleaning_bridge.can_dispatch(staff): return -1
|
|
if task.task_type in [&"rescue_pickup", &"rescue_transport"]:
|
|
if not elevators.get_actor_trip(_staff_path_actor_id(staff.staff_id)).is_empty():
|
|
return -1
|
|
var route := path_service.find_backyard_path(staff.current_cell, task.target_cell, true)
|
|
return _estimated_actor_path_minutes(route, [&"staff"]) if not route.is_empty() else -1
|
|
if staff.current_cell == task.target_cell:
|
|
return 0
|
|
var path := path_service.find_path(
|
|
staff.current_cell,
|
|
task.target_cell,
|
|
[WorldService.LAYER_FRONT, WorldService.LAYER_BACK],
|
|
[&"staff"]
|
|
)
|
|
if not path.is_empty():
|
|
return _estimated_actor_path_minutes(path, [&"staff"])
|
|
return -1
|
|
|
|
func _sync_facility_staffing(absolute_minute: int) -> void:
|
|
if not _staffing_projection_enabled:
|
|
return
|
|
var staff_members := staff_service.get_staff_members()
|
|
for facility: FacilityInstance in facility_service.get_instances():
|
|
var active_ids: Array[StringName] = []
|
|
for staff: StaffRuntime in staff_members:
|
|
if (staff_commute.is_present(staff, facility) or CleaningBridge.is_deployed(staff, facility, staff_service)) and not _is_anri_manager(staff):
|
|
active_ids.append(staff.staff_id)
|
|
facility_service.set_operational_status(
|
|
facility.instance_id,
|
|
{&"active_staff_ids": active_ids},
|
|
absolute_minute
|
|
)
|
|
|
|
func _project_guest_candidates(guest_id: StringName) -> Array:
|
|
var guest := guest_service.get_guest(guest_id)
|
|
var candidates: Array = []
|
|
if guest == null:
|
|
return candidates
|
|
var query_goals: Array = []
|
|
for instance: FacilityInstance in facility_service.get_instances():
|
|
query_goals.append_array(_facility_access_candidates(instance))
|
|
path_service.begin_guest_path_query_batch(guest.current_cell, query_goals)
|
|
for facility: FacilityInstance in facility_service.get_instances():
|
|
var definition := definition_registry.get_facility(facility.definition_id)
|
|
if definition == null or &"staff_only" in definition.tags:
|
|
continue
|
|
if &"entrance" in definition.tags and _guest_has_checked_in(guest):
|
|
continue
|
|
var has_inputs := true
|
|
for item_id: StringName in _sorted_name_keys(definition.inventory_inputs):
|
|
if inventory_service.available_quantity(facility.local_inventory_id, item_id) < int(definition.inventory_inputs[item_id]):
|
|
has_inputs = false
|
|
break
|
|
if not has_inputs:
|
|
# Preserve the existing exclusion without doing unnecessary path queries,
|
|
# but let the decision evidence explain why this facility was unavailable.
|
|
candidates.append({&"facility_id": facility.instance_id, &"inputs_available": false})
|
|
continue
|
|
var access := _find_facility_access_path(guest.current_cell, facility)
|
|
var path: Array = access.get(&"path", [])
|
|
var need_relief := _need_relief_for_facility_tags(definition.tags)
|
|
candidates.append({
|
|
&"facility_id": facility.instance_id,
|
|
&"facility_tags": definition.tags.duplicate(),
|
|
&"is_open": facility.state in [FacilityInstance.State.OPEN, FacilityInstance.State.DEGRADED],
|
|
&"reachable": bool(access.get(&"reachable", false)),
|
|
&"travel_minutes": _estimated_actor_path_minutes(path, []),
|
|
&"expected_wait": facility.queue_entries.size() * facility.base_service_minutes / maxi(facility.capacity, 1),
|
|
&"estimated_total_gold": get_facility_business_price(facility),
|
|
&"expected_quality": clampi(facility.base_quality, 0, 100),
|
|
&"need_relief": need_relief,
|
|
&"risk_level": 0,
|
|
&"illegal_level": 0,
|
|
&"group_fit": mini(facility.capacity, 4) * 5,
|
|
&"service_category": _service_category_for_tags(definition.tags),
|
|
&"path_handle": {
|
|
&"target_cell": access.get(&"target_cell", guest.current_cell),
|
|
&"graph_revision": path_service.graph_revision,
|
|
},
|
|
})
|
|
path_service.end_guest_path_query_batch()
|
|
return candidates
|
|
|
|
func _facility_access_candidates(facility: FacilityInstance) -> Array:
|
|
var candidate_cells: Dictionary = {}
|
|
var occupied: Array[Vector3i] = facility.occupied_cells.duplicate()
|
|
if occupied.is_empty():
|
|
occupied.append(facility.origin)
|
|
for cell: Vector3i in occupied:
|
|
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
|
|
if path_service.has_cell(facility.origin):
|
|
candidate_cells[facility.origin] = true
|
|
var ordered: Array = candidate_cells.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
|
|
)
|
|
return ordered
|
|
|
|
|
|
func _find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary:
|
|
var ordered := _facility_access_candidates(facility)
|
|
var best_path: Array[Vector3i] = []
|
|
var best_cell := facility.origin
|
|
for candidate: Vector3i in ordered:
|
|
var path := path_service.find_path(start, candidate)
|
|
if path.is_empty():
|
|
continue
|
|
if best_path.is_empty() or path_service.calculate_guest_batch_path_cost(start, candidate, path) < path_service.calculate_guest_batch_path_cost(start, best_cell, best_path):
|
|
best_path.assign(path)
|
|
best_cell = candidate
|
|
return {&"reachable": not best_path.is_empty(), &"path": best_path, &"target_cell": best_cell}
|
|
|
|
func _need_relief_for_facility_tags(tags: Array[StringName]) -> Dictionary:
|
|
var relief := {
|
|
&"hunger": 0, &"sleep": 0, &"hygiene": 0,
|
|
&"medical": 0, &"leisure": 0, &"intimacy": 0,
|
|
}
|
|
if &"dining" in tags:
|
|
relief[&"hunger"] = 80
|
|
relief[&"leisure"] = 10
|
|
if &"lodging" in tags:
|
|
relief[&"sleep"] = 90
|
|
relief[&"hygiene"] = maxi(int(relief[&"hygiene"]), 10)
|
|
if &"social" in tags or &"leisure" in tags:
|
|
relief[&"leisure"] = maxi(int(relief[&"leisure"]), 40)
|
|
if &"hygiene" in tags or &"bath" in tags:
|
|
relief[&"hygiene"] = maxi(int(relief[&"hygiene"]), 80)
|
|
if &"medical" in tags:
|
|
relief[&"medical"] = 90
|
|
elif &"wellness" in tags:
|
|
relief[&"medical"] = maxi(int(relief[&"medical"]), 20)
|
|
if &"adult_only" in tags or &"restricted_service" in tags:
|
|
relief[&"intimacy"] = 80
|
|
return relief
|
|
|
|
func _service_category_for_tags(tags: Array[StringName]) -> StringName:
|
|
if &"dining" in tags:
|
|
return &"food"
|
|
if &"lodging" in tags:
|
|
return &"lodging"
|
|
if &"social" in tags:
|
|
return &"leisure"
|
|
return &"service"
|
|
|
|
func _path_handle_target(path_handle: Variant, fallback: Vector3i) -> Vector3i:
|
|
if path_handle is Dictionary:
|
|
var value: Variant = path_handle.get(&"target_cell", path_handle.get("target_cell", fallback))
|
|
var parsed: Variant = _parse_world_cell(value)
|
|
if parsed != null:
|
|
return parsed
|
|
return fallback
|
|
|
|
func _event_actor_exists(actor_id: StringName) -> bool:
|
|
if actor_id == &"player":
|
|
return true
|
|
return guest_service.has_guest(actor_id) or staff_service.has_staff(actor_id)
|
|
|
|
func _event_entity_exists(entity_id: Variant) -> bool:
|
|
var normalized := StringName(entity_id)
|
|
return _event_actor_exists(normalized) \
|
|
or facility_service.has_instance(normalized) \
|
|
or relationship_service.has_relationship(normalized) \
|
|
or event_service.get_instance(normalized) != null
|
|
|
|
func _event_path_exists(value: Variant) -> bool:
|
|
if not value is Dictionary:
|
|
return false
|
|
var from_value: Variant = _parse_world_cell(value.get(&"from", value.get("from", null)))
|
|
var to_value: Variant = _parse_world_cell(value.get(&"to", value.get("to", null)))
|
|
if from_value == null or to_value == null:
|
|
return false
|
|
return not path_service.find_path(from_value, to_value).is_empty()
|
|
|
|
func _roll_authority_inspection(_day_index: int, _chance: int, _terms: Dictionary) -> int:
|
|
var stream := get_rng_stream(&"authority")
|
|
return stream.randi_range(0, 99) if stream != null else 99
|
|
|
|
func _authority_line_of_sight(
|
|
from_cell: Vector3i,
|
|
to_cell: Vector3i,
|
|
_context: Dictionary
|
|
) -> bool:
|
|
if from_cell.z != to_cell.z \
|
|
or not world_service.is_in_bounds(from_cell) \
|
|
or not world_service.is_in_bounds(to_cell):
|
|
return false
|
|
if from_cell == to_cell:
|
|
return true
|
|
var x := from_cell.x
|
|
var y := from_cell.y
|
|
var delta_x := absi(to_cell.x - from_cell.x)
|
|
var delta_y := absi(to_cell.y - from_cell.y)
|
|
var step_x := 1 if from_cell.x < to_cell.x else -1
|
|
var step_y := 1 if from_cell.y < to_cell.y else -1
|
|
var error := delta_x - delta_y
|
|
while x != to_cell.x or y != to_cell.y:
|
|
var doubled_error := error * 2
|
|
if doubled_error > -delta_y:
|
|
error -= delta_y
|
|
x += step_x
|
|
if doubled_error < delta_x:
|
|
error += delta_x
|
|
y += step_y
|
|
var current := Vector3i(x, y, from_cell.z)
|
|
if current == to_cell:
|
|
return true
|
|
if not world_service.is_walkable(current) \
|
|
or not world_service.get_occupant(current).is_empty():
|
|
return false
|
|
return true
|
|
|
|
func _authority_path_query(
|
|
from_cell: Vector3i,
|
|
to_cell: Vector3i,
|
|
context: Dictionary
|
|
) -> Dictionary:
|
|
var connector_access := _name_array(
|
|
context.get(&"connector_access", context.get("connector_access", []))
|
|
)
|
|
var path := path_service.find_path(
|
|
from_cell,
|
|
to_cell,
|
|
[WorldService.LAYER_FRONT, WorldService.LAYER_BACK],
|
|
connector_access
|
|
)
|
|
if path.is_empty():
|
|
return {
|
|
&"reachable": false,
|
|
&"reason": StringName(path_service.last_error if not path_service.last_error.is_empty() else "PATH_UNREACHABLE"),
|
|
&"travel_minutes": 0,
|
|
}
|
|
return {
|
|
&"reachable": true,
|
|
&"reason": &"OK",
|
|
&"travel_minutes": maxi(path.size() - 1, 0),
|
|
&"path": path,
|
|
}
|
|
|
|
func _apply_authority_resolution_boundary(
|
|
payload: Dictionary,
|
|
phase: StringName,
|
|
rollback_token: Variant
|
|
) -> Dictionary:
|
|
if phase == &"rollback":
|
|
if not rollback_token is Dictionary \
|
|
or not rollback_token.get(&"economy", null) is Dictionary \
|
|
or not rollback_token.get(&"game_state", null) is Dictionary \
|
|
or not rollback_token.get(&"facilities", null) is Dictionary \
|
|
or not rollback_token.get(&"domain_events", null) is Dictionary \
|
|
or not rollback_token.get(&"id_generator", null) is Dictionary:
|
|
return {&"ok": false, &"error": "Authority rollback token is malformed."}
|
|
var economy_ok: bool = economy_service.restore_snapshot(rollback_token[&"economy"])
|
|
var facilities_ok: bool = facility_service.restore_snapshot(rollback_token[&"facilities"])
|
|
facilities_ok = facility_cleanliness.restore_snapshot(rollback_token.get(&"facility_cleanliness", {})) and facilities_ok
|
|
var events_ok: bool = domain_events.restore_snapshot(rollback_token[&"domain_events"])
|
|
var ids_ok: bool = id_generator.restore_snapshot(rollback_token[&"id_generator"])
|
|
var state_ok: bool = _state.restore_snapshot(rollback_token[&"game_state"])
|
|
return {
|
|
&"ok": economy_ok and state_ok and facilities_ok and events_ok and ids_ok,
|
|
&"error": "Authority fine rollback failed." if not economy_ok or not state_ok or not facilities_ok or not events_ok or not ids_ok else "",
|
|
}
|
|
var resolution_id := StringName(payload.get(&"resolution_id", payload.get("resolution_id", "")))
|
|
var inspection_id := StringName(payload.get(&"inspection_id", payload.get("inspection_id", "")))
|
|
var fine_amount := int(payload.get(&"fine_amount", payload.get("fine_amount", -1)))
|
|
var absolute_minute := int(payload.get(&"absolute_minute", payload.get("absolute_minute", -1)))
|
|
var idempotency_key := _authority_resolution_idempotency_key(resolution_id)
|
|
if resolution_id.is_empty() or inspection_id.is_empty() or fine_amount < 0 \
|
|
or absolute_minute != _runtime_event_minute() \
|
|
or not economy_service.has_account(PLAYER_CASH_ACCOUNT) \
|
|
or not economy_service.has_account(AUTHORITY_FINE_ACCOUNT):
|
|
return {&"ok": false, &"error": "Authority resolution payload is invalid."}
|
|
if phase == &"validate":
|
|
var existing_transaction := economy_service.get_transaction_id_for_idempotency_key(idempotency_key)
|
|
if not existing_transaction.is_empty():
|
|
if not _authority_fine_transaction_matches(
|
|
economy_service, existing_transaction, inspection_id, fine_amount
|
|
):
|
|
return {&"ok": false, &"error": "Authority fine idempotency key conflicts with its ledger transaction."}
|
|
return {&"ok": true, &"token": {
|
|
&"economy": economy_service.create_snapshot(),
|
|
&"facility_upkeep": facility_upkeep.create_snapshot(),
|
|
&"upkeep_settlement": upkeep_settlement.create_snapshot(),
|
|
&"game_state": _state.create_snapshot(),
|
|
&"facilities": facility_service.create_snapshot(),
|
|
&"facility_cleanliness": facility_cleanliness.create_snapshot(),
|
|
&"domain_events": domain_events.create_snapshot(),
|
|
&"id_generator": id_generator.create_snapshot(),
|
|
&"already_applied": true,
|
|
&"transaction_id": existing_transaction,
|
|
}}
|
|
if fine_amount > 0 \
|
|
and not economy_service.account_allows_negative(PLAYER_CASH_ACCOUNT) \
|
|
and economy_service.get_balance(PLAYER_CASH_ACCOUNT) < fine_amount:
|
|
return {&"ok": false, &"error": "PLAYER_CASH cannot fund the Authority fine."}
|
|
return {&"ok": true, &"token": {
|
|
&"economy": economy_service.create_snapshot(),
|
|
&"facility_upkeep": facility_upkeep.create_snapshot(),
|
|
&"upkeep_settlement": upkeep_settlement.create_snapshot(),
|
|
&"game_state": _state.create_snapshot(),
|
|
&"facilities": facility_service.create_snapshot(),
|
|
&"facility_cleanliness": facility_cleanliness.create_snapshot(),
|
|
&"domain_events": domain_events.create_snapshot(),
|
|
&"id_generator": id_generator.create_snapshot(),
|
|
&"already_applied": false,
|
|
}}
|
|
if phase != &"apply" or not rollback_token is Dictionary:
|
|
return {&"ok": false, &"token": rollback_token, &"error": "Unknown Authority resolution phase or token."}
|
|
if StringName(payload.get(&"result", &"")) == &"TEMPORARY_CLOSURE":
|
|
var targets: Dictionary = {}
|
|
for raw_id: Variant in payload.get(&"evidence_ids", []):
|
|
var evidence := authority_service.get_evidence(StringName(raw_id))
|
|
if evidence != null and evidence.is_discovered() and not evidence.is_expired(absolute_minute) and facility_service.has_instance(evidence.facility_id):
|
|
targets[evidence.facility_id] = true
|
|
for target_id: StringName in _sorted_name_keys(targets):
|
|
var stopped := facility_service.set_operational_status(target_id, {&"suspension_reason": &"RAIDED"}, absolute_minute)
|
|
if not stopped.is_success():
|
|
return {&"ok": false, &"token": rollback_token, &"error": stopped.message}
|
|
if bool(rollback_token.get(&"already_applied", false)) or fine_amount == 0:
|
|
return {
|
|
&"ok": true,
|
|
&"transaction_id": StringName(rollback_token.get(&"transaction_id", &"")),
|
|
}
|
|
var posted := economy_service.transfer(
|
|
PLAYER_CASH_ACCOUNT,
|
|
AUTHORITY_FINE_ACCOUNT,
|
|
fine_amount,
|
|
absolute_minute,
|
|
&"AUTHORITY_FINE",
|
|
inspection_id,
|
|
idempotency_key,
|
|
&"authority.inspection.fine"
|
|
)
|
|
if not posted.is_success():
|
|
return {
|
|
&"ok": false,
|
|
&"token": rollback_token,
|
|
&"error": posted.message,
|
|
}
|
|
return {
|
|
&"ok": true,
|
|
&"transaction_id": posted.details.get(&"transaction_id", &""),
|
|
}
|
|
|
|
func _authority_resolution_idempotency_key(resolution_id: StringName) -> StringName:
|
|
return StringName("authority.resolution.%s" % resolution_id)
|
|
|
|
func _authority_fine_transaction_matches(
|
|
economy: EconomyService,
|
|
transaction_id: StringName,
|
|
inspection_id: StringName,
|
|
fine_amount: int
|
|
) -> bool:
|
|
if fine_amount == 0:
|
|
return transaction_id.is_empty()
|
|
var entries := economy.get_transaction_entries(transaction_id)
|
|
if entries.size() != 1:
|
|
return false
|
|
var entry: LedgerEntry = entries[0]
|
|
return entry.debit_account == AUTHORITY_FINE_ACCOUNT \
|
|
and entry.credit_account == PLAYER_CASH_ACCOUNT \
|
|
and entry.amount == fine_amount \
|
|
and entry.category == &"AUTHORITY_FINE" \
|
|
and entry.source_entity_id == inspection_id
|
|
|
|
func _roll_defense_round(_key: StringName, _context: Dictionary) -> int:
|
|
var stream := get_rng_stream(&"defense")
|
|
return stream.randi_range(0, 100) if stream != null else 50
|
|
|
|
func _apply_defense_rng_boundary(
|
|
_context: Dictionary,
|
|
phase: StringName,
|
|
token: Variant
|
|
) -> Dictionary:
|
|
var stream := get_rng_stream(&"defense")
|
|
if stream == null:
|
|
return {&"ok": false, &"error": "Defense RNG stream is unavailable."}
|
|
if phase == &"snapshot":
|
|
return {&"ok": true, &"token": stream.snapshot()}
|
|
if phase == &"commit":
|
|
return {&"ok": true, &"token": token}
|
|
if phase == &"rollback":
|
|
if not token is Dictionary or not stream.restore(token):
|
|
return {&"ok": false, &"error": "Defense RNG rollback token is invalid."}
|
|
return {&"ok": true}
|
|
return {&"ok": false, &"error": "Unknown Defense RNG boundary phase."}
|
|
|
|
func _apply_defense_skill_boundary(
|
|
payload: Dictionary,
|
|
phase: StringName,
|
|
rollback_token: Variant
|
|
) -> Dictionary:
|
|
if phase == &"rollback":
|
|
return _restore_defense_skill_token(rollback_token)
|
|
var cost_report := _parse_defense_skill_cost(payload)
|
|
if not bool(cost_report[&"ok"]):
|
|
return cost_report
|
|
var cost: Dictionary = cost_report[&"cost"]
|
|
var raid_id := StringName(payload.get(&"raid_id", payload.get("raid_id", "")))
|
|
var action_id := StringName(payload.get(&"action_id", payload.get("action_id", "")))
|
|
if raid_id.is_empty() or action_id.is_empty():
|
|
return {&"ok": false, &"error": "Defense skill boundary IDs are invalid."}
|
|
if phase == &"validate":
|
|
var token := {
|
|
&"economy": economy_service.create_snapshot(),
|
|
&"facility_upkeep": facility_upkeep.create_snapshot(),
|
|
&"upkeep_settlement": upkeep_settlement.create_snapshot(),
|
|
&"game_state": _state.create_snapshot(),
|
|
&"inventory": inventory_service.create_snapshot(),
|
|
&"domain_events": domain_events.create_snapshot(),
|
|
&"id_generator": id_generator.create_snapshot(),
|
|
&"cost": cost.duplicate(true),
|
|
&"reservation_ids": [],
|
|
}
|
|
var gold := int(cost[&"gold"])
|
|
var mana := int(cost[&"mana"])
|
|
if economy_service.get_balance(PLAYER_CASH_ACCOUNT) < gold \
|
|
or int(_state.get_resource(&"mana")) < mana:
|
|
return {&"ok": false, &"error": "Defense skill resources are insufficient."}
|
|
var ledger_key := _defense_skill_ledger_key(raid_id, action_id)
|
|
if gold > 0 and not economy_service.get_transaction_id_for_idempotency_key(ledger_key).is_empty():
|
|
return {&"ok": false, &"error": "Defense skill ledger key already exists without an applied skill."}
|
|
var reservation_ids: Array[StringName] = []
|
|
var items: Array = cost[&"items"]
|
|
for item_index: int in range(items.size()):
|
|
var item: Dictionary = items[item_index]
|
|
var reserved := inventory_service.reserve_item(
|
|
StringName(item[&"inventory_id"]),
|
|
StringName(item[&"item_id"]),
|
|
int(item[&"quantity"]),
|
|
action_id,
|
|
&"defense_skill",
|
|
_defense_operation_minute(),
|
|
-1,
|
|
StringName("defense.skill.reserve.%s.%s.%d" % [raid_id, action_id, item_index])
|
|
)
|
|
if not bool(reserved.get(&"ok", false)):
|
|
token[&"reservation_ids"] = reservation_ids
|
|
return {
|
|
&"ok": false,
|
|
&"token": token,
|
|
&"error": "Defense skill item reservation failed.",
|
|
&"details": reserved,
|
|
}
|
|
for reservation_value: Variant in reserved.get(&"reservation_ids", []):
|
|
reservation_ids.append(StringName(reservation_value))
|
|
token[&"reservation_ids"] = reservation_ids
|
|
return {&"ok": true, &"token": token}
|
|
if phase != &"apply" or not _defense_skill_token_is_valid(rollback_token):
|
|
return {&"ok": false, &"token": rollback_token, &"error": "Defense skill apply token is invalid."}
|
|
var token: Dictionary = rollback_token
|
|
var mana := int(cost[&"mana"])
|
|
if mana > 0 and not bool(_state.apply_resource_deltas({&"mana": -mana})):
|
|
return {&"ok": false, &"token": token, &"error": "Defense skill Mana application failed."}
|
|
var gold := int(cost[&"gold"])
|
|
if gold > 0:
|
|
var posted := economy_service.transfer(
|
|
PLAYER_CASH_ACCOUNT,
|
|
OPERATING_EXPENSE_ACCOUNT,
|
|
gold,
|
|
_defense_operation_minute(),
|
|
&"DEFENSE_SKILL_COST",
|
|
action_id,
|
|
_defense_skill_ledger_key(raid_id, action_id),
|
|
&"defense.skill.cost"
|
|
)
|
|
if not posted.is_success():
|
|
return {&"ok": false, &"token": token, &"error": posted.message}
|
|
var reservation_ids: Array[StringName] = []
|
|
for reservation_value: Variant in token[&"reservation_ids"]:
|
|
reservation_ids.append(StringName(reservation_value))
|
|
if not reservation_ids.is_empty():
|
|
var consumed := inventory_service.consume_reservations(
|
|
reservation_ids,
|
|
StringName("defense.skill.consume.%s.%s" % [raid_id, action_id]),
|
|
_defense_operation_minute()
|
|
)
|
|
if not bool(consumed.get(&"ok", false)):
|
|
return {&"ok": false, &"token": token, &"error": "Defense skill item consumption failed.", &"details": consumed}
|
|
return {&"ok": true, &"token": token}
|
|
|
|
func _parse_defense_skill_cost(payload: Dictionary) -> Dictionary:
|
|
var metadata_value: Variant = payload.get(&"metadata", payload.get("metadata", {}))
|
|
if not metadata_value is Dictionary:
|
|
return {&"ok": false, &"error": "Defense skill metadata must be a Dictionary."}
|
|
var metadata: Dictionary = metadata_value
|
|
var cost_value: Variant = metadata.get(&"cost", metadata.get("cost", {}))
|
|
if not cost_value is Dictionary:
|
|
return {&"ok": false, &"error": "Defense skill metadata.cost must be a Dictionary."}
|
|
var cost_data: Dictionary = cost_value
|
|
var gold_value: Variant = cost_data.get(&"gold", cost_data.get("gold", 0))
|
|
var mana_value: Variant = cost_data.get(&"mana", cost_data.get("mana", 0))
|
|
if not gold_value is int or not mana_value is int:
|
|
return {&"ok": false, &"error": "Defense skill Gold and Mana costs must be integers."}
|
|
var gold := int(gold_value)
|
|
var mana := int(mana_value)
|
|
if gold < 0 or mana < 0:
|
|
return {&"ok": false, &"error": "Defense skill costs cannot be negative."}
|
|
var items_value: Variant = cost_data.get(&"items", cost_data.get("items", []))
|
|
if not items_value is Array:
|
|
return {&"ok": false, &"error": "Defense skill item costs must be an Array."}
|
|
var items: Array[Dictionary] = []
|
|
for raw_item: Variant in items_value:
|
|
if not raw_item is Dictionary:
|
|
return {&"ok": false, &"error": "Defense skill item cost is malformed."}
|
|
var inventory_value: Variant = raw_item.get(&"inventory_id", raw_item.get("inventory_id", ""))
|
|
var item_value: Variant = raw_item.get(&"item_id", raw_item.get("item_id", ""))
|
|
var quantity_value: Variant = raw_item.get(&"quantity", raw_item.get("quantity", 0))
|
|
if not (inventory_value is String or inventory_value is StringName) \
|
|
or not (item_value is String or item_value is StringName) \
|
|
or not quantity_value is int:
|
|
return {&"ok": false, &"error": "Defense skill item cost types are invalid."}
|
|
var inventory_id := StringName(inventory_value)
|
|
var item_id := StringName(item_value)
|
|
var quantity := int(quantity_value)
|
|
if inventory_id.is_empty() or item_id.is_empty() or quantity <= 0:
|
|
return {&"ok": false, &"error": "Defense skill item cost requires inventory_id, item_id and positive quantity."}
|
|
items.append({&"inventory_id": inventory_id, &"item_id": item_id, &"quantity": quantity})
|
|
items.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
|
|
var left_key := "%s:%s" % [left[&"inventory_id"], left[&"item_id"]]
|
|
var right_key := "%s:%s" % [right[&"inventory_id"], right[&"item_id"]]
|
|
return left_key < right_key
|
|
)
|
|
return {&"ok": true, &"cost": {&"gold": gold, &"mana": mana, &"items": items}}
|
|
|
|
func _defense_skill_ledger_key(raid_id: StringName, action_id: StringName) -> StringName:
|
|
return StringName("defense.skill.%s.%s" % [raid_id, action_id])
|
|
|
|
func _defense_skill_token_is_valid(token: Variant) -> bool:
|
|
return token is Dictionary \
|
|
and token.get(&"economy", null) is Dictionary \
|
|
and token.get(&"game_state", null) is Dictionary \
|
|
and token.get(&"inventory", null) is Dictionary \
|
|
and token.get(&"domain_events", null) is Dictionary \
|
|
and token.get(&"id_generator", null) is Dictionary \
|
|
and token.get(&"reservation_ids", null) is Array
|
|
|
|
func _restore_defense_skill_token(token: Variant) -> Dictionary:
|
|
if not _defense_skill_token_is_valid(token):
|
|
return {&"ok": false, &"error": "Defense skill rollback token is malformed."}
|
|
var economy_ok: bool = economy_service.restore_snapshot(token[&"economy"])
|
|
var state_ok: bool = _state.restore_snapshot(token[&"game_state"])
|
|
var inventory_ok: bool = inventory_service.restore_snapshot(token[&"inventory"])
|
|
var events_ok: bool = domain_events.restore_snapshot(token[&"domain_events"])
|
|
var ids_ok: bool = id_generator.restore_snapshot(token[&"id_generator"])
|
|
return {
|
|
&"ok": economy_ok and state_ok and inventory_ok and events_ok and ids_ok,
|
|
&"error": "Defense skill rollback failed." if (
|
|
not economy_ok or not state_ok or not inventory_ok or not events_ok or not ids_ok
|
|
) else "",
|
|
}
|
|
|
|
func _apply_defense_outcome_boundary(
|
|
payload: Dictionary,
|
|
phase: StringName,
|
|
rollback_token: Variant
|
|
) -> Dictionary:
|
|
if phase == &"rollback":
|
|
return _restore_defense_outcome_token(rollback_token)
|
|
var validation := _validate_defense_outcome_payload(payload)
|
|
if not bool(validation[&"ok"]):
|
|
return validation
|
|
if phase == &"validate":
|
|
return {&"ok": true, &"token": {
|
|
&"staff": staff_service.create_snapshot(),
|
|
&"facilities": facility_service.create_snapshot(),
|
|
&"facility_cleanliness": facility_cleanliness.create_snapshot(),
|
|
&"domain_events": domain_events.create_snapshot(),
|
|
&"id_generator": id_generator.create_snapshot(),
|
|
}}
|
|
if phase != &"apply" or not _defense_outcome_token_is_valid(rollback_token):
|
|
return {&"ok": false, &"token": rollback_token, &"error": "Defense outcome apply token is invalid."}
|
|
var token: Dictionary = rollback_token
|
|
var combatant_results: Array = payload[&"combatant_results"]
|
|
for result_value: Variant in combatant_results:
|
|
var result: Dictionary = result_value
|
|
if StringName(result[&"side"]) != &"DEFENDER":
|
|
continue
|
|
var staff := staff_service.get_staff(StringName(result[&"actor_id"]))
|
|
staff.health = int(result[&"health"])
|
|
staff.fatigue = int(result[&"fatigue"])
|
|
var defense_state := StringName(result[&"state"])
|
|
if defense_state == &"ACTIVE":
|
|
staff.transition_to(StaffRuntime.State.AVAILABLE, &"DEFENSE_RELEASED", _defense_operation_minute())
|
|
elif defense_state == &"INCAPACITATED":
|
|
staff.transition_to(StaffRuntime.State.INCAPACITATED, &"DEFENSE_INCAPACITATED", _defense_operation_minute())
|
|
elif defense_state == &"RETREATED":
|
|
staff.transition_to(StaffRuntime.State.FLEEING, &"DEFENSE_RETREATED", _defense_operation_minute())
|
|
var asset_results: Array = payload[&"asset_results"]
|
|
for result_value: Variant in asset_results:
|
|
var result: Dictionary = result_value
|
|
var updated := facility_service.set_operational_status(
|
|
StringName(result[&"asset_id"]),
|
|
{&"durability": int(result[&"integrity"])},
|
|
_defense_operation_minute()
|
|
)
|
|
if not updated.is_success():
|
|
return {&"ok": false, &"token": token, &"error": updated.message}
|
|
var staff_report := staff_service.validate_invariants()
|
|
if not bool(staff_report.get(&"ok", false)):
|
|
return {&"ok": false, &"token": token, &"error": "Defense outcome violates Staff invariants.", &"details": staff_report}
|
|
return {&"ok": true, &"token": token}
|
|
|
|
func _validate_defense_outcome_payload(payload: Dictionary) -> Dictionary:
|
|
var raid_id := StringName(payload.get(&"raid_id", payload.get("raid_id", "")))
|
|
var resolution_id := StringName(payload.get(&"resolution_id", payload.get("resolution_id", "")))
|
|
var operation_kind := StringName(payload.get(&"operation_kind", payload.get("operation_kind", "")))
|
|
var absolute_minute := int(payload.get(&"absolute_minute", payload.get("absolute_minute", -1)))
|
|
var combatants_value: Variant = payload.get(&"combatant_results", payload.get("combatant_results", null))
|
|
var assets_value: Variant = payload.get(&"asset_results", payload.get("asset_results", null))
|
|
var raid := defense_service.get_raid(raid_id)
|
|
if raid_id.is_empty() or resolution_id.is_empty() \
|
|
or operation_kind not in DefenseService.VALID_OPERATION_KINDS \
|
|
or absolute_minute != _defense_operation_minute() \
|
|
or not combatants_value is Array or not assets_value is Array \
|
|
or raid.is_empty():
|
|
return {&"ok": false, &"error": "Defense outcome payload root is invalid."}
|
|
var expected_combatants: Dictionary = raid[&"combatants"]
|
|
var expected_assets: Dictionary = raid[&"assets"]
|
|
if combatants_value.size() != expected_combatants.size() \
|
|
or assets_value.size() != expected_assets.size():
|
|
return {&"ok": false, &"error": "Defense outcome does not cover the complete Raid roster."}
|
|
var seen_combatants: Dictionary = {}
|
|
for value: Variant in combatants_value:
|
|
if not value is Dictionary:
|
|
return {&"ok": false, &"error": "Defense combatant outcome is malformed."}
|
|
var actor_id := StringName(value.get(&"actor_id", value.get("actor_id", "")))
|
|
var side := StringName(value.get(&"side", value.get("side", "")))
|
|
var state := StringName(value.get(&"state", value.get("state", "")))
|
|
var health := int(value.get(&"health", value.get("health", -1)))
|
|
var maximum := int(value.get(&"max_health", value.get("max_health", -1)))
|
|
var fatigue := int(value.get(&"fatigue", value.get("fatigue", -1)))
|
|
if actor_id.is_empty() or seen_combatants.has(actor_id) or not expected_combatants.has(actor_id) \
|
|
or side not in [&"DEFENDER", &"ATTACKER"] \
|
|
or state not in [&"ACTIVE", &"INCAPACITATED", &"RETREATED"] \
|
|
or health < 0 or health > maximum or fatigue < 0 or fatigue > 100 \
|
|
or (health == 0 and state == &"ACTIVE"):
|
|
return {&"ok": false, &"error": "Defense combatant outcome values are invalid."}
|
|
var expected_combatant: Dictionary = expected_combatants[actor_id]
|
|
if side != StringName(expected_combatant[&"side"]) \
|
|
or maximum != int(expected_combatant[&"max_health"]):
|
|
return {&"ok": false, &"error": "Defense combatant outcome differs from the Raid roster."}
|
|
seen_combatants[actor_id] = true
|
|
if side == &"DEFENDER" and (maximum != StaffRuntime.MAX_PSYCHOLOGY_VALUE or not staff_service.has_staff(actor_id)):
|
|
return {&"ok": false, &"error": "Defense outcome references invalid Staff."}
|
|
var seen_assets: Dictionary = {}
|
|
for value: Variant in assets_value:
|
|
if not value is Dictionary:
|
|
return {&"ok": false, &"error": "Defense asset outcome is malformed."}
|
|
var asset_id := StringName(value.get(&"asset_id", value.get("asset_id", "")))
|
|
var integrity := int(value.get(&"integrity", value.get("integrity", -1)))
|
|
var maximum := int(value.get(&"max_integrity", value.get("max_integrity", -1)))
|
|
if asset_id.is_empty() or seen_assets.has(asset_id) or not expected_assets.has(asset_id) \
|
|
or maximum != FacilityInstance.DEFAULT_DURABILITY \
|
|
or integrity < 0 or integrity > maximum \
|
|
or not facility_service.has_instance(asset_id):
|
|
return {&"ok": false, &"error": "Defense outcome references invalid Facility data."}
|
|
var expected_asset: Dictionary = expected_assets[asset_id]
|
|
if maximum != int(expected_asset[&"max_integrity"]):
|
|
return {&"ok": false, &"error": "Defense asset outcome differs from the Raid roster."}
|
|
seen_assets[asset_id] = true
|
|
return {&"ok": true}
|
|
|
|
func _defense_outcome_token_is_valid(token: Variant) -> bool:
|
|
return token is Dictionary \
|
|
and token.get(&"staff", null) is Dictionary \
|
|
and token.get(&"facilities", null) is Dictionary \
|
|
and token.get(&"domain_events", null) is Dictionary \
|
|
and token.get(&"id_generator", null) is Dictionary
|
|
|
|
func _restore_defense_outcome_token(token: Variant) -> Dictionary:
|
|
if not _defense_outcome_token_is_valid(token):
|
|
return {&"ok": false, &"error": "Defense outcome rollback token is malformed."}
|
|
var staff_ok: bool = staff_service.restore_snapshot(token[&"staff"])
|
|
var facilities_ok: bool = facility_service.restore_snapshot(token[&"facilities"])
|
|
facilities_ok = facility_cleanliness.restore_snapshot(token.get(&"facility_cleanliness", {})) and facilities_ok
|
|
var events_ok: bool = domain_events.restore_snapshot(token[&"domain_events"])
|
|
var ids_ok: bool = id_generator.restore_snapshot(token[&"id_generator"])
|
|
return {
|
|
&"ok": staff_ok and facilities_ok and events_ok and ids_ok,
|
|
&"error": "Defense outcome rollback failed." if not staff_ok or not facilities_ok or not events_ok or not ids_ok else "",
|
|
}
|
|
|
|
func _execute_event_action_boundary(
|
|
action: EventAction,
|
|
phase: StringName,
|
|
context: Dictionary,
|
|
rollback_token: Variant
|
|
) -> Dictionary:
|
|
if action.type not in [
|
|
EventAction.Type.ADD_LEDGER_ENTRY,
|
|
EventAction.Type.ADD_OR_REMOVE_ITEM,
|
|
EventAction.Type.CREATE_TASK,
|
|
EventAction.Type.CREATE_DEFENSE_RAID,
|
|
EventAction.Type.SET_FACILITY_STATE,
|
|
EventAction.Type.TELEPORT_ENTITY_FOR_RECOVERY,
|
|
]:
|
|
return {&"not_handled": true}
|
|
if phase == &"rollback":
|
|
return _restore_event_action_token(rollback_token)
|
|
var validation := _build_event_defense_request(action, context) if action.type == EventAction.Type.CREATE_DEFENSE_RAID else _validate_event_action(action)
|
|
if not bool(validation.get(&"ok", false)) or phase == &"validate":
|
|
return validation
|
|
var token := _capture_event_action_token()
|
|
if action.type == EventAction.Type.CREATE_DEFENSE_RAID:
|
|
token[&"defense"] = defense_service.create_snapshot()
|
|
var result := _apply_event_action(action, context)
|
|
if not bool(result.get(&"ok", false)):
|
|
var rollback := _restore_event_action_token(token)
|
|
if not bool(rollback.get(&"ok", false)):
|
|
result[&"fatal"] = true
|
|
result[&"rollback"] = rollback
|
|
result[&"error"] = "Event Action failed and rollback failed: %s; %s" % [result.get(&"error", ""), rollback.get(&"error", "")]
|
|
return result
|
|
result[&"token"] = token
|
|
return result
|
|
|
|
func _validate_event_action(action: EventAction) -> Dictionary:
|
|
match action.type:
|
|
EventAction.Type.ADD_LEDGER_ENTRY:
|
|
var from_account := StringName(action.payload.get(&"from_account", action.payload.get("from_account", PLAYER_CASH_ACCOUNT)))
|
|
var to_account := StringName(action.payload.get(&"to_account", action.payload.get("to_account", OPERATING_EXPENSE_ACCOUNT)))
|
|
var amount := int(action.payload.get(&"amount", action.payload.get("amount", 0)))
|
|
if amount <= 0 or not economy_service.has_account(from_account) or not economy_service.has_account(to_account):
|
|
return {&"ok": false, &"error": "Ledger Action has invalid accounts or amount."}
|
|
if not economy_service.account_allows_negative(from_account) and economy_service.get_balance(from_account) < amount:
|
|
return {&"ok": false, &"error": "Ledger Action source cannot fund the amount."}
|
|
EventAction.Type.ADD_OR_REMOVE_ITEM:
|
|
var inventory_id := StringName(action.payload.get(&"inventory_id", action.payload.get("inventory_id", "")))
|
|
var item_id := StringName(action.payload.get(&"item_id", action.payload.get("item_id", "")))
|
|
var delta := int(action.payload.get(&"quantity_delta", action.payload.get("quantity_delta", 0)))
|
|
if inventory_id.is_empty() or item_id.is_empty() or delta == 0:
|
|
return {&"ok": false, &"error": "Inventory Action requires inventory, item and non-zero delta."}
|
|
if delta < 0 and inventory_service.available_quantity(inventory_id, item_id) < -delta:
|
|
return {&"ok": false, &"error": "Inventory Action stock is unavailable."}
|
|
EventAction.Type.CREATE_TASK:
|
|
if StringName(action.payload.get(&"task_type", action.payload.get("task_type", ""))).is_empty():
|
|
return {&"ok": false, &"error": "CREATE_TASK requires task_type."}
|
|
EventAction.Type.SET_FACILITY_STATE:
|
|
var facility_id := StringName(action.payload.get(&"facility_id", action.payload.get("facility_id", "")))
|
|
var conditions_value: Variant = action.payload.get(&"conditions", action.payload.get("conditions", null))
|
|
if not facility_service.has_instance(facility_id) or not conditions_value is Dictionary:
|
|
return {&"ok": false, &"error": "Facility Action target or conditions are invalid."}
|
|
var defense_owner: StringName = _defense_lock_owner(facility_id, &"ASSET")
|
|
if not defense_owner.is_empty() \
|
|
and (conditions_value.has(&"durability") or conditions_value.has("durability")):
|
|
return {&"ok": false, &"error": "Event Action cannot mutate Defense-owned Facility durability."}
|
|
EventAction.Type.TELEPORT_ENTITY_FOR_RECOVERY:
|
|
var entity_id := StringName(action.payload.get(&"entity_id", action.payload.get("entity_id", "")))
|
|
var cell_value: Variant = _parse_world_cell(
|
|
action.payload.get(&"cell", action.payload.get("cell", null))
|
|
)
|
|
if not _event_actor_exists(entity_id) or cell_value == null:
|
|
return {&"ok": false, &"error": "Recovery teleport target is invalid."}
|
|
var target_cell: Vector3i = cell_value
|
|
var traffic := path_service.get_cell_traffic(target_cell)
|
|
var actor_id := (
|
|
_guest_path_actor_id(entity_id)
|
|
if guest_service.has_guest(entity_id)
|
|
else _staff_path_actor_id(entity_id)
|
|
)
|
|
if traffic.is_empty() or bool(traffic.get(&"temporary_block", false)) \
|
|
or not path_service.has_actor_projection(actor_id):
|
|
return {&"ok": false, &"error": "Recovery teleport requires a projected actor and an unblocked walkable target."}
|
|
return {&"ok": true}
|
|
|
|
func _apply_event_action(action: EventAction, context: Dictionary) -> Dictionary:
|
|
var instance_id := StringName(context.get(&"event_instance_id", ""))
|
|
match action.type:
|
|
EventAction.Type.CREATE_DEFENSE_RAID:
|
|
return _apply_event_defense_raid(action, context)
|
|
EventAction.Type.ADD_LEDGER_ENTRY:
|
|
var from_account := StringName(action.payload.get(&"from_account", action.payload.get("from_account", PLAYER_CASH_ACCOUNT)))
|
|
var to_account := StringName(action.payload.get(&"to_account", action.payload.get("to_account", OPERATING_EXPENSE_ACCOUNT)))
|
|
var amount := int(action.payload.get(&"amount", action.payload.get("amount", 0)))
|
|
var result := economy_service.transfer(
|
|
from_account, to_account, amount, _runtime_event_minute(),
|
|
&"EVENT_ACTION", instance_id,
|
|
StringName("event.action.%s.%s" % [instance_id, action.action_id])
|
|
)
|
|
return {&"ok": result.is_success(), &"error": result.message}
|
|
EventAction.Type.ADD_OR_REMOVE_ITEM:
|
|
var inventory_id := StringName(action.payload.get(&"inventory_id", action.payload.get("inventory_id", "")))
|
|
var item_id := StringName(action.payload.get(&"item_id", action.payload.get("item_id", "")))
|
|
var delta := int(action.payload.get(&"quantity_delta", action.payload.get("quantity_delta", 0)))
|
|
if delta > 0:
|
|
var added := inventory_service.add_stack(inventory_id, item_id, delta, 100, -1, _runtime_event_minute())
|
|
return {&"ok": bool(added.get(&"ok", false)), &"error": added.get(&"message", "")}
|
|
var reserved := inventory_service.reserve_item(
|
|
inventory_id, item_id, -delta, instance_id, &"event_action",
|
|
_runtime_event_minute(), _runtime_event_minute() + 1,
|
|
StringName("event.item.reserve.%s.%s" % [instance_id, action.action_id])
|
|
)
|
|
if not bool(reserved.get(&"ok", false)):
|
|
return {&"ok": false, &"error": reserved.get(&"message", "Item reservation failed.")}
|
|
var consumed := inventory_service.consume_reservations(
|
|
_name_array(reserved.get(&"reservation_ids", [])),
|
|
StringName("event.item.consume.%s.%s" % [instance_id, action.action_id]),
|
|
_runtime_event_minute()
|
|
)
|
|
return {&"ok": bool(consumed.get(&"ok", false)), &"error": consumed.get(&"message", "")}
|
|
EventAction.Type.CREATE_TASK:
|
|
var raw_options: Variant = action.payload.get(&"options", action.payload.get("options", {}))
|
|
var options: Dictionary = raw_options.duplicate(true) if raw_options is Dictionary else {}
|
|
var created := staff_service.create_task(
|
|
StringName(action.payload.get(&"task_type", action.payload.get("task_type", ""))),
|
|
int(action.payload.get(&"priority", action.payload.get("priority", 50))),
|
|
_runtime_event_minute(),
|
|
options,
|
|
StringName(action.payload.get(&"task_id", action.payload.get("task_id", "")))
|
|
)
|
|
return {&"ok": created.is_success(), &"error": created.message}
|
|
EventAction.Type.SET_FACILITY_STATE:
|
|
var updated := facility_service.set_operational_status(
|
|
StringName(action.payload.get(&"facility_id", action.payload.get("facility_id", ""))),
|
|
action.payload.get(&"conditions", action.payload.get("conditions", {})),
|
|
_runtime_event_minute()
|
|
)
|
|
return {&"ok": updated.is_success(), &"error": updated.message}
|
|
EventAction.Type.TELEPORT_ENTITY_FOR_RECOVERY:
|
|
var entity_id := StringName(action.payload.get(&"entity_id", action.payload.get("entity_id", "")))
|
|
var cell: Vector3i = _parse_world_cell(action.payload.get(&"cell", action.payload.get("cell", null)))
|
|
var guest := guest_service.get_guest(entity_id)
|
|
var actor_id: StringName
|
|
var moved := false
|
|
if guest != null:
|
|
actor_id = _guest_path_actor_id(entity_id)
|
|
moved = guest_service.record_path_success(
|
|
entity_id, cell, _runtime_event_minute()
|
|
)
|
|
else:
|
|
var staff := staff_service.get_staff(entity_id)
|
|
actor_id = _staff_path_actor_id(entity_id)
|
|
staff.current_cell = cell
|
|
moved = true
|
|
if not moved or not path_service.project_actor_position(
|
|
actor_id, cell, _runtime_event_minute()
|
|
):
|
|
return {&"ok": false, &"error": "Recovery teleport could not commit its Path projection."}
|
|
_movement_credits[actor_id] = {
|
|
&"credit": 0,
|
|
&"updated_at": _runtime_event_minute(),
|
|
}
|
|
_publish_actor_event(&"ENTITY_RECOVERY_TELEPORTED", &"event_service", [entity_id, actor_id], {
|
|
&"cell": cell,
|
|
&"path_actor_id": actor_id,
|
|
})
|
|
return {&"ok": true}
|
|
return {&"ok": false, &"error": "Unsupported Event Action."}
|
|
|
|
func _capture_event_action_token() -> Dictionary:
|
|
return {
|
|
&"economy": economy_service.create_snapshot(),
|
|
&"facility_upkeep": facility_upkeep.create_snapshot(),
|
|
&"upkeep_settlement": upkeep_settlement.create_snapshot(),
|
|
&"game_state": _state.create_snapshot(),
|
|
&"inventory": inventory_service.create_snapshot(),
|
|
&"staff": staff_service.create_snapshot(),
|
|
&"facilities": facility_service.create_snapshot(),
|
|
&"facility_cleanliness": facility_cleanliness.create_snapshot(),
|
|
&"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(),
|
|
}
|
|
|
|
func _restore_event_action_token(token: Variant) -> Dictionary:
|
|
if not token is Dictionary:
|
|
return {&"ok": false, &"error": "Event rollback token is malformed."}
|
|
# Every participant must be asked to restore even if an earlier participant
|
|
# reports an error; short-circuiting here would leave a half-rolled-back world.
|
|
var economy_ok: bool = economy_service.restore_snapshot(token.get(&"economy", {}))
|
|
var state_ok: bool = _state.restore_snapshot(token.get(&"game_state", {}))
|
|
var inventory_ok: bool = inventory_service.restore_snapshot(token.get(&"inventory", {}))
|
|
var staff_ok: bool = staff_service.restore_snapshot(token.get(&"staff", {}))
|
|
var facilities_ok: bool = facility_service.restore_snapshot(token.get(&"facilities", {}))
|
|
facilities_ok = facility_cleanliness.restore_snapshot(token.get(&"facility_cleanliness", {})) and facilities_ok
|
|
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:
|
|
_movement_credits = (credits_value as Dictionary).duplicate(true)
|
|
var defense_ok := true
|
|
if token.has(&"defense"):
|
|
# This creates PREPARING incidents only; rollback must not reset another
|
|
# raid's pre-existing same-tick automatic/manual attempt guard.
|
|
defense_ok = token[&"defense"] is Dictionary and defense_service.restore_snapshot(token[&"defense"])
|
|
var events_ok: bool = domain_events.restore_snapshot(token.get(&"domain_events", {}))
|
|
var ids_ok: bool = id_generator.restore_snapshot(token.get(&"id_generator", {}))
|
|
var ok: bool = economy_ok and state_ok and inventory_ok and staff_ok \
|
|
and facilities_ok and guests_ok and path_ok and credits_ok and defense_ok and events_ok and ids_ok
|
|
return {&"ok": ok, &"error": "Event Action rollback failed." if not ok else ""}
|
|
|
|
func _reserve_event_cost(cost: Dictionary, context: Dictionary) -> Dictionary:
|
|
var normalized: Dictionary = {}
|
|
for raw_resource: Variant in cost:
|
|
var resource_id := StringName(raw_resource)
|
|
var amount := int(cost[raw_resource])
|
|
if resource_id.is_empty() or amount < 0:
|
|
return {&"ok": false, &"error": "Event Choice cost is invalid."}
|
|
normalized[resource_id] = amount
|
|
var economy_before := economy_service.create_snapshot()
|
|
var state_before: Dictionary = _state.create_snapshot()
|
|
var ids_before := id_generator.create_snapshot()
|
|
var instance_id := StringName(context.get(&"event_instance_id", ""))
|
|
var reservation_id: StringName = &""
|
|
for amount: int in normalized.values():
|
|
if amount > 0:
|
|
if id_generator.get_counter(&"event_cost") >= StableIdGenerator.MAX_COUNTER:
|
|
return {&"ok": false, &"error": "Event cost reservation IDs are exhausted."}
|
|
reservation_id = id_generator.next_id(&"event_cost")
|
|
break
|
|
var escrow_account := StringName("EVENT_ESCROW:%s" % instance_id)
|
|
var reservation := {&"event_instance_id": instance_id, &"cost": normalized, &"escrow_account": escrow_account}
|
|
if not reservation_id.is_empty():
|
|
reservation[&"reservation_id"] = reservation_id
|
|
var gold := int(normalized.get(&"gold", 0))
|
|
if gold > 0:
|
|
if not economy_service.has_account(escrow_account):
|
|
economy_service.register_account(escrow_account)
|
|
var transfer := economy_service.transfer(
|
|
PLAYER_CASH_ACCOUNT, escrow_account, gold, _runtime_event_minute(),
|
|
&"EVENT_COST_RESERVED", instance_id,
|
|
_event_cost_ledger_key(reservation, &"reserve")
|
|
)
|
|
if not transfer.is_success():
|
|
economy_service.restore_snapshot(economy_before)
|
|
_state.restore_snapshot(state_before)
|
|
id_generator.restore_snapshot(ids_before)
|
|
return {&"ok": false, &"error": transfer.message}
|
|
var other_costs := normalized.duplicate(true)
|
|
other_costs.erase(&"gold")
|
|
if not other_costs.is_empty() and not _state.spend(other_costs):
|
|
economy_service.restore_snapshot(economy_before)
|
|
_state.restore_snapshot(state_before)
|
|
id_generator.restore_snapshot(ids_before)
|
|
return {&"ok": false, &"error": "Event Choice resources are unavailable."}
|
|
return {&"ok": true, &"token": reservation}
|
|
|
|
func _release_event_cost(token: Variant, _context: Dictionary) -> Dictionary:
|
|
if token is Dictionary and token.is_empty():
|
|
return {&"ok": true}
|
|
if not token is Dictionary or not token.get(&"cost", null) is Dictionary:
|
|
return {&"ok": false, &"error": "Event Cost release token is malformed."}
|
|
var cost: Dictionary = token[&"cost"]
|
|
var instance_id := StringName(token.get(&"event_instance_id", ""))
|
|
var escrow_account := StringName(token.get(&"escrow_account", ""))
|
|
var gold := int(cost.get(&"gold", 0))
|
|
if gold > 0:
|
|
var released := economy_service.transfer(
|
|
escrow_account, PLAYER_CASH_ACCOUNT, gold, _runtime_event_minute(),
|
|
&"EVENT_COST_RELEASED", instance_id,
|
|
_event_cost_ledger_key(token, &"release")
|
|
)
|
|
if not released.is_success():
|
|
return {&"ok": false, &"error": released.message}
|
|
for raw_resource: Variant in cost:
|
|
var resource_id := StringName(raw_resource)
|
|
if resource_id != &"gold":
|
|
_state.add_resource(resource_id, int(cost[raw_resource]))
|
|
return {&"ok": true}
|
|
|
|
func _commit_event_cost(token: Variant, _context: Dictionary) -> Dictionary:
|
|
if token is Dictionary and token.is_empty():
|
|
return {&"ok": true}
|
|
if not token is Dictionary or not token.get(&"cost", null) is Dictionary:
|
|
return {&"ok": false, &"error": "Event Cost commit token is malformed."}
|
|
var cost: Dictionary = token[&"cost"]
|
|
var gold := int(cost.get(&"gold", 0))
|
|
if gold <= 0:
|
|
return {&"ok": true}
|
|
var instance_id := StringName(token.get(&"event_instance_id", ""))
|
|
var committed := economy_service.transfer(
|
|
StringName(token.get(&"escrow_account", "")), OPERATING_EXPENSE_ACCOUNT,
|
|
gold, _runtime_event_minute(), &"EVENT_COST_COMMITTED", instance_id,
|
|
_event_cost_ledger_key(token, &"commit")
|
|
)
|
|
return {&"ok": committed.is_success(), &"error": committed.message}
|
|
|
|
func _event_cost_ledger_key(token: Dictionary, operation: StringName) -> StringName:
|
|
var reservation_id := StringName(token.get(&"reservation_id", ""))
|
|
if not reservation_id.is_empty():
|
|
return StringName("event.reserved_cost.%s.%s" % [operation, reservation_id])
|
|
# Existing pending save tokens keep the original ledger operation identity.
|
|
return StringName("event.cost.%s.%s" % [operation, token.get(&"event_instance_id", "")])
|
|
|
|
func _on_relationship_threshold_candidate(candidate: Dictionary) -> void:
|
|
var candidate_id := StringName(candidate.get(&"candidate_id", ""))
|
|
if candidate_id.is_empty() or event_service.get_instance(candidate_id) != null:
|
|
return
|
|
event_service.enqueue_candidate(
|
|
&"relationship.threshold",
|
|
candidate.get(&"actor_ids", []),
|
|
int(candidate.get(&"created_at", _runtime_event_minute())),
|
|
{
|
|
&"event_instance_id": candidate_id,
|
|
&"deduplication_key": StringName(candidate.get(&"deduplication_key", candidate_id)),
|
|
&"context": {&"relationship_candidate": candidate.duplicate(true)},
|
|
}
|
|
)
|
|
|
|
func _route_lock_index(value: Variant) -> int:
|
|
if value is int:
|
|
return value if value >= 0 and value < RelationshipState.RouteLock.keys().size() else -1
|
|
return RelationshipState.RouteLock.keys().find(String(value).to_upper())
|
|
|
|
|
|
func _validate_construction_facility_crosslinks(
|
|
world: WorldService,
|
|
construction: ConstructionService,
|
|
facilities: FacilityService,
|
|
projector: FacilityInfrastructureProjector = null
|
|
) -> bool:
|
|
for construction_facility: Dictionary in construction.get_facilities():
|
|
var instance_id := StringName(construction_facility.get(&"instance_id", &""))
|
|
var definition_id := StringName(construction_facility.get(&"definition_id", &""))
|
|
var definition := definition_registry.get_facility(definition_id)
|
|
if instance_id.is_empty() or definition == null:
|
|
return false
|
|
if _facility_definition_is_schema_proxy(definition):
|
|
if facilities.has_instance(instance_id):
|
|
return false
|
|
if _facility_definition_is_supported_infrastructure_proxy(definition):
|
|
if projector == null or not projector.has_projection(instance_id):
|
|
return false
|
|
elif projector != null and projector.has_projection(instance_id):
|
|
return false
|
|
for cell: Vector3i in construction_facility.get(&"occupied_cells", []):
|
|
if world.get_occupant(cell) != instance_id \
|
|
or world.get_occupant_kind(cell) != &"facility":
|
|
return false
|
|
continue
|
|
var operational := facilities.get_instance(instance_id)
|
|
if operational == null or (projector != null and projector.has_projection(instance_id)):
|
|
return false
|
|
if operational.definition_id != StringName(
|
|
construction_facility.get(&"definition_id", &"")
|
|
) or operational.origin != construction_facility.get(&"origin", Vector3i.ZERO):
|
|
return false
|
|
var expected_cells: Dictionary = {}
|
|
for cell: Vector3i in construction_facility.get(&"occupied_cells", []):
|
|
expected_cells[cell] = true
|
|
var actual_cells: Dictionary = {}
|
|
for cell: Vector3i in operational.occupied_cells:
|
|
actual_cells[cell] = true
|
|
if expected_cells != actual_cells:
|
|
return false
|
|
for cell: Vector3i in expected_cells:
|
|
if world.get_occupant(cell) != instance_id \
|
|
or world.get_occupant_kind(cell) != &"facility":
|
|
return false
|
|
return true
|
|
|
|
|
|
func _facility_definition_is_schema_proxy(definition: Variant) -> bool:
|
|
return definition is FacilityDefinition and &"schema_facility_proxy" in definition.tags
|
|
|
|
|
|
func _facility_definition_is_supported_infrastructure_proxy(definition: Variant) -> bool:
|
|
return _facility_definition_is_schema_proxy(definition) \
|
|
and definition.id in FacilityInfrastructureProjector.SUPPORTED_KINDS
|
|
|
|
|
|
func _facility_definition_is_unsupported_vertical_proxy(definition: Variant) -> bool:
|
|
return _facility_definition_is_schema_proxy(definition) \
|
|
and definition.id in FacilityInfrastructureProjector.UNSUPPORTED_VERTICAL_KINDS
|
|
|
|
|
|
func _migrate_legacy_infrastructure_proxies(
|
|
construction: ConstructionService,
|
|
facilities: FacilityService,
|
|
projector: FacilityInfrastructureProjector
|
|
) -> bool:
|
|
if construction == null or facilities == null or projector == null:
|
|
return false
|
|
for instance: Dictionary in construction.get_facilities():
|
|
var instance_id := StringName(instance.get(&"instance_id", &""))
|
|
var definition := definition_registry.get_facility(
|
|
StringName(instance.get(&"definition_id", &""))
|
|
)
|
|
if instance_id.is_empty() or definition == null:
|
|
return false
|
|
if not _facility_definition_is_schema_proxy(definition):
|
|
continue
|
|
if facilities.has_instance(instance_id) \
|
|
and not facilities.unregister_instance(instance_id).is_success():
|
|
return false
|
|
if _facility_definition_is_unsupported_vertical_proxy(definition):
|
|
continue
|
|
if not _facility_definition_is_supported_infrastructure_proxy(definition):
|
|
return false
|
|
var projected := projector.project_completed_facility(
|
|
StringName("runtime.migrate.v6:%s" % instance_id),
|
|
definition,
|
|
instance
|
|
)
|
|
if not projected.is_success():
|
|
return false
|
|
return true
|
|
|
|
|
|
func _validate_infrastructure_crosslinks(
|
|
world: WorldService,
|
|
path: PathService,
|
|
construction: ConstructionService,
|
|
_resource_state: Variant,
|
|
excavation: ExcavationService,
|
|
spatial: SpatialSubslotService,
|
|
adapter: RouteSubslotPathAdapter,
|
|
projector: FacilityInfrastructureProjector,
|
|
require_minute_alignment := true
|
|
) -> bool:
|
|
if world == null or path == null or construction == null or excavation == null \
|
|
or spatial == null or adapter == null or projector == null:
|
|
return false
|
|
# An active projector validates its bound Spatial and RouteAdapter services
|
|
# itself. Reuse that composed check only for these exact dependencies.
|
|
# Quarantine intentionally suppresses its live dependency check, so retain
|
|
# the independent validations on that path (and for a different binding).
|
|
var composed_dependencies := projector.is_bound_to(adapter, spatial, path) and not projector.is_quarantined()
|
|
if not adapter.is_bound_to(spatial, path) \
|
|
or (not composed_dependencies and (
|
|
not bool(spatial.validate_invariants().get(&"ok", false))
|
|
or not bool(adapter.validate_invariants().get(&"ok", false)))) \
|
|
or not bool(projector.validate_invariants().get(&"ok", false)) \
|
|
or not bool(excavation.validate_invariants().get(&"ok", false)) \
|
|
or (require_minute_alignment \
|
|
and excavation.current_minute != construction.current_minute) \
|
|
or not _same_vector3i_set(
|
|
adapter.get_base_walkable_cells(false), world.get_walkable_cells(false)
|
|
) \
|
|
or not _same_vector3i_set(
|
|
adapter.get_projected_route_cells(), spatial.get_route_cells()
|
|
) \
|
|
or not path.matches_passable_cells(adapter.get_projected_walkable_cells()):
|
|
return false
|
|
var expected_projection_ids: Dictionary = {}
|
|
for instance: Dictionary in construction.get_facilities():
|
|
var instance_id := StringName(instance.get(&"instance_id", &""))
|
|
var definition_id := StringName(instance.get(&"definition_id", &""))
|
|
var definition := definition_registry.get_facility(definition_id)
|
|
if instance_id.is_empty() or definition == null:
|
|
return false
|
|
if not _facility_definition_is_schema_proxy(definition):
|
|
if projector.has_projection(instance_id):
|
|
return false
|
|
continue
|
|
if _facility_definition_is_unsupported_vertical_proxy(definition):
|
|
if projector.has_projection(instance_id):
|
|
return false
|
|
continue
|
|
if not _facility_definition_is_supported_infrastructure_proxy(definition):
|
|
return false
|
|
expected_projection_ids[instance_id] = true
|
|
var projection := projector.get_projection(instance_id)
|
|
if projection.is_empty() \
|
|
or StringName(projection.get(&"definition_id", &"")) != definition_id \
|
|
or StringName(projection.get(&"source_job_id", &"")) \
|
|
!= StringName(instance.get(&"source_job_id", &"")) \
|
|
or projection.get(&"origin", null) != instance.get(&"origin", null) \
|
|
or int(projection.get(&"rotation", -1)) != int(instance.get(&"rotation", -2)) \
|
|
or int(projection.get(&"layer", -1)) != int(instance.get(&"layer", -2)) \
|
|
or not _same_vector3i_set(
|
|
projection.get(&"cells", []), instance.get(&"occupied_cells", [])
|
|
):
|
|
return false
|
|
var actual_projection_ids: Dictionary = {}
|
|
for instance_id: StringName in projector.get_projection_ids():
|
|
if actual_projection_ids.has(instance_id):
|
|
return false
|
|
actual_projection_ids[instance_id] = true
|
|
return actual_projection_ids == expected_projection_ids
|
|
|
|
|
|
func _same_vector3i_set(first: Variant, second: Variant) -> bool:
|
|
if not first is Array or not second is Array or first.size() != second.size():
|
|
return false
|
|
var expected: Dictionary = {}
|
|
for value: Variant in first:
|
|
if not value is Vector3i or expected.has(value):
|
|
return false
|
|
expected[value] = true
|
|
for value: Variant in second:
|
|
if not value is Vector3i or not expected.has(value):
|
|
return false
|
|
expected.erase(value)
|
|
return expected.is_empty()
|
|
|
|
|
|
func _validate_runtime_crosslinks(
|
|
facilities: FacilityService,
|
|
guests: GuestService,
|
|
economy: EconomyService,
|
|
inventory: InventoryService,
|
|
staff: StaffService,
|
|
absolute_minute: int,
|
|
staffing_projection_enabled: bool
|
|
) -> bool:
|
|
if absolute_minute < 0 \
|
|
or facilities.current_minute != absolute_minute \
|
|
or guests.current_minute != absolute_minute \
|
|
or inventory.current_minute != absolute_minute \
|
|
or staff.current_minute != absolute_minute:
|
|
return false
|
|
for assignment: Dictionary in staff.get_work_assignments():
|
|
var facility_id := StringName(assignment[&"facility_id"])
|
|
if facility_id.is_empty():
|
|
continue
|
|
var assigned_facility := facilities.get_instance(facility_id)
|
|
if assigned_facility == null:
|
|
return false
|
|
var assigned_staff := staff.get_staff(StringName(assignment[&"staff_id"]))
|
|
var definition := definition_registry.get_facility(assigned_facility.definition_id)
|
|
if assigned_staff == null or definition == null or definition.staff_max <= 0 \
|
|
or (not _staff_role_matches_facility(assigned_staff.role_id, definition) and not _is_anri_manager(assigned_staff)):
|
|
return false
|
|
var queued_links: Dictionary = {}
|
|
var service_links: Dictionary = {}
|
|
for facility: FacilityInstance in facilities.get_instances():
|
|
if staffing_projection_enabled:
|
|
for staff_id: StringName in facility.active_staff_ids:
|
|
var active_staff := staff.get_staff(staff_id)
|
|
if active_staff == null \
|
|
or active_staff.assigned_facility_id != facility.instance_id \
|
|
or (active_staff.state != StaffRuntime.State.AVAILABLE and not CleaningBridge.is_deployed(active_staff, facility, staff)) \
|
|
or _is_anri_manager(active_staff):
|
|
return false
|
|
for request: ServiceRequest in facilities.get_queue(facility.instance_id):
|
|
if not _reservation_references_are_valid(
|
|
request.reserved_inventory_ids, request.request_id, inventory
|
|
):
|
|
return false
|
|
for guest_id: StringName in request.guest_ids:
|
|
if not guests.has_guest(guest_id):
|
|
continue
|
|
var guest := guests.get_guest(guest_id)
|
|
if guest.state != GuestRuntime.State.QUEUING \
|
|
or guest.queue_request_id != request.request_id \
|
|
or guest.target_facility_id != facility.instance_id:
|
|
return false
|
|
queued_links[guest_id] = request.request_id
|
|
for service: ServiceInstance in facilities.get_active_services(facility.instance_id):
|
|
if not _reservation_references_are_valid(
|
|
service.inventory_reservations, service.request_id, inventory
|
|
):
|
|
return false
|
|
for guest_id: StringName in service.guest_ids:
|
|
if not guests.has_guest(guest_id):
|
|
continue
|
|
var guest := guests.get_guest(guest_id)
|
|
if guest.state != GuestRuntime.State.RECEIVING_SERVICE \
|
|
or guest.current_service_id != service.service_id \
|
|
or guest.target_facility_id != facility.instance_id:
|
|
return false
|
|
service_links[guest_id] = service.service_id
|
|
for guest: GuestRuntime in guests.get_guests():
|
|
if not guest.queue_request_id.is_empty() \
|
|
and queued_links.get(guest.guest_id, &"") != guest.queue_request_id:
|
|
return false
|
|
if not guest.current_service_id.is_empty() \
|
|
and service_links.get(guest.guest_id, &"") != guest.current_service_id:
|
|
return false
|
|
var account_id := _guest_wallet_account(guest.guest_id)
|
|
if not economy.has_account(account_id) \
|
|
or economy.get_balance(account_id) != guest.wallet_gold:
|
|
return false
|
|
for task: StaffTask in staff.get_tasks():
|
|
if not _reservation_references_are_valid(
|
|
task.reservation_ids, task.task_id, inventory
|
|
):
|
|
return false
|
|
return true
|
|
|
|
func _validate_authority_economy_crosslinks(
|
|
authority: AuthorityService,
|
|
economy: EconomyService,
|
|
absolute_minute: int
|
|
) -> bool:
|
|
if authority == null or authority.current_minute != absolute_minute \
|
|
or not bool(authority.validate_invariants().get(&"ok", false)) \
|
|
or not economy.has_account(AUTHORITY_FINE_ACCOUNT):
|
|
return false
|
|
var authority_snapshot := authority.create_snapshot()
|
|
var raw_resolutions: Variant = authority_snapshot.get(&"applied_resolutions", null)
|
|
if not raw_resolutions is Array:
|
|
return false
|
|
var expected_fine_balance := 0
|
|
for raw_resolution: Variant in raw_resolutions:
|
|
if not raw_resolution is Dictionary \
|
|
or not raw_resolution.get(&"payload", null) is Dictionary:
|
|
return false
|
|
var resolution_id := StringName(raw_resolution.get(&"resolution_id", ""))
|
|
var payload: Dictionary = raw_resolution[&"payload"]
|
|
var inspection_id := StringName(payload.get(&"inspection_id", ""))
|
|
var fine_amount := int(payload.get(&"fine_amount", -1))
|
|
if resolution_id.is_empty() or inspection_id.is_empty() or fine_amount < 0:
|
|
return false
|
|
expected_fine_balance += fine_amount
|
|
if fine_amount == 0:
|
|
continue
|
|
var transaction_id := economy.get_transaction_id_for_idempotency_key(
|
|
_authority_resolution_idempotency_key(resolution_id)
|
|
)
|
|
if transaction_id.is_empty() or not _authority_fine_transaction_matches(
|
|
economy,
|
|
transaction_id,
|
|
inspection_id,
|
|
fine_amount
|
|
):
|
|
return false
|
|
return economy.get_balance(AUTHORITY_FINE_ACCOUNT) == expected_fine_balance
|
|
|
|
func _validate_live_defense_crosslinks() -> bool:
|
|
return _validate_defense_crosslinks(
|
|
defense_service,
|
|
staff_service,
|
|
facility_service,
|
|
_defense_operation_minute()
|
|
)
|
|
|
|
func _validate_defense_crosslinks(
|
|
defense: DefenseService,
|
|
staff: StaffService,
|
|
facilities: FacilityService,
|
|
absolute_minute: int
|
|
) -> bool:
|
|
if defense == null or staff == null or facilities == null \
|
|
or absolute_minute < 0 or defense.current_minute != absolute_minute \
|
|
or not bool(defense.validate_invariants().get(&"ok", false)):
|
|
return false
|
|
var defender_owners: Dictionary = {}
|
|
var asset_owners: Dictionary = {}
|
|
for raid_id: StringName in defense.get_raid_ids():
|
|
var raid := defense.get_raid(raid_id)
|
|
if raid.is_empty():
|
|
return false
|
|
var state := int(raid.get(&"state", -1))
|
|
var terminal := state in DefenseService.TERMINAL_STATES
|
|
var preparing := state == DefenseService.RaidState.PREPARING
|
|
var combatants_value: Variant = raid.get(&"combatants", null)
|
|
var assets_value: Variant = raid.get(&"assets", null)
|
|
if not combatants_value is Dictionary or not assets_value is Dictionary:
|
|
return false
|
|
var combatants: Dictionary = combatants_value
|
|
for actor_id: StringName in _sorted_name_keys(combatants):
|
|
var combatant_value: Variant = combatants[actor_id]
|
|
if not combatant_value is Dictionary:
|
|
return false
|
|
var combatant: Dictionary = combatant_value
|
|
if StringName(combatant.get(&"side", &"")) != &"DEFENDER":
|
|
continue
|
|
if not terminal:
|
|
if defender_owners.has(actor_id):
|
|
return false
|
|
defender_owners[actor_id] = raid_id
|
|
if int(combatant.get(&"max_health", -1)) != StaffRuntime.MAX_PSYCHOLOGY_VALUE \
|
|
or int(combatant.get(&"initial_health", -1)) != StaffRuntime.MAX_PSYCHOLOGY_VALUE:
|
|
return false
|
|
# A terminal Raid is historical. Its reconciled Staff may later heal,
|
|
# work elsewhere, leave the roster, or enter another Raid; requiring the
|
|
# old outcome to remain a permanent live mirror would make future saves
|
|
# unrestorable. Capacity baselines remain self-contained above.
|
|
if terminal:
|
|
continue
|
|
var linked_staff := staff.get_staff(actor_id)
|
|
if linked_staff == null or linked_staff.state != StaffRuntime.State.UNAVAILABLE \
|
|
or not linked_staff.current_task_id.is_empty():
|
|
return false
|
|
if preparing and (
|
|
int(combatant.get(&"health", -1)) != linked_staff.health
|
|
or int(combatant.get(&"fatigue", -1)) != linked_staff.fatigue
|
|
):
|
|
return false
|
|
var assets: Dictionary = assets_value
|
|
for asset_id: StringName in _sorted_name_keys(assets):
|
|
var asset_value: Variant = assets[asset_id]
|
|
if not asset_value is Dictionary:
|
|
return false
|
|
var asset: Dictionary = asset_value
|
|
if not terminal:
|
|
if asset_owners.has(asset_id):
|
|
return false
|
|
asset_owners[asset_id] = raid_id
|
|
if int(asset.get(&"max_integrity", -1)) != FacilityInstance.DEFAULT_DURABILITY \
|
|
or int(asset.get(&"initial_integrity", -1)) != FacilityInstance.DEFAULT_DURABILITY:
|
|
return false
|
|
# Terminal assets are historical for the same reason as terminal Staff:
|
|
# the live Facility can subsequently be repaired, reused, or demolished.
|
|
if terminal:
|
|
continue
|
|
var facility := facilities.get_instance(asset_id)
|
|
if facility == null:
|
|
return false
|
|
if preparing and int(asset.get(&"integrity", -1)) != facility.durability:
|
|
return false
|
|
return true
|
|
|
|
func _defense_lock_owner(
|
|
external_id: StringName,
|
|
lock_kind: StringName,
|
|
excluded_raid_id: StringName = &""
|
|
) -> StringName:
|
|
if defense_service == null or external_id.is_empty() \
|
|
or lock_kind not in [&"DEFENDER", &"ASSET"]:
|
|
return &""
|
|
for raid_id: StringName in defense_service.get_raid_ids():
|
|
if raid_id == excluded_raid_id:
|
|
continue
|
|
var raid := defense_service.get_raid(raid_id)
|
|
if raid.is_empty() or int(raid.get(&"state", -1)) in DefenseService.TERMINAL_STATES:
|
|
continue
|
|
if lock_kind == &"DEFENDER":
|
|
var combatants_value: Variant = raid.get(&"combatants", {})
|
|
if not combatants_value is Dictionary or not combatants_value.has(external_id):
|
|
continue
|
|
var combatant_value: Variant = combatants_value[external_id]
|
|
if combatant_value is Dictionary \
|
|
and StringName(combatant_value.get(&"side", &"")) == &"DEFENDER":
|
|
return raid_id
|
|
else:
|
|
var assets_value: Variant = raid.get(&"assets", {})
|
|
if assets_value is Dictionary and assets_value.has(external_id):
|
|
return raid_id
|
|
return &""
|
|
|
|
func _reservation_references_are_valid(
|
|
reservation_ids: Array[StringName],
|
|
requester_id: StringName,
|
|
inventory: InventoryService
|
|
) -> bool:
|
|
for reservation_id: StringName in reservation_ids:
|
|
var reservation := inventory.get_reservation(reservation_id)
|
|
if reservation == null or reservation.requester_id != requester_id:
|
|
return false
|
|
return true
|
|
|
|
func _advance_mana(absolute_minute: int) -> void:
|
|
if _operations_tick_blocked:
|
|
return
|
|
var infrastructure_preflight := _preflight_proxy_completions(absolute_minute)
|
|
if not bool(infrastructure_preflight.get(&"ok", false)):
|
|
_latch_infrastructure_failure(
|
|
StringName(infrastructure_preflight.get(
|
|
&"code", &"INFRASTRUCTURE_PREFLIGHT_FAILED"
|
|
)),
|
|
absolute_minute,
|
|
String(infrastructure_preflight.get(
|
|
&"reason", "Infrastructure completion preflight failed."
|
|
)),
|
|
infrastructure_preflight.get(&"details", {})
|
|
)
|
|
return
|
|
var report := mana_network_service.advance_one_minute(absolute_minute)
|
|
# Finite reserve is an additional gate, even between network evaluations and
|
|
# for zero-demand facilities. The network remains the local supply authority.
|
|
preload("res://scripts/mana/mana_power_projection.gd").apply(
|
|
facility_service, mana_network_service, int(_state.get_resource(&"mana")), absolute_minute
|
|
)
|
|
for change: Dictionary in report.get(&"state_changes", []):
|
|
var consumer_id := StringName(change.get(&"consumer_id", ""))
|
|
_publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"),
|
|
&"MANA_STATE_CHANGED",
|
|
absolute_minute,
|
|
&"mana_network_service",
|
|
[consumer_id],
|
|
change,
|
|
0
|
|
))
|
|
|
|
func _on_facility_completed(instance: Dictionary) -> void:
|
|
# The staged Runtime boundary has already projected/registered this Facility
|
|
# and committed its DomainEvents before Construction releases raw signals.
|
|
if _construction_committing_notifications:
|
|
return
|
|
var instance_id := StringName(instance.get(&"instance_id", &""))
|
|
var definition := definition_registry.get_facility(
|
|
StringName(instance.get(&"definition_id", &""))
|
|
)
|
|
if instance_id.is_empty() or definition == null:
|
|
_latch_infrastructure_failure(
|
|
&"INVALID_COMPLETED_FACILITY",
|
|
_runtime_event_minute(),
|
|
"Construction emitted an invalid completed Facility.",
|
|
{&"instance_id": instance_id}
|
|
)
|
|
return
|
|
if _facility_definition_is_schema_proxy(definition):
|
|
if not _facility_definition_is_supported_infrastructure_proxy(definition):
|
|
_latch_infrastructure_failure(
|
|
&"UNSUPPORTED_VERTICAL_TOPOLOGY",
|
|
_runtime_event_minute(),
|
|
"An unsupported vertical proxy reached Construction completion.",
|
|
{&"instance_id": instance_id, &"definition_id": definition.id}
|
|
)
|
|
return
|
|
var projection_request_id := StringName(
|
|
"runtime.complete:%s" % String(instance.get(&"source_job_id", instance_id))
|
|
)
|
|
var projected := facility_infrastructure_projector.project_completed_facility(
|
|
projection_request_id,
|
|
definition,
|
|
instance
|
|
)
|
|
if not projected.is_success():
|
|
_latch_infrastructure_failure(
|
|
&"FACILITY_INFRASTRUCTURE_PROJECTION_FAILED",
|
|
_runtime_event_minute(),
|
|
projected.message,
|
|
projected.details
|
|
)
|
|
return
|
|
_publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"),
|
|
&"FACILITY_INFRASTRUCTURE_PROJECTED",
|
|
_runtime_event_minute(),
|
|
&"facility_infrastructure_projector",
|
|
[instance_id],
|
|
projected.details.duplicate(true),
|
|
0
|
|
))
|
|
else:
|
|
_register_facility_mana_consumer(instance)
|
|
_register_operational_facility(instance)
|
|
_publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"),
|
|
&"FACILITY_COMPLETED",
|
|
_runtime_event_minute(),
|
|
&"construction_service",
|
|
[instance_id],
|
|
instance,
|
|
0
|
|
))
|
|
|
|
|
|
func _on_excavation_completed(report: Dictionary) -> void:
|
|
var job_id := StringName(report.get(&"job_id", &""))
|
|
if job_id.is_empty():
|
|
return
|
|
var synchronized := route_subslot_path_adapter.synchronize_projection(
|
|
StringName("runtime.excavation:%s" % job_id),
|
|
world_service.get_walkable_cells()
|
|
)
|
|
if not synchronized.is_success():
|
|
_latch_infrastructure_failure(
|
|
&"EXCAVATION_ROUTE_SYNCHRONIZATION_FAILED",
|
|
int(report.get(&"completed_at", _runtime_event_minute())),
|
|
synchronized.message,
|
|
synchronized.details
|
|
)
|
|
return
|
|
_publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"),
|
|
&"EXCAVATION_COMPLETED",
|
|
int(report.get(&"completed_at", _runtime_event_minute())),
|
|
&"excavation_service",
|
|
[job_id],
|
|
report.duplicate(true),
|
|
0
|
|
))
|
|
|
|
func _on_facility_demolished(instance_id: StringName, refund: Dictionary) -> void:
|
|
if not mana_network_service.get_node(instance_id).is_empty():
|
|
mana_network_service.unregister_node(instance_id)
|
|
if facility_service.has_instance(instance_id):
|
|
facility_service.unregister_instance(instance_id)
|
|
_publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"),
|
|
&"FACILITY_DEMOLISHED",
|
|
_runtime_event_minute(),
|
|
&"construction_service",
|
|
[instance_id],
|
|
{&"refund": refund},
|
|
0
|
|
))
|
|
|
|
func _on_ledger_transaction_posted(transaction_id: StringName, _entries: Array) -> void:
|
|
if _operations_flushing_economy_notifications.has(transaction_id):
|
|
return
|
|
if _state != null:
|
|
_state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT))
|
|
|
|
|
|
func get_mvp_content_report() -> Dictionary:
|
|
return mvp_content_report.duplicate(true)
|
|
|
|
|
|
func get_mvp_guest_definition_ids() -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
for definition: GuestDefinition in mvp_content_catalog.build_all_guest_definitions():
|
|
result.append(definition.guest_type_id)
|
|
return result
|
|
|
|
|
|
func get_mvp_staff_candidate_ids() -> Array[StringName]:
|
|
return mvp_content_catalog.get_staff_candidate_ids()
|
|
|
|
|
|
func get_mvp_staff_candidate_payload(candidate_id: StringName) -> Dictionary:
|
|
return mvp_content_catalog.staff_hire_payload(candidate_id).duplicate(true)
|
|
|
|
|
|
## Read-only presentation data for the hiring screen. The validated catalog
|
|
## remains authoritative and the UI never receives a mutable reference to it.
|
|
func get_mvp_staff_candidate_summaries() -> Array[Dictionary]:
|
|
var summaries: Array[Dictionary] = []
|
|
for candidate_id: StringName in mvp_content_catalog.get_recruitable_staff_ids():
|
|
var entry := mvp_content_catalog.get_recruitment_entry(candidate_id)
|
|
var payload := mvp_content_catalog.staff_hire_payload(candidate_id)
|
|
if entry.is_empty() or payload.is_empty():
|
|
continue
|
|
var options_value: Variant = payload.get(&"options", {})
|
|
var options: Dictionary = options_value if options_value is Dictionary else {}
|
|
var skills_value: Variant = options.get(&"skills", {})
|
|
var skills: Dictionary = skills_value if skills_value is Dictionary else {}
|
|
summaries.append({
|
|
&"candidate_id": candidate_id,
|
|
&"display_name": String(entry.get(&"display_name", candidate_id)),
|
|
&"age": int(payload.get(&"age", 0)),
|
|
&"role_id": StringName(payload.get(&"role_id", &"")),
|
|
&"daily_wage": int(payload.get(&"daily_wage", 0)),
|
|
&"signing_cost": int(payload.get(&"signing_cost", 0)),
|
|
&"preferred_shift": StringName(payload.get(&"preferred_shift", &"any")),
|
|
&"skills": skills.duplicate(true),
|
|
&"hired": _hired_staff_candidate_ids.has(candidate_id),
|
|
&"unique": candidate_id == &"heroine_anri",
|
|
})
|
|
return summaries
|
|
|
|
|
|
## Defensive presentation snapshot for the Staff management panel. Planned
|
|
## assignment remains visible at night/off-duty even when the active link clears.
|
|
func get_staff_management_report() -> Dictionary:
|
|
var rows: Array[Dictionary] = []
|
|
for staff: StaffRuntime in staff_service.get_staff_members():
|
|
var entry := mvp_content_catalog.get_recruitment_entry(staff.staff_id)
|
|
var plan := staff_service.get_work_assignment(staff.staff_id)
|
|
var shift := staff_service.get_shift(staff.shift_id)
|
|
var template_id: StringName = &"DAY"
|
|
var effective_from := _runtime_event_minute()
|
|
var next_start_at := -1
|
|
var assigned_facility_id := staff.assigned_facility_id
|
|
if not plan.is_empty():
|
|
template_id = StringName(plan[&"template_id"])
|
|
assigned_facility_id = StringName(plan[&"facility_id"])
|
|
effective_from = int(plan[&"effective_from"])
|
|
next_start_at = int(plan[&"next_start_at"])
|
|
elif shift != null:
|
|
template_id = shift.template_id
|
|
effective_from = shift.start_at
|
|
else:
|
|
template_id = &"NIGHT" if StringName(entry.get(&"preferred_shift", &"day")) == &"night" else &"DAY"
|
|
var block_reason := _staff_assignment_block_reason(staff)
|
|
rows.append({
|
|
&"staff_id": staff.staff_id, &"display_name": String(entry.get(&"display_name", staff.staff_id)),
|
|
&"role_id": staff.role_id, &"state": staff.state, &"state_name": StaffRuntime.State.keys()[staff.state],
|
|
&"assigned_facility_id": assigned_facility_id, &"active_facility_id": staff.assigned_facility_id,
|
|
&"template_id": template_id, &"recurring": not plan.is_empty() and not assigned_facility_id.is_empty(),
|
|
&"effective_from": effective_from, &"next_start_at": next_start_at,
|
|
&"daily_wage": staff.get_effective_daily_wage(), &"health": staff.health, &"fatigue": staff.fatigue, &"unpaid_wages": staff.unpaid_wages,
|
|
&"current_task_id": staff.current_task_id, &"rescue_status": _staff_rescue_status(staff.staff_id),
|
|
&"rest_status": get_staff_rest_report(staff.staff_id).get(&"label", ""),
|
|
&"can_edit": block_reason.is_empty(),
|
|
&"assignment_block_reason": block_reason,
|
|
&"unique_contract": _staff_unique_contract_report(staff),
|
|
&"original_relationship": relationship_service.get_original_relationship(staff.staff_id),
|
|
})
|
|
var facilities: Array[Dictionary] = []
|
|
for facility: FacilityInstance in facility_service.get_instances():
|
|
var definition := definition_registry.get_facility(facility.definition_id)
|
|
if definition == null or definition.staff_max <= 0:
|
|
continue
|
|
facilities.append({
|
|
&"facility_id": facility.instance_id, &"display_name": definition.display_name,
|
|
&"definition_id": facility.definition_id, &"layer": facility.origin.z, &"origin": facility.origin,
|
|
&"required_roles": definition.required_roles.duplicate(), &"staff_min": definition.staff_min, &"staff_max": definition.staff_max,
|
|
})
|
|
return {&"staff": rows, &"facilities": facilities}
|
|
|
|
|
|
## Original 6.1 / 6.2 presentation, derived from validated canonical content
|
|
## and the same planned placement as pricing. This report owns all containers;
|
|
## it does not add a hire, consent, growth policy, save field or tick-side effect.
|
|
func _staff_unique_contract_report(staff: StaffRuntime) -> Dictionary:
|
|
if staff == null or staff.employment_kind != StaffRuntime.EmploymentKind.UNIQUE or not mvp_content_catalog.is_valid():
|
|
return {}
|
|
var definition := mvp_content_catalog.get_entry(&"unique_characters", staff.staff_id)
|
|
if definition.is_empty() or StringName(definition.get(&"role_id", &"")) != staff.role_id:
|
|
return {}
|
|
var passive_effects: Array[Dictionary] = []
|
|
var contract := {
|
|
&"canonical_id": staff.staff_id,
|
|
&"free_employment": true,
|
|
&"free_upkeep": staff.get_effective_daily_wage() == 0,
|
|
&"permanent_removal_forbidden": true,
|
|
&"passive_effects": passive_effects,
|
|
}
|
|
# Only Anri's explicitly specified effect is presented here. Neither kind
|
|
# heroine nor a zero wage grants this effect to another employee.
|
|
if not _is_anri_manager(staff):
|
|
return contract
|
|
var effects: Dictionary = definition.get(&"mechanical_effects", {})
|
|
if not effects.has(&"assigned_floor_revenue_multiplier_bp"):
|
|
return contract
|
|
var plan := staff_service.get_work_assignment(staff.staff_id)
|
|
var facility_id := StringName(plan.get(&"facility_id", &""))
|
|
var facility := facility_service.get_instance(facility_id)
|
|
var effect := {
|
|
&"effect_id": &"assigned_floor_revenue_multiplier_bp",
|
|
&"display_name": "看板娘の微笑",
|
|
&"multiplier_bp": int(effects[&"assigned_floor_revenue_multiplier_bp"]),
|
|
&"scope": &"ASSIGNED_FLOOR_ALL_LAYERS",
|
|
&"applied": facility != null,
|
|
&"assigned_facility_id": facility_id,
|
|
}
|
|
if facility != null:
|
|
effect[&"floor_y"] = facility.origin.y
|
|
passive_effects.append(effect)
|
|
return contract
|
|
|
|
|
|
func get_hired_staff_candidate_ids() -> Array[StringName]:
|
|
return _sorted_name_keys(_hired_staff_candidate_ids)
|
|
|
|
|
|
func get_live_progression_report() -> Dictionary:
|
|
# Project current cash/facility facts without changing saved progression.
|
|
var projected := ProgressionService.new()
|
|
var source := progression_service.create_snapshot()
|
|
if not projected.restore_snapshot(source):
|
|
return {&"snapshot":source, &"evaluation":{&"eligible":false,&"requirements":[],&"reason":&"UNAVAILABLE"}}
|
|
var synchronized := projected.update_metrics({
|
|
&"asset_gold":economy_service.get_balance(PLAYER_CASH_ACCOUNT),
|
|
&"active_above_ground_facilities":_count_active_facilities(facility_service,false),
|
|
&"active_underground_facilities":_count_active_facilities(facility_service,true),
|
|
})
|
|
var evaluation := projected.evaluate_rank()
|
|
if not synchronized:
|
|
evaluation[&"eligible"] = false
|
|
evaluation[&"reason"] = &"UNAVAILABLE"
|
|
var gate := _operations_construction_investment_gate(0)
|
|
if gate != null:
|
|
evaluation[&"eligible"] = false
|
|
evaluation[&"reason"] = &"OPERATIONS_BLOCKED"
|
|
evaluation[&"block_reason"] = gate.details.get(&"reason", &"")
|
|
return {&"snapshot":projected.create_snapshot(), &"evaluation":evaluation}
|
|
|
|
|
|
func get_progression_report(target_rank := -1) -> Dictionary:
|
|
return {
|
|
&"snapshot": progression_service.create_snapshot(),
|
|
&"evaluation": progression_service.evaluate_rank(target_rank).duplicate(true),
|
|
}
|
|
|
|
|
|
func _saved_content_manifest_matches(catalog: MvpContentCatalog, data: Dictionary) -> bool:
|
|
var expected := _create_mvp_content_manifest(catalog)
|
|
var saved: Variant = data.get(&"catalog_manifest", null)
|
|
if _strict_variants_equal(expected, saved):
|
|
return true
|
|
# The sole v1-to-v2 catalog addition is the chapel cleric. Require the
|
|
# complete old manifest, not an arbitrary subset of current content.
|
|
expected[&"manifest_version"] = 1
|
|
expected[&"staff_candidate_ids"].erase("staff_uma")
|
|
if not _strict_variants_equal(expected, saved):
|
|
return false
|
|
var hired: Variant = data.get(&"hired_staff_candidate_ids", null)
|
|
var staff_section: Variant = data.get(&"staff", null)
|
|
if not hired is Array or "staff_uma" in hired or not staff_section is Dictionary:
|
|
return false
|
|
var staff_rows: Variant = staff_section.get(&"staff", null)
|
|
if not staff_rows is Array:
|
|
return false
|
|
for staff: Variant in staff_rows:
|
|
if not staff is Dictionary or String(staff.get(&"staff_id", "")) == "staff_uma":
|
|
return false
|
|
return true
|
|
|
|
|
|
func _clear_mvp_content_manifest_cache() -> void:
|
|
_mvp_manifest_cache_catalog = null
|
|
_mvp_manifest_cache_generation = -1
|
|
_mvp_manifest_cache = {}
|
|
|
|
|
|
func _create_mvp_content_manifest(catalog: MvpContentCatalog) -> Dictionary:
|
|
# Restore validates a temporary catalog through the full original builder.
|
|
# A rejected restore must not replace or invalidate the live Runtime cache.
|
|
if catalog != mvp_content_catalog:
|
|
return _build_mvp_content_manifest(catalog)
|
|
var generation := catalog.get_manifest_generation()
|
|
if _mvp_manifest_cache_catalog == catalog \
|
|
and _mvp_manifest_cache_generation == generation:
|
|
return _mvp_manifest_cache.duplicate(true)
|
|
var manifest := _build_mvp_content_manifest(catalog)
|
|
_mvp_manifest_cache_catalog = catalog
|
|
_mvp_manifest_cache_generation = generation
|
|
_mvp_manifest_cache = manifest
|
|
# Legacy v1 matching edits its returned manifest, including a nested array.
|
|
# The private template must stay owned even on the first cache fill.
|
|
return _mvp_manifest_cache.duplicate(true)
|
|
|
|
|
|
func _build_mvp_content_manifest(catalog: MvpContentCatalog) -> Dictionary:
|
|
var guest_definition_ids: Array[String] = []
|
|
for definition: GuestDefinition in catalog.build_all_guest_definitions():
|
|
guest_definition_ids.append(String(definition.guest_type_id))
|
|
return {
|
|
&"manifest_version": MVP_CONTENT_MANIFEST_VERSION,
|
|
&"schema_version": MvpContentCatalog.SCHEMA_VERSION,
|
|
&"guest_class_ids": _names_to_strings_for_manifest(catalog.get_guest_class_ids()),
|
|
&"species_ids": _names_to_strings_for_manifest(catalog.get_species_ids()),
|
|
&"staff_candidate_ids": _names_to_strings_for_manifest(catalog.get_staff_candidate_ids()),
|
|
&"unique_character_ids": _names_to_strings_for_manifest(catalog.get_unique_character_ids()),
|
|
&"guest_definition_ids": guest_definition_ids,
|
|
}
|
|
|
|
|
|
func _names_to_strings_for_manifest(values: Array[StringName]) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for value: StringName in values:
|
|
result.append(String(value))
|
|
return result
|
|
|
|
|
|
func _catalog_definitions_are_registered(service: GuestService) -> bool:
|
|
if not bool(mvp_content_report.get(&"valid", false)):
|
|
return false
|
|
return _saved_catalog_guest_definitions_match(
|
|
service.create_snapshot(),
|
|
mvp_content_catalog
|
|
)
|
|
|
|
|
|
func _saved_catalog_guest_definitions_match(
|
|
guest_snapshot: Dictionary,
|
|
catalog: MvpContentCatalog
|
|
) -> bool:
|
|
var raw_definitions: Variant = guest_snapshot.get(&"definitions", null)
|
|
if not raw_definitions is Array:
|
|
return false
|
|
var saved_by_id: Dictionary = {}
|
|
for raw_definition: Variant in raw_definitions:
|
|
if not raw_definition is Dictionary:
|
|
return false
|
|
var raw_id: Variant = raw_definition.get(&"guest_type_id", null)
|
|
if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return false
|
|
var definition_id := StringName(raw_id)
|
|
if definition_id.is_empty() or saved_by_id.has(definition_id):
|
|
return false
|
|
saved_by_id[definition_id] = raw_definition
|
|
var expected_definitions := catalog.build_all_guest_definitions()
|
|
if expected_definitions.size() != 32:
|
|
return false
|
|
for definition: GuestDefinition in expected_definitions:
|
|
if not saved_by_id.has(definition.guest_type_id) \
|
|
or not _strict_variants_equal(
|
|
definition.create_snapshot(),
|
|
saved_by_id[definition.guest_type_id]
|
|
):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _register_or_validate_legacy_catalog_definitions(
|
|
service: GuestService,
|
|
catalog: MvpContentCatalog
|
|
) -> bool:
|
|
var definitions := catalog.build_all_guest_definitions()
|
|
if definitions.size() != 32:
|
|
return false
|
|
for definition: GuestDefinition in definitions:
|
|
var existing := service.get_definition(definition.guest_type_id)
|
|
if existing == null:
|
|
if not service.register_definition(definition):
|
|
return false
|
|
elif not _strict_variants_equal(
|
|
existing.create_snapshot(),
|
|
definition.create_snapshot()
|
|
):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _serialize_hired_staff_candidate_ids() -> Array[String]:
|
|
var result: Array[String] = []
|
|
for candidate_id: StringName in _sorted_name_keys(_hired_staff_candidate_ids):
|
|
result.append(String(candidate_id))
|
|
return result
|
|
|
|
|
|
func _is_mvp_staff_candidate_id(staff_id: StringName) -> bool:
|
|
return mvp_content_catalog.get_staff_candidate_ids().has(staff_id)
|
|
|
|
|
|
func _deserialize_hired_staff_candidate_ids(
|
|
raw_ids: Array,
|
|
catalog: MvpContentCatalog
|
|
) -> Variant:
|
|
var allowed: Dictionary = {}
|
|
for candidate_id: StringName in catalog.get_recruitable_staff_ids():
|
|
allowed[candidate_id] = true
|
|
var result: Dictionary = {}
|
|
var previous := ""
|
|
for raw_id: Variant in raw_ids:
|
|
if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return null
|
|
var candidate_id := StringName(raw_id)
|
|
var candidate_text := String(candidate_id)
|
|
if not allowed.has(candidate_id) or result.has(candidate_id) \
|
|
or (not previous.is_empty() and candidate_text <= previous):
|
|
return null
|
|
result[candidate_id] = true
|
|
previous = candidate_text
|
|
return result
|
|
|
|
|
|
func _validate_catalog_staff_crosslinks(
|
|
staff: StaffService,
|
|
hired_ids: Dictionary,
|
|
catalog: MvpContentCatalog
|
|
) -> bool:
|
|
if hired_ids.has(&"heroine_anri") and not staff.has_staff(&"heroine_anri"):
|
|
return false
|
|
var candidate_ids: Dictionary = {}
|
|
for candidate_id: StringName in catalog.get_recruitable_staff_ids():
|
|
candidate_ids[candidate_id] = true
|
|
for member: StaffRuntime in staff.get_staff_members():
|
|
if not candidate_ids.has(member.staff_id):
|
|
continue
|
|
if not hired_ids.has(member.staff_id):
|
|
return false
|
|
var canonical := catalog.staff_hire_payload(member.staff_id)
|
|
if canonical.is_empty() \
|
|
or member.role_id != StringName(canonical[&"role_id"]) \
|
|
or member.daily_wage != int(canonical[&"daily_wage"]) \
|
|
or member.get_employment_kind_name() != StringName(canonical[&"options"][&"employment_kind"]) \
|
|
or not _strict_variants_equal(
|
|
member.permitted_task_tags,
|
|
(canonical[&"options"] as Dictionary)[&"permitted_task_tags"]
|
|
) \
|
|
or not _strict_variants_equal(
|
|
member.skills,
|
|
(canonical[&"options"] as Dictionary)[&"skills"]
|
|
):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _derive_legacy_hired_staff_candidate_ids(
|
|
staff: StaffService,
|
|
staff_snapshot: Dictionary,
|
|
catalog: MvpContentCatalog
|
|
) -> Dictionary:
|
|
var allowed: Dictionary = {}
|
|
for candidate_id: StringName in catalog.get_recruitable_staff_ids():
|
|
allowed[candidate_id] = true
|
|
var result: Dictionary = {}
|
|
for member: StaffRuntime in staff.get_staff_members():
|
|
if allowed.has(member.staff_id):
|
|
result[member.staff_id] = true
|
|
var raw_departed: Variant = staff_snapshot.get(&"departed_payroll", [])
|
|
if raw_departed is Array:
|
|
for raw_record: Variant in raw_departed:
|
|
if raw_record is Dictionary:
|
|
var candidate_id := StringName(raw_record.get(&"staff_id", ""))
|
|
if allowed.has(candidate_id):
|
|
result[candidate_id] = true
|
|
return result
|
|
|
|
|
|
func _strict_variants_equal(expected: Variant, actual: Variant) -> bool:
|
|
if typeof(expected) != typeof(actual):
|
|
return false
|
|
if expected is Dictionary:
|
|
var expected_dictionary := expected as Dictionary
|
|
var actual_dictionary := actual as Dictionary
|
|
if expected_dictionary.size() != actual_dictionary.size():
|
|
return false
|
|
var unmatched_actual_keys: Array = actual_dictionary.keys()
|
|
for expected_key: Variant in expected_dictionary:
|
|
var matched_index := -1
|
|
for index in range(unmatched_actual_keys.size()):
|
|
var actual_key: Variant = unmatched_actual_keys[index]
|
|
if typeof(actual_key) == typeof(expected_key) and actual_key == expected_key:
|
|
matched_index = index
|
|
break
|
|
if matched_index < 0:
|
|
return false
|
|
var matched_key: Variant = unmatched_actual_keys[matched_index]
|
|
if not _strict_variants_equal(
|
|
expected_dictionary[expected_key],
|
|
actual_dictionary[matched_key]
|
|
):
|
|
return false
|
|
unmatched_actual_keys.remove_at(matched_index)
|
|
return unmatched_actual_keys.is_empty()
|
|
if expected is Array:
|
|
var expected_array := expected as Array
|
|
var actual_array := actual as Array
|
|
if expected_array.size() != actual_array.size():
|
|
return false
|
|
for index in range(expected_array.size()):
|
|
if not _strict_variants_equal(expected_array[index], actual_array[index]):
|
|
return false
|
|
return true
|
|
return expected == actual
|
|
|
|
|
|
func _operations_snapshot_comparison_value(value: Variant) -> Variant:
|
|
if typeof(value) == TYPE_STRING_NAME:
|
|
return String(value)
|
|
if value is Array:
|
|
var normalized_array: Array = []
|
|
for member: Variant in value:
|
|
normalized_array.append(_operations_snapshot_comparison_value(member))
|
|
return normalized_array
|
|
if value is Dictionary:
|
|
var normalized_dictionary: Dictionary = {}
|
|
for raw_key: Variant in value:
|
|
var normalized_key: Variant = raw_key
|
|
if typeof(raw_key) == TYPE_STRING_NAME:
|
|
normalized_key = String(raw_key)
|
|
if normalized_dictionary.has(normalized_key):
|
|
# A String/StringName alias collision is not a canonical snapshot.
|
|
return null
|
|
normalized_dictionary[normalized_key] = _operations_snapshot_comparison_value(
|
|
value[raw_key]
|
|
)
|
|
return normalized_dictionary
|
|
return value
|
|
|
|
|
|
func get_actor_movement_credit(actor_id: StringName) -> Dictionary:
|
|
if not _movement_credits.has(actor_id):
|
|
return {}
|
|
return (_movement_credits[actor_id] as Dictionary).duplicate(true)
|
|
|
|
|
|
func _guest_path_actor_id(guest_id: StringName) -> StringName:
|
|
return StringName("guest:%s" % guest_id)
|
|
|
|
|
|
func _staff_path_actor_id(staff_id: StringName) -> StringName:
|
|
return StringName("staff:%s" % staff_id)
|
|
|
|
|
|
func _expected_runtime_actor_cells(
|
|
guests: GuestService,
|
|
staff: StaffService
|
|
) -> Dictionary:
|
|
var expected: Dictionary = {}
|
|
for guest: GuestRuntime in guests.get_guests():
|
|
var guest_actor_id := _guest_path_actor_id(guest.guest_id)
|
|
if guest_actor_id.is_empty() or expected.has(guest_actor_id):
|
|
return {}
|
|
expected[guest_actor_id] = guest.current_cell
|
|
for member: StaffRuntime in staff.get_staff_members():
|
|
var staff_actor_id := _staff_path_actor_id(member.staff_id)
|
|
if staff_actor_id.is_empty() or expected.has(staff_actor_id):
|
|
return {}
|
|
expected[staff_actor_id] = member.current_cell
|
|
return expected
|
|
|
|
|
|
func _serialize_movement_credits() -> Array[Dictionary]:
|
|
var saved: Array[Dictionary] = []
|
|
for actor_id: StringName in _sorted_name_keys(_movement_credits):
|
|
var entry: Dictionary = _movement_credits[actor_id]
|
|
saved.append({
|
|
&"actor_id": String(actor_id),
|
|
&"credit": int(entry[&"credit"]),
|
|
&"updated_at": int(entry[&"updated_at"]),
|
|
})
|
|
return saved
|
|
|
|
|
|
func _deserialize_movement_credits(raw_entries: Array, absolute_minute: int) -> Variant:
|
|
var restored: Dictionary = {}
|
|
for raw_entry: Variant in raw_entries:
|
|
if not raw_entry is Dictionary:
|
|
return null
|
|
var actor_id_value: Variant = raw_entry.get(&"actor_id", null)
|
|
var credit_value: Variant = raw_entry.get(&"credit", null)
|
|
var updated_at_value: Variant = raw_entry.get(&"updated_at", null)
|
|
if not (actor_id_value is String or actor_id_value is StringName) \
|
|
or not credit_value is int or not updated_at_value is int:
|
|
return null
|
|
var actor_id := StringName(actor_id_value)
|
|
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 >= STAIR_MOVEMENT_CREDIT \
|
|
or updated_at < 0 or updated_at > absolute_minute:
|
|
return null
|
|
restored[actor_id] = {
|
|
&"credit": credit,
|
|
&"updated_at": updated_at,
|
|
}
|
|
return restored
|
|
|
|
|
|
func _rebuild_legacy_path_actor_projection(
|
|
path: PathService,
|
|
guests: GuestService,
|
|
staff: StaffService,
|
|
absolute_minute: int
|
|
) -> Variant:
|
|
var expected := _expected_runtime_actor_cells(guests, staff)
|
|
var entries: Array[Dictionary] = []
|
|
var progress_entries: Array[Dictionary] = []
|
|
var expected_density: Dictionary = {}
|
|
var rebuilt: Dictionary = {}
|
|
for actor_id: StringName in _sorted_name_keys(expected):
|
|
var cell: Vector3i = expected[actor_id]
|
|
if not path.has_cell(cell):
|
|
return null
|
|
entries.append({&"actor_id": actor_id, &"cell": cell})
|
|
progress_entries.append({
|
|
&"actor_id": actor_id,
|
|
&"last_cell": cell,
|
|
&"last_progress_at": absolute_minute,
|
|
&"last_observed_at": absolute_minute,
|
|
&"stuck_reported_at": -1,
|
|
})
|
|
expected_density[cell] = int(expected_density.get(cell, 0)) + 1
|
|
rebuilt[actor_id] = {&"credit": 0, &"updated_at": absolute_minute}
|
|
var migrated_path := path.create_snapshot()
|
|
migrated_path[&"actor_cells"] = entries
|
|
migrated_path[&"actor_progress"] = progress_entries
|
|
migrated_path[&"traffic_revision"] = int(migrated_path[&"traffic_revision"]) + 1
|
|
for traffic: Dictionary in migrated_path[&"cell_traffic"]:
|
|
traffic[&"current_density"] = int(expected_density.get(traffic[&"cell"], 0))
|
|
if not path.restore_snapshot(migrated_path):
|
|
return null
|
|
return rebuilt
|
|
|
|
|
|
func _validate_runtime_path_crosslinks(
|
|
path: PathService,
|
|
guests: GuestService,
|
|
staff: StaffService,
|
|
credits: Dictionary,
|
|
absolute_minute: int
|
|
) -> bool:
|
|
if path == null or guests == null or staff == null or absolute_minute < 0:
|
|
return false
|
|
var expected := _expected_runtime_actor_cells(guests, staff)
|
|
if expected.size() != guests.guest_count() + staff.get_staff_members().size() \
|
|
or credits.size() != expected.size():
|
|
return false
|
|
var path_snapshot := path.create_snapshot()
|
|
var raw_actor_cells: Variant = path_snapshot.get(&"actor_cells", null)
|
|
var raw_progress: Variant = path_snapshot.get(&"actor_progress", null)
|
|
var raw_traffic: Variant = path_snapshot.get(&"cell_traffic", null)
|
|
if not raw_actor_cells is Array or not raw_progress is Array or not raw_traffic is Array:
|
|
return false
|
|
var projected: Dictionary = {}
|
|
for raw_actor: Variant in raw_actor_cells:
|
|
if not raw_actor is Dictionary:
|
|
return false
|
|
var actor_id := StringName(raw_actor.get(&"actor_id", &""))
|
|
var cell: Variant = raw_actor.get(&"cell", null)
|
|
if actor_id.is_empty() or not cell is Vector3i or projected.has(actor_id):
|
|
return false
|
|
projected[actor_id] = cell
|
|
if projected.size() != expected.size():
|
|
return false
|
|
var progress_by_actor: Dictionary = {}
|
|
for raw_entry: Variant in raw_progress:
|
|
if not raw_entry is Dictionary:
|
|
return false
|
|
var actor_id := StringName(raw_entry.get(&"actor_id", &""))
|
|
if actor_id.is_empty() or progress_by_actor.has(actor_id):
|
|
return false
|
|
progress_by_actor[actor_id] = raw_entry
|
|
if progress_by_actor.size() != expected.size():
|
|
return false
|
|
var expected_density: Dictionary = {}
|
|
for actor_id: StringName in _sorted_name_keys(expected):
|
|
var cell: Vector3i = expected[actor_id]
|
|
if projected.get(actor_id, null) != cell or not credits.has(actor_id):
|
|
return false
|
|
var credit_entry: Variant = credits[actor_id]
|
|
var progress_entry: Variant = progress_by_actor.get(actor_id, null)
|
|
if not credit_entry is Dictionary or not progress_entry is Dictionary:
|
|
return false
|
|
var credit_value: Variant = credit_entry.get(&"credit", null)
|
|
var credit_updated_value: Variant = credit_entry.get(&"updated_at", null)
|
|
var last_progress_value: Variant = progress_entry.get(&"last_progress_at", null)
|
|
var last_observed_value: Variant = progress_entry.get(&"last_observed_at", null)
|
|
var stuck_reported_value: Variant = progress_entry.get(&"stuck_reported_at", null)
|
|
if not credit_value is int or not credit_updated_value is int \
|
|
or not last_progress_value is int or not last_observed_value is int \
|
|
or not stuck_reported_value is int:
|
|
return false
|
|
var credit := int(credit_value)
|
|
var updated_at := int(credit_updated_value)
|
|
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 >= _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 \
|
|
or last_observed_at != updated_at \
|
|
or last_observed_at > absolute_minute \
|
|
or (stuck_reported_at != -1 and (
|
|
stuck_reported_at < last_progress_at or stuck_reported_at > last_observed_at
|
|
)):
|
|
return false
|
|
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
|
|
var cell: Variant = raw_cell.get(&"cell", null)
|
|
var density_value: Variant = raw_cell.get(&"current_density", null)
|
|
if not cell is Vector3i or not density_value is int \
|
|
or int(density_value) != int(expected_density.get(cell, 0)):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _path_snapshot_cells_match_world(
|
|
path_snapshot: Dictionary,
|
|
walkable_cells: Array[Vector3i]
|
|
) -> bool:
|
|
var schema_value: Variant = path_snapshot.get(
|
|
&"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.SHAFT_SNAPSHOT_VERSION, PathService.SNAPSHOT_VERSION]:
|
|
return false
|
|
var raw_traffic: Variant = path_snapshot.get(
|
|
&"cell_traffic",
|
|
path_snapshot.get("cell_traffic", null)
|
|
)
|
|
if not raw_traffic is Array or raw_traffic.size() != walkable_cells.size():
|
|
return false
|
|
var expected_cells: Dictionary = {}
|
|
for cell: Vector3i in walkable_cells:
|
|
if expected_cells.has(cell):
|
|
return false
|
|
expected_cells[cell] = true
|
|
var actual_cells: Dictionary = {}
|
|
for raw_entry: Variant in raw_traffic:
|
|
if not raw_entry is Dictionary:
|
|
return false
|
|
var cell_value: Variant = raw_entry.get(&"cell", raw_entry.get("cell", null))
|
|
if not cell_value is Vector3i \
|
|
or actual_cells.has(cell_value) \
|
|
or not expected_cells.has(cell_value):
|
|
return false
|
|
actual_cells[cell_value] = true
|
|
return actual_cells.size() == expected_cells.size()
|
|
|
|
|
|
func _serialize_logistics_requests() -> Array[Dictionary]:
|
|
var saved: Array[Dictionary] = []
|
|
for request_id: StringName in _sorted_name_keys(logistics_requests):
|
|
saved.append((logistics_requests[request_id] as LogisticsRequest).create_snapshot())
|
|
return saved
|
|
|
|
|
|
func _derive_legacy_logistics_ownership(
|
|
dispatcher: LogisticsDispatcher,
|
|
requests: Dictionary,
|
|
absolute_minute: int
|
|
) -> Dictionary:
|
|
var latest_history: Dictionary = {}
|
|
for assignment: Dictionary in dispatcher.get_assignment_history():
|
|
var request_id := StringName(assignment.get(&"request_id", &""))
|
|
var assigned_at := int(assignment.get(&"assigned_at", -1))
|
|
if request_id.is_empty() or assigned_at < 0 or assigned_at > absolute_minute:
|
|
return {}
|
|
latest_history[request_id] = assignment
|
|
var expected: Dictionary = {}
|
|
for request_id: StringName in _sorted_name_keys(requests):
|
|
var request: LogisticsRequest = requests[request_id]
|
|
if request.state not in [
|
|
LogisticsRequest.State.ASSIGNED,
|
|
LogisticsRequest.State.PICKING_UP,
|
|
LogisticsRequest.State.DELIVERING,
|
|
]:
|
|
continue
|
|
if request.assigned_carrier_id.is_empty() or request.total_weight <= 0 \
|
|
or not latest_history.has(request_id):
|
|
return {}
|
|
var latest: Dictionary = latest_history[request_id]
|
|
if StringName(latest.get(&"carrier_id", &"")) != request.assigned_carrier_id \
|
|
or int(latest.get(&"weight", -1)) != request.total_weight:
|
|
return {}
|
|
expected[request_id] = {
|
|
&"carrier_id": request.assigned_carrier_id,
|
|
&"weight": request.total_weight,
|
|
&"assigned_at": int(latest[&"assigned_at"]),
|
|
}
|
|
return expected
|
|
|
|
|
|
func _validate_logistics_dispatcher_crosslinks(
|
|
dispatcher: LogisticsDispatcher,
|
|
requests: Dictionary,
|
|
absolute_minute: int
|
|
) -> bool:
|
|
if dispatcher == null or absolute_minute < 0:
|
|
return false
|
|
var ownership := dispatcher.get_assignment_ownership()
|
|
var expected_ids: Dictionary = {}
|
|
for request_id: StringName in _sorted_name_keys(requests):
|
|
var request: LogisticsRequest = requests[request_id]
|
|
if request.created_at < 0 or request.state_changed_at < request.created_at \
|
|
or request.state_changed_at > absolute_minute:
|
|
return false
|
|
var active := request.state in [
|
|
LogisticsRequest.State.ASSIGNED,
|
|
LogisticsRequest.State.PICKING_UP,
|
|
LogisticsRequest.State.DELIVERING,
|
|
]
|
|
if not active:
|
|
if not request.assigned_carrier_id.is_empty():
|
|
return false
|
|
continue
|
|
if request.assigned_carrier_id.is_empty() or request.total_weight <= 0:
|
|
return false
|
|
expected_ids[request_id] = true
|
|
var entry: Variant = ownership.get(request_id, null)
|
|
if not entry is Dictionary \
|
|
or StringName(entry.get(&"carrier_id", &"")) != request.assigned_carrier_id \
|
|
or int(entry.get(&"weight", -1)) != request.total_weight:
|
|
return false
|
|
var assigned_at := int(entry.get(&"assigned_at", -1))
|
|
if assigned_at < request.created_at or assigned_at > request.state_changed_at:
|
|
return false
|
|
if ownership.size() != expected_ids.size():
|
|
return false
|
|
for request_id: StringName in _sorted_name_keys(ownership):
|
|
if not expected_ids.has(request_id):
|
|
return false
|
|
var latest_history: Dictionary = {}
|
|
var history_fingerprints: Dictionary = {}
|
|
for assignment: Dictionary in dispatcher.get_assignment_history():
|
|
var request_id := StringName(assignment.get(&"request_id", &""))
|
|
if request_id.is_empty() or not requests.has(request_id):
|
|
return false
|
|
var request: LogisticsRequest = requests[request_id]
|
|
var carrier_id := StringName(assignment.get(&"carrier_id", &""))
|
|
var weight := int(assignment.get(&"weight", -1))
|
|
var assigned_at := int(assignment.get(&"assigned_at", -1))
|
|
var fingerprint := "%s|%s|%d|%d" % [request_id, carrier_id, weight, assigned_at]
|
|
if carrier_id.is_empty() or weight != request.total_weight \
|
|
or assigned_at < request.created_at or assigned_at > absolute_minute \
|
|
or history_fingerprints.has(fingerprint):
|
|
return false
|
|
history_fingerprints[fingerprint] = true
|
|
latest_history[request_id] = assignment
|
|
for request_id: StringName in _sorted_name_keys(ownership):
|
|
if not latest_history.has(request_id):
|
|
return false
|
|
var entry: Dictionary = ownership[request_id]
|
|
var latest: Dictionary = latest_history[request_id]
|
|
if StringName(latest[&"carrier_id"]) != StringName(entry[&"carrier_id"]) \
|
|
or int(latest[&"weight"]) != int(entry[&"weight"]) \
|
|
or int(latest[&"assigned_at"]) != int(entry[&"assigned_at"]):
|
|
return false
|
|
return true
|
|
|
|
func _serialize_event_definitions() -> Array[Dictionary]:
|
|
var saved: Array[Dictionary] = []
|
|
for definition: EventDefinition in event_service.get_definitions():
|
|
saved.append(definition.create_snapshot())
|
|
return saved
|
|
|
|
func _deserialize_logistics_requests(raw_requests: Array) -> Variant:
|
|
var restored: Dictionary = {}
|
|
for raw_request: Variant in raw_requests:
|
|
if not raw_request is Dictionary:
|
|
return null
|
|
var request := LogisticsRequest.from_snapshot(raw_request)
|
|
if request == null or restored.has(request.request_id):
|
|
return null
|
|
restored[request.request_id] = request
|
|
return restored
|
|
|
|
func _sorted_logistics_requests() -> Array:
|
|
var requests: Array = []
|
|
for request_id: StringName in _sorted_name_keys(logistics_requests):
|
|
requests.append(logistics_requests[request_id])
|
|
return requests
|
|
|
|
func _sorted_name_keys(source: Dictionary) -> Array[StringName]:
|
|
var keys: Array[StringName] = []
|
|
for raw_key: Variant in source:
|
|
keys.append(StringName(raw_key))
|
|
keys.sort_custom(func(first: StringName, second: StringName) -> bool:
|
|
return String(first) < String(second)
|
|
)
|
|
return keys
|
|
|
|
func _name_array(source: Variant) -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
if not source is Array:
|
|
return result
|
|
for value: Variant in source:
|
|
result.append(StringName(value))
|
|
return result
|
|
|
|
func _guest_wallet_account(guest_id: StringName) -> StringName:
|
|
return StringName("GUEST_WALLET:%s" % guest_id)
|
|
|
|
func _outstanding_service_commitment(guest_id: StringName) -> int:
|
|
var total := 0
|
|
var counted_requests: Dictionary = {}
|
|
for facility: FacilityInstance in facility_service.get_instances():
|
|
for request: ServiceRequest in facility_service.get_queue(facility.instance_id):
|
|
if not request.guest_ids.is_empty() and request.guest_ids[0] == guest_id:
|
|
total += request.quoted_price
|
|
counted_requests[request.request_id] = true
|
|
for service: ServiceInstance in facility_service.get_active_services(facility.instance_id):
|
|
if counted_requests.has(service.request_id):
|
|
continue
|
|
var request := facility_service.get_request(service.request_id)
|
|
if request != null and not request.guest_ids.is_empty() \
|
|
and request.guest_ids[0] == guest_id:
|
|
total += request.quoted_price
|
|
counted_requests[request.request_id] = true
|
|
return total
|
|
|
|
func _facility_revenue_account(facility_id: StringName) -> StringName:
|
|
return StringName("FACILITY_REVENUE:%s" % facility_id)
|
|
|
|
func _command_result_from_dictionary(
|
|
command_id: int,
|
|
result: Dictionary,
|
|
fallback_message: String
|
|
) -> CommandResult:
|
|
if bool(result.get(&"ok", result.get("ok", false))):
|
|
return CommandResult.succeeded(command_id, result.duplicate(true))
|
|
return CommandResult.failed(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
command_id,
|
|
String(result.get(&"message", result.get("message", fallback_message))),
|
|
result.duplicate(true)
|
|
)
|
|
|
|
func _register_facility_mana_consumer(instance: Dictionary) -> void:
|
|
var instance_id := StringName(instance.get(&"instance_id", &""))
|
|
var definition_id := StringName(instance.get(&"definition_id", &""))
|
|
var definition := definition_registry.get_facility(definition_id)
|
|
if instance_id.is_empty() or definition == null or definition.maintenance_mana <= 0:
|
|
return
|
|
var priority := ManaNetworkService.ConsumerPriority.NORMAL
|
|
if &"entrance" in definition.tags or &"medical" in definition.tags:
|
|
priority = ManaNetworkService.ConsumerPriority.ESSENTIAL
|
|
if mana_network_service.register_consumer(instance_id, definition.maintenance_mana, priority):
|
|
mana_network_service.connect_nodes(instance_id, STARTER_MANA_BUS)
|
|
|
|
func _register_operational_facility(construction_instance: Dictionary) -> void:
|
|
var instance_id := StringName(construction_instance.get(&"instance_id", &""))
|
|
var definition_id := StringName(construction_instance.get(&"definition_id", &""))
|
|
var definition := definition_registry.get_facility(definition_id)
|
|
if instance_id.is_empty() or definition == null or facility_service.has_instance(instance_id):
|
|
return
|
|
economy_service.register_account(_facility_revenue_account(instance_id))
|
|
facility_service.create_instance({
|
|
&"instance_id": instance_id,
|
|
&"definition_id": definition_id,
|
|
&"origin": construction_instance.get(&"origin", Vector3i.ZERO),
|
|
&"rotation": construction_instance.get(&"rotation", 0),
|
|
&"occupied_cells": construction_instance.get(&"occupied_cells", []),
|
|
&"local_inventory_id": StringName("facility.inventory:%s" % instance_id),
|
|
&"power_available": int(_state.get_resource(&"mana")) > 0,
|
|
&"customer_capacity": definition.customer_capacity,
|
|
&"service_attribute": definition.service_attribute,
|
|
&"capacity": maxi(definition.capacity, 1),
|
|
&"queue_capacity": definition.queue_capacity,
|
|
&"base_service_minutes": definition.base_service_minutes,
|
|
&"base_quality": definition.base_quality,
|
|
&"base_price": definition.base_price,
|
|
&"staff_min": definition.staff_min,
|
|
&"staff_max": definition.staff_max,
|
|
&"created_at": int(construction_instance.get(&"completed_at", _clock.absolute_minute)),
|
|
})
|
|
|
|
func _validate_service_start(request: ServiceRequest, _facility: FacilityInstance, absolute_minute: int) -> Dictionary:
|
|
if int(_state.get_resource(&"mana")) == 0:
|
|
return {&"ok": false, &"message": "The tower has no stored Mana."}
|
|
for guest_id: StringName in request.guest_ids:
|
|
var guest := guest_service.get_guest(guest_id)
|
|
if guest == null:
|
|
continue
|
|
if guest.state != GuestRuntime.State.QUEUING \
|
|
or guest.queue_request_id != request.request_id \
|
|
or guest.target_facility_id != request.facility_id:
|
|
return {&"ok": false, &"message": "Guest and Facility queue ownership do not match."}
|
|
if guest.transition_tick == absolute_minute \
|
|
and guest.transitions_this_tick >= GuestService.MAX_TRANSITIONS_PER_TICK:
|
|
return {&"ok": false, &"message": "Guest transition budget is exhausted for this minute."}
|
|
for reservation_id: StringName in request.reserved_inventory_ids:
|
|
var reservation := inventory_service.get_reservation(reservation_id)
|
|
if reservation == null or not reservation.is_active() or reservation.is_expired(absolute_minute) \
|
|
or inventory_service.is_stack_expired(reservation.stack_id, absolute_minute):
|
|
return {&"ok": false, &"message": "A required inventory reservation is unavailable or expired."}
|
|
var payer_account := _guest_wallet_account(request.guest_ids[0])
|
|
if not economy_service.has_account(payer_account) \
|
|
or economy_service.get_balance(payer_account) < request.quoted_price:
|
|
return {&"ok": false, &"message": "The guest cannot pay the quoted service price."}
|
|
return {&"ok": true}
|
|
|
|
func _complete_service_transaction(
|
|
service: ServiceInstance,
|
|
request: ServiceRequest,
|
|
facility: FacilityInstance,
|
|
absolute_minute: int
|
|
) -> Variant:
|
|
# A sale is only observable after its inventory, rewards and cleanliness
|
|
# effects commit. Rollback removes provisional receipts before scope exit.
|
|
economy_service.begin_notification_scope()
|
|
var result: Variant = _apply_service_transaction(service, request, facility, absolute_minute)
|
|
if result is bool and result:
|
|
_state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT))
|
|
economy_service.end_notification_scope()
|
|
return result
|
|
|
|
|
|
func _apply_service_transaction(
|
|
service: ServiceInstance,
|
|
request: ServiceRequest,
|
|
facility: FacilityInstance,
|
|
absolute_minute: int
|
|
) -> Variant:
|
|
for guest_id: StringName in service.guest_ids:
|
|
var guest := guest_service.get_guest(guest_id)
|
|
if guest == null:
|
|
continue
|
|
if guest.state != GuestRuntime.State.RECEIVING_SERVICE \
|
|
or guest.current_service_id != service.service_id \
|
|
or guest.target_facility_id != facility.instance_id:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED, 0,
|
|
"Guest and Facility service ownership do not match."
|
|
)
|
|
if guest.transition_tick == absolute_minute \
|
|
and guest.transitions_this_tick >= GuestService.MAX_TRANSITIONS_PER_TICK:
|
|
return CommandResult.failed(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED, 0,
|
|
"Guest transition budget is exhausted for this minute."
|
|
)
|
|
var inventory_before := inventory_service.create_snapshot()
|
|
var economy_before := economy_service.create_snapshot()
|
|
var state_before: Dictionary = _state.create_snapshot()
|
|
if not service.inventory_reservations.is_empty():
|
|
var consumed := inventory_service.consume_reservations(
|
|
service.inventory_reservations,
|
|
StringName("service.consume.%s" % service.service_id),
|
|
absolute_minute
|
|
)
|
|
if not bool(consumed.get(&"ok", false)):
|
|
return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, "Reserved service inventory could not be consumed.", consumed)
|
|
|
|
if service.price_snapshot > 0:
|
|
var payer_account := _guest_wallet_account(request.guest_ids[0])
|
|
var revenue_account := _facility_revenue_account(facility.instance_id)
|
|
if not economy_service.has_account(payer_account) or not economy_service.has_account(revenue_account):
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
return CommandResult.failed(CommandResult.Code.NOT_FOUND, 0, "A service settlement account is missing.")
|
|
var sale_entries: Array = [
|
|
LedgerEntry.new().configure(
|
|
&"", absolute_minute, revenue_account, payer_account,
|
|
service.price_snapshot, &"SERVICE_SALE", facility.instance_id
|
|
),
|
|
LedgerEntry.new().configure(
|
|
&"", absolute_minute, PLAYER_CASH_ACCOUNT, revenue_account,
|
|
service.price_snapshot, &"SERVICE_CASH_SWEEP", facility.instance_id
|
|
),
|
|
]
|
|
var payment := economy_service.post(
|
|
sale_entries,
|
|
StringName("service.payment.%s" % service.service_id)
|
|
)
|
|
if not payment.is_success():
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
economy_service.restore_snapshot(economy_before)
|
|
_state.restore_snapshot(state_before)
|
|
return payment
|
|
|
|
var definition := definition_registry.get_facility(facility.definition_id)
|
|
if definition != null:
|
|
for item_id: StringName in _sorted_name_keys(definition.inventory_outputs):
|
|
var output := inventory_service.add_stack(
|
|
facility.local_inventory_id,
|
|
item_id,
|
|
int(definition.inventory_outputs[item_id]),
|
|
service.quality_snapshot,
|
|
-1,
|
|
absolute_minute
|
|
)
|
|
if not bool(output.get(&"ok", false)):
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
economy_service.restore_snapshot(economy_before)
|
|
_state.restore_snapshot(state_before)
|
|
return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, "Facility outputs could not be committed.", output)
|
|
var progression_before: Dictionary = {}
|
|
if definition != null and definition.faith_per_service > 0:
|
|
progression_before = progression_service.create_snapshot()
|
|
var next_faith := int(progression_service.metrics[&"faith_lifetime"]) + definition.faith_per_service
|
|
if not progression_service.update_metrics({&"faith_lifetime":next_faith}) or not _state.apply_resource_deltas({&"faith":definition.faith_per_service}):
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
economy_service.restore_snapshot(economy_before)
|
|
_state.restore_snapshot(state_before)
|
|
progression_service.restore_snapshot(progression_before)
|
|
return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, "Service Faith reward could not be committed.")
|
|
if definition != null and definition.cleanliness_decay_per_service > 0:
|
|
if not facility_cleanliness.record_use(facility.instance_id, service.service_id, definition.cleanliness_decay_per_service):
|
|
inventory_service.restore_snapshot(inventory_before)
|
|
economy_service.restore_snapshot(economy_before)
|
|
_state.restore_snapshot(state_before)
|
|
if not progression_before.is_empty(): progression_service.restore_snapshot(progression_before)
|
|
return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, "Facility cleanliness could not be committed.")
|
|
return true
|
|
|
|
func _on_facility_state_changed(instance_id: StringName, old_state: int, new_state: int, reasons: Array[StringName]) -> void:
|
|
_publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"), &"FACILITY_STATE_CHANGED", _runtime_event_minute(),
|
|
&"facility_service", [instance_id],
|
|
{&"old_state": old_state, &"state": new_state, &"reasons": reasons}, 0
|
|
))
|
|
|
|
func _on_service_started(service: ServiceInstance) -> void:
|
|
var effective_minute := _runtime_event_minute()
|
|
for guest_id: StringName in service.guest_ids:
|
|
var guest := guest_service.get_guest(guest_id)
|
|
if guest == null:
|
|
continue
|
|
var wallet_account := _guest_wallet_account(guest_id)
|
|
if economy_service.has_account(wallet_account):
|
|
guest.wallet_gold = economy_service.get_balance(wallet_account)
|
|
var synchronized := guest_service.begin_service(
|
|
guest_id, service.facility_id, service.service_id, effective_minute
|
|
)
|
|
if not bool(synchronized.get(&"ok", false)):
|
|
_publish_actor_event(
|
|
&"GUEST_SERVICE_SYNC_FAILED", &"simulation_runtime",
|
|
[guest_id, service.service_id], synchronized
|
|
)
|
|
_publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"), &"SERVICE_STARTED", effective_minute,
|
|
&"facility_service", [service.facility_id, service.service_id], service.create_snapshot(), 0
|
|
))
|
|
|
|
func _on_service_interrupted(service: ServiceInstance, reason: StringName) -> void:
|
|
_publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"), &"SERVICE_INTERRUPTED", _runtime_event_minute(),
|
|
&"facility_service", [service.facility_id, service.service_id],
|
|
{&"reason": reason, &"service": service.create_snapshot()}, 0
|
|
))
|
|
|
|
func _on_service_completed(service: ServiceInstance) -> void:
|
|
var effective_minute := _runtime_event_minute()
|
|
for guest_id: StringName in service.guest_ids:
|
|
var guest := guest_service.get_guest(guest_id)
|
|
if guest == null:
|
|
continue
|
|
var wallet_account := _guest_wallet_account(guest_id)
|
|
if economy_service.has_account(wallet_account):
|
|
guest.wallet_gold = economy_service.get_balance(wallet_account)
|
|
var synchronized := guest_service.complete_service(
|
|
guest_id,
|
|
service.service_id,
|
|
effective_minute,
|
|
_service_satisfaction_sample(service, guest),
|
|
_service_need_effects(service.facility_id)
|
|
)
|
|
if not bool(synchronized.get(&"ok", false)):
|
|
_publish_actor_event(
|
|
&"GUEST_SERVICE_SYNC_FAILED", &"simulation_runtime",
|
|
[guest_id, service.service_id], synchronized
|
|
)
|
|
_publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"), &"SERVICE_COMPLETED", effective_minute,
|
|
&"facility_service", [service.facility_id, service.service_id], service.create_snapshot(), 0
|
|
))
|
|
|
|
func _on_service_failed(service: ServiceInstance, reason: StringName) -> void:
|
|
var effective_minute := _runtime_event_minute()
|
|
for reservation_id: StringName in service.inventory_reservations:
|
|
inventory_service.release_reservation(
|
|
reservation_id,
|
|
StringName("service.failed.%s.%s" % [service.service_id, reservation_id]),
|
|
effective_minute
|
|
)
|
|
for guest_id: StringName in service.guest_ids:
|
|
var guest := guest_service.get_guest(guest_id)
|
|
if guest != null and guest.current_service_id == service.service_id:
|
|
guest_service.interrupt_service(
|
|
guest_id, service.service_id, effective_minute
|
|
)
|
|
_publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"), &"SERVICE_FAILED", effective_minute,
|
|
&"facility_service", [service.facility_id, service.service_id],
|
|
{&"reason": reason, &"service": service.create_snapshot()}, 0
|
|
))
|
|
|
|
func _on_guest_spawned(guest: GuestRuntime) -> void:
|
|
if _operations_flushing_guest_spawn_notifications.has(guest.guest_id):
|
|
return
|
|
var actor_id := _guest_path_actor_id(guest.guest_id)
|
|
var projected := path_service.actor_enter_cell(
|
|
actor_id,
|
|
guest.current_cell,
|
|
_runtime_event_minute()
|
|
)
|
|
if projected:
|
|
_movement_credits[actor_id] = {
|
|
&"credit": 0,
|
|
&"updated_at": _runtime_event_minute(),
|
|
}
|
|
var payload := guest.create_snapshot()
|
|
payload[&"path_actor_id"] = actor_id
|
|
payload[&"path_projected"] = projected
|
|
payload[&"path_error"] = path_service.last_error if not projected else ""
|
|
_publish_actor_event(&"GUEST_SPAWNED", &"guest_service", [guest.guest_id], payload)
|
|
|
|
func _on_guest_state_changed(
|
|
guest_id: StringName,
|
|
old_state: int,
|
|
new_state: int,
|
|
reason: StringName
|
|
) -> void:
|
|
_publish_actor_event(&"GUEST_STATE_CHANGED", &"guest_service", [guest_id], {
|
|
&"old_state": old_state,
|
|
&"state": new_state,
|
|
&"reason": reason,
|
|
})
|
|
|
|
func _on_guest_queue_abandoned(
|
|
guest_id: StringName,
|
|
facility_id: StringName,
|
|
request_id: StringName
|
|
) -> void:
|
|
_publish_actor_event(
|
|
&"GUEST_QUEUE_ABANDONED", &"guest_service",
|
|
[guest_id, facility_id, request_id], {}
|
|
)
|
|
|
|
func _on_guest_recovery_started(
|
|
guest_id: StringName,
|
|
recovery_kind: StringName,
|
|
target_cell: Vector3i
|
|
) -> void:
|
|
_publish_actor_event(&"GUEST_RECOVERY_STARTED", &"guest_service", [guest_id], {
|
|
&"recovery_kind": recovery_kind,
|
|
&"target_cell": target_cell,
|
|
})
|
|
|
|
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()
|
|
var payload := persistent_record.duplicate(true)
|
|
payload[&"path_actor_id"] = actor_id
|
|
payload[&"path_projection_removed"] = path_removed
|
|
_publish_actor_event(&"GUEST_DESPAWNED", &"guest_service", [guest_id], payload)
|
|
|
|
func _on_staff_registered(staff: StaffRuntime) -> void:
|
|
_staffing_projection_enabled = true
|
|
var actor_id := _staff_path_actor_id(staff.staff_id)
|
|
var projected := path_service.actor_enter_cell(
|
|
actor_id,
|
|
staff.current_cell,
|
|
_runtime_event_minute()
|
|
)
|
|
if projected:
|
|
_movement_credits[actor_id] = {
|
|
&"credit": 0,
|
|
&"updated_at": _runtime_event_minute(),
|
|
}
|
|
var payload := staff.create_snapshot()
|
|
payload[&"path_actor_id"] = actor_id
|
|
payload[&"path_projected"] = projected
|
|
payload[&"path_error"] = path_service.last_error if not projected else ""
|
|
_publish_actor_event(&"STAFF_REGISTERED", &"staff_service", [staff.staff_id], payload)
|
|
|
|
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], {
|
|
&"reason": reason,
|
|
&"path_actor_id": actor_id,
|
|
&"path_projection_removed": path_removed,
|
|
})
|
|
|
|
func _on_shift_registered(shift: ShiftInstance) -> void:
|
|
_publish_actor_event(
|
|
&"STAFF_SHIFT_REGISTERED", &"staff_service",
|
|
[shift.staff_id, shift.shift_instance_id], shift.create_snapshot()
|
|
)
|
|
|
|
func _on_shift_state_changed(staff_id: StringName, shift_id: StringName, state: int) -> void:
|
|
_publish_actor_event(
|
|
&"STAFF_SHIFT_STATE_CHANGED", &"staff_service",
|
|
[staff_id, shift_id], {&"state": state}
|
|
)
|
|
|
|
func _on_staff_task_registered(task: StaffTask) -> void:
|
|
_publish_actor_event(&"STAFF_TASK_REGISTERED", &"staff_service", [task.task_id], task.create_snapshot())
|
|
|
|
func _on_staff_task_assigned(task_id: StringName, staff_id: StringName) -> void:
|
|
var task := staff_service.get_task(task_id)
|
|
if task != null and task.state == StaffTask.State.RESERVED:
|
|
task.begin_moving(_runtime_event_minute())
|
|
_publish_actor_event(&"STAFF_TASK_ASSIGNED", &"staff_service", [task_id, staff_id], {})
|
|
|
|
func _on_staff_task_reopened(task_id: StringName, reason: StringName) -> void:
|
|
_publish_actor_event(&"STAFF_TASK_REOPENED", &"staff_service", [task_id], {&"reason": reason})
|
|
|
|
func _on_staff_task_completed(task_id: StringName, staff_id: StringName) -> void:
|
|
var task := staff_service.get_task(task_id)
|
|
if task != null and task.task_type == CleaningBridge.TASK_TYPE:
|
|
if not cleaning_bridge.completed(task, staff_service.get_staff(staff_id)):
|
|
_latch_infrastructure_failure(&"CLEANING_COMPLETION_FAILED", _runtime_event_minute(), "Cleaning task effect could not be committed.")
|
|
if task != null and task.task_type == &"rescue_transport":
|
|
var patient := staff_service.get_staff(task.target_entity_id)
|
|
var clinic := facility_service.get_instance(task.rescue_facility_id)
|
|
if patient != null and clinic != null and patient.current_cell == task.target_cell and clinic.operation_state == FacilityInstance.OperationState.OPERATING:
|
|
patient.health = 100
|
|
patient.fatigue = 0
|
|
patient.transition_to(StaffRuntime.State.OFF_DUTY, &"RESCUE_TREATED", _runtime_event_minute())
|
|
_publish_actor_event(&"STAFF_RESCUED", &"staff_service", [patient.staff_id, staff_id, clinic.instance_id], {})
|
|
_publish_actor_event(&"STAFF_TASK_COMPLETED", &"staff_service", [task_id, staff_id], {})
|
|
|
|
func _on_authority_heat_changed(
|
|
old_value: int,
|
|
new_value: int,
|
|
source_id: StringName
|
|
) -> void:
|
|
var entity_ids: Array[StringName] = []
|
|
if not source_id.is_empty():
|
|
entity_ids.append(source_id)
|
|
_publish_actor_event(&"AUTHORITY_HEAT_CHANGED", &"authority_service", entity_ids, {
|
|
&"old_value": old_value,
|
|
&"new_value": new_value,
|
|
&"source_id": source_id,
|
|
})
|
|
|
|
func _on_authority_incident_created(incident: AuthorityIncident) -> void:
|
|
_publish_actor_event(
|
|
&"INCIDENT_CREATED",
|
|
&"authority_service",
|
|
[incident.incident_id],
|
|
incident.create_snapshot()
|
|
)
|
|
|
|
func _on_authority_incident_detector_added(
|
|
incident_id: StringName,
|
|
detector_id: StringName
|
|
) -> void:
|
|
var incident := authority_service.get_incident(incident_id)
|
|
_publish_actor_event(
|
|
&"INCIDENT_DETECTOR_ADDED",
|
|
&"authority_service",
|
|
[incident_id, detector_id],
|
|
{&"incident": incident.create_snapshot() if incident != null else {}}
|
|
)
|
|
|
|
func _on_authority_evidence_registered(evidence: AuthorityEvidence) -> void:
|
|
_publish_actor_event(
|
|
&"AUTHORITY_EVIDENCE_REGISTERED",
|
|
&"authority_service",
|
|
[evidence.evidence_id, evidence.incident_id, evidence.facility_id],
|
|
evidence.create_snapshot()
|
|
)
|
|
|
|
func _on_authority_evidence_discovered(
|
|
evidence: AuthorityEvidence,
|
|
method: StringName
|
|
) -> void:
|
|
_publish_actor_event(
|
|
&"AUTHORITY_EVIDENCE_DISCOVERED",
|
|
&"authority_service",
|
|
[evidence.evidence_id, evidence.incident_id, evidence.facility_id],
|
|
{
|
|
&"method": method,
|
|
&"evidence": evidence.create_snapshot(),
|
|
&"authority_evidence_points": authority_service.get_authority_state().evidence_points,
|
|
}
|
|
)
|
|
|
|
func _on_authority_inspection_scheduled(inspection: AuthorityInspection) -> void:
|
|
_publish_actor_event(
|
|
&"INSPECTION_SCHEDULED",
|
|
&"authority_service",
|
|
[inspection.inspection_id, inspection.inspector_id],
|
|
inspection.create_snapshot()
|
|
)
|
|
|
|
func _on_authority_inspection_state_changed(
|
|
inspection_id: StringName,
|
|
old_state: int,
|
|
new_state: int,
|
|
reason: StringName
|
|
) -> void:
|
|
var inspection := authority_service.get_inspection(inspection_id)
|
|
var entity_ids: Array[StringName] = [inspection_id]
|
|
if inspection != null:
|
|
entity_ids.append(inspection.inspector_id)
|
|
var payload := {
|
|
&"old_state": StringName(AuthorityInspection.State.keys()[old_state]),
|
|
&"new_state": StringName(AuthorityInspection.State.keys()[new_state]),
|
|
&"reason": reason,
|
|
}
|
|
_publish_actor_event(
|
|
&"INSPECTION_STATE_CHANGED",
|
|
&"authority_service",
|
|
entity_ids,
|
|
payload
|
|
)
|
|
if new_state == AuthorityInspection.State.ARRIVING:
|
|
_publish_actor_event(
|
|
&"INSPECTION_STARTED",
|
|
&"authority_service",
|
|
entity_ids,
|
|
payload
|
|
)
|
|
|
|
func _on_authority_resolution_applied(
|
|
inspection_id: StringName,
|
|
resolution: Dictionary
|
|
) -> void:
|
|
_publish_actor_event(
|
|
&"INSPECTION_RESOLVED",
|
|
&"authority_service",
|
|
[inspection_id, StringName(resolution.get(&"resolution_id", ""))],
|
|
resolution.duplicate(true)
|
|
)
|
|
|
|
func _on_defense_raid_created(raid_id: StringName) -> void:
|
|
_publish_actor_event(
|
|
&"DEFENSE_RAID_CREATED",
|
|
&"defense_service",
|
|
[raid_id],
|
|
defense_service.get_raid(raid_id)
|
|
)
|
|
|
|
func _on_defense_raid_state_changed(
|
|
raid_id: StringName,
|
|
old_state: int,
|
|
new_state: int,
|
|
reason: StringName
|
|
) -> void:
|
|
_publish_actor_event(
|
|
&"DEFENSE_RAID_STATE_CHANGED",
|
|
&"defense_service",
|
|
[raid_id],
|
|
{
|
|
&"old_state": StringName(DefenseService.RaidState.keys()[old_state]),
|
|
&"new_state": StringName(DefenseService.RaidState.keys()[new_state]),
|
|
&"reason": reason,
|
|
}
|
|
)
|
|
|
|
func _on_defense_active_skill_applied(
|
|
raid_id: StringName,
|
|
action_id: StringName,
|
|
round_number: int
|
|
) -> void:
|
|
_publish_actor_event(
|
|
&"DEFENSE_SKILL_APPLIED",
|
|
&"defense_service",
|
|
[raid_id, action_id],
|
|
{&"round_number": round_number}
|
|
)
|
|
|
|
func _on_defense_round_resolved(raid_id: StringName, resolution: Dictionary) -> void:
|
|
var entity_ids: Array[StringName] = [raid_id]
|
|
var resolution_id := StringName(resolution.get(&"resolution_id", ""))
|
|
var wave_id := StringName(resolution.get(&"wave_id", ""))
|
|
if not resolution_id.is_empty():
|
|
entity_ids.append(resolution_id)
|
|
if not wave_id.is_empty():
|
|
entity_ids.append(wave_id)
|
|
_publish_actor_event(
|
|
&"DEFENSE_ROUND_RESOLVED",
|
|
&"defense_service",
|
|
entity_ids,
|
|
resolution.duplicate(true)
|
|
)
|
|
|
|
func _on_defense_raid_resolved(raid_id: StringName, outcome: Dictionary) -> void:
|
|
var entity_ids: Array[StringName] = [raid_id]
|
|
var resolution_id := StringName(outcome.get(&"resolution_id", ""))
|
|
if not resolution_id.is_empty():
|
|
entity_ids.append(resolution_id)
|
|
_publish_actor_event(
|
|
&"DEFENSE_RAID_RESOLVED",
|
|
&"defense_service",
|
|
entity_ids,
|
|
outcome.duplicate(true)
|
|
)
|
|
|
|
func _publish_authority_evaluation(details: Dictionary) -> void:
|
|
var entity_ids: Array[StringName] = []
|
|
var inspection_id := StringName(details.get(&"inspection_id", ""))
|
|
if not inspection_id.is_empty():
|
|
entity_ids.append(inspection_id)
|
|
_publish_actor_event(
|
|
&"AUTHORITY_INSPECTION_EVALUATED",
|
|
&"authority_service",
|
|
entity_ids,
|
|
details.duplicate(true)
|
|
)
|
|
|
|
func _on_relationship_registered(state: RelationshipState) -> void:
|
|
_publish_actor_event(
|
|
&"RELATIONSHIP_REGISTERED",
|
|
&"relationship_service",
|
|
[state.actor_a_id, state.actor_b_id, state.relationship_id],
|
|
state.create_snapshot()
|
|
)
|
|
|
|
func _on_relationship_value_changed(
|
|
relationship_id: StringName,
|
|
metric_id: StringName,
|
|
old_value: int,
|
|
new_value: int
|
|
) -> void:
|
|
var state := relationship_service.get_relationship(relationship_id)
|
|
var actor_ids: Array[StringName] = [relationship_id]
|
|
if state != null:
|
|
actor_ids = [state.actor_a_id, state.actor_b_id, relationship_id]
|
|
_publish_actor_event(&"RELATIONSHIP_VALUE_CHANGED", &"relationship_service", actor_ids, {
|
|
&"metric_id": metric_id,
|
|
&"old_value": old_value,
|
|
&"new_value": new_value,
|
|
})
|
|
|
|
func _on_relationship_shield_changed(
|
|
relationship_id: StringName,
|
|
old_value: int,
|
|
new_value: int
|
|
) -> void:
|
|
var state := relationship_service.get_relationship(relationship_id)
|
|
var actor_ids: Array[StringName] = [relationship_id]
|
|
if state != null:
|
|
actor_ids = [state.actor_a_id, state.actor_b_id, relationship_id]
|
|
_publish_actor_event(&"RELATIONSHIP_SHIELD_CHANGED", &"relationship_service", actor_ids, {
|
|
&"old_value": old_value,
|
|
&"new_value": new_value,
|
|
})
|
|
|
|
func _on_relationship_route_lock_changed(
|
|
relationship_id: StringName,
|
|
old_lock: int,
|
|
new_lock: int,
|
|
route_id: StringName
|
|
) -> void:
|
|
var state := relationship_service.get_relationship(relationship_id)
|
|
var actor_ids: Array[StringName] = [relationship_id]
|
|
if state != null:
|
|
actor_ids = [state.actor_a_id, state.actor_b_id, relationship_id]
|
|
_publish_actor_event(&"RELATIONSHIP_ROUTE_LOCK_CHANGED", &"relationship_service", actor_ids, {
|
|
&"old_lock": old_lock,
|
|
&"new_lock": new_lock,
|
|
&"route_id": route_id,
|
|
})
|
|
|
|
func _on_event_effect_transaction_started() -> void:
|
|
economy_service.begin_notification_scope()
|
|
|
|
|
|
func _on_event_effect_transaction_finished() -> void:
|
|
# A nested action batch ends before the enclosing choice cost commits.
|
|
# Keep the authoritative mirror current without releasing observer receipts.
|
|
if economy_service.notification_scope_depth() == 1:
|
|
_state.set_resource(&"gold", economy_service.get_balance(PLAYER_CASH_ACCOUNT))
|
|
economy_service.end_notification_scope()
|
|
|
|
|
|
func _on_event_enqueued(instance: EventInstance) -> void:
|
|
_publish_actor_event(
|
|
&"EVENT_ENQUEUED", &"event_service", instance.actor_ids,
|
|
instance.create_snapshot()
|
|
)
|
|
|
|
func _on_event_started(instance: EventInstance) -> void:
|
|
_publish_actor_event(
|
|
&"EVENT_STARTED", &"event_service", instance.actor_ids,
|
|
instance.create_snapshot()
|
|
)
|
|
|
|
func _on_event_finished(instance: EventInstance) -> void:
|
|
_resolve_relationship_threshold_event(instance)
|
|
_publish_actor_event(
|
|
&"EVENT_FINISHED", &"event_service", instance.actor_ids,
|
|
instance.create_snapshot()
|
|
)
|
|
|
|
func _on_event_cancelled(instance: EventInstance, reason: StringName) -> void:
|
|
_consume_relationship_candidate_from_event(instance)
|
|
# Placement rejection occurs before an Event starts and never owns a pause.
|
|
if reason != &"PLACEMENT_PROTECTION" and event_service.get_foreground_instance() == null and not event_service.pause_requested:
|
|
_clock.set_paused(false)
|
|
_publish_actor_event(&"EVENT_CANCELLED", &"event_service", instance.actor_ids, {
|
|
&"reason": reason,
|
|
&"event": instance.create_snapshot(),
|
|
})
|
|
|
|
func _on_event_choice_resolved(
|
|
instance_id: StringName,
|
|
choice_id: StringName,
|
|
result: Dictionary
|
|
) -> void:
|
|
var instance := event_service.get_instance(instance_id)
|
|
var actor_ids: Array[StringName] = []
|
|
if instance != null:
|
|
actor_ids = instance.actor_ids.duplicate()
|
|
_publish_actor_event(&"EVENT_CHOICE_RESOLVED", &"event_service", actor_ids, {
|
|
&"event_instance_id": instance_id,
|
|
&"choice_id": choice_id,
|
|
&"result": result.duplicate(true),
|
|
})
|
|
|
|
func _resolve_relationship_threshold_event(instance: EventInstance) -> void:
|
|
if instance.source_event_id != &"relationship.threshold":
|
|
return
|
|
var raw_candidate: Variant = instance.context.get(&"relationship_candidate", null)
|
|
if not raw_candidate is Dictionary:
|
|
return
|
|
var candidate: Dictionary = raw_candidate
|
|
if instance.selected_choice_id == &"acknowledge":
|
|
var relationship_id := StringName(candidate.get(&"relationship_id", ""))
|
|
var metric_id := StringName(candidate.get(&"metric_id", ""))
|
|
var state := relationship_service.get_relationship(relationship_id)
|
|
if state != null and RelationshipState.METRIC_IDS.has(metric_id):
|
|
var threshold := int(candidate.get(&"threshold", state.get_metric(metric_id)))
|
|
var delta := maxi(threshold - state.get_metric(metric_id), 0)
|
|
if delta > 0:
|
|
relationship_service.apply_value_change(
|
|
relationship_id,
|
|
metric_id,
|
|
delta,
|
|
_runtime_event_minute(),
|
|
{&"important": true, &"allow_threshold_crossing": true}
|
|
)
|
|
_consume_relationship_candidate_from_event(instance)
|
|
|
|
func _consume_relationship_candidate_from_event(instance: EventInstance) -> void:
|
|
var raw_candidate: Variant = instance.context.get(&"relationship_candidate", null)
|
|
if not raw_candidate is Dictionary:
|
|
return
|
|
var candidate_id := StringName(raw_candidate.get(&"candidate_id", ""))
|
|
if not candidate_id.is_empty():
|
|
relationship_service.consume_threshold_candidate(candidate_id)
|
|
|
|
func _publish_actor_event(
|
|
event_type: StringName,
|
|
source_system: StringName,
|
|
entity_ids: Array[StringName],
|
|
payload: Dictionary
|
|
) -> void:
|
|
_publish_domain_event(SimulationDomainEvent.new(
|
|
id_generator.next_id(&"domain_event"),
|
|
event_type,
|
|
_runtime_event_minute(),
|
|
source_system,
|
|
entity_ids,
|
|
payload,
|
|
0
|
|
))
|
|
|
|
func _service_need_effects(facility_id: StringName) -> Dictionary:
|
|
var effects := {
|
|
&"hunger": 0, &"sleep": 0, &"hygiene": 0,
|
|
&"medical": 0, &"leisure": 0, &"intimacy": 0,
|
|
}
|
|
var facility := facility_service.get_instance(facility_id)
|
|
if facility == null:
|
|
return effects
|
|
var definition := definition_registry.get_facility(facility.definition_id)
|
|
if definition == null:
|
|
return effects
|
|
if &"dining" in definition.tags:
|
|
effects[&"hunger"] = -70
|
|
effects[&"leisure"] = -10
|
|
if &"lodging" in definition.tags:
|
|
effects[&"sleep"] = -80
|
|
effects[&"hygiene"] = mini(int(effects[&"hygiene"]), -10)
|
|
if &"social" in definition.tags or &"leisure" in definition.tags:
|
|
effects[&"leisure"] = mini(int(effects[&"leisure"]), -40)
|
|
if &"hygiene" in definition.tags or &"bath" in definition.tags:
|
|
effects[&"hygiene"] = -75
|
|
if &"medical" in definition.tags:
|
|
effects[&"medical"] = -85
|
|
elif &"wellness" in definition.tags:
|
|
effects[&"medical"] = -20
|
|
if &"adult_only" in definition.tags or &"restricted_service" in definition.tags:
|
|
effects[&"intimacy"] = -75
|
|
return effects
|
|
|
|
func _parse_world_cell(value: Variant) -> Variant:
|
|
if value is Vector3i:
|
|
return value
|
|
if value is Array and value.size() == 3:
|
|
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
|
|
return null
|
|
|
|
func _guest_state_index(value: Variant) -> int:
|
|
if value is int:
|
|
return value if value >= 0 and value < GuestRuntime.State.size() else -1
|
|
var normalized := String(value).to_upper()
|
|
return GuestRuntime.State.keys().find(normalized)
|
|
|
|
func _publish_snapshot(_absolute_minute: int) -> void:
|
|
latest_snapshot = create_snapshot()
|
|
_latest_digest_dirty = true
|
|
if not snapshot_published.get_connections().is_empty():
|
|
snapshot_published.emit(latest_snapshot, latest_digest)
|
|
state_updated.emit(latest_snapshot)
|
|
|
|
|
|
func _execute_purchase_supplies(command: SimulationCommand) -> CommandResult:
|
|
return supply_management.purchase(self, command)
|
|
|
|
|
|
func _execute_set_supply_auto_purchase(command: SimulationCommand) -> CommandResult:
|
|
return supply_management.set_auto_purchase(self, command)
|
|
|
|
|
|
func _execute_set_kitchen_production(command: SimulationCommand) -> CommandResult:
|
|
return supply_management.set_production(self, command)
|
|
|
|
|
|
func get_supply_management_report() -> Dictionary:
|
|
return supply_management.get_report(self)
|
|
|
|
|
|
func preview_supply_purchase(warehouse_id: StringName, item_id: StringName, quantity: int) -> Dictionary:
|
|
return supply_management.preview_purchase(self, warehouse_id, item_id, quantity)
|
|
|
|
|
|
# Existing saves retain each facility's stored base rate; reception never quotes
|
|
# a new direct sale. Already queued/in-service requests retain their old quote.
|
|
func _is_anri_manager(staff: StaffRuntime) -> bool:
|
|
return staff.staff_id == &"heroine_anri" and staff.employment_kind == StaffRuntime.EmploymentKind.UNIQUE and staff.role_id == &"innkeeper"
|
|
|
|
|
|
func get_floor_price_multiplier(floor_y: int) -> int:
|
|
var anri := staff_service.get_staff(&"heroine_anri")
|
|
if anri == null or not _is_anri_manager(anri): return 10000
|
|
var assignment := staff_service.get_work_assignment(anri.staff_id)
|
|
var facility := facility_service.get_instance(StringName(assignment.get(&"facility_id", &"")))
|
|
if facility == null or facility.origin.y != floor_y: return 10000
|
|
var definition := mvp_content_catalog.get_entry(&"unique_characters", anri.staff_id)
|
|
return int(definition.get(&"mechanical_effects", {}).get(&"assigned_floor_revenue_multiplier_bp", 10000))
|
|
|
|
|
|
## Original 6.2 / 6.4: Amelia's assigned chapel receives triple donations.
|
|
## This is a placement passive; attendance does not remove it. Existing service
|
|
## quotes remain frozen by ServiceRequest/ServiceInstance as for Anri.
|
|
func get_donation_multiplier(facility: FacilityInstance) -> int:
|
|
if facility.definition_id != &"chapel": return 1
|
|
var amelia := staff_service.get_staff(&"heroine_amelia")
|
|
if amelia == null or amelia.employment_kind != StaffRuntime.EmploymentKind.UNIQUE or amelia.role_id != &"cleric": return 1
|
|
var assignment := staff_service.get_work_assignment(amelia.staff_id)
|
|
return 3 if StringName(assignment.get(&"facility_id", &"")) == facility.instance_id else 1
|
|
|
|
|
|
func get_facility_business_price(facility: FacilityInstance) -> int:
|
|
var donation := FacilityPricing.quote(_business_base_price(facility), facility.policy_current) * get_donation_multiplier(facility)
|
|
return FacilityPricing.apply_multiplier(donation, get_floor_price_multiplier(facility.origin.y))
|
|
|
|
|
|
## Owned presentation data; accepted request/service quotations stay frozen.
|
|
## Matching-surcharge charging remains pending until its inputs are specified.
|
|
func get_facility_service_pricing_readmodel(facility: FacilityInstance) -> Dictionary:
|
|
return FacilityPricing.pending_service_readmodel(
|
|
get_facility_business_price(facility), facility.service_attribute
|
|
)
|
|
|
|
|
|
func _business_base_price(facility: FacilityInstance) -> int:
|
|
return 0 if facility.definition_id == &"reception" else facility.base_price
|
|
|
|
|
|
func get_business_management_report() -> Dictionary:
|
|
var minute: int = _clock.absolute_minute
|
|
var start := (minute / SimulationKernel.MINUTES_PER_DAY) * SimulationKernel.MINUTES_PER_DAY
|
|
var totals: Dictionary = {}
|
|
for entry: LedgerEntry in economy_service.get_entries():
|
|
if entry.absolute_minute < start or entry.absolute_minute > minute:
|
|
continue
|
|
if not totals.has(entry.source_entity_id):
|
|
totals[entry.source_entity_id] = {&"revenue": 0, &"expenses": 0}
|
|
if entry.category == &"SERVICE_SALE":
|
|
totals[entry.source_entity_id][&"revenue"] += entry.amount
|
|
elif entry.category in [&"OPERATING_EXPENSE", &"STAFF_SIGNING_COST", &"EVENT_ACTION", &"EVENT_COST_COMMITTED", &"DEFENSE_SKILL_COST", &"AUTHORITY_FINE", &"REFUND"]:
|
|
totals[entry.source_entity_id][&"expenses"] += entry.amount
|
|
var rows: Array[Dictionary] = []
|
|
var kitchens: Array[Dictionary] = []
|
|
for facility: FacilityInstance in facility_service.get_instances():
|
|
var definition := definition_registry.get_facility(facility.definition_id)
|
|
if definition == null:
|
|
continue
|
|
if facility.definition_id == &"kitchen" and facility.origin.z == WorldService.LAYER_BACK:
|
|
kitchens.append({&"facility_id": facility.instance_id, &"display_name": definition.display_name, &"origin": Vector2i(facility.origin.x, facility.origin.y), &"layer": facility.origin.z, &"operation_state": StringName(FacilityInstance.OperationState.keys()[facility.operation_state])})
|
|
var amounts: Dictionary = totals.get(facility.instance_id, {})
|
|
var base := _business_base_price(facility)
|
|
var service_pricing := get_facility_service_pricing_readmodel(facility)
|
|
var queued_customers: Dictionary = {}
|
|
for request: ServiceRequest in facility_service.get_queue(facility.instance_id):
|
|
for guest_id: StringName in request.guest_ids:
|
|
queued_customers[guest_id] = true
|
|
rows.append({
|
|
&"facility_id": facility.instance_id,
|
|
&"definition_id": facility.definition_id,
|
|
&"category": definition.category,
|
|
&"display_name": definition.display_name,
|
|
&"origin": Vector2i(facility.origin.x, facility.origin.y),
|
|
&"layer": facility.origin.z,
|
|
&"state": StringName(FacilityInstance.State.keys()[facility.state]),
|
|
&"kitchen_dependency": facility_service.get_required_kitchen_report(facility.instance_id),
|
|
&"business_open": facility.operator_open,
|
|
&"price_mode": StringName(facility.policy_current.get(&"price_mode", &"standard")),
|
|
&"base_price": base,
|
|
&"price": service_pricing[&"normal_price"],
|
|
&"service_pricing": service_pricing,
|
|
&"floor_price_multiplier_bp": get_floor_price_multiplier(facility.origin.y),
|
|
&"donation_multiplier": get_donation_multiplier(facility),
|
|
&"faith_per_service": definition.faith_per_service,
|
|
&"can_price": base > 0,
|
|
&"queued_count": facility.queue_entries.size(),
|
|
&"active_count": facility.active_services.size(),
|
|
&"queued_customer_count": queued_customers.size(),
|
|
&"active_customer_count": facility.customer_count(),
|
|
&"customer_capacity": facility.customer_capacity,
|
|
&"cleanliness": facility_cleanliness.get_facility(facility.instance_id).get("current", -1),
|
|
&"cleaning_status": cleaning_bridge.status(facility.instance_id),
|
|
&"today_revenue": int(amounts.get(&"revenue", 0)),
|
|
&"today_expenses": int(amounts.get(&"expenses", 0)),
|
|
})
|
|
return {&"minute": minute, &"modes": FacilityPricing.modes(), &"facilities": rows, &"kitchens": kitchens}
|
|
|
|
|
|
func _guest_has_checked_in(guest: GuestRuntime) -> bool:
|
|
for visited_id: StringName in guest.visited_facility_ids:
|
|
var visited := facility_service.get_instance(visited_id)
|
|
if visited != null and visited.definition_id == &"reception":
|
|
return true
|
|
return false
|
|
|
|
|
|
func _service_satisfaction_sample(service: ServiceInstance, _guest: GuestRuntime) -> int:
|
|
# P1 uses a common expectation of 50 until archetypes have an explicit
|
|
# expected-quality field. Quality sensitivity is a separate concept.
|
|
var expectation := 50
|
|
return clampi(service.quality_snapshot + FacilityPricing.satisfaction_modifier(
|
|
service.policy_snapshot, service.quality_snapshot, expectation
|
|
), 0, 100)
|
|
|
|
|
|
func _release_closed_business_queues(absolute_minute: int) -> void:
|
|
for facility: FacilityInstance in facility_service.get_instances():
|
|
if facility.operator_open and facility.is_within_opening_hours(absolute_minute):
|
|
continue
|
|
for request: ServiceRequest in facility_service.get_queue(facility.instance_id):
|
|
_release_closed_business_request(facility, request, absolute_minute)
|
|
for guest: GuestRuntime in guest_service.get_guests():
|
|
if guest.state != GuestRuntime.State.MOVING:
|
|
continue
|
|
var facility := facility_service.get_instance(guest.target_facility_id)
|
|
if facility == null or (facility.operator_open and facility.is_within_opening_hours(absolute_minute)):
|
|
continue
|
|
var transition := guest_service.transition_guest(guest.guest_id, GuestRuntime.State.REPLANNING, absolute_minute, &"FACILITY_CLOSED")
|
|
if bool(transition.get(&"ok", false)):
|
|
guest.target_facility_id = &""
|
|
guest.path_handle = null
|
|
|
|
|
|
func _release_closed_business_request(facility: FacilityInstance, request: ServiceRequest, minute: int) -> void:
|
|
# Validate the entire group before any queue/state signals become observable.
|
|
for guest_id: StringName in request.guest_ids:
|
|
var guest := guest_service.get_guest(guest_id)
|
|
if guest == null:
|
|
continue
|
|
if guest.state != GuestRuntime.State.QUEUING or guest.queue_request_id != request.request_id or guest.target_facility_id != facility.instance_id:
|
|
return
|
|
if guest.transition_tick == minute and guest.transitions_this_tick >= GuestService.MAX_TRANSITIONS_PER_TICK:
|
|
return
|
|
# A group releases its one reservation and queue atomically. A transition-budget
|
|
# failure retries next minute, without taking patience or dropping any stock.
|
|
var before_inventory := inventory_service.create_snapshot()
|
|
var before_facility := facility_service.create_snapshot()
|
|
var before_guest := guest_service.create_snapshot()
|
|
var before_events := domain_events.create_snapshot()
|
|
var before_ids := id_generator.create_snapshot()
|
|
var released := false
|
|
var release := func(_guest_id: StringName = &"", _facility_id: StringName = &"", _request_id: StringName = &"") -> Dictionary:
|
|
for reservation_id: StringName in request.reserved_inventory_ids:
|
|
var result := inventory_service.release_reservation(reservation_id, StringName("business.close.%s.%s" % [request.request_id, reservation_id]), minute)
|
|
if not bool(result.get(&"ok", false)):
|
|
return result
|
|
var result := facility_service.leave_queue(facility.instance_id, request.request_id, &"FACILITY_CLOSED")
|
|
return {&"ok": result.is_success()}
|
|
var ok := true
|
|
for guest_id: StringName in request.guest_ids:
|
|
if not guest_service.has_guest(guest_id):
|
|
continue
|
|
var result: Dictionary = guest_service.release_queue_for_replanning(guest_id, minute, release if not released else Callable())
|
|
if not bool(result.get(&"ok", false)):
|
|
ok = false
|
|
break
|
|
released = true
|
|
if ok and not released:
|
|
ok = bool(release.call().get(&"ok", false))
|
|
if not ok:
|
|
inventory_service.restore_snapshot(before_inventory)
|
|
facility_service.restore_snapshot(before_facility)
|
|
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 required_credit := _step_movement_credit(path_service, path[0], path[1], [&"staff"])
|
|
var credit := _movement_credit_after_one_minute(actor_id, staff.current_cell, minute)
|
|
credit = mini(credit, required_credit + MOVEMENT_CREDIT_SCALE - 1)
|
|
if credit < required_credit:
|
|
_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 - 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"})
|
|
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:
|
|
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 := int(_state.get_resource(&"mana")) > 0
|
|
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)
|
|
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:
|
|
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()):
|
|
var connector_id := path_service.get_step_connector(path[index - 1], path[index], access)
|
|
if elevators.owns_connector(connector_id) \
|
|
or _step_movement_credit_for_connector(path_service, path[index - 1], path[index], connector_id) > MOVEMENT_CREDIT_SCALE:
|
|
var cost: int
|
|
if access.is_empty() and path_service.get_script().resource_path == "res://scripts/world/path_service.gd" \
|
|
and path[0] is Vector3i and path[path.size() - 1] is Vector3i:
|
|
cost = path_service.calculate_guest_batch_path_cost(path[0], path[path.size() - 1], path)
|
|
else:
|
|
cost = path_service.calculate_path_cost(path, access)
|
|
return ceili(float(cost) / 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"]:
|
|
if trip.has(&"companion_id"): continue
|
|
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 staff.state == StaffRuntime.State.OFF_DUTY and get_staff_rest_report(staff.staff_id).get(&"reachable", false):
|
|
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"})
|
|
|
|
|
|
## 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:
|
|
return _step_movement_credit_for_connector(path,from,to,path.get_step_connector(from,to,access))
|
|
|
|
|
|
static func _step_movement_credit_for_connector(path: PathService, from: Vector3i, to: Vector3i, connector_id: StringName) -> int:
|
|
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 is_facility_reachable(facility_id: StringName) -> bool:
|
|
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 false
|
|
# Read-only UI queries need existence, not the cheapest destination.
|
|
# Share Guest AI's candidates and path rules without caching topology state.
|
|
for candidate: Vector3i in _facility_access_candidates(facility):
|
|
if not path_service.find_path(entrance, candidate).is_empty():
|
|
return true
|
|
return false
|
|
|
|
|
|
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)
|
|
|
|
|
|
func _staff_is_being_carried(patient_id: StringName) -> bool:
|
|
for task: StaffTask in staff_service.get_tasks():
|
|
if task.task_type == &"rescue_transport" and not task.is_terminal() and not task.assigned_staff_id.is_empty() and task.target_entity_id == patient_id:
|
|
return true
|
|
return false
|
|
|
|
func _rescue_destination(patient_cell: Vector3i) -> Dictionary:
|
|
var best := {}
|
|
var distance := 2147483647
|
|
for clinic: FacilityInstance in facility_service.get_instances():
|
|
if clinic.definition_id != &"infirmary" or clinic.operation_state != FacilityInstance.OperationState.OPERATING:
|
|
continue
|
|
var cells := {clinic.origin: true}
|
|
for cell: Vector3i in clinic.occupied_cells:
|
|
for delta in [Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]:
|
|
cells[cell + delta] = true
|
|
var ordered := cells.keys()
|
|
ordered.sort_custom(func(a: Vector3i, b: Vector3i): return a.y < b.y if a.y != b.y else a.x < b.x)
|
|
for cell: Vector3i in ordered:
|
|
if cell.z != WorldService.LAYER_BACK:
|
|
continue
|
|
var route := path_service.find_backyard_path(patient_cell, cell, true)
|
|
if not route.is_empty() and route.size() < distance:
|
|
distance = route.size()
|
|
best = {&"facility_id": clinic.instance_id, &"cell": cell}
|
|
return best
|
|
|
|
func _handle_rescue_arrival(task: StaffTask, carrier: StaffRuntime, minute: int) -> bool:
|
|
if task.task_type == CleaningBridge.TASK_TYPE:
|
|
return carrier.current_cell == task.target_cell and cleaning_bridge.task_can_work(task, carrier)
|
|
if task.task_type not in [&"rescue_pickup", &"rescue_transport"]:
|
|
return true
|
|
var patient := staff_service.get_staff(task.target_entity_id)
|
|
if patient == null or patient.state != StaffRuntime.State.INCAPACITATED or carrier.current_cell != task.target_cell:
|
|
return false
|
|
if task.task_type == &"rescue_transport":
|
|
var clinic := facility_service.get_instance(task.rescue_facility_id)
|
|
return clinic != null and clinic.operation_state == FacilityInstance.OperationState.OPERATING and patient.current_cell == carrier.current_cell
|
|
if patient.current_cell != carrier.current_cell:
|
|
return false
|
|
var destination := _rescue_destination(patient.current_cell)
|
|
if destination.is_empty():
|
|
return false
|
|
task.task_type = &"rescue_transport"
|
|
task.rescue_facility_id = destination[&"facility_id"]
|
|
task.target_cell = destination[&"cell"]
|
|
task.state = StaffTask.State.MOVING
|
|
task.state_changed_at = minute
|
|
_publish_actor_event(&"STAFF_RESCUE_PICKED_UP", &"staff_service", [patient.staff_id, carrier.staff_id], {&"facility_id": task.rescue_facility_id})
|
|
return false
|
|
|
|
func _advance_staff_rescue(minute: int) -> void:
|
|
for task: StaffTask in staff_service.get_tasks():
|
|
if task.task_type not in [&"rescue_pickup", &"rescue_transport"] or task.is_terminal():
|
|
continue
|
|
var patient := staff_service.get_staff(task.target_entity_id)
|
|
var carrier := staff_service.get_staff(task.assigned_staff_id)
|
|
var invalid := patient == null or patient.state != StaffRuntime.State.INCAPACITATED
|
|
if not task.assigned_staff_id.is_empty():
|
|
invalid = invalid or carrier == null or not carrier.can_take_shift() or carrier.current_task_id != task.task_id
|
|
if task.task_type == &"rescue_transport":
|
|
var clinic := facility_service.get_instance(task.rescue_facility_id)
|
|
invalid = invalid or carrier == null or clinic == null or clinic.operation_state != FacilityInstance.OperationState.OPERATING
|
|
if patient != null and carrier != null:
|
|
invalid = invalid or patient.current_cell != carrier.current_cell
|
|
if invalid:
|
|
if carrier != null and elevators.get_actor_trip(_staff_path_actor_id(carrier.staff_id)).get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED:
|
|
continue # Keep the pair aboard until a real or emergency landing.
|
|
staff_service.cancel_task(task.task_id, &"RESCUE_INTERRUPTED", minute)
|
|
if patient != null: elevators.cancel_actor(_staff_path_actor_id(patient.staff_id))
|
|
if carrier != null: elevators.cancel_actor(_staff_path_actor_id(carrier.staff_id))
|
|
for patient: StaffRuntime in staff_service.get_staff_members():
|
|
if not _defense_lock_owner(patient.staff_id, &"DEFENDER").is_empty():
|
|
continue
|
|
if patient.health > 0 and patient.state != StaffRuntime.State.INCAPACITATED:
|
|
continue
|
|
if not patient.current_task_id.is_empty():
|
|
staff_service.cancel_task(patient.current_task_id, &"STAFF_COLLAPSED", minute)
|
|
if patient.state != StaffRuntime.State.INCAPACITATED or patient.state_reason.is_empty():
|
|
patient.transition_to(StaffRuntime.State.INCAPACITATED, &"STAFF_COLLAPSED", minute)
|
|
_publish_actor_event(&"STAFF_COLLAPSED", &"staff_service", [patient.staff_id], {&"cell": patient.current_cell})
|
|
if patient.current_cell.z == WorldService.LAYER_FRONT and patient.state_reason != &"COLLAPSE_WITNESSED":
|
|
for guest: GuestRuntime in guest_service.get_guests():
|
|
if _authority_line_of_sight(guest.current_cell, patient.current_cell, {}):
|
|
if progression_service.update_metrics({&"reputation": maxi(int(progression_service.metrics[&"reputation"]) - 1, 0)}):
|
|
patient.state_reason = &"COLLAPSE_WITNESSED"
|
|
_publish_actor_event(&"STAFF_COLLAPSE_WITNESSED", &"staff_service", [patient.staff_id, guest.guest_id], {})
|
|
break
|
|
var pending := false
|
|
for task: StaffTask in staff_service.get_tasks():
|
|
if task.task_type in [&"rescue_pickup", &"rescue_transport"] and task.target_entity_id == patient.staff_id and not task.is_terminal():
|
|
pending = true
|
|
if not elevators.get_actor_trip(_staff_path_actor_id(patient.staff_id)).is_empty():
|
|
continue
|
|
if pending or _rescue_destination(patient.current_cell).is_empty():
|
|
continue
|
|
staff_service.create_task(&"rescue_pickup", 95, minute, {
|
|
&"target_entity_id": patient.staff_id, &"target_cell": patient.current_cell,
|
|
&"required_role_tags": [&"medic"], &"task_tags": [&"rescue"],
|
|
&"estimated_minutes": 30, &"interrupt_level": 0, &"emergency": true,
|
|
})
|
|
|
|
|
|
func get_staff_rescue_report() -> Dictionary:
|
|
var report := {&"waiting": 0, &"transporting": 0, &"treating": 0}
|
|
for patient: StaffRuntime in staff_service.get_staff_members():
|
|
if patient.state != StaffRuntime.State.INCAPACITATED:
|
|
continue
|
|
var category: StringName = &"waiting"
|
|
for task: StaffTask in staff_service.get_tasks():
|
|
if task.target_entity_id == patient.staff_id and task.task_type == &"rescue_transport" and not task.is_terminal():
|
|
category = &"treating" if task.state == StaffTask.State.WORKING else &"transporting"
|
|
report[category] += 1
|
|
return report
|
|
|
|
|
|
func _staff_rescue_status(staff_id: StringName) -> String:
|
|
var staff := staff_service.get_staff(staff_id)
|
|
if staff == null or staff.state != StaffRuntime.State.INCAPACITATED:
|
|
return ""
|
|
for task: StaffTask in staff_service.get_tasks():
|
|
if task.target_entity_id != staff_id or task.is_terminal(): continue
|
|
if task.task_type == &"rescue_transport":
|
|
return "治療中" if task.state == StaffTask.State.WORKING else "搬送中"
|
|
if task.task_type == &"rescue_pickup" and not task.assigned_staff_id.is_empty():
|
|
return "救護班が向かっています"
|
|
return "救護待ち(医療職・医務室・裏経路を確認)"
|
|
|
|
|
|
## Rest destinations derive from saved facilities and position, with no hidden timer.
|
|
func get_staff_rest_report(staff_id: StringName) -> Dictionary:
|
|
var staff := staff_service.get_staff(staff_id)
|
|
if staff == null or staff.state != StaffRuntime.State.OFF_DUTY or staff.health <= 0 or not staff.current_task_id.is_empty(): return {}
|
|
if not _defense_lock_owner(staff_id, &"DEFENDER").is_empty() or event_service.get_actor_locks().has(staff_id): return {}
|
|
var best := {&"reachable": false, &"resting": false, &"label": "休憩先なし(稼働中の待機室・宿舎と経路を確認)"}
|
|
var best_cost := 2147483647
|
|
for room: FacilityInstance in facility_service.get_instances():
|
|
if room.definition_id not in [&"staff_waiting_room", &"staff_dormitory"] or room.operation_state != FacilityInstance.OperationState.OPERATING: continue
|
|
var route := staff_commute.find_facility_access_path(staff.current_cell, room)
|
|
if not route.get(&"reachable", false): continue
|
|
var cost := path_service.calculate_path_cost(route[&"path"], [&"staff"])
|
|
if cost >= best_cost: continue
|
|
best_cost = cost
|
|
var resting := StaffCommuteService.is_at_facility(staff, room) and not is_staff_in_transit(staff_id)
|
|
best = {&"reachable": true, &"resting": resting, &"cell": route[&"cell"], &"facility_id": room.instance_id,
|
|
&"label": ("宿舎で休憩中" if room.definition_id == &"staff_dormitory" else "待機室で休憩中") if resting else "休憩先へ移動中"}
|
|
return best
|
|
|
|
|
|
func _advance_weather(absolute_minute: int) -> void:
|
|
weather.advance_to(absolute_minute)
|
|
|
|
|
|
func get_weather_report() -> Dictionary:
|
|
return {&"kind": weather.get_kind(), &"minute": weather.get_minute()}
|
|
|
|
|
|
## Read-only migration preview; applying dimensions remains a separate transaction.
|
|
func preview_facility_dimension_migration(instance_id: StringName) -> Dictionary:
|
|
var instance := construction_service.get_facility(instance_id)
|
|
if instance.is_empty():
|
|
return {&"geometry_ok": false, &"ready_to_apply": false, &"errors": [&"COMPLETED_FACILITY_REQUIRED"]}
|
|
var weights := {}
|
|
for built: Dictionary in construction_service.get_facilities():
|
|
var definition := definition_registry.get_facility(built[&"definition_id"])
|
|
if definition == null:
|
|
return {&"geometry_ok": false, &"ready_to_apply": false, &"errors": [&"MISSING_FACILITY_DEFINITION"]}
|
|
weights[built[&"instance_id"]] = definition.weight_per_cell
|
|
var preview: Dictionary = preload("res://scripts/construction/facility_resize_preview.gd").inspect(world_service, instance, weights[instance_id], weights)
|
|
if preview.has(&"remaining_gates"):
|
|
preview[&"remaining_gates"].erase(&"DEFINITION_WEIGHT_VALIDATION")
|
|
preview[&"definition_weights"] = weights
|
|
if preview.get(&"geometry_ok", false):
|
|
preview[&"construction_candidate"] = preload("res://scripts/construction/facility_resize_preview.gd").prepare_construction_candidate(world_service, construction_service, instance_id, weights[instance_id], weights)
|
|
preview[&"actors_on_removed_cells"] = []
|
|
for guest: GuestRuntime in guest_service.get_guests():
|
|
if guest.current_cell in preview.get(&"removed", []):
|
|
preview[&"actors_on_removed_cells"].append({&"kind": &"guest", &"id": guest.guest_id, &"cell": guest.current_cell})
|
|
for worker: StaffRuntime in staff_service.get_staff_members():
|
|
if worker.current_cell in preview.get(&"removed", []):
|
|
preview[&"actors_on_removed_cells"].append({&"kind": &"staff", &"id": worker.staff_id, &"cell": worker.current_cell})
|
|
preview[&"construction_jobs"] = []
|
|
for job: ConstructionJob in construction_service.get_jobs():
|
|
preview[&"construction_jobs"].append({&"job_id": job.job_id, &"definition_id": job.definition_id, &"state": job.state})
|
|
return preview
|
|
|
|
## Assemble and validate a snapshot in an isolated Runtime, including access
|
|
## preservation. Disk backup, actor relocation and atomic commit remain separate.
|
|
func prepare_facility_dimension_snapshot(instance_id: StringName) -> Dictionary:
|
|
if construction_service.get_facility(instance_id).is_empty() and construction_service.get_job(instance_id) != null:
|
|
return _prepare_construction_dimension_snapshot(instance_id)
|
|
var preview := preview_facility_dimension_migration(instance_id)
|
|
var result := {&"ok": false, &"ready_to_apply": false, &"errors": [], &"preview": preview}
|
|
if not preview.get(&"construction_candidate", {}).get(&"ok", false):
|
|
result[&"errors"].append(&"CONSTRUCTION_CANDIDATE_REQUIRED")
|
|
return result
|
|
if not preview[&"actors_on_removed_cells"].is_empty():
|
|
result[&"errors"].append(&"ACTOR_RELOCATION_REQUIRED")
|
|
return result
|
|
var candidate := create_snapshot()
|
|
var data: Dictionary = candidate[&"systems"][&"runtime"]
|
|
data[&"world"] = preview[&"construction_candidate"][&"world"].duplicate(true)
|
|
data[&"construction"] = preview[&"construction_candidate"][&"construction"].duplicate(true)
|
|
var found := false
|
|
for instance: Dictionary in data[&"facilities"][&"instances"]:
|
|
if StringName(instance[&"instance_id"]) != instance_id: continue
|
|
found = true
|
|
var cells: Array = []
|
|
for cell: Vector3i in preview[&"target_cells"]: cells.append([cell.x, cell.y, cell.z])
|
|
instance[&"occupied_cells"] = cells
|
|
if not found:
|
|
result[&"errors"].append(&"OPERATIONAL_FACILITY_REQUIRED")
|
|
return result
|
|
var isolated: Variant = get_script().new()
|
|
var candidate_clock: Variant = load("res://autoload/game_clock.gd").new()
|
|
var candidate_state: Variant = load("res://autoload/game_state.gd").new()
|
|
candidate_clock.reset(1, 6, 0)
|
|
candidate_state.reset()
|
|
isolated.initialize(candidate_clock, candidate_state, world_seed)
|
|
# The immutable definition resources must match the source registry,
|
|
# including any explicitly registered expansion definitions.
|
|
isolated.definition_registry = definition_registry
|
|
var restored: bool = isolated.restore_snapshot(candidate)
|
|
if restored:
|
|
result[&"routes"] = preload("res://scripts/construction/facility_resize_routes.gd").inspect(self, isolated, instance_id)
|
|
if result[&"routes"][&"errors"].is_empty() and not result[&"routes"][&"guest_replans"].is_empty():
|
|
result[&"retargeting"] = preload("res://scripts/construction/facility_resize_routes.gd").retarget_guests(isolated, result[&"routes"])
|
|
if result[&"retargeting"][&"ok"]:
|
|
candidate = isolated.create_snapshot()
|
|
restored = isolated.restore_snapshot(candidate)
|
|
if restored:
|
|
result[&"routes"] = preload("res://scripts/construction/facility_resize_routes.gd").inspect(self, isolated, instance_id)
|
|
isolated.free()
|
|
candidate_clock.free()
|
|
candidate_state.free()
|
|
if not restored:
|
|
result[&"errors"].append(&"RUNTIME_CANDIDATE_REJECTED")
|
|
return result
|
|
if not result[&"routes"][&"ok"]:
|
|
result[&"errors"].append(&"ROUTE_MIGRATION_REQUIRED")
|
|
return result
|
|
result[&"ok"] = true
|
|
result[&"snapshot"] = candidate
|
|
return result
|
|
|
|
## Explicit disk migration. Source Runtime stays untouched; the formal package
|
|
## is replaced only after isolated domain/route validation and verified backup.
|
|
func migrate_facility_dimensions_at_path(save_path: String, instance_id: StringName = &"", expected_source_digest: String = "") -> Dictionary:
|
|
var loaded := SaveService.load_snapshot(save_path)
|
|
if not loaded[&"ok"]: return loaded
|
|
if not expected_source_digest.is_empty() and SimulationDigest.compute(loaded[&"snapshot"]) != expected_source_digest:
|
|
return {&"ok": false, &"code": &"MIGRATION_SOURCE_CHANGED"}
|
|
var isolated: Variant = get_script().new()
|
|
var migration_clock: Variant = load("res://autoload/game_clock.gd").new()
|
|
var migration_state: Variant = load("res://autoload/game_state.gd").new()
|
|
migration_clock.reset(1, 6, 0); migration_state.reset()
|
|
isolated.initialize(migration_clock, migration_state, world_seed)
|
|
isolated.definition_registry = definition_registry
|
|
var prepared := {&"ok": false, &"errors": [&"SOURCE_RUNTIME_REJECTED"]}
|
|
if isolated.restore_snapshot(loaded[&"snapshot"]):
|
|
if instance_id.is_empty():
|
|
prepared = isolated.prepare_all_facility_dimension_snapshots()
|
|
else:
|
|
prepared = isolated.prepare_facility_dimension_snapshot(instance_id)
|
|
isolated.free(); migration_clock.free(); migration_state.free()
|
|
if not prepared[&"ok"]:
|
|
return {&"ok": false, &"code": &"DIMENSION_MIGRATION_REJECTED", &"details": prepared}
|
|
return SaveService.replace_snapshot_with_backup(save_path, loaded[&"snapshot"], prepared[&"snapshot"])
|
|
|
|
## Runs on an isolated migration Runtime; caller commits only the final snapshot.
|
|
func prepare_all_facility_dimension_snapshots() -> Dictionary:
|
|
var original := create_snapshot()
|
|
var targets: Array[StringName] = []
|
|
var shapes: Dictionary = preload("res://scripts/construction/facility_resize_preview.gd").DIMENSIONS
|
|
for job: ConstructionJob in construction_service.get_jobs():
|
|
if job.is_terminal() or not shapes.has(job.definition_id): continue
|
|
var size: Vector2i = shapes[job.definition_id][&"new"]
|
|
if job.rotation in [90,270]: size = Vector2i(size.y,size.x)
|
|
if not preload("res://scripts/construction/facility_resize_preview.gd")._same_cells(job.reserved_cells, world_service.cells_for_footprint(job.origin,size)):
|
|
targets.append(job.job_id)
|
|
for facility: Dictionary in construction_service.get_facilities():
|
|
if not shapes.has(facility[&"definition_id"]): continue
|
|
var size: Vector2i = shapes[facility[&"definition_id"]][&"new"]
|
|
if facility[&"rotation"] in [90,270]: size = Vector2i(size.y,size.x)
|
|
if not preload("res://scripts/construction/facility_resize_preview.gd")._same_cells(facility[&"occupied_cells"], world_service.cells_for_footprint(facility[&"origin"],size)):
|
|
targets.append(facility[&"instance_id"])
|
|
for id: StringName in targets:
|
|
var prepared := prepare_facility_dimension_snapshot(id)
|
|
if not prepared[&"ok"] or not restore_snapshot(prepared[&"snapshot"]):
|
|
var rolled_back := restore_snapshot(original)
|
|
return {&"ok": false, &"errors": [&"BATCH_DIMENSION_REJECTED"], &"facility_id": id, &"details": prepared, &"rolled_back": rolled_back}
|
|
return {&"ok": true, &"snapshot": create_snapshot(), &"facility_ids": targets}
|
|
|
|
func _prepare_construction_dimension_snapshot(job_id: StringName) -> Dictionary:
|
|
var weights := {}
|
|
for facility: Dictionary in construction_service.get_facilities():
|
|
var definition := definition_registry.get_facility(facility[&"definition_id"])
|
|
if definition == null: return {&"ok": false, &"errors": [&"MISSING_FACILITY_DEFINITION"]}
|
|
weights[facility[&"instance_id"]] = definition.weight_per_cell
|
|
var job := construction_service.get_job(job_id)
|
|
var definition := definition_registry.get_facility(job.definition_id)
|
|
if job.is_terminal() or definition == null:
|
|
return {&"ok": false, &"errors": [&"ACTIVE_JOB_REQUIRED"]}
|
|
weights[job_id] = definition.weight_per_cell
|
|
var prepared: Dictionary = preload("res://scripts/construction/facility_resize_preview.gd").prepare_construction_candidate(world_service, construction_service, job_id, definition.weight_per_cell, weights)
|
|
if not prepared[&"ok"]: return prepared
|
|
var candidate := create_snapshot()
|
|
candidate[&"systems"][&"runtime"][&"world"] = prepared[&"world"]
|
|
candidate[&"systems"][&"runtime"][&"construction"] = prepared[&"construction"]
|
|
var isolated: Variant = get_script().new()
|
|
var candidate_clock: Variant = load("res://autoload/game_clock.gd").new()
|
|
var candidate_state: Variant = load("res://autoload/game_state.gd").new()
|
|
candidate_clock.reset(1,6,0); candidate_state.reset()
|
|
isolated.initialize(candidate_clock, candidate_state, world_seed)
|
|
isolated.definition_registry = definition_registry
|
|
var valid: bool = isolated.restore_snapshot(candidate)
|
|
isolated.free(); candidate_clock.free(); candidate_state.free()
|
|
if not valid: return {&"ok": false, &"errors": [&"RUNTIME_CANDIDATE_REJECTED"]}
|
|
return {&"ok": true, &"ready_to_apply": false, &"snapshot": candidate}
|
|
|
|
## Read-only saved-game preflight used by the load menu before confirmation.
|
|
func preview_facility_dimensions_at_path(save_path: String) -> Dictionary:
|
|
var loaded := SaveService.inspect_snapshot(save_path)
|
|
if not loaded[&"ok"]: return loaded
|
|
var isolated: Variant = get_script().new()
|
|
var preview_clock: Variant = load("res://autoload/game_clock.gd").new()
|
|
var preview_state: Variant = load("res://autoload/game_state.gd").new()
|
|
preview_clock.reset(1,6,0); preview_state.reset()
|
|
isolated.initialize(preview_clock,preview_state,world_seed)
|
|
isolated.definition_registry=definition_registry
|
|
var result := {&"ok": false, &"code": &"SOURCE_RUNTIME_REJECTED"}
|
|
if isolated.restore_snapshot(loaded[&"snapshot"]):
|
|
result=isolated.prepare_all_facility_dimension_snapshots()
|
|
result[&"source_digest"]=SimulationDigest.compute(loaded[&"snapshot"])
|
|
result[&"changes"]=[]
|
|
if result[&"ok"]:
|
|
for id: StringName in result[&"facility_ids"]:
|
|
var facility: Dictionary=isolated.construction_service.get_facility(id)
|
|
var job: ConstructionJob=isolated.construction_service.get_job(id)
|
|
var definition_id: StringName=facility.get(&"definition_id",&"") if job==null else job.definition_id
|
|
var definition: FacilityDefinition=definition_registry.get_facility(definition_id)
|
|
result[&"changes"].append({&"id":id,&"name":definition.display_name,&"size":definition.footprint,&"building":job!=null})
|
|
result.erase(&"snapshot")
|
|
isolated.free();preview_clock.free();preview_state.free()
|
|
return result
|
|
|
|
|
|
func _on_cleanliness_facility_registered(facility: FacilityInstance) -> void:
|
|
var values := cleaning_policy.create_snapshot()
|
|
if facility_cleanliness.get_facility(facility.instance_id).is_empty():
|
|
facility_cleanliness.register_facility(facility.instance_id, values["initial_cleanliness"], values["maximum_cleanliness"])
|
|
|
|
|
|
func _on_cleanliness_facility_unregistered(facility_id: StringName) -> void:
|
|
for task: StaffTask in staff_service.get_tasks():
|
|
if task.task_type == CleaningBridge.TASK_TYPE and task.target_entity_id == facility_id and not task.is_terminal():
|
|
staff_service.cancel_task(task.task_id, &"CLEANING_TARGET_REMOVED", _runtime_event_minute())
|
|
if not facility_cleanliness.get_facility(facility_id).is_empty():
|
|
facility_cleanliness.unregister_facility(facility_id)
|
|
_sync_facility_staffing(_runtime_event_minute())
|
|
|
|
|
|
func _validate_cleanliness_crosslinks(cleanliness: RefCounted, facilities: FacilityService, staff: StaffService, path: PathService) -> bool:
|
|
var snapshot: Dictionary = cleanliness.create_snapshot()
|
|
var instances := facilities.get_instances()
|
|
var completed_cleanings: Dictionary = {}
|
|
if snapshot["facilities"].size() != instances.size(): return false
|
|
for facility: FacilityInstance in instances:
|
|
var row: Dictionary = cleanliness.get_facility(facility.instance_id)
|
|
if row.is_empty() or row["maximum"] != 100 or row["initial"] != 100: return false
|
|
for receipt: Dictionary in snapshot["receipts"]:
|
|
if receipt["kind"] == "clean":
|
|
var task := staff.get_task(StringName(receipt["source_id"]))
|
|
if task == null or task.task_type != CleaningBridge.TASK_TYPE or task.state != StaffTask.State.COMPLETED or task.target_entity_id != StringName(receipt["facility_id"]) or receipt["amount"] != cleaning_policy.create_snapshot()["recovery_per_completed_task"]: return false
|
|
if task.progress_minutes != task.estimated_minutes: return false
|
|
completed_cleanings[task.task_id] = true
|
|
continue
|
|
if receipt["kind"] != "use": return false
|
|
var service := facilities.get_service(StringName(receipt["source_id"]))
|
|
if service == null or service.state != ServiceInstance.State.COMPLETED or service.facility_id != StringName(receipt["facility_id"]): return false
|
|
var facility := facilities.get_instance(service.facility_id)
|
|
var definition := definition_registry.get_facility(facility.definition_id)
|
|
if definition == null or receipt["amount"] != definition.cleanliness_decay_per_service: return false
|
|
var pending: Dictionary = {}
|
|
for task: StaffTask in staff.get_tasks():
|
|
if task.task_type != CleaningBridge.TASK_TYPE: continue
|
|
if task.estimated_minutes != cleaning_policy.create_snapshot()["work_minutes"] or task.required_role_tags != [&"cleaner"]: return false
|
|
if task.state == StaffTask.State.COMPLETED and facilities.has_instance(task.target_entity_id) and not completed_cleanings.has(task.task_id): return false
|
|
if task.is_terminal(): continue
|
|
var facility := facilities.get_instance(task.target_entity_id)
|
|
if facility == null or task.target_cell not in CleaningBridge.access_cells(facility, path, false) or pending.has(task.target_entity_id): return false
|
|
pending[task.target_entity_id] = true
|
|
if not task.assigned_staff_id.is_empty():
|
|
var worker := staff.get_staff(task.assigned_staff_id)
|
|
if not CleaningBridge.is_deployed(worker, facilities.get_instance(worker.assigned_facility_id) if worker != null else null, staff): return false
|
|
return true
|
|
|
|
func _automatic_staff_work_units(task_id: StringName) -> int:
|
|
var task := staff_service.get_task(task_id)
|
|
# Cleaning is progressed after attendance, dependency and cancellation checks.
|
|
return 0 if task != null and task.task_type == CleaningBridge.TASK_TYPE else 1
|
|
|
|
|
|
## This is a configured event sink, not a thief/riot/loan predicate. Producers
|
|
## must supply a cause identity and a complete, approved threat template.
|
|
func _build_event_defense_request(action: EventAction, context: Dictionary) -> Dictionary:
|
|
var instance_value: Variant = context.get(&"event_instance_id", context.get("event_instance_id", null))
|
|
var source_value: Variant = context.get(&"threat_source_id", context.get("threat_source_id", null))
|
|
var template_value: Variant = action.payload.get(&"template_id", action.payload.get("template_id", null))
|
|
var kind_value: Variant = action.payload.get(&"source_kind", action.payload.get("source_kind", null))
|
|
for raw: Variant in [instance_value, source_value, template_value, kind_value]:
|
|
if typeof(raw) not in [TYPE_STRING, TYPE_STRING_NAME] or String(raw).is_empty():
|
|
return {&"ok": false, &"error": "Configured threat requires event, source, template and source-kind identities."}
|
|
var source_kind := String(kind_value).to_upper()
|
|
if source_kind not in ["THIEF", "BANKRUPTCY_RIOT", "BANK_DELINQUENCY", "AUTHORITY_ESCALATION"]:
|
|
return {&"ok": false, &"error": "Configured threat source kind is unknown."}
|
|
var identity := JSON.stringify([String(instance_value), String(action.action_id)], "", true, true)
|
|
var raid_id := StringName("event_defense_raid." + identity.sha256_text())
|
|
var prepared := _prepare_event_threat_template(action, StringName(source_value), raid_id)
|
|
if not bool(prepared.get(&"ok", false)):
|
|
return prepared
|
|
var definition: Dictionary = prepared[&"definition"]
|
|
var digest := String(prepared[&"digest"])
|
|
var event_source := {
|
|
"event_instance_id": String(instance_value), "event_action_id": String(action.action_id),
|
|
"template_id": String(template_value), "template_digest": digest,
|
|
"source_kind": source_kind,
|
|
}
|
|
definition[&"event_source"] = event_source
|
|
var existing := defense_service.get_raid(raid_id)
|
|
if not existing.is_empty():
|
|
if existing.get(&"event_source", {}) != event_source or not _event_threat_template_matches_raid(existing, prepared[&"canonical_raid"]):
|
|
return {&"ok": false, &"error": "Event threat identity conflicts with an already-created incident."}
|
|
return {&"ok": true, &"raid_id": raid_id, &"definition": definition, &"is_replay": true}
|
|
for objective_id: StringName in prepared[&"objective_ids"]:
|
|
var objective := facility_service.get_instance(objective_id)
|
|
if objective == null or objective.durability <= 0:
|
|
return {&"ok": false, &"error": "Configured threat objective is unavailable."}
|
|
if not _defense_lock_owner(objective_id, &"ASSET", raid_id).is_empty():
|
|
return {&"ok": false, &"error": "Configured threat objective already belongs to another active incident."}
|
|
return {&"ok": true, &"raid_id": raid_id, &"definition": definition, &"is_replay": false}
|
|
|
|
|
|
func _prepare_event_threat_template(action: EventAction, source_id: StringName, raid_id: StringName) -> Dictionary:
|
|
var raw_definition: Variant = action.payload.get(&"definition", action.payload.get("definition", null))
|
|
if not raw_definition is Dictionary:
|
|
return {&"ok": false, &"error": "Configured threat requires its complete definition."}
|
|
var definition: Dictionary = raw_definition.duplicate(true)
|
|
# Do not silently turn service defaults into newly approved enemy values.
|
|
for field: StringName in [&"round_minutes", &"max_duration_minutes", &"defender_retreat_threshold", &"terrain_bonus", &"command_bonus", &"surprise_penalty", &"multiple_entry_penalty"]:
|
|
if typeof(definition.get(field, definition.get(String(field), null))) != TYPE_INT:
|
|
return {&"ok": false, &"error": "Configured threat requires an explicit integer: %s." % field}
|
|
var retreat_value: Variant = definition.get(&"retreat_policy", definition.get("retreat_policy", null))
|
|
if typeof(retreat_value) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return {&"ok": false, &"error": "Configured threat requires an explicit retreat policy."}
|
|
for field: StringName in [&"entry_points", &"objective_ids"]:
|
|
var raw_ids: Variant = definition.get(field, definition.get(String(field), null))
|
|
if not raw_ids is Array or raw_ids.is_empty():
|
|
return {&"ok": false, &"error": "Configured threat requires explicit %s." % field}
|
|
for raw_id: Variant in raw_ids:
|
|
if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME] or String(raw_id).is_empty():
|
|
return {&"ok": false, &"error": "Configured threat entry/objective IDs must be nonempty names."}
|
|
var raw_waves: Variant = definition.get(&"waves", definition.get("waves", null))
|
|
if not raw_waves is Array or raw_waves.is_empty():
|
|
return {&"ok": false, &"error": "Configured threat requires explicit waves."}
|
|
for raw_wave: Variant in raw_waves:
|
|
if not raw_wave is Dictionary:
|
|
return {&"ok": false, &"error": "Configured threat wave must be a dictionary."}
|
|
for field: StringName in [&"threat_strength", &"max_rounds", &"retreat_threshold"]:
|
|
if typeof(raw_wave.get(field, raw_wave.get(String(field), null))) != TYPE_INT:
|
|
return {&"ok": false, &"error": "Configured threat wave requires an explicit integer: %s." % field}
|
|
definition[&"source_id"] = source_id
|
|
# An isolated aggregate validates and canonicalizes without touching live
|
|
# clocks, RNG, IDs, facilities, events or the queue. Time zero is only the
|
|
# canonical probe's timestamp, never the actual incident creation time.
|
|
definition.erase(&"event_source")
|
|
definition.erase("event_source")
|
|
var probe := DefenseService.new()
|
|
var valid := probe.create_raid(raid_id, 0, definition)
|
|
if not valid.is_success():
|
|
return {&"ok": false, &"error": valid.message, &"details": valid.details}
|
|
var canonical: Dictionary = (probe.create_snapshot()[&"raids"] as Array)[0]
|
|
var digest := JSON.stringify(canonical, "", true, true).sha256_text()
|
|
var canonical_raid := probe.get_raid(raid_id)
|
|
return {&"ok": true, &"definition": definition, &"digest": digest, &"objective_ids": canonical_raid[&"objective_ids"], &"canonical_raid": canonical_raid}
|
|
|
|
|
|
func _apply_event_defense_raid(action: EventAction, context: Dictionary) -> Dictionary:
|
|
var request := _build_event_defense_request(action, context)
|
|
if not bool(request.get(&"ok", false)):
|
|
return request
|
|
if bool(request.get(&"is_replay", false)):
|
|
return {&"ok": true, &"raid_id": request[&"raid_id"], &"is_replay": true}
|
|
var advanced := defense_service.advance_time(_defense_operation_minute())
|
|
if not advanced.is_success():
|
|
return {&"ok": false, &"error": advanced.message}
|
|
var created := defense_service.create_raid(request[&"raid_id"], _defense_operation_minute(), request[&"definition"])
|
|
if not created.is_success():
|
|
return {&"ok": false, &"error": created.message}
|
|
if not _validate_live_defense_crosslinks():
|
|
return {&"ok": false, &"error": "Configured event threat violates live Defense crosslinks."}
|
|
return {&"ok": true, &"raid_id": request[&"raid_id"], &"is_replay": false}
|
|
|
|
|
|
## Historical provenance is checked against the saved event definition and
|
|
## cause context, without requiring a departed guest or repaid loan to exist.
|
|
func _validate_event_defense_crosslinks(events: EventService, defense: DefenseService) -> bool:
|
|
for raid_id: StringName in defense.get_raid_ids():
|
|
var raid := defense.get_raid(raid_id)
|
|
var source: Dictionary = raid.get(&"event_source", {})
|
|
if source.is_empty():
|
|
continue
|
|
var instance_id := StringName(source["event_instance_id"])
|
|
var action_id := StringName(source["event_action_id"])
|
|
var identity := JSON.stringify([String(instance_id), String(action_id)], "", true, true)
|
|
if raid_id != StringName("event_defense_raid." + identity.sha256_text()):
|
|
return false
|
|
var instance := events.get_instance(instance_id)
|
|
if instance == null:
|
|
return false
|
|
var source_value: Variant = instance.context.get(&"threat_source_id", instance.context.get("threat_source_id", null))
|
|
if typeof(source_value) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(source_value) != raid[&"source_id"]:
|
|
return false
|
|
var definition := events.get_definition(instance.resolved_event_id)
|
|
if definition == null:
|
|
return false
|
|
var actions: Array[EventAction] = []
|
|
for collection: Array in [definition.automatic_actions, definition.fallback_actions, definition.failure_actions]:
|
|
actions.append_array(collection)
|
|
for choice: EventChoice in definition.choices:
|
|
actions.append_array(choice.actions)
|
|
var matches := false
|
|
for action: EventAction in actions:
|
|
if action.action_id != action_id or action.type != EventAction.Type.CREATE_DEFENSE_RAID:
|
|
continue
|
|
if String(action.payload.get(&"template_id", action.payload.get("template_id", ""))) != source["template_id"] or String(action.payload.get(&"source_kind", action.payload.get("source_kind", ""))).to_upper() != source["source_kind"]:
|
|
continue
|
|
var prepared := _prepare_event_threat_template(action, StringName(source_value), raid_id)
|
|
if bool(prepared.get(&"ok", false)) and String(prepared[&"digest"]) == source["template_digest"] and _event_threat_template_matches_raid(raid, prepared[&"canonical_raid"]):
|
|
matches = true
|
|
break
|
|
if not matches:
|
|
return false
|
|
return true
|
|
|
|
|
|
## Compare the immutable creation configuration to its original template.
|
|
## Combat progress, damage, roster, timestamps and player retreat-policy edits
|
|
## remain mutable and must not be compared to a newly-created probe.
|
|
func _event_threat_template_matches_raid(raid: Dictionary, canonical: Dictionary) -> bool:
|
|
for field: StringName in [&"raid_id", &"source_id", &"max_duration_minutes", &"round_minutes", &"terrain_bonus", &"command_bonus", &"surprise_penalty", &"multiple_entry_penalty", &"entry_points", &"objective_ids"]:
|
|
if not _strict_variants_equal(raid.get(field, null), canonical.get(field, null)):
|
|
return false
|
|
var waves: Array = raid.get(&"waves", [])
|
|
var template_waves: Array = canonical.get(&"waves", [])
|
|
if waves.size() != template_waves.size():
|
|
return false
|
|
for index: int in range(waves.size()):
|
|
var wave: Dictionary = waves[index]
|
|
var template_wave: Dictionary = template_waves[index]
|
|
for field: StringName in [&"wave_id", &"threat_strength", &"retreat_threshold", &"max_rounds", &"entry_points", &"objective_ids"]:
|
|
if not _strict_variants_equal(wave.get(field, null), template_wave.get(field, null)):
|
|
return false
|
|
return true
|