2x2 FRONT warp at 3000G/30 materials/360 min, 15G/day and 1 Mana upkeep. Every completed warp pair is linked by one path connector, so crossing is a single step; a 3-Mana fare per rider is charged once in the step's tick. Warps close while either end is unpowered, the reserve is empty or below one fare, and blocked arrivals offer no step. Saves validate the derived links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13749 lines
608 KiB
GDScript
13749 lines
608 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 PeepingRooms := preload("res://scripts/special/peeping_room_service.gd")
|
|
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 ManaReserveCycle = preload("res://scripts/mana/mana_reserve_cycle.gd")
|
|
const WarpService = preload("res://scripts/transport/warp_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 peeping_room_service := PeepingRooms.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 mana_reserve := ManaReserveCycle.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()
|
|
peeping_room_service = PeepingRooms.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
|
|
peeping_room_service.configure(world_service, facility_service, construction_service, path_service, id_generator)
|
|
_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"))
|
|
for command_type: StringName in [&"peeping_room_validate", &"peeping_room_place", &"peeping_room_relink", &"peeping_room_record_evidence"]:
|
|
register_command_executor(command_type, Callable(self, "_execute_peeping_room"))
|
|
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(),
|
|
&"mana_reserve": mana_reserve.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
|
|
if not peeping_room_service.is_empty():
|
|
owned_systems[&"runtime"][&"special_peeping_rooms"] = peeping_room_service.create_snapshot()
|
|
# 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 runtime_data.has(&"special_peeping_rooms"):
|
|
runtime_current_keys.append(&"special_peeping_rooms")
|
|
if runtime_data.has(&"mana_reserve"):
|
|
runtime_current_keys.append(&"mana_reserve")
|
|
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
|
|
# A06: saves without the finite reserve keep an explicit stored amount
|
|
# (0 stays stopped) only within capacity; nothing is silently truncated.
|
|
var restored_mana_reserve := ManaReserveCycle.new()
|
|
var saved_game_resources: Variant = snapshot[&"game_state"].get(&"resources", null)
|
|
if not saved_game_resources is Dictionary:
|
|
return false
|
|
var saved_mana: Variant = saved_game_resources.get(&"mana", saved_game_resources.get("mana", null))
|
|
if runtime_data.has(&"mana_reserve"):
|
|
# Between boundaries Mana may exceed capacity; the next boundary records
|
|
# and discards the excess, so only a valid integer amount is required.
|
|
if not ManaReserveCycle.stored_amount_is_valid(saved_mana) \
|
|
or not restored_mana_reserve.restore_snapshot(runtime_data[&"mana_reserve"]) \
|
|
or restored_mana_reserve.get_minute() > restored_minute:
|
|
return false
|
|
elif not ManaReserveCycle.amount_is_valid(saved_mana) \
|
|
or not restored_mana_reserve.start_at(restored_minute):
|
|
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
|
|
if not WarpService.connectors_match(restored_path, restored_construction.get_facilities()):
|
|
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
|
|
var restored_peeping_rooms := PeepingRooms.new()
|
|
restored_peeping_rooms.configure(restored_world, restored_facilities, restored_construction, restored_path, restored_id_generator)
|
|
if runtime_data.has(&"special_peeping_rooms"):
|
|
if not runtime_data[&"special_peeping_rooms"] is Dictionary:
|
|
return false
|
|
for raw_key: Variant in runtime_data:
|
|
if raw_key == &"special_peeping_rooms" and typeof(raw_key) != TYPE_STRING_NAME:
|
|
return false
|
|
if not restored_peeping_rooms.restore_section(runtime_data.get(&"special_peeping_rooms", null), restored_minute):
|
|
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
|
|
peeping_room_service = restored_peeping_rooms
|
|
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
|
|
mana_reserve = restored_mana_reserve
|
|
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(),
|
|
&"mana_reserve": mana_reserve.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", &"mana_reserve",
|
|
&"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"])
|
|
mana_ok = mana_reserve.restore_snapshot(saved[&"mana_reserve"]) and mana_ok
|
|
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(mana_reserve.create_snapshot(), saved[&"mana_reserve"]) \
|
|
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)
|
|
mana_reserve = ManaReserveCycle.new()
|
|
mana_reserve.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.instance_unregistered.connect(Callable(self, "_on_peeping_target_removed"))
|
|
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 bool(_synchronize_warps().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 definition.id == WarpService.DEFINITION_ID:
|
|
_register_facility_mana_consumer(instance)
|
|
var warp_sync := _synchronize_warps()
|
|
if not bool(warp_sync.get(&"ok", false)):
|
|
return {&"ok": false, &"code": &"WARP_SYNC_FAILED", &"reason": String(warp_sync.get(&"reason", "")), &"details": warp_sync}
|
|
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)
|
|
var guest_warp := _warp_step_connector(path[0], path[1], [])
|
|
if accumulated < required_credit or not _warp_fare_affordable(guest_warp, 1):
|
|
_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
|
|
_charge_warp_fare(guest_warp, [actor_id], previous_cell, guest.current_cell)
|
|
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)
|
|
var staff_warp := _warp_step_connector(path[0], path[1], [&"staff"])
|
|
var warp_riders := 2 if task.task_type == &"rescue_transport" else 1
|
|
if accumulated < required_credit or not _warp_fare_affordable(staff_warp, warp_riders):
|
|
_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)
|
|
_charge_warp_fare(staff_warp, [actor_id, _staff_path_actor_id(patient.staff_id)], previous_cell, staff.current_cell)
|
|
else:
|
|
_charge_warp_fare(staff_warp, [actor_id], previous_cell, staff.current_cell)
|
|
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
|
|
|
|
## HUD view of the A06 reserve: supply, consumption and storage per interval.
|
|
func get_mana_reserve_report() -> Dictionary:
|
|
var demand := _mana_reserve_demand()
|
|
return {
|
|
&"amount": int(_state.get_resource(&"mana")),
|
|
&"capacity": ManaReserveCycle.CAPACITY,
|
|
&"generation": ManaReserveCycle.WORLD_TREE_GENERATION,
|
|
&"interval_minutes": ManaReserveCycle.INTERVAL_MINUTES,
|
|
&"demand": demand,
|
|
&"net": ManaReserveCycle.WORLD_TREE_GENERATION - demand,
|
|
&"last_flow": mana_reserve.get_last_flow(),
|
|
}
|
|
|
|
|
|
## A06 upkeep demand: every owned consumer, open or not, except destroyed
|
|
## facilities. Removed facilities are already unregistered from the network.
|
|
func _mana_reserve_demand() -> int:
|
|
var destroyed: Array[StringName] = []
|
|
for facility: FacilityInstance in facility_service.get_instances():
|
|
if facility.suspension_reason == &"DESTROYED":
|
|
destroyed.append(facility.instance_id)
|
|
return mana_network_service.get_total_consumer_demand(destroyed)
|
|
|
|
|
|
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
|
|
# A06: generation and upkeep demand settle before local distribution and the
|
|
# whole-tower gate, so the network and projection see the committed reserve.
|
|
var reserve_flow := mana_reserve.advance_to(
|
|
absolute_minute, int(_state.get_resource(&"mana")), _mana_reserve_demand()
|
|
)
|
|
if not bool(reserve_flow.get(&"ok", false)):
|
|
_latch_infrastructure_failure(
|
|
StringName(reserve_flow.get(&"code", &"MANA_RESERVE_FAILED")),
|
|
absolute_minute,
|
|
"The finite Mana reserve could not settle this boundary.",
|
|
{&"mana": int(_state.get_resource(&"mana"))}
|
|
)
|
|
return
|
|
if bool(reserve_flow.get(&"processed", false)):
|
|
_state.set_resource(&"mana", int(reserve_flow[&"after"]))
|
|
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
|
|
)
|
|
var warp_sync := _synchronize_warps()
|
|
if not bool(warp_sync.get(&"ok", false)):
|
|
_latch_infrastructure_failure(&"WARP_SYNC_FAILED", absolute_minute,
|
|
"Warp connectors could not follow the current power state.", warp_sync)
|
|
return
|
|
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:
|
|
peeping_room_service.target_removed(instance_id)
|
|
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_peeping_target_removed(instance_id: StringName) -> void:
|
|
peeping_room_service.target_removed(instance_id)
|
|
|
|
func _execute_peeping_room(command: SimulationCommand) -> CommandResult:
|
|
var payload := command.payload
|
|
var report: Dictionary
|
|
if command.command_type == &"peeping_room_place":
|
|
report = PeepingRooms.result(&"BUILD_DEFINITION_PENDING_D04")
|
|
elif command.command_type == &"peeping_room_validate":
|
|
if typeof(payload.get(&"origin")) != TYPE_VECTOR3I or typeof(payload.get(&"footprint", Vector2i(2, 1))) != TYPE_VECTOR2I or typeof(payload.get(&"target_instance_id", &"")) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
report = PeepingRooms.result(&"INVALID_PAYLOAD")
|
|
else:
|
|
report = peeping_room_service.validate_placement(payload[&"origin"], payload.get(&"footprint", Vector2i(2, 1)), StringName(payload.get(&"target_instance_id", &"")))
|
|
else:
|
|
var key: StringName = &"target_instance_id" if command.command_type == &"peeping_room_relink" else &"source_event_key"
|
|
if typeof(payload.get(&"room_id")) not in [TYPE_STRING, TYPE_STRING_NAME] or typeof(payload.get(key, &"")) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
report = PeepingRooms.result(&"INVALID_PAYLOAD")
|
|
elif command.command_type == &"peeping_room_relink":
|
|
report = peeping_room_service.relink(StringName(payload[&"room_id"]), StringName(payload.get(key, &"")))
|
|
else:
|
|
report = peeping_room_service.record_evidence(StringName(payload[&"room_id"]), StringName(payload.get(key, &"")), _runtime_event_minute())
|
|
if report[&"ok"]:
|
|
return CommandResult.succeeded(command.command_id, report)
|
|
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, String(report[&"reason"]), report)
|
|
|
|
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)
|
|
var commute_warp := _warp_step_connector(path[0], path[1], [&"staff"])
|
|
if credit < required_credit or not _warp_fare_affordable(commute_warp, 1):
|
|
_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]
|
|
_charge_warp_fare(commute_warp, [actor_id], previous, staff.current_cell)
|
|
_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})
|
|
|
|
|
|
## A07: warp connectors exist for every completed warp pair and are usable only
|
|
## while both warps are powered and the reserve can pay one fare.
|
|
func _synchronize_warps() -> Dictionary:
|
|
var available := {}
|
|
var reserve_ok := int(_state.get_resource(&"mana")) > 0
|
|
for warp: Dictionary in WarpService.warps_from_facilities(construction_service.get_facilities()):
|
|
var state := mana_network_service.get_consumer_state(warp[&"instance_id"])
|
|
available[warp[&"instance_id"]] = reserve_ok and state in [ManaNetworkService.ConsumerState.ACTIVE, ManaNetworkService.ConsumerState.DEGRADED]
|
|
return WarpService.synchronize(path_service, construction_service.get_facilities(), available,
|
|
int(_state.get_resource(&"mana")) >= WarpService.MANA_PER_TRIP)
|
|
|
|
|
|
func _warp_step_connector(from: Vector3i, to: Vector3i, access: Array[StringName]) -> StringName:
|
|
var connector_id := path_service.get_step_connector(from, to, access)
|
|
return connector_id if WarpService.is_warp_connector(connector_id) else &""
|
|
|
|
|
|
func _warp_fare_affordable(connector_id: StringName, riders: int) -> bool:
|
|
return connector_id.is_empty() or int(_state.get_resource(&"mana")) >= WarpService.MANA_PER_TRIP * riders
|
|
|
|
|
|
## Charged once, in the same tick as the committed step; affordability was
|
|
## checked before the step, so the deduction cannot fail here.
|
|
func _charge_warp_fare(connector_id: StringName, actor_ids: Array, from: Vector3i, to: Vector3i) -> void:
|
|
if connector_id.is_empty():
|
|
return
|
|
var fare := WarpService.MANA_PER_TRIP * actor_ids.size()
|
|
_state.apply_resource_deltas({&"mana": -fare})
|
|
var subjects: Array[StringName] = [connector_id]
|
|
for actor_id: Variant in actor_ids:
|
|
subjects.append(StringName(actor_id))
|
|
_publish_actor_event(&"WARP_TRAVELED", &"warp", subjects, {
|
|
&"connector_id": connector_id, &"from_cell": from, &"cell": to,
|
|
&"riders": actor_ids.size(), &"mana": fare})
|
|
|
|
|
|
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
|