Files
TheTree_mock/scripts/core/simulation_kernel.gd
T

259 lines
7.3 KiB
GDScript

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 := 30.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, 4]:
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))
if restored_speed not in [1, 2, 4]:
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())