class_name SimulationKernel extends Node signal time_changed(day: int, hour: int, minute: int) signal day_started(day: int) signal settlement_requested(day: int) signal tick_started(absolute_minute: int) signal tick_phase_started(phase: TickPhase, absolute_minute: int) signal tick_completed(absolute_minute: int) signal simulation_overloaded(backlog_minutes: int) enum TickPhase { APPLY_COMMANDS, TIME_BOUNDARIES, MANA, CONSTRUCTION, LOGISTICS, FACILITY_COMPLETION, VITALS_AND_ENVIRONMENT, INCIDENTS_AND_EVENTS, FACILITY_QUEUES, AI_DECISIONS, PUBLISH_SNAPSHOT, } const MINUTES_PER_DAY := 24 * 60 const START_ABSOLUTE_MINUTE := 6 * 60 const REAL_SECONDS_PER_GAME_HOUR := 10.0 const REAL_SECONDS_PER_GAME_MINUTE := REAL_SECONDS_PER_GAME_HOUR / 60.0 const MAX_TICKS_PER_FRAME := 8 const MAX_BACKLOG_MINUTES := 120 var absolute_minute := START_ABSOLUTE_MINUTE var day := 1 var minute_of_day := START_ABSOLUTE_MINUTE var speed := 1 var paused := false var snapshot_version := 0 var performance_budget := SimulationPerformanceBudget.new() var _accumulator := 0.0 var _skip_next_process := false var _phase_handlers: Dictionary = {} var _pause_request_serial := 0 var _abort_current_tick_requested := false var _last_tick_advanced := false func _ready() -> void: _emit_current_time() func _process(delta: float) -> void: process_real_delta(delta) func process_real_delta(delta: float) -> int: if _skip_next_process: _skip_next_process = false return 0 if paused or speed <= 0 or delta <= 0.0: return 0 var frame_started_at_us := Time.get_ticks_usec() _accumulator += delta * speed var due_ticks := int(_accumulator / REAL_SECONDS_PER_GAME_MINUTE) var tick_budget := mini(due_ticks, MAX_TICKS_PER_FRAME) var ticks_run := 0 for _index in range(tick_budget): if paused: break advance_one_minute() if not _last_tick_advanced: break _accumulator -= REAL_SECONDS_PER_GAME_MINUTE ticks_run += 1 # A boundary handler may pause in the middle of a catch-up frame. Never # consume another queued minute after that decision. if paused: break if ticks_run > 0: performance_budget.record_simulation_frame( int(Time.get_ticks_usec() - frame_started_at_us), speed, ticks_run ) var backlog_minutes := get_backlog_minutes() if backlog_minutes > MAX_BACKLOG_MINUTES: paused = true simulation_overloaded.emit(backlog_minutes) return ticks_run func reset(start_day := 1, start_hour := 6, start_minute := 0) -> void: var safe_day := maxi(start_day, 1) var safe_minute := posmod(start_hour * 60 + start_minute, MINUTES_PER_DAY) absolute_minute = (safe_day - 1) * MINUTES_PER_DAY + safe_minute _sync_calendar_fields() _accumulator = 0.0 paused = false snapshot_version = 0 performance_budget.clear() _abort_current_tick_requested = false _last_tick_advanced = false _emit_current_time() func advance_minutes(amount: int) -> void: if amount <= 0: return # Tests and deterministic tooling historically use an already-paused clock as # an explicit manual stepping mode. Preserve that contract, while a pause # raised during an initially-running batch is a hard boundary stop. var manual_paused_stepping := paused var pause_serial_before := _pause_request_serial for _index in range(amount): if not manual_paused_stepping and paused: break advance_one_minute() if not _last_tick_advanced: break if _pause_request_serial != pause_serial_before \ or (not manual_paused_stepping and paused): break func advance_one_minute() -> void: _abort_current_tick_requested = false _last_tick_advanced = false tick_started.emit(absolute_minute) _run_phase(TickPhase.APPLY_COMMANDS) # A Runtime gate can reject the tick before the Clock changes. This is # distinct from an ordinary pause raised by a command that already committed # for the upcoming minute. if _abort_current_tick_requested: _abort_current_tick_requested = false return var previous_absolute_minute := absolute_minute absolute_minute += 1 _sync_calendar_fields() _handle_time_boundaries(previous_absolute_minute) _run_phase(TickPhase.TIME_BOUNDARIES) for phase in [ TickPhase.MANA, TickPhase.CONSTRUCTION, TickPhase.LOGISTICS, TickPhase.FACILITY_COMPLETION, TickPhase.VITALS_AND_ENVIRONMENT, TickPhase.INCIDENTS_AND_EVENTS, TickPhase.FACILITY_QUEUES, TickPhase.AI_DECISIONS, TickPhase.PUBLISH_SNAPSHOT, ]: _run_phase(phase) snapshot_version += 1 tick_completed.emit(absolute_minute) _emit_current_time() _abort_current_tick_requested = false _last_tick_advanced = true func request_current_tick_abort() -> void: _abort_current_tick_requested = true func set_speed(value: int) -> bool: if value not in [1, 2, 3]: return false speed = value paused = false return true func toggle_pause() -> bool: paused = not paused if paused: _pause_request_serial += 1 return paused func set_paused(value: bool) -> void: paused = value if value: _pause_request_serial += 1 func get_hour() -> int: return minute_of_day / 60 func get_minute() -> int: return minute_of_day % 60 func get_backlog_minutes() -> int: return int(_accumulator / REAL_SECONDS_PER_GAME_MINUTE) func get_performance_reports() -> Array[Dictionary]: return performance_budget.get_all_reports() func register_tick_handler(phase: TickPhase, handler_id: StringName, callback: Callable) -> bool: if handler_id.is_empty() or not callback.is_valid(): return false if not _phase_handlers.has(phase): _phase_handlers[phase] = {} var handlers: Dictionary = _phase_handlers[phase] if handlers.has(handler_id): return false handlers[handler_id] = callback return true func unregister_tick_handler(phase: TickPhase, handler_id: StringName) -> void: if not _phase_handlers.has(phase): return var handlers: Dictionary = _phase_handlers[phase] handlers.erase(handler_id) func clock_snapshot() -> Dictionary: return { &"absolute_minute": absolute_minute, &"speed": speed, &"paused": paused, &"snapshot_version": snapshot_version, } func restore_clock(snapshot: Dictionary) -> bool: if not snapshot.has(&"absolute_minute"): return false var restored_speed := int(snapshot.get(&"speed", 1)) # Legacy x4 saves resume at the original specification maximum, x3. if restored_speed == 4: restored_speed = 3 if restored_speed not in [1, 2, 3]: return false absolute_minute = maxi(int(snapshot[&"absolute_minute"]), 0) speed = restored_speed paused = bool(snapshot.get(&"paused", true)) snapshot_version = maxi(int(snapshot.get(&"snapshot_version", 0)), 0) _accumulator = 0.0 _skip_next_process = true _sync_calendar_fields() _emit_current_time() return true func _run_phase(phase: TickPhase) -> void: tick_phase_started.emit(phase, absolute_minute) if not _phase_handlers.has(phase): return var handlers: Dictionary = _phase_handlers[phase] var handler_ids: Array = handlers.keys() handler_ids.sort_custom(func(left: Variant, right: Variant) -> bool: return String(left) < String(right) ) for handler_id: Variant in handler_ids: var callback: Callable = handlers[handler_id] if callback.is_valid(): callback.call(absolute_minute) func _sync_calendar_fields() -> void: day = absolute_minute / MINUTES_PER_DAY + 1 minute_of_day = absolute_minute % MINUTES_PER_DAY func _handle_time_boundaries(previous_absolute_minute: int) -> void: if minute_of_day == 0: var completed_day := previous_absolute_minute / MINUTES_PER_DAY + 1 settlement_requested.emit(completed_day) day_started.emit(day) func _emit_current_time() -> void: time_changed.emit(day, get_hour(), get_minute())