Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
262 lines
7.4 KiB
GDScript
262 lines
7.4 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 := 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())
|