@tool class_name EditorSimulationDriver extends RefCounted signal action_activated( cabinet_id: StringName, action_id: StringName, context: Dictionary ) signal action_deactivated( cabinet_id: StringName, action_id: StringName, context: Dictionary ) signal device_state_changed( device_id: StringName, active: bool ) signal trace_entry_created( entry: Dictionary ) signal sequence_started( sequence: TestSequence ) signal sequence_finished( sequence: TestSequence ) signal sequence_stopped var sequence_settling: bool = false var room: RoomDefinition var scenario: ScenarioDefinition var logic_engine: RoomLogicEngine var simulated_device_states: Dictionary = {} var hardware_backend: SimulatedHardwareBackend var pending_hardware_deactivations: Array[Dictionary] = [] var paused: bool = false var active_sequence: TestSequence = null var sequence_start_time: float = 0.0 var pending_sequence_steps: Array[Dictionary] = [] var sequence_running: bool = false func start_sequence( sequence: TestSequence ) -> void: if sequence == null: return # # Starting a rehearsal means starting from # a clean simulated-room state. # reset_simulation() active_sequence = sequence sequence_start_time = ( _get_simulation_time() ) pending_sequence_steps.clear() for step in sequence.get_sorted_steps(): pending_sequence_steps.append( { "execute_at": ( sequence_start_time + step.time_offset_seconds ), "step": step, } ) sequence_running = true sequence_settling = false trace_entry_created.emit( { "type": &"sequence", "time": _get_simulation_time(), "sequence_id": sequence.sequence_id, "display_name": sequence.display_name, "state": &"started", } ) sequence_started.emit( sequence ) # # Execute all steps authored at 0.0 immediately. # _process_pending_sequence_steps() func advance( delta_seconds: float ) -> void: if paused: return _advance_internal( delta_seconds ) func configure( room_definition: RoomDefinition, scenario_definition: ScenarioDefinition ) -> void: room = room_definition scenario = scenario_definition pending_hardware_deactivations.clear() simulated_device_states.clear() logic_engine = RoomLogicEngine.new() logic_engine.configure( room_definition ) logic_engine.action_requested.connect( _on_logic_action_requested ) logic_engine.trace_entry_created.connect( _on_logic_trace_entry_created ) hardware_backend = SimulatedHardwareBackend.new() hardware_backend.pin_state_changed.connect( _on_simulated_pin_state_changed ) hardware_backend.trace_entry_created.connect( _on_hardware_trace_entry_created ) func simulate_game_event( cabinet_id: StringName, event_id: StringName ) -> void: if scenario == null: push_error( "EditorSimulationDriver has no scenario." ) return var assignment := ( scenario.get_assignment_for_cabinet( cabinet_id ) ) if assignment == null: push_error( "No scenario assignment exists for cabinet '%s'." % cabinet_id ) return # # Feed the event into the room-level logic engine. # emit_room_event( cabinet_id, event_id ) # # Preserve the existing scenario mapping: # # game event # -> cabinet hardware action # var actions := ( assignment.get_actions_for_event( event_id ) ) for action_id in actions: _activate_action( cabinet_id, action_id, { "game_event": event_id } ) func _activate_action( cabinet_id: StringName, action_id: StringName, context: Dictionary ) -> void: if room == null: return var assignment := ( scenario.get_assignment_for_cabinet( cabinet_id ) ) if assignment == null: return if assignment.cabinet == null: return if assignment.cabinet.hardware_config == null: return var action := ( assignment.cabinet.hardware_config.get_action( action_id ) ) if action == null: push_error( "Editor simulation could not find hardware action '%s' on cabinet '%s'." % [ action_id, cabinet_id ] ) return action_activated.emit( cabinet_id, action_id, context ) hardware_backend.activate_action( cabinet_id, action, context ) match action.action_type: HardwareAction.ActionType.PULSE_OUTPUT: _schedule_deactivation( cabinet_id, action_id, context, action.pulse_duration_ms ) HardwareAction.ActionType.DIGITAL_OUTPUT: # # For now, treat a digital output as latched. # We'll add explicit ON/OFF editing later. # pass func _schedule_deactivation( cabinet_id: StringName, action_id: StringName, context: Dictionary, duration_ms: int ) -> void: var duration_seconds := ( float(duration_ms) / 1000.0 ) pending_hardware_deactivations.append( { "execute_at": ( _get_simulation_time() + duration_seconds ), "cabinet_id": cabinet_id, "action_id": action_id, "context": context.duplicate(), } ) func _get_simulation_time() -> float: if logic_engine == null: return 0.0 return logic_engine.get_simulation_time() func emit_room_event( source_id: StringName, event_id: StringName ) -> void: if logic_engine == null: return logic_engine.emit_event( source_id, event_id ) func _on_logic_action_requested( target_id: StringName, action_id: StringName, rule_id: StringName ) -> void: if room == null: return # # First: direct room-device action. # var device := room.get_device( target_id ) if device != null: _apply_device_action( device, action_id, rule_id ) return # # Second: cabinet hardware action. # var assignment := ( scenario.get_assignment_for_cabinet( target_id ) ) if assignment != null: _activate_action( target_id, action_id, { "logic_rule": rule_id } ) return print( "[SIM] Unhandled logic action: ", target_id, ".", action_id ) func _apply_device_action( device: RoomDeviceDefinition, action_id: StringName, _rule_id: StringName ) -> void: match action_id: &"activate": _set_device_active( device.device_id, true ) _emit_device_event( device.device_id, &"activated" ) &"deactivate": _set_device_active( device.device_id, false ) _emit_device_event( device.device_id, &"deactivated" ) &"turn_on": _set_device_active( device.device_id, true ) _emit_device_event( device.device_id, &"turned_on" ) &"turn_off": _set_device_active( device.device_id, false ) _emit_device_event( device.device_id, &"turned_off" ) &"unlock": _set_device_active( device.device_id, true ) _emit_device_event( device.device_id, &"unlocked" ) &"lock": _set_device_active( device.device_id, false ) _emit_device_event( device.device_id, &"locked" ) _: print( "[SIM] Unsupported device action: ", device.device_id, ".", action_id ) func _set_device_active( device_id: StringName, active: bool ) -> void: var previous_active := bool( simulated_device_states.get( device_id, false ) ) simulated_device_states[ device_id ] = active device_state_changed.emit( device_id, active ) # # Record the simulated physical-state transition. # if previous_active != active: trace_entry_created.emit( { "type": &"device", "time": _get_simulation_time(), "device_id": device_id, "active": active, } ) func _emit_device_event( device_id: StringName, event_id: StringName ) -> void: if logic_engine == null: return logic_engine.emit_event( device_id, event_id ) func _on_logic_trace_entry_created( entry: Dictionary ) -> void: var timed_entry := ( entry.duplicate(true) ) timed_entry["time"] = ( _get_simulation_time() ) print( "[SIM TRACE] ", timed_entry ) trace_entry_created.emit( timed_entry ) func _propagate_hardware_action_to_devices( cabinet_id: StringName, action_id: StringName, active: bool, context: Dictionary ) -> void: if room == null: return for binding in room.bindings: if binding == null: continue if binding.cabinet_id != cabinet_id: continue if binding.hardware_action_id != action_id: continue var device := room.get_device( binding.device_id ) if device == null: continue _set_device_active( device.device_id, active ) print( "[SIM DEVICE PATH] ", cabinet_id, ".", action_id, " -> ", device.device_id, " active=", active, " context=", context ) if active: _emit_device_event( device.device_id, &"activated" ) else: _emit_device_event( device.device_id, &"deactivated" ) func _on_simulated_pin_state_changed( cabinet_id: StringName, _gpio_pin: int, active: bool, action_id: StringName, context: Dictionary ) -> void: _propagate_hardware_action_to_devices( cabinet_id, action_id, active, context ) func _on_hardware_trace_entry_created( entry: Dictionary ) -> void: var timed_entry := ( entry.duplicate(true) ) timed_entry["time"] = ( _get_simulation_time() ) print( "[GPIO SIM] ", timed_entry ) trace_entry_created.emit( timed_entry ) func _process_pending_hardware_deactivations() -> void: if pending_hardware_deactivations.is_empty(): return var ready: Array[Dictionary] = [] var remaining: Array[Dictionary] = [] for pending in pending_hardware_deactivations: var execute_at: float = ( pending.get( "execute_at", 0.0 ) ) if execute_at <= _get_simulation_time(): ready.append( pending ) else: remaining.append( pending ) pending_hardware_deactivations = remaining for pending in ready: _deactivate_hardware_action( pending.get( "cabinet_id", &"" ), pending.get( "action_id", &"" ), pending.get( "context", {} ) ) func _deactivate_hardware_action( cabinet_id: StringName, action_id: StringName, context: Dictionary ) -> void: if scenario == null: return var assignment := ( scenario.get_assignment_for_cabinet( cabinet_id ) ) if assignment == null: return if assignment.cabinet == null: return if assignment.cabinet.hardware_config == null: return var action := ( assignment.cabinet.hardware_config.get_action( action_id ) ) if action == null: return action_deactivated.emit( cabinet_id, action_id, context ) if hardware_backend != null: hardware_backend.deactivate_action( cabinet_id, action, context ) func set_paused( value: bool ) -> void: paused = value func is_paused() -> bool: return paused func step( delta_seconds: float ) -> void: if delta_seconds <= 0.0: return _advance_internal( delta_seconds ) func reset_simulation() -> void: pending_sequence_steps.clear() active_sequence = null sequence_running = false sequence_start_time = 0.0 sequence_settling = false pending_hardware_deactivations.clear() simulated_device_states.clear() if logic_engine != null: logic_engine.reset() if hardware_backend != null: hardware_backend.reset() paused = false func _advance_internal( delta_seconds: float ) -> void: if delta_seconds <= 0.0: return if logic_engine == null: return logic_engine.advance( delta_seconds ) _process_pending_sequence_steps() _process_pending_hardware_deactivations() _finish_sequence_if_ready() func _process_pending_sequence_steps() -> void: if not sequence_running: return if pending_sequence_steps.is_empty(): _finish_sequence_if_ready() return _finish_sequence_if_ready() var current_time := ( _get_simulation_time() ) var ready: Array[Dictionary] = [] var remaining: Array[Dictionary] = [] for pending in pending_sequence_steps: var execute_at := float( pending.get( "execute_at", 0.0 ) ) if execute_at <= current_time: ready.append( pending ) else: remaining.append( pending ) pending_sequence_steps = remaining for pending in ready: var step := ( pending.get( "step" ) as TestSequenceStep ) if step == null: continue _execute_sequence_step( step ) _finish_sequence_if_ready() func _execute_sequence_step( step: TestSequenceStep ) -> void: trace_entry_created.emit( { "type": &"input", "time": _get_simulation_time(), "source_id": step.source_id, "event_id": step.event_id, "note": step.note, } ) # # If the source is a scenario cabinet, this is a # game/cabinet event and must travel through the # existing scenario mapping as well as room logic. # if scenario != null: var assignment := ( scenario.get_assignment_for_cabinet( step.source_id ) ) if assignment != null: simulate_game_event( step.source_id, step.event_id ) return # # Otherwise this is a generic room/world input. # emit_room_event( step.source_id, step.event_id ) func _finish_sequence_if_ready() -> void: if not sequence_running: return # # Still waiting to inject authored inputs. # if not pending_sequence_steps.is_empty(): return # # All authored inputs have been injected. # Enter settling state once. # if not sequence_settling: sequence_settling = true if active_sequence != null: trace_entry_created.emit( { "type": &"sequence", "time": _get_simulation_time(), "sequence_id": active_sequence.sequence_id, "display_name": active_sequence.display_name, "state": &"settling", } ) # # Room logic still has delayed work. # if ( logic_engine != null and logic_engine.has_pending_actions() ): return # # Hardware pulses are still active. # if not pending_hardware_deactivations.is_empty(): return # # Everything has settled. # var completed_sequence := active_sequence sequence_running = false sequence_settling = false active_sequence = null if completed_sequence == null: return trace_entry_created.emit( { "type": &"sequence", "time": _get_simulation_time(), "sequence_id": completed_sequence.sequence_id, "display_name": completed_sequence.display_name, "state": &"finished", } ) sequence_finished.emit( completed_sequence ) func stop_sequence() -> void: if not sequence_running: return pending_sequence_steps.clear() sequence_running = false sequence_settling = false active_sequence = null trace_entry_created.emit( { "type": &"sequence", "time": _get_simulation_time(), "state": &"stopped", } ) sequence_stopped.emit()