summaryrefslogtreecommitdiff
path: root/addons/escoria-core/testing/ashes/interpreter
diff options
context:
space:
mode:
authorradiohotline <radoihotline@disroot.rg>2026-08-25 16:33:56 +0300
committerradiohotline <radoihotline@disroot.rg>2026-08-25 16:33:56 +0300
commita89e744ed0e8c759caf041a580112365bbfe4303 (patch)
treedd61e320904176995727e86a857d00c90d4933ba /addons/escoria-core/testing/ashes/interpreter
parent6d53d8cac78250dc888aa49d5f9d4175c6652feb (diff)
escoria updateHEADmain
Diffstat (limited to 'addons/escoria-core/testing/ashes/interpreter')
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd1879
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc3
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc8
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc14
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc9
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc12
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc5
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc8
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc14
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc6
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc5
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc9
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc6
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_hidden_authored_default_after_filtering.esc10
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_visible_authored_default_after_filtering.esc10
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc11
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc5
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc15
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc17
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc11
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc4
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc6
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc3
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc8
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc11
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc2
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc2
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc10
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc10
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc7
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc12
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc6
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc5
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc11
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd16
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd.uid1
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd32
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd.uid0
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd15
-rw-r--r--addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd.uid1
102 files changed, 2345 insertions, 0 deletions
diff --git a/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd b/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd
new file mode 100644
index 0000000..e03d122
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd
@@ -0,0 +1,1879 @@
+# GdUnit generated TestSuite
+class_name ESCInterpreterTest
+extends GdUnitTestSuite
+
+@warning_ignore("unused_parameter")
+@warning_ignore("return_value_discarded")
+
+const MARK_SCENE := preload("res://game/characters/mark/mark.tscn")
+const SIMPLE_CHOOSER_SCENE := preload("res://addons/escoria-dialog-simple/chooser/simple.tscn")
+
+var _terminate_on_errors_before: bool
+var _command_directories_before: PackedStringArray
+var _dialog_player_before
+
+
+class DialogPlayerDouble extends ESCDialogPlayer:
+ var _choice_indexes: Array = []
+ var _choices_consumed: int = 0
+ var _dialogs_started: Array = []
+
+
+ func _init(choice_indexes: Array = []):
+ _choice_indexes = choice_indexes.duplicate()
+
+
+ func start_dialog_choices(dialog: ESCDialog, _type: String = "simple"):
+ var option_keys: Array = []
+ var option_texts: Array = []
+ var option_source_indexes: Array = []
+ for option in dialog.options:
+ option_keys.append(option.translation_key)
+ option_texts.append(option.option)
+ option_source_indexes.append(option.source_option_index)
+
+ var timeout_option := dialog.get_timeout_option()
+
+ _dialogs_started.append({
+ "avatar": dialog.avatar,
+ "timeout": dialog.timeout,
+ "timeout_option": dialog.timeout_option,
+ "authored_options_count": dialog.authored_options_count,
+ "visible_timeout_option": timeout_option.option if timeout_option else null,
+ "options_size": dialog.options.size(),
+ "option_keys": option_keys,
+ "option_texts": option_texts,
+ "option_source_indexes": option_source_indexes
+ })
+
+ # Mirror the real dialog player closely enough for interpreter tests:
+ # only valid options are choosable, and scripted indexes deterministically
+ # drive nested dialog flows without needing UI interaction.
+ var valid_options: Array = []
+ for option in dialog.options:
+ if option.is_valid():
+ valid_options.append(option)
+
+ var choice_index := 0
+ if not _choice_indexes.is_empty():
+ choice_index = int(_choice_indexes.pop_front())
+ _choices_consumed += 1
+
+ call_deferred("_emit_choice", valid_options[choice_index] if choice_index < valid_options.size() else null)
+
+
+ func _emit_choice(option) -> void:
+ option_chosen.emit(option)
+
+
+ func get_choices_consumed() -> int:
+ return _choices_consumed
+
+
+ func get_remaining_choices() -> int:
+ return _choice_indexes.size()
+
+
+ func get_dialogs_started() -> Array:
+ return _dialogs_started.duplicate(true)
+
+
+func before() -> void:
+ # Interpreter tests intentionally trigger runtime errors in a few cases, so
+ # they disable terminate-on-error and install a test-only command directory.
+ # The dialog player is also replaced on demand by a deterministic test
+ # double so dialog execution can run headlessly.
+ ESCInterpreterFactory.reset_interpreter()
+ escoria.event_manager.interrupt([], false)
+ escoria.event_manager.clear_event_queue()
+ _terminate_on_errors_before = ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_ERRORS
+ )
+ _command_directories_before = PackedStringArray(
+ ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.COMMAND_DIRECTORIES
+ )
+ )
+ _dialog_player_before = escoria.get("dialog_player")
+
+ ESCProjectSettingsManager.set_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_ERRORS,
+ false
+ )
+ ESCProjectSettingsManager.set_setting(
+ ESCProjectSettingsManager.COMMAND_DIRECTORIES,
+ PackedStringArray([
+ "res://addons/escoria-core/testing/ashes/interpreter/support/commands"
+ ] + Array(_command_directories_before))
+ )
+ escoria.command_registry.registry.clear()
+ escoria.set("dialog_player", null)
+
+
+func after() -> void:
+ # Restore shared Escoria settings and globals so each test starts from the
+ # same baseline and does not leak command registry or dialog-player state.
+ ESCInterpreterFactory.reset_interpreter()
+ escoria.event_manager.interrupt([], false)
+ escoria.event_manager.clear_event_queue()
+ ESCProjectSettingsManager.set_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_ERRORS,
+ _terminate_on_errors_before
+ )
+ ESCProjectSettingsManager.set_setting(
+ ESCProjectSettingsManager.COMMAND_DIRECTORIES,
+ _command_directories_before
+ )
+ escoria.command_registry.registry.clear()
+ escoria.set("dialog_player", _dialog_player_before)
+
+
+func test_execute_block_restores_scope_after_break() -> void:
+ # This fixture creates a nested block-local `x`, exits that block with
+ # `break`, and then reads `x` afterward into a global. If `_execute_block()`
+ # fails to restore `_environment` on the early return, the later lookup will
+ # incorrectly resolve the inner `x` instead of the outer one.
+ var source := _load_fixture("block_scope_restores_after_break.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("block_scope_restores_after_break.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new([], {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ await interpreter.interpret(statements)
+
+ var globals := interpreter.get_global_values()
+ assert_bool(globals.has("result")).is_true()
+ assert_float(float(globals["result"])).is_equal(1.0)
+
+ interpreter.cleanup()
+
+
+func test_invalid_numeric_comparison_does_not_succeed_silently() -> void:
+ # This fixture performs an invalid numeric comparison. Invalid numeric
+ # operations should log an interpreter error and evaluate to `null` rather
+ # than continuing into a native GDScript operator error.
+ var source := _load_fixture("invalid_numeric_comparison.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("invalid_numeric_comparison.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new([], {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ await interpreter.interpret(statements)
+
+ var globals := interpreter.get_global_values()
+ assert_bool(globals.has("result")).is_true()
+ assert_bool(globals["result"] == null).is_true()
+
+ interpreter.cleanup()
+
+
+func test_print_with_zero_arguments_does_not_native_crash() -> void:
+ # This fixture calls the builtin `print()` with zero arguments. The current
+ # implementation only rejects calls with more than one argument, so this
+ # test captures the actual runtime behavior before we decide on a fix.
+ var source := _load_fixture("print_zero_args.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("print_zero_args.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new([], {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ await interpreter.interpret(statements)
+
+ interpreter.cleanup()
+
+
+func test_print_with_two_arguments_does_not_native_crash() -> void:
+ # This fixture calls the builtin `print()` with too many arguments. The
+ # builtin should reject the call cleanly and avoid any native runtime error.
+ var source := _load_fixture("print_two_args.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("print_two_args.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new([], {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ await interpreter.interpret(statements)
+
+ interpreter.cleanup()
+
+
+func test_stop_in_nested_block_prevents_later_statements() -> void:
+ # `stop` should unwind the current event immediately, even when it is issued
+ # from inside a nested block. The later assignment must therefore never run.
+ var outcome := await _interpret_fixture("stop_nested_block.esc")
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_float(float(globals["result"])).is_equal(0.0)
+
+
+func test_done_in_nested_dialog_block_prevents_later_statements() -> void:
+ # `done` should conclude the current dialog from inside a nested block, but
+ # it must not abort the rest of the event. The assignment after the dialog
+ # should still execute, proving the dialog exits cleanly without leaking a
+ # control-flow sentinel to the enclosing event body.
+ var outcome := await _interpret_fixture(
+ "done_nested_block_in_dialog.esc",
+ [0]
+ )
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_float(float(globals["result"])).is_equal(1.0)
+
+
+func test_break_one_returns_to_parent_dialog_level() -> void:
+ # `break 1` should move dialog control back up one level, not continue the
+ # remainder of the current outer option body. The scripted choice sequence
+ # must therefore consume a third outer-dialog choice after the nested break;
+ # if the dialog ends early instead, the final string alone is ambiguous.
+ var outcome := await _interpret_fixture(
+ "nested_dialog_break_one.esc",
+ [0, 0, 1]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("start-done")
+ assert_int(int(outcome.dialog_choices_consumed)).is_equal(3)
+ assert_int(int(outcome.remaining_dialog_choices)).is_equal(0)
+
+
+func test_done_exits_entire_nested_dialog_tree() -> void:
+ # `done` should conclude the entire dialog from any nesting depth. After the
+ # nested choice issues `done`, the outer option body must not continue and
+ # the outer dialog must not be shown again. Execution should resume only
+ # after the dialog statement, so the final value should reflect the initial
+ # value plus the post-dialog append.
+ var outcome := await _interpret_fixture(
+ "done_exits_entire_nested_dialog.esc",
+ [0, 0]
+ )
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("start-done")
+
+
+func test_dialog_followup_runs_after_all_options_are_exhausted() -> void:
+ # When each option invalidates itself, the dialog should conclude once no
+ # valid options remain. Execution must then continue with the statement after
+ # the dialog block.
+ var outcome := await _interpret_fixture(
+ "dialog_exhausted_options_runs_followup.esc",
+ [0, 0]
+ )
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("start-say1-say2-finished")
+ assert_bool(bool(globals["option1_is_valid"])).is_false()
+ assert_bool(bool(globals["option2_is_valid"])).is_false()
+ assert_int(int(outcome.dialog_choices_consumed)).is_equal(2)
+ assert_int(int(outcome.remaining_dialog_choices)).is_equal(0)
+
+
+func test_break_two_exits_two_dialog_levels() -> void:
+ # `break 2` from the innermost dialog should move back up two dialog levels
+ # to the outermost dialog in this fixture shape. As with `break 1`, the test
+ # also verifies that the final scripted outer choice was actually consumed,
+ # which proves the outer dialog stayed alive after the nested unwind.
+ var outcome := await _interpret_fixture(
+ "break_two_exits_two_dialog_levels.esc",
+ [0, 0, 0, 1]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("start-done")
+ assert_int(int(outcome.dialog_choices_consumed)).is_equal(4)
+ assert_int(int(outcome.remaining_dialog_choices)).is_equal(0)
+
+
+func test_stop_in_dialog_option_prevents_later_event_statements() -> void:
+ # `stop` issued from a dialog option should abort the entire event, not just
+ # the current dialog. The assignment after the dialog must therefore never run.
+ var outcome := await _interpret_fixture(
+ "stop_in_dialog_option.esc",
+ [0]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_float(float(outcome.globals["result"])).is_equal(0.0)
+
+
+func test_delayed_command_in_dialog_option_preserves_ordering() -> void:
+ # A blocking command inside a dialog option must complete before the option
+ # body resumes and before execution continues after the dialog. If it does
+ # not block correctly, the final append would observe `unset` instead of the
+ # command-written value.
+ var outcome := await _interpret_fixture(
+ "delayed_command_in_dialog_option.esc",
+ [0]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("cmd-after")
+
+
+func test_delayed_command_in_loop_preserves_ordering() -> void:
+ # A blocking command inside a loop body must finish before the loop can
+ # continue to the next statement. The later append therefore proves the
+ # delayed command completed before the event resumed after the loop.
+ var outcome := await _interpret_fixture("delayed_command_in_loop.esc")
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("cmd-after")
+ assert_bool(outcome.globals.has("count")).is_true()
+ assert_float(float(outcome.globals["count"])).is_equal(1.0)
+
+
+func test_interrupting_delayed_command_stops_event_progress() -> void:
+ # Interrupting an in-flight command should stop the surrounding event from
+ # resuming normal execution. The command records that it observed the
+ # interrupt, and the later append must never run.
+ var source := _load_fixture("delayed_command_interrupt.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("delayed_command_interrupt.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ var event_stmt: ESCGrammarStmts.Event = statements[0]
+ interpreter.call_deferred("interpret", statements)
+ await escoria.get_tree().create_timer(0.05).timeout
+ interpreter.interrupt()
+ await event_stmt.finished
+
+ var globals := interpreter.get_global_values()
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("start")
+ assert_bool(globals.has("was_interrupted")).is_true()
+ assert_bool(bool(globals["was_interrupted"])).is_true()
+ assert_bool(event_stmt.is_interrupted()).is_true()
+
+ interpreter.cleanup()
+
+
+func test_interrupting_delayed_command_in_dialog_stops_event_progress() -> void:
+ # Interrupting an in-flight command inside a dialog option should abort the
+ # surrounding event just as cleanly as interruption in a plain block. The
+ # dialog must not resume and the post-dialog append must never run.
+ var source := _load_fixture("delayed_command_interrupt_in_dialog.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("delayed_command_interrupt_in_dialog.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ var event_stmt: ESCGrammarStmts.Event = statements[0]
+ interpreter.call_deferred("interpret", statements)
+ await escoria.get_tree().create_timer(0.05).timeout
+ interpreter.interrupt()
+ await event_stmt.finished
+
+ var globals := interpreter.get_global_values()
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("start")
+ assert_bool(globals.has("was_interrupted")).is_true()
+ assert_bool(bool(globals["was_interrupted"])).is_true()
+ assert_bool(event_stmt.is_interrupted()).is_true()
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_interrupting_delayed_command_stops_multi_iteration_loop() -> void:
+ # Interrupting a delayed command in the first loop iteration should stop the
+ # loop itself, not just the current statement. The counter must therefore
+ # stay at zero and the post-loop append must never run.
+ var source := _load_fixture("delayed_command_interrupt_in_multi_loop.esc")
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path("delayed_command_interrupt_in_multi_loop.esc"))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+
+ assert_object(statements[0]).is_instanceof(ESCGrammarStmts.Event)
+ var event_stmt: ESCGrammarStmts.Event = statements[0]
+ interpreter.call_deferred("interpret", statements)
+ await escoria.get_tree().create_timer(0.05).timeout
+ interpreter.interrupt()
+ await event_stmt.finished
+
+ var globals := interpreter.get_global_values()
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("start")
+ assert_bool(globals.has("was_interrupted")).is_true()
+ assert_bool(bool(globals["was_interrupted"])).is_true()
+ assert_bool(globals.has("count")).is_true()
+ assert_float(float(globals["count"])).is_equal(0.0)
+ assert_bool(event_stmt.is_interrupted()).is_true()
+
+ interpreter.cleanup()
+
+
+func test_invalid_command_does_not_continue_event() -> void:
+ # A missing command should fail the current event cleanly instead of letting
+ # later statements continue as if the command had succeeded.
+ var outcome := await _interpret_fixture("invalid_command_does_not_continue.esc")
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("start")
+
+
+func test_invalid_command_in_dialog_does_not_continue_event() -> void:
+ # A missing command inside a dialog option should still terminate the event
+ # cleanly. The assignment after the dialog must not run.
+ var outcome := await _interpret_fixture(
+ "invalid_command_in_dialog_stops_event.esc",
+ [0]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("start")
+
+
+func test_invalid_command_in_loop_does_not_continue_event() -> void:
+ # A missing command inside a loop body should escape the loop as a terminal
+ # runtime error instead of allowing later loop or post-loop statements to run.
+ var outcome := await _interpret_fixture("invalid_command_in_loop_stops_event.esc")
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("start")
+ assert_bool(outcome.globals.has("count")).is_true()
+ assert_float(float(outcome.globals["count"])).is_equal(0.0)
+
+
+func test_script_preserves_multi_target_use_events_without_creating_untargeted_use() -> void:
+ # Room05-style scripts can define the same action multiple times with
+ # different targets. Both targeted events must survive compilation, while an
+ # untargeted lookup for the same action must not accidentally match either.
+ var script_object := _compile_fixture_script("multi_target_use_events.esc")
+
+ assert_bool(script_object.has_event_with_target("use", "r5_wrench")).is_true()
+ assert_bool(script_object.has_event_with_target("use", "r5_pen")).is_true()
+ assert_bool(script_object.has_event_with_target("use")).is_false()
+
+ var wrench_event = script_object.get_event_with_target("use", "r5_wrench")
+ var pen_event = script_object.get_event_with_target("use", "r5_pen")
+
+ assert_object(wrench_event).is_not_null()
+ assert_object(pen_event).is_not_null()
+ assert_object(wrench_event).is_not_same(pen_event)
+ assert_str(wrench_event.get_target_name()).is_equal("r5_wrench")
+ assert_str(pen_event.get_target_name()).is_equal("r5_pen")
+ assert_int(wrench_event.get_num_statements_in_block()).is_equal(1)
+ assert_int(pen_event.get_num_statements_in_block()).is_equal(1)
+
+
+func test_dialog_option_scope_shadowing_restores_outer_local() -> void:
+ # A dialog option body introduces nested block scope. Assignments inside the
+ # option should see the inner shadowing variable, while execution after the
+ # dialog must still resolve the original outer local.
+ var outcome := await _interpret_fixture(
+ "dialog_scope_shadowing.esc",
+ [0]
+ )
+ assert_bool(outcome.globals.has("result")).is_true()
+ assert_str(String(outcome.globals["result"])).is_equal("inner:outer")
+
+
+func test_interrupting_background_channel_does_not_interrupt_front_channel() -> void:
+ # Background-channel interruption should only affect commands running on
+ # that channel. The front event must still complete normally while the
+ # background event should finish as interrupted once its in-flight command
+ # observes the interrupt.
+ var events = _load_fixture_events("channel_interrupt_isolation.esc")
+ assert_bool(events.has_event_with_target("front_event")).is_true()
+ assert_bool(events.has_event_with_target("background_event")).is_true()
+ escoria.globals_manager.set_global("channel_front_result", "start")
+ escoria.globals_manager.set_global("channel_background_result", "start")
+ escoria.globals_manager.set_global("channel_front_was_interrupted", false)
+ escoria.globals_manager.set_global("channel_background_was_interrupted", false)
+
+ var signal_results := {
+ "front": null,
+ "background": null,
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ if event_name == "front_event":
+ signal_results["front"] = [return_code, event_name]
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if event_name == "background_event" and finished_channel_name == "bg_test":
+ signal_results["background"] = [return_code, event_name, finished_channel_name]
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_event"))
+ escoria.event_manager.queue_background_event(
+ "bg_test",
+ events.get_event_with_target("background_event")
+ )
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt_channel("bg_test")
+
+ while signal_results["front"] == null or signal_results["background"] == null:
+ await escoria.get_tree().process_frame
+
+ var front_finished = signal_results["front"]
+ var background_finished = signal_results["background"]
+
+ assert_int(int(front_finished[0])).is_equal(ESCExecution.RC_OK)
+ assert_int(int(background_finished[0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(escoria.globals_manager.get_global("channel_front_result"))).is_equal("front-cmd-after")
+ assert_bool(escoria.globals_manager.get_global("channel_front_was_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("channel_background_result"))).is_equal("start")
+ assert_bool(escoria.globals_manager.get_global("channel_background_was_interrupted") == true).is_true()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_concurrent_channels_complete_without_interrupting_each_other() -> void:
+ # Two events on different channels should be able to run at the same time
+ # and still complete normally when no interrupt is issued.
+ var events = _load_fixture_events("channel_interrupt_isolation.esc")
+ assert_bool(events.has_event_with_target("front_event")).is_true()
+ assert_bool(events.has_event_with_target("background_event")).is_true()
+ escoria.globals_manager.set_global("channel_front_result", "start")
+ escoria.globals_manager.set_global("channel_background_result", "start")
+ escoria.globals_manager.set_global("channel_front_was_interrupted", false)
+ escoria.globals_manager.set_global("channel_background_was_interrupted", false)
+
+ var signal_results := {
+ "front": null,
+ "background": null,
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ if event_name == "front_event":
+ signal_results["front"] = [return_code, event_name]
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if event_name == "background_event" and finished_channel_name == "bg_test":
+ signal_results["background"] = [return_code, event_name, finished_channel_name]
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_event"))
+ escoria.event_manager.queue_background_event(
+ "bg_test",
+ events.get_event_with_target("background_event")
+ )
+
+ while signal_results["front"] == null or signal_results["background"] == null:
+ await escoria.get_tree().process_frame
+
+ var front_finished = signal_results["front"]
+ var background_finished = signal_results["background"]
+
+ assert_int(int(front_finished[0])).is_equal(ESCExecution.RC_OK)
+ assert_int(int(background_finished[0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(escoria.globals_manager.get_global("channel_front_result"))).is_equal("front-cmd-after")
+ assert_bool(escoria.globals_manager.get_global("channel_front_was_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("channel_background_result"))).is_equal("bg-cmd-after")
+ assert_bool(escoria.globals_manager.get_global("channel_background_was_interrupted") == true).is_false()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_interrupting_channel_clears_queued_successor_events() -> void:
+ # Interrupting a channel should stop the currently running event and purge
+ # later queued events on that same channel before they ever start running.
+ var events = _load_fixture_events("channel_interrupt_clears_queue.esc")
+ assert_bool(events.has_event_with_target("first")).is_true()
+ assert_bool(events.has_event_with_target("second")).is_true()
+ escoria.globals_manager.set_global("bg_queue_interrupted", false)
+
+ var background_finishes: Array = []
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ background_finishes.append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("first"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("second"))
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt_channel("bg_test")
+
+ while background_finishes.is_empty():
+ await escoria.get_tree().process_frame
+
+ assert_int(background_finishes.size()).is_equal(1)
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(background_finishes[0][1])).is_equal("first")
+ assert_str(String(background_finishes[0][2])).is_equal("bg_test")
+ assert_str(String(escoria.globals_manager.get_global("bg_queue_result"))).is_equal("first")
+ assert_bool(escoria.globals_manager.get_global("bg_queue_interrupted") == true).is_true()
+
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_concurrent_channels_do_not_block_other_channel_queues() -> void:
+ # Each channel should serialize only its own queue. A front-channel event
+ # must complete independently while a background channel works through two
+ # queued events in order.
+ var events = _load_fixture_events("channel_queue_progress_isolated.esc")
+ assert_bool(events.has_event_with_target("front_event")).is_true()
+ assert_bool(events.has_event_with_target("background_first")).is_true()
+ assert_bool(events.has_event_with_target("background_second")).is_true()
+ escoria.globals_manager.set_global("isolated_front_result", "start")
+ escoria.globals_manager.set_global("isolated_background_result", "start")
+ escoria.globals_manager.set_global("isolated_front_interrupted", false)
+ escoria.globals_manager.set_global("isolated_background_interrupted", false)
+
+ var signal_results := {
+ "front": null,
+ "background_finishes": [],
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ if event_name == "front_event":
+ signal_results["front"] = [return_code, event_name]
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ signal_results["background_finishes"].append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_event"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_first"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_second"))
+
+ while signal_results["front"] == null or signal_results["background_finishes"].size() < 2:
+ await escoria.get_tree().process_frame
+
+ var front_finished = signal_results["front"]
+ var background_finishes: Array = signal_results["background_finishes"]
+
+ assert_int(int(front_finished[0])).is_equal(ESCExecution.RC_OK)
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[0][1])).is_equal("background_first")
+ assert_int(int(background_finishes[1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[1][1])).is_equal("background_second")
+ assert_str(String(escoria.globals_manager.get_global("isolated_front_result"))).is_equal("front-cmd-after")
+ assert_bool(escoria.globals_manager.get_global("isolated_front_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("isolated_background_result"))).is_equal("first-cmd-after-first-second")
+ assert_bool(escoria.globals_manager.get_global("isolated_background_interrupted") == true).is_false()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_global_interrupt_stops_running_channels_and_clears_queues() -> void:
+ # A global interrupt should stop every currently running channel and purge
+ # any queued successor events before they start. This fixture queues one
+ # running and one queued event on both `_front` and `bg_test`.
+ var events = _load_fixture_events("global_interrupt_clears_all_channels.esc")
+ assert_bool(events.has_event_with_target("front_running")).is_true()
+ assert_bool(events.has_event_with_target("front_queued")).is_true()
+ assert_bool(events.has_event_with_target("background_running")).is_true()
+ assert_bool(events.has_event_with_target("background_queued")).is_true()
+ escoria.globals_manager.set_global("global_interrupt_front_result", "start")
+ escoria.globals_manager.set_global("global_interrupt_background_result", "start")
+ escoria.globals_manager.set_global("global_interrupt_front_was_interrupted", false)
+ escoria.globals_manager.set_global("global_interrupt_background_was_interrupted", false)
+
+ var signal_results := {
+ "front_finishes": [],
+ "background_finishes": [],
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ signal_results["front_finishes"].append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ signal_results["background_finishes"].append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_running"))
+ escoria.event_manager.queue_event(events.get_event_with_target("front_queued"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_running"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_queued"))
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt()
+
+ while signal_results["front_finishes"].is_empty() or signal_results["background_finishes"].is_empty():
+ await escoria.get_tree().process_frame
+
+ await escoria.get_tree().create_timer(0.05).timeout
+
+ assert_int(signal_results["front_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(signal_results["front_finishes"][0][1])).is_equal("front_running")
+ assert_int(signal_results["background_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_running")
+ assert_str(String(signal_results["background_finishes"][0][2])).is_equal("bg_test")
+ assert_str(String(escoria.globals_manager.get_global("global_interrupt_front_result"))).is_equal("start")
+ assert_bool(escoria.globals_manager.get_global("global_interrupt_front_was_interrupted") == true).is_true()
+ assert_str(String(escoria.globals_manager.get_global("global_interrupt_background_result"))).is_equal("start")
+ assert_bool(escoria.globals_manager.get_global("global_interrupt_background_was_interrupted") == true).is_true()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_queue_event_block_waits_for_correct_background_event_under_concurrency() -> void:
+ # Blocking queue_event waits must ignore unrelated background completions,
+ # including earlier events on the same channel, and return only when the
+ # specifically requested background event finishes.
+ var script_object := _compile_fixture_script("queue_event_block_waits_for_correct_background_event.esc")
+ assert_bool(script_object.has_event_with_target("background_first")).is_true()
+ assert_bool(script_object.has_event_with_target("background_target")).is_true()
+ assert_bool(script_object.has_event_with_target("front_event")).is_true()
+ escoria.globals_manager.set_global("block_wait_result", "start")
+ escoria.globals_manager.set_global("block_wait_front_result", "start")
+
+ escoria.event_manager.queue_background_event(
+ "bg_test",
+ script_object.get_event_with_target("background_first")
+ )
+ escoria.event_manager.queue_event(script_object.get_event_with_target("front_event"))
+
+ var block_rc: int = await escoria.event_manager.queue_event_from_esc(
+ script_object,
+ "background_target",
+ "bg_test",
+ true
+ )
+
+ assert_int(block_rc).is_equal(ESCExecution.RC_OK)
+ assert_str(String(escoria.globals_manager.get_global("block_wait_result"))).is_equal("first-target")
+ assert_str(String(escoria.globals_manager.get_global("block_wait_front_result"))).is_equal("front")
+
+
+func test_queue_event_block_waits_for_correct_front_event_under_concurrency() -> void:
+ # Blocking front queue_event waits must ignore both background completions
+ # and earlier foreground events, returning only when the specifically
+ # requested front event finishes.
+ var script_object := _compile_fixture_script("queue_event_block_waits_for_correct_front_event.esc")
+ assert_bool(script_object.has_event_with_target("front_first")).is_true()
+ assert_bool(script_object.has_event_with_target("front_target")).is_true()
+ assert_bool(script_object.has_event_with_target("background_event")).is_true()
+ escoria.globals_manager.set_global("front_block_wait_result", "start")
+ escoria.globals_manager.set_global("front_block_wait_background_result", "start")
+
+ escoria.event_manager.queue_event(script_object.get_event_with_target("front_first"))
+ escoria.event_manager.queue_background_event(
+ "bg_test",
+ script_object.get_event_with_target("background_event")
+ )
+
+ var block_rc: int = await escoria.event_manager.queue_event_from_esc(
+ script_object,
+ "front_target",
+ "_front",
+ true
+ )
+
+ assert_int(block_rc).is_equal(ESCExecution.RC_OK)
+ assert_str(String(escoria.globals_manager.get_global("front_block_wait_result"))).is_equal("first-target")
+ assert_str(String(escoria.globals_manager.get_global("front_block_wait_background_result"))).is_equal("background")
+
+
+func test_sequential_front_events_preserve_globals_between_runtime_interpreters() -> void:
+ # Each event-manager run currently observes globals written by earlier runs.
+ # The ownership refactor must preserve that user-visible behavior even though
+ # the underlying interpreter instances will stop sharing a singleton slot.
+ var events = _load_fixture_events("interpreter_runtime_state_persists_between_events.esc")
+ assert_bool(events.has_event_with_target("first")).is_true()
+ assert_bool(events.has_event_with_target("second")).is_true()
+
+ var front_finishes: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ front_finishes.append([return_code, event_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("first"))
+ while front_finishes.size() < 1:
+ await escoria.get_tree().process_frame
+
+ escoria.event_manager.queue_event(events.get_event_with_target("second"))
+ while front_finishes.size() < 2:
+ await escoria.get_tree().process_frame
+
+ assert_int(front_finishes.size()).is_equal(2)
+ assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(front_finishes[0][1])).is_equal("first")
+ assert_int(int(front_finishes[1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(front_finishes[1][1])).is_equal("second")
+ assert_str(String(escoria.globals_manager.get_global("interpreter_runtime_result"))).is_equal("first-second")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+
+
+func test_background_event_sees_globals_written_by_previous_front_event() -> void:
+ # Front and background channels each create their own runtime interpreter.
+ # A background event queued after a front event must still observe globals
+ # written by that earlier front-channel execution.
+ var events = _load_fixture_events("interpreter_runtime_state_persists_between_events.esc")
+ assert_bool(events.has_event_with_target("background_seed")).is_true()
+ assert_bool(events.has_event_with_target("background_after")).is_true()
+
+ var front_finishes: Array = []
+ var background_finishes: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ front_finishes.append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ background_finishes.append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("background_seed"))
+ while front_finishes.size() < 1:
+ await escoria.get_tree().process_frame
+
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_after"))
+ while background_finishes.size() < 1:
+ await escoria.get_tree().process_frame
+
+ assert_int(front_finishes.size()).is_equal(1)
+ assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(front_finishes[0][1])).is_equal("background_seed")
+ assert_int(background_finishes.size()).is_equal(1)
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[0][1])).is_equal("background_after")
+ assert_str(String(background_finishes[0][2])).is_equal("bg_test")
+ assert_str(String(escoria.globals_manager.get_global("interpreter_runtime_background_result"))).is_equal("first-background")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_running_interpreter_sees_new_global_defined_by_other_channel() -> void:
+ # A runtime interpreter that is already running must observe globals declared
+ # by a concurrent channel after it has started. Otherwise overlapping events
+ # can regress from shared-global behavior to "Undefined variable" failures.
+ var events = _load_fixture_events("concurrent_new_global_visibility.esc")
+ assert_bool(events.has_event_with_target("front_waits_for_new_global")).is_true()
+ assert_bool(events.has_event_with_target("background_declares_new_global")).is_true()
+
+ var front_finishes: Array = []
+ var background_finishes: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ front_finishes.append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ background_finishes.append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_waits_for_new_global"))
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_declares_new_global"))
+
+ while front_finishes.is_empty() or background_finishes.is_empty():
+ await escoria.get_tree().process_frame
+
+ assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(str(front_finishes[0][1])).is_equal("front_waits_for_new_global")
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(str(background_finishes[0][1])).is_equal("background_declares_new_global")
+ assert_str(str(escoria.globals_manager.get_global("concurrent_visibility_result"))).is_equal("from-background")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_queue_background_event_allows_consecutive_duplicate_queue_entries() -> void:
+ # Queueing the same background event twice in a row should be allowed on a
+ # background channel. In practice these queues are populated via ASHES
+ # commands, so the second request should remain queued behind the first run.
+ var events = _load_fixture_events("background_duplicate_suppression.esc")
+ assert_bool(events.has_event_with_target("repeat_event")).is_true()
+ escoria.globals_manager.set_global("background_duplicate_count", 0)
+ escoria.globals_manager.set_global("background_duplicate_last", "start")
+
+ var background_finishes: Array = []
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ background_finishes.append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event"))
+
+ while background_finishes.size() < 2:
+ await escoria.get_tree().process_frame
+
+ assert_int(background_finishes.size()).is_equal(2)
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[0][1])).is_equal("repeat_event")
+ assert_str(String(background_finishes[0][2])).is_equal("bg_test")
+ assert_int(int(background_finishes[1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[1][1])).is_equal("repeat_event")
+ assert_str(String(background_finishes[1][2])).is_equal("bg_test")
+ assert_int(int(escoria.globals_manager.get_global("background_duplicate_count"))).is_equal(2)
+ assert_str(String(escoria.globals_manager.get_global("background_duplicate_last"))).is_equal("tick")
+
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_queue_background_event_allows_same_event_after_current_run_starts() -> void:
+ # Once the first background event is already running, queueing the same event
+ # again on that channel should queue a second run behind it instead of being
+ # suppressed as a duplicate.
+ var events = _load_fixture_events("background_duplicate_suppression.esc")
+ assert_bool(events.has_event_with_target("repeat_event")).is_true()
+ escoria.globals_manager.set_global("background_duplicate_count", 0)
+ escoria.globals_manager.set_global("background_duplicate_last", "start")
+
+ var background_finishes: Array = []
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ background_finishes.append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event"))
+ await escoria.get_tree().create_timer(0.01).timeout
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("repeat_event"))
+
+ while background_finishes.size() < 2:
+ await escoria.get_tree().process_frame
+
+ assert_int(background_finishes.size()).is_equal(2)
+ assert_int(int(background_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[0][1])).is_equal("repeat_event")
+ assert_int(int(background_finishes[1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(background_finishes[1][1])).is_equal("repeat_event")
+ assert_int(int(escoria.globals_manager.get_global("background_duplicate_count"))).is_equal(2)
+ assert_str(String(escoria.globals_manager.get_global("background_duplicate_last"))).is_equal("tick")
+
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_global_interrupt_preserves_exception_events() -> void:
+ # `interrupt(exceptions=...)` should leave only the named running and queued
+ # events alive. Here the front running event and one queued background event
+ # are excepted, while the other front/background events must be cleared.
+ var events = _load_fixture_events("global_interrupt_preserves_exceptions.esc")
+ assert_bool(events.has_event_with_target("front_keep")).is_true()
+ assert_bool(events.has_event_with_target("front_drop")).is_true()
+ assert_bool(events.has_event_with_target("background_drop")).is_true()
+ assert_bool(events.has_event_with_target("background_keep")).is_true()
+ escoria.globals_manager.set_global("interrupt_except_front_result", "start")
+ escoria.globals_manager.set_global("interrupt_except_front_interrupted", false)
+ escoria.globals_manager.set_global("interrupt_except_background_result", "start")
+ escoria.globals_manager.set_global("interrupt_except_background_interrupted", false)
+ escoria.globals_manager.set_global("interrupt_except_background_keep_result", "start")
+
+ var signal_results := {
+ "front_finishes": [],
+ "background_finishes": [],
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ signal_results["front_finishes"].append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ signal_results["background_finishes"].append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_keep"))
+ escoria.event_manager.queue_event(events.get_event_with_target("front_drop"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_drop"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_keep"))
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt(PackedStringArray(["front_keep", "background_keep"]))
+
+ while signal_results["front_finishes"].is_empty() or signal_results["background_finishes"].size() < 2:
+ await escoria.get_tree().process_frame
+
+ assert_int(signal_results["front_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["front_finishes"][0][1])).is_equal("front_keep")
+ assert_int(signal_results["background_finishes"].size()).is_equal(2)
+ assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_drop")
+ assert_int(int(signal_results["background_finishes"][1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["background_finishes"][1][1])).is_equal("background_keep")
+ assert_str(String(escoria.globals_manager.get_global("interrupt_except_front_result"))).is_equal("front-cmd-after")
+ assert_bool(escoria.globals_manager.get_global("interrupt_except_front_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("interrupt_except_background_result"))).is_equal("start")
+ assert_bool(escoria.globals_manager.get_global("interrupt_except_background_interrupted") == true).is_true()
+ assert_str(String(escoria.globals_manager.get_global("interrupt_except_background_keep_result"))).is_equal("bg-keep-after")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_scheduled_front_event_runs_after_current_front_event_while_background_is_busy() -> void:
+ # Scheduled events always feed the `_front` queue. They should still dispatch
+ # while a background channel is busy, but must wait for the current front
+ # event to finish before the scheduled front event runs.
+ var events = _load_fixture_events("scheduled_front_event_with_busy_background.esc")
+ assert_bool(events.has_event_with_target("front_running")).is_true()
+ assert_bool(events.has_event_with_target("scheduled_front")).is_true()
+ assert_bool(events.has_event_with_target("background_running")).is_true()
+ escoria.globals_manager.set_global("scheduled_front_result", "start")
+ escoria.globals_manager.set_global("scheduled_front_interrupted", false)
+ escoria.globals_manager.set_global("scheduled_background_result", "start")
+ escoria.globals_manager.set_global("scheduled_background_interrupted", false)
+
+ var signal_results := {
+ "front_finishes": [],
+ "background_finishes": [],
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ signal_results["front_finishes"].append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ signal_results["background_finishes"].append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_running"))
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_running"))
+ escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.05, "")
+
+ while signal_results["front_finishes"].size() < 2 or signal_results["background_finishes"].is_empty():
+ await escoria.get_tree().process_frame
+
+ assert_int(signal_results["front_finishes"].size()).is_equal(2)
+ assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["front_finishes"][0][1])).is_equal("front_running")
+ assert_int(int(signal_results["front_finishes"][1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["front_finishes"][1][1])).is_equal("scheduled_front")
+ assert_int(signal_results["background_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_running")
+ assert_str(String(escoria.globals_manager.get_global("scheduled_front_result"))).is_equal("front-running-after-scheduled")
+ assert_bool(escoria.globals_manager.get_global("scheduled_front_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("scheduled_background_result"))).is_equal("background-running-after")
+ assert_bool(escoria.globals_manager.get_global("scheduled_background_interrupted") == true).is_false()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_global_interrupt_preserves_scheduled_events_before_dispatch() -> void:
+ # Global interrupt should stop the currently running event, but it should
+ # not clear scheduled events that have not yet been promoted into the front
+ # queue. The scheduled front event should therefore still dispatch and
+ # complete normally after the interrupted event finishes.
+ var events = _load_fixture_events("scheduled_event_interrupted_before_dispatch.esc")
+ assert_bool(events.has_event_with_target("front_running")).is_true()
+ assert_bool(events.has_event_with_target("scheduled_front")).is_true()
+ escoria.globals_manager.set_global("scheduled_interrupt_result", "start")
+ escoria.globals_manager.set_global("scheduled_interrupt_was_interrupted", false)
+
+ var front_finishes: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ front_finishes.append([return_code, event_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+
+ escoria.event_manager.queue_event(events.get_event_with_target("front_running"))
+ escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.2, "")
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt()
+
+ while front_finishes.is_empty():
+ await escoria.get_tree().process_frame
+
+ await escoria.get_tree().create_timer(0.25).timeout
+
+ assert_int(front_finishes.size()).is_equal(2)
+ assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(front_finishes[0][1])).is_equal("front_running")
+ assert_int(int(front_finishes[1][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(front_finishes[1][1])).is_equal("scheduled_front")
+ assert_str(String(escoria.globals_manager.get_global("scheduled_interrupt_result"))).is_equal("start-scheduled")
+ assert_bool(escoria.globals_manager.get_global("scheduled_interrupt_was_interrupted") == true).is_true()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+
+
+func test_clear_event_queue_preserves_scheduled_events() -> void:
+ # Clearing the event queue should only remove events that are already queued
+ # for execution. Scheduled events live in a separate list until `_process()`
+ # promotes them into the front queue, so they should still dispatch after
+ # `clear_event_queue()` is called.
+ var events = _load_fixture_events("scheduled_event_interrupted_before_dispatch.esc")
+ assert_bool(events.has_event_with_target("scheduled_front")).is_true()
+ escoria.globals_manager.set_global("scheduled_interrupt_result", "start")
+
+ var front_finishes: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ front_finishes.append([return_code, event_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+
+ escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.05, "")
+ escoria.event_manager.clear_event_queue()
+
+ while front_finishes.is_empty():
+ await escoria.get_tree().process_frame
+
+ assert_int(front_finishes.size()).is_equal(1)
+ assert_int(int(front_finishes[0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(front_finishes[0][1])).is_equal("scheduled_front")
+ assert_str(String(escoria.globals_manager.get_global("scheduled_interrupt_result"))).is_equal("start-scheduled")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+
+
+func test_interrupt_channel_does_not_affect_still_scheduled_front_events() -> void:
+ # A scheduled front event should be unaffected by `interrupt_channel()` for a
+ # background channel while it is still waiting in `scheduled_events`. Once it
+ # is later promoted into `_front`, it should run like any other front-queue
+ # event.
+ var events = _load_fixture_events("scheduled_front_event_with_busy_background.esc")
+ assert_bool(events.has_event_with_target("scheduled_front")).is_true()
+ assert_bool(events.has_event_with_target("background_running")).is_true()
+ escoria.globals_manager.set_global("scheduled_front_result", "start")
+ escoria.globals_manager.set_global("scheduled_front_interrupted", false)
+ escoria.globals_manager.set_global("scheduled_background_result", "start")
+ escoria.globals_manager.set_global("scheduled_background_interrupted", false)
+
+ var signal_results := {
+ "front_finishes": [],
+ "background_finishes": [],
+ }
+ var on_front_finished := func(return_code, event_name) -> void:
+ signal_results["front_finishes"].append([return_code, event_name])
+ var on_background_finished := func(return_code, event_name, finished_channel_name) -> void:
+ if finished_channel_name == "bg_test":
+ signal_results["background_finishes"].append([return_code, event_name, finished_channel_name])
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+ escoria.event_manager.background_event_finished.connect(on_background_finished)
+
+ escoria.event_manager.queue_background_event("bg_test", events.get_event_with_target("background_running"))
+ escoria.event_manager.schedule_event(events.get_event_with_target("scheduled_front"), 0.2, "")
+
+ await escoria.get_tree().create_timer(0.05).timeout
+ escoria.event_manager.interrupt_channel("bg_test")
+
+ while signal_results["front_finishes"].is_empty() or signal_results["background_finishes"].is_empty():
+ await escoria.get_tree().process_frame
+
+ assert_int(signal_results["background_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["background_finishes"][0][0])).is_equal(ESCExecution.RC_INTERRUPTED)
+ assert_str(String(signal_results["background_finishes"][0][1])).is_equal("background_running")
+ assert_int(signal_results["front_finishes"].size()).is_equal(1)
+ assert_int(int(signal_results["front_finishes"][0][0])).is_equal(ESCExecution.RC_OK)
+ assert_str(String(signal_results["front_finishes"][0][1])).is_equal("scheduled_front")
+ assert_str(String(escoria.globals_manager.get_global("scheduled_front_result"))).is_equal("start-scheduled")
+ assert_bool(escoria.globals_manager.get_global("scheduled_front_interrupted") == true).is_false()
+ assert_str(String(escoria.globals_manager.get_global("scheduled_background_result"))).is_equal("start")
+ assert_bool(escoria.globals_manager.get_global("scheduled_background_interrupted") == true).is_true()
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ escoria.event_manager.background_event_finished.disconnect(on_background_finished)
+
+
+func test_immediate_command_preserves_statement_ordering() -> void:
+ # A synchronous command should complete before the next statement runs. The
+ # trailing assignment observes the command's mutation and appends to it.
+ var outcome := await _interpret_fixture("immediate_command_ordering.esc")
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("cmd-after")
+
+
+func test_delayed_command_preserves_statement_ordering() -> void:
+ # An asynchronous command must also block statement sequencing. If the
+ # interpreter failed to await it, the trailing assignment would append to the
+ # pre-command value instead of the command-written one.
+ var outcome := await _interpret_fixture("delayed_command_ordering.esc")
+ var globals: Dictionary = outcome.globals
+ assert_bool(globals.has("result")).is_true()
+ assert_str(String(globals["result"])).is_equal("cmd-after")
+
+
+func test_set_animations_updates_persisted_player_resource_on_repeat_calls() -> void:
+ # Room changes restore player animations from the reserved
+ # `ANIMATION_RESOURCES` global. Repeated `set_animations()` calls must update
+ # the stored path each time, otherwise the next room load re-applies a stale
+ # costume even if the live player was changed back already.
+ var previous_animations = escoria.globals_manager.get_global(
+ escoria.room_manager.GLOBAL_ANIMATION_RESOURCES
+ )
+ var room := ESCRoom.new()
+ room.global_id = "test_room_issue_1226"
+ escoria.object_manager.set_current_room(room)
+
+ var player := MARK_SCENE.instantiate() as ESCPlayer
+ player.global_id = "player"
+ escoria.object_manager.register_object(
+ ESCObject.new(player.global_id, player),
+ room,
+ true,
+ false
+ )
+ escoria.globals_manager.set_global(
+ escoria.room_manager.GLOBAL_ANIMATION_RESOURCES,
+ {},
+ true
+ )
+
+ var command := SetAnimationsCommand.new()
+ var jester_animations := "res://game/characters/mark/mark_animations_jester.tres"
+ var default_animations := "res://game/characters/mark/mark_animations.tres"
+
+ assert_int(command.run([player.global_id, jester_animations])).is_equal(ESCExecution.RC_OK)
+ assert_int(command.run([player.global_id, default_animations])).is_equal(ESCExecution.RC_OK)
+
+ var animations: Dictionary = escoria.globals_manager.get_global(
+ escoria.room_manager.GLOBAL_ANIMATION_RESOURCES
+ )
+ assert_bool(animations.has(player.global_id)).is_true()
+ assert_str(String(animations[player.global_id])).is_equal(default_animations)
+
+ var room_key := ESCRoomObjectsKey.new()
+ room_key.room_global_id = room.global_id
+ room_key.room_instance_id = room.get_instance_id()
+ escoria.object_manager.unregister_object_by_global_id(player.global_id, room_key)
+ escoria.globals_manager.set_global(
+ escoria.room_manager.GLOBAL_ANIMATION_RESOURCES,
+ previous_animations if previous_animations != null else {},
+ true
+ )
+ player.free()
+ room.free()
+
+
+func test_set_speed_rejects_negative_values_but_accepts_zero() -> void:
+ var room := ESCRoom.new()
+ room.global_id = "test_room_set_speed_contract"
+ escoria.object_manager.set_current_room(room)
+
+ var player := MARK_SCENE.instantiate() as ESCPlayer
+ player.global_id = "player"
+ escoria.object_manager.register_object(
+ ESCObject.new(player.global_id, player),
+ room,
+ true,
+ false
+ )
+
+ var command := SetSpeedCommand.new()
+ var initial_speed := player.speed
+
+ assert_bool(command.validate([player.global_id, -1])).is_false()
+ assert_int(player.speed).is_equal(initial_speed)
+
+ assert_bool(command.validate([player.global_id, 0])).is_true()
+ assert_int(command.run([player.global_id, 0])).is_equal(ESCExecution.RC_OK)
+ assert_int(player.speed).is_equal(0)
+
+ var room_key := ESCRoomObjectsKey.new()
+ room_key.room_global_id = room.global_id
+ room_key.room_instance_id = room.get_instance_id()
+ escoria.object_manager.unregister_object_by_global_id(player.global_id, room_key)
+ player.free()
+ room.free()
+
+
+func test_trigger_actions_select_targeted_events_for_activating_object() -> void:
+ var script_object := _compile_fixture_script("trigger_targeted_events_use_object_id.esc")
+ var room := ESCRoom.new()
+ room.global_id = "test_trigger_targeted_events_room"
+ escoria.object_manager.set_current_room(room)
+
+ var trigger_node := Node2D.new()
+ var trigger_object := ESCObject.new("trigger_under_test", trigger_node)
+ trigger_object.events = script_object.events
+ escoria.object_manager.register_object(trigger_object, room, true, false)
+
+ escoria.globals_manager.set_global("trigger_in_result", "unset", true)
+ escoria.globals_manager.set_global("trigger_out_result", "unset", true)
+
+ var finished_events: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ if return_code == ESCExecution.RC_OK:
+ finished_events.append(event_name)
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_IN,
+ [trigger_object.global_id, "player", "trigger_in"]
+ )
+
+ if not await _wait_for_finished_events(finished_events, 1, "targeted trigger_in"):
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+ return
+
+ assert_str(String(escoria.globals_manager.get_global("trigger_in_result"))).is_equal(
+ "player-in"
+ )
+ assert_str(String(finished_events[0])).is_equal("trigger_in")
+
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_OUT,
+ [trigger_object.global_id, "player", "trigger_out"]
+ )
+
+ if not await _wait_for_finished_events(finished_events, 2, "targeted trigger_out"):
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+ return
+
+ assert_str(String(escoria.globals_manager.get_global("trigger_out_result"))).is_equal(
+ "player-out"
+ )
+ assert_str(String(finished_events[1])).is_equal("trigger_out")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+
+
+func test_trigger_actions_fall_back_to_targetless_events() -> void:
+ var script_object := _compile_fixture_script("trigger_targeted_events_use_object_id.esc")
+ var room := ESCRoom.new()
+ room.global_id = "test_trigger_targetless_events_room"
+ escoria.object_manager.set_current_room(room)
+
+ var trigger_node := Node2D.new()
+ var trigger_object := ESCObject.new("trigger_under_test", trigger_node)
+ trigger_object.events = script_object.events
+ escoria.object_manager.register_object(trigger_object, room, true, false)
+
+ escoria.globals_manager.set_global("trigger_in_result", "unset", true)
+ escoria.globals_manager.set_global("trigger_out_result", "unset", true)
+
+ var finished_events: Array = []
+ var on_front_finished := func(return_code, event_name) -> void:
+ if return_code == ESCExecution.RC_OK:
+ finished_events.append(event_name)
+
+ escoria.event_manager.event_finished.connect(on_front_finished)
+
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_IN,
+ [trigger_object.global_id, "unexpected_actor", "trigger_in"]
+ )
+
+ if not await _wait_for_finished_events(finished_events, 1, "targetless trigger_in fallback"):
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+ return
+
+ assert_str(String(escoria.globals_manager.get_global("trigger_in_result"))).is_equal(
+ "fallback-in"
+ )
+ assert_str(String(finished_events[0])).is_equal("trigger_in")
+
+ escoria.action_manager.do(
+ escoria.action_manager.ACTION.TRIGGER_OUT,
+ [trigger_object.global_id, "unexpected_actor", "trigger_out"]
+ )
+
+ if not await _wait_for_finished_events(finished_events, 2, "targetless trigger_out fallback"):
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+ return
+
+ assert_str(String(escoria.globals_manager.get_global("trigger_out_result"))).is_equal(
+ "fallback-out"
+ )
+ assert_str(String(finished_events[1])).is_equal("trigger_out")
+
+ escoria.event_manager.event_finished.disconnect(on_front_finished)
+
+ _cleanup_registered_object(trigger_object, trigger_node, room)
+
+
+func _cleanup_registered_object(trigger_object: ESCObject, trigger_node: Node, room: ESCRoom) -> void:
+ var room_key := ESCRoomObjectsKey.new()
+ room_key.room_global_id = room.global_id
+ room_key.room_instance_id = room.get_instance_id()
+ escoria.object_manager.unregister_object_by_global_id(trigger_object.global_id, room_key)
+ trigger_node.free()
+ room.free()
+
+
+func _wait_for_finished_events(
+ finished_events: Array,
+ expected_size: int,
+ context: String
+) -> bool:
+ for _frame in range(120):
+ if finished_events.size() >= expected_size:
+ return true
+
+ await escoria.get_tree().process_frame
+
+ fail(
+ "Timed out waiting for %s; expected %d finished event(s), got %d." % [
+ context,
+ expected_size,
+ finished_events.size()
+ ]
+ )
+ return false
+
+
+func _interpret_fixture(name: String, dialog_choices: Array = []) -> Dictionary:
+ # Shared interpreter harness for fixture-based tests. This intentionally
+ # runs the full scan -> parse -> resolve -> interpret pipeline so the tests
+ # cover the real runtime path rather than isolated visitor calls.
+ var source := _load_fixture(name)
+ var compiler := ESCCompiler.new()
+ var scanner := ESCScanner.new()
+ scanner.set_source(source)
+ scanner.set_filename(_fixture_path(name))
+
+ var tokens: Array = scanner.scan_tokens()
+ var parser := ESCParser.new()
+ parser.init(compiler, tokens, "")
+
+ var statements := parser.parse()
+ assert_bool(compiler.had_error).is_false()
+ assert_int(statements.size()).is_equal(1)
+
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ resolver.resolve(statements)
+ var dialog_player = null
+
+ if not dialog_choices.is_empty():
+ # Install a temporary deterministic dialog player only for tests that
+ # need scripted choices, then clean it up afterward to avoid orphan nodes.
+ dialog_player = DialogPlayerDouble.new(dialog_choices)
+ escoria.set("dialog_player", dialog_player)
+
+ await interpreter.interpret(statements)
+
+ var globals := interpreter.get_global_values().duplicate(true)
+ var dialog_choices_consumed := 0
+ var remaining_dialog_choices := 0
+
+ if dialog_player:
+ dialog_choices_consumed = dialog_player.get_choices_consumed()
+ remaining_dialog_choices = dialog_player.get_remaining_choices()
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+ return {
+ "globals": globals,
+ "dialog_choices_consumed": dialog_choices_consumed,
+ "remaining_dialog_choices": remaining_dialog_choices,
+ }
+
+
+func test_dialog_start_args_are_passed_to_runtime_dialog() -> void:
+ var script_object := _compile_fixture_script("dialog_start_args_reach_runtime.esc")
+ var dialog_player := DialogPlayerDouble.new([1])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(1)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_str(started_dialogs[0]["avatar"]).is_equal("res://game/dialog_avatars/player.tres")
+ assert_int(started_dialogs[0]["timeout"]).is_equal(5)
+ assert_int(started_dialogs[0]["timeout_option"]).is_equal(2)
+ assert_int(started_dialogs[0]["authored_options_count"]).is_equal(2)
+ assert_int(started_dialogs[0]["options_size"]).is_equal(2)
+ assert_str(started_dialogs[0]["visible_timeout_option"]).is_equal("Second option")
+ assert_array(started_dialogs[0]["option_source_indexes"]).contains_exactly([1, 2])
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_timeout_option_uses_authored_index_after_filtering() -> void:
+ var script_object := _compile_fixture_script("dialog_timeout_visible_authored_default_after_filtering.esc")
+ var dialog_player := DialogPlayerDouble.new([1])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(1)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_int(started_dialogs[0]["timeout"]).is_equal(5)
+ assert_int(started_dialogs[0]["timeout_option"]).is_equal(3)
+ assert_int(started_dialogs[0]["authored_options_count"]).is_equal(3)
+ assert_int(started_dialogs[0]["options_size"]).is_equal(2)
+ assert_array(started_dialogs[0]["option_source_indexes"]).contains_exactly([2, 3])
+ assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Second option", "Third option"])
+ assert_str(started_dialogs[0]["visible_timeout_option"]).is_equal("Third option")
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_timeout_option_hidden_after_filtering_is_not_available() -> void:
+ var script_object := _compile_fixture_script("dialog_timeout_hidden_authored_default_after_filtering.esc")
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(1)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_int(started_dialogs[0]["timeout"]).is_equal(5)
+ assert_int(started_dialogs[0]["timeout_option"]).is_equal(1)
+ assert_int(started_dialogs[0]["authored_options_count"]).is_equal(3)
+ assert_int(started_dialogs[0]["options_size"]).is_equal(2)
+ assert_array(started_dialogs[0]["option_source_indexes"]).contains_exactly([2, 3])
+ assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Second option", "Third option"])
+ assert_object(started_dialogs[0]["visible_timeout_option"]).is_null()
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_simple_chooser_does_not_start_timer_when_timeout_default_is_hidden() -> void:
+ var dialog := ESCDialog.new()
+ dialog.timeout = 5
+ dialog.timeout_option = 1
+ dialog.authored_options_count = 2
+ dialog.options = [
+ _make_dialog_option("Visible option", 2),
+ ]
+
+ var chooser = SIMPLE_CHOOSER_SCENE.instantiate()
+ add_child(chooser)
+ chooser.set_dialog(dialog)
+ chooser.show_chooser()
+
+ assert_bool(chooser.get_node("Timer").is_stopped()).is_true()
+ assert_float(chooser.get_node("TimerProgress").value).is_equal(0.0)
+
+ chooser.queue_free()
+
+
+func test_simple_chooser_starts_timer_when_timeout_default_is_visible() -> void:
+ var dialog := ESCDialog.new()
+ dialog.timeout = 5
+ dialog.timeout_option = 2
+ dialog.authored_options_count = 2
+ dialog.options = [
+ _make_dialog_option("First option", 1),
+ _make_dialog_option("Second option", 2),
+ ]
+
+ var chooser = SIMPLE_CHOOSER_SCENE.instantiate()
+ add_child(chooser)
+ chooser.set_dialog(dialog)
+ chooser.show_chooser()
+
+ assert_bool(chooser.get_node("Timer").is_stopped()).is_false()
+
+ chooser.queue_free()
+
+
+func test_dialog_start_invalid_avatar_type_returns_error() -> void:
+ var script_object := _compile_fixture_script("dialog_start_invalid_avatar_type.esc")
+ var dialog_player := DialogPlayerDouble.new([])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(0)
+ assert_int(dialog_player.get_dialogs_started().size()).is_equal(0)
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_start_invalid_timeout_type_returns_error() -> void:
+ var script_object := _compile_fixture_script("dialog_start_invalid_timeout_type.esc")
+ var dialog_player := DialogPlayerDouble.new([])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(0)
+ assert_int(dialog_player.get_dialogs_started().size()).is_equal(0)
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_option_translation_key_is_passed_to_runtime_dialog() -> void:
+ var script_object := _compile_fixture_script("dialog_option_translation_key.esc")
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(1)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["UNCLE_SVEN", ""])
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_start_invalid_timeout_option_type_returns_error() -> void:
+ var script_object := _compile_fixture_script("dialog_start_invalid_timeout_option_type.esc")
+ var dialog_player := DialogPlayerDouble.new([])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(0)
+ assert_int(dialog_player.get_dialogs_started().size()).is_equal(0)
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_start_invalid_timeout_option_negative_returns_error() -> void:
+ var script_object := _compile_fixture_script("dialog_start_invalid_timeout_option_negative.esc")
+ var dialog_player := DialogPlayerDouble.new([])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+ assert_int(dialog_player.get_choices_consumed()).is_equal(0)
+ assert_int(dialog_player.get_dialogs_started().size()).is_equal(0)
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_option_translation_key_beats_legacy_prefix_fallback() -> void:
+ var script_object := _compile_fixture_script("dialog_option_translation_key_beats_legacy_prefix.esc")
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["OUTER_KEY"])
+ assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["INNER_KEY:Fallback text"])
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_option_translation_key_with_condition_survives_runtime_filtering() -> void:
+ var script_object := _compile_fixture_script("dialog_option_translation_key_with_condition.esc")
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_int(started_dialogs[0]["options_size"]).is_equal(1)
+ assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["CONDITIONAL_KEY"])
+ assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Conditional option"])
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func test_dialog_option_translation_key_with_expression_preserves_key_and_value() -> void:
+ var script_object := _compile_fixture_script("dialog_option_translation_key_with_expression.esc")
+ var dialog_player := DialogPlayerDouble.new([0])
+ escoria.set("dialog_player", dialog_player)
+ var interpreter := ESCInterpreter.new(ESCCompiler.load_commands(), {})
+ var resolver := ESCResolver.new(interpreter)
+ var event: ESCGrammarStmts.Event = script_object.get_event_with_target("talk")
+
+ assert_object(event).is_not_null()
+
+ resolver.resolve(event)
+
+ await interpreter.interpret(event)
+
+ var started_dialogs := dialog_player.get_dialogs_started()
+ assert_int(started_dialogs.size()).is_equal(1)
+ assert_array(started_dialogs[0]["option_keys"]).contains_exactly(["DYNAMIC_KEY"])
+ assert_array(started_dialogs[0]["option_texts"]).contains_exactly(["Dynamic option"])
+
+ _cleanup_dialog_test(interpreter, dialog_player)
+
+
+func _cleanup_dialog_test(interpreter: ESCInterpreter, dialog_player: DialogPlayerDouble) -> void:
+ interpreter.cleanup()
+
+ if dialog_player:
+ escoria.set("dialog_player", null)
+ dialog_player.queue_free()
+
+
+func test_compiler_rejects_exact_duplicate_event_targets() -> void:
+ var source := _load_fixture("duplicate_exact_target_event_overwrites.esc")
+ var compiler := ESCCompiler.new()
+ var script_object := compiler.compile(source)
+
+ assert_bool(compiler.had_error).is_true()
+ assert_int(script_object.events.events.size()).is_equal(1)
+ assert_object(script_object.get_event_with_target("use", "wrench")).is_not_null()
+
+
+func _load_fixture(name: String) -> String:
+ return FileAccess.get_file_as_string(_fixture_path(name))
+
+
+func _load_fixture_events(name: String):
+ var script_object := _compile_fixture_script(name)
+ return script_object.events
+
+
+func _compile_fixture_script(name: String) -> ESCScript:
+ var source := _load_fixture(name)
+ var compiler := ESCCompiler.new()
+ var script_object := compiler.compile(source, _fixture_path(name))
+ assert_bool(compiler.had_error).is_false()
+ return script_object
+
+
+func _fixture_path(name: String) -> String:
+ return "res://addons/escoria-core/testing/ashes/interpreter/fixtures/%s" % name
+
+
+func _make_dialog_option(text: String, source_option_index: int) -> ESCDialogOption:
+ var option := ESCDialogOption.new()
+ option.option = text
+ option.source_option_index = source_option_index
+ option.set_is_valid(true)
+ return option
diff --git a/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd.uid b/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd.uid
new file mode 100644
index 0000000..f4c0246
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/esc_interpreter_test.gd.uid
@@ -0,0 +1 @@
+uid://cpogk3hmwoaom
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc
new file mode 100644
index 0000000..6ab1752
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc
@@ -0,0 +1,3 @@
+:repeat_event
+ test_delayed_set_global("background_duplicate_last", "tick", 0.05)
+ inc_global("background_duplicate_count")
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc.uid
new file mode 100644
index 0000000..eeed814
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/background_duplicate_suppression.esc.uid
@@ -0,0 +1 @@
+uid://c5o8v1l4n3xmd
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc
new file mode 100644
index 0000000..e4c2d34
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc
@@ -0,0 +1,8 @@
+:test
+ global result = 0
+ var x = 1
+ while true:
+ if true:
+ var x = 2
+ break
+ result = x
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc.uid
new file mode 100644
index 0000000..1060cfd
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/block_scope_restores_after_break.esc.uid
@@ -0,0 +1 @@
+uid://ccf4pbps7lq2k
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc
new file mode 100644
index 0000000..cb170eb
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc
@@ -0,0 +1,14 @@
+:test
+ global result = "start"
+ ?!
+ - "Outer"
+ ?!
+ - "Middle"
+ ?!
+ - "Break out"
+ break 2
+ result = "after_inner"
+ result = "after_middle"
+ - "Exit outer"
+ break
+ result = result + "-done"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc.uid
new file mode 100644
index 0000000..31dc1f6
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/break_two_exits_two_dialog_levels.esc.uid
@@ -0,0 +1 @@
+uid://dorj4y3h76w2j
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc
new file mode 100644
index 0000000..4e9f649
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc
@@ -0,0 +1,7 @@
+:first
+ global bg_queue_result = "first"
+ test_interruptible_set_global("bg_queue_result", "running", "bg_queue_interrupted", 0.2)
+ bg_queue_result = bg_queue_result + "-after"
+
+:second
+ bg_queue_result = "second"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc.uid
new file mode 100644
index 0000000..45a95b6
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_clears_queue.esc.uid
@@ -0,0 +1 @@
+uid://dp5nx4h5bqgu1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc
new file mode 100644
index 0000000..e3130d4
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc
@@ -0,0 +1,9 @@
+:front_event
+ global channel_front_result = "start"
+ test_interruptible_set_global("channel_front_result", "front-cmd", "channel_front_was_interrupted", 0.2)
+ channel_front_result = channel_front_result + "-after"
+
+:background_event
+ global channel_background_result = "start"
+ test_interruptible_set_global("channel_background_result", "bg-cmd", "channel_background_was_interrupted", 0.2)
+ channel_background_result = channel_background_result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc.uid
new file mode 100644
index 0000000..5e3ad25
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_interrupt_isolation.esc.uid
@@ -0,0 +1 @@
+uid://cx76t0u0r5f4f
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc
new file mode 100644
index 0000000..31098f8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc
@@ -0,0 +1,12 @@
+:front_event
+ global isolated_front_result = "start"
+ test_interruptible_set_global("isolated_front_result", "front-cmd", "isolated_front_interrupted", 0.2)
+ isolated_front_result = isolated_front_result + "-after"
+
+:background_first
+ global isolated_background_result = "start"
+ test_interruptible_set_global("isolated_background_result", "first-cmd", "isolated_background_interrupted", 0.2)
+ isolated_background_result = isolated_background_result + "-after-first"
+
+:background_second
+ isolated_background_result = isolated_background_result + "-second"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc.uid
new file mode 100644
index 0000000..7961883
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/channel_queue_progress_isolated.esc.uid
@@ -0,0 +1 @@
+uid://dzq6qk1ufhcv0
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc
new file mode 100644
index 0000000..3df3b97
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc
@@ -0,0 +1,7 @@
+:front_waits_for_new_global
+ global concurrent_visibility_result = "start"
+ test_delayed_set_global("concurrent_visibility_progress", "waiting", 0.2)
+ concurrent_visibility_result = shared_runtime_global
+
+:background_declares_new_global
+ global shared_runtime_global = "from-background"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc.uid
new file mode 100644
index 0000000..f19670e
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/concurrent_new_global_visibility.esc.uid
@@ -0,0 +1 @@
+uid://a226b7bb41b142b5812d0d3dbd389a31
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc
new file mode 100644
index 0000000..2ae480f
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc
@@ -0,0 +1,7 @@
+:test
+ global result = "unset"
+ ?!
+ - "Run delayed"
+ test_delayed_set_global("result", "cmd", 0.01)
+ break
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc.uid
new file mode 100644
index 0000000..d89858e
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_dialog_option.esc.uid
@@ -0,0 +1 @@
+uid://e0743a93c87c4db4af251658723ed469
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc
new file mode 100644
index 0000000..e55b9a6
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc
@@ -0,0 +1,7 @@
+:test
+ global result = "start"
+ global count = 0
+ while count < 1:
+ test_delayed_set_global("result", "cmd", 0.01)
+ count = count + 1
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc.uid
new file mode 100644
index 0000000..e832298
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_in_loop.esc.uid
@@ -0,0 +1 @@
+uid://c08224d490434f5dbe8529aa021c6041
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc
new file mode 100644
index 0000000..5aa9aed
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc
@@ -0,0 +1,5 @@
+:test
+ global result = "start"
+ global was_interrupted = false
+ test_interruptible_set_global("result", "cmd", "was_interrupted", 0.2)
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt.esc.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc
new file mode 100644
index 0000000..e9187b1
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc
@@ -0,0 +1,7 @@
+:test
+ global result = "start"
+ global was_interrupted = false
+ ?!
+ - "Wait"
+ test_interruptible_set_global("result", "cmd", "was_interrupted", 0.2)
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_dialog.esc.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc
new file mode 100644
index 0000000..fc19824
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc
@@ -0,0 +1,8 @@
+:test
+ global result = "start"
+ global was_interrupted = false
+ global count = 0
+ while count < 3:
+ test_interruptible_set_global("result", "cmd", "was_interrupted", 0.2)
+ count = count + 1
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_interrupt_in_multi_loop.esc.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc
new file mode 100644
index 0000000..52ef2a4
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc
@@ -0,0 +1,4 @@
+:test
+ global result = "unset"
+ test_delayed_set_global("result", "cmd", 0.01)
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc.uid
new file mode 100644
index 0000000..a014454
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/delayed_command_ordering.esc.uid
@@ -0,0 +1 @@
+uid://bl7s16m8hlyj1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc
new file mode 100644
index 0000000..eaf762e
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc
@@ -0,0 +1,14 @@
+:talk
+ global result = "start"
+ global option1_is_valid = true
+ global option2_is_valid = true
+
+ ?!
+ - "option1" [option1_is_valid]
+ result = result + "-say1"
+ option1_is_valid = false
+ - "option2" [option2_is_valid]
+ result = result + "-say2"
+ option2_is_valid = false
+
+ result = result + "-finished"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc.uid
new file mode 100644
index 0000000..fac2cb3
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_exhausted_options_runs_followup.esc.uid
@@ -0,0 +1 @@
+uid://cn1f4l17yg4x8
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc
new file mode 100644
index 0000000..69f88db
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc
@@ -0,0 +1,6 @@
+:talk
+ ?!
+ - UNCLE_SVEN:"Uncle Sven sends regards."
+ break
+ - "Fallback"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc.uid
new file mode 100644
index 0000000..d371627
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key.esc.uid
@@ -0,0 +1 @@
+uid://dcjcq0w2qj1h2
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc
new file mode 100644
index 0000000..1be9d33
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc
@@ -0,0 +1,4 @@
+:talk
+ ?!
+ - OUTER_KEY:"INNER_KEY:Fallback text"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc.uid
new file mode 100644
index 0000000..aff1543
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_beats_legacy_prefix.esc.uid
@@ -0,0 +1 @@
+uid://cb71rbl5a7hlj
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc
new file mode 100644
index 0000000..718a5fc
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc
@@ -0,0 +1,7 @@
+:talk
+ global show_first = true
+ ?!
+ - CONDITIONAL_KEY:"Conditional option" [show_first]
+ break
+ - HIDDEN_KEY:"Hidden option" [false]
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc.uid
new file mode 100644
index 0000000..4f944b1
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_condition.esc.uid
@@ -0,0 +1 @@
+uid://bnep8t7ivgu7f
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc
new file mode 100644
index 0000000..587bd39
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc
@@ -0,0 +1,5 @@
+:talk
+ var option_text = "Dynamic option"
+ ?!
+ - DYNAMIC_KEY:option_text
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc.uid
new file mode 100644
index 0000000..56eee44
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_option_translation_key_with_expression.esc.uid
@@ -0,0 +1 @@
+uid://bf67f4h73tba2
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc
new file mode 100644
index 0000000..924cf52
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc
@@ -0,0 +1,9 @@
+:test
+ global result = "start"
+ var x = "outer"
+ ?!
+ - "Use inner"
+ var x = "inner"
+ result = x
+ break
+ result = result + ":" + x
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_scope_shadowing.esc.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc
new file mode 100644
index 0000000..aab29c6
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc
@@ -0,0 +1,6 @@
+:talk
+ ?! "res://game/dialog_avatars/player.tres" 5 2
+ - "First option"
+ pass
+ - "Second option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc.uid
new file mode 100644
index 0000000..8a474a9
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_args_reach_runtime.esc.uid
@@ -0,0 +1 @@
+uid://b3a4m3l6nr6m7
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc
new file mode 100644
index 0000000..368ef0b
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc
@@ -0,0 +1,4 @@
+:talk
+ ?! 123 5 2
+ - "Only option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc.uid
new file mode 100644
index 0000000..5708f33
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_avatar_type.esc.uid
@@ -0,0 +1 @@
+uid://chev11x0d0lg0
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc
new file mode 100644
index 0000000..06cab55
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc
@@ -0,0 +1,4 @@
+:talk
+ ?! "res://game/dialog_avatars/player.tres" 5 0 - 1
+ - "Only option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc.uid
new file mode 100644
index 0000000..a7cf6b8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_negative.esc.uid
@@ -0,0 +1 @@
+uid://db2w5uh1qvqst
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc
new file mode 100644
index 0000000..bc71bc7
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc
@@ -0,0 +1,4 @@
+:talk
+ ?! "res://game/dialog_avatars/player.tres" 5 "two"
+ - "Only option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc.uid
new file mode 100644
index 0000000..deb663d
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_option_type.esc.uid
@@ -0,0 +1 @@
+uid://b5fstn4fo0igo
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc
new file mode 100644
index 0000000..351be56
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc
@@ -0,0 +1,4 @@
+:talk
+ ?! "res://game/dialog_avatars/player.tres" "five" 2
+ - "Only option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc.uid
new file mode 100644
index 0000000..db53e65
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_start_invalid_timeout_type.esc.uid
@@ -0,0 +1 @@
+uid://cy8r7l4o3dmn0
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_hidden_authored_default_after_filtering.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_hidden_authored_default_after_filtering.esc
new file mode 100644
index 0000000..80f8ef7
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_hidden_authored_default_after_filtering.esc
@@ -0,0 +1,10 @@
+:talk
+ global show_first = false
+
+ ?! "-" 5 1
+ - "First option" [show_first]
+ break
+ - "Second option"
+ break
+ - "Third option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_visible_authored_default_after_filtering.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_visible_authored_default_after_filtering.esc
new file mode 100644
index 0000000..0067327
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/dialog_timeout_visible_authored_default_after_filtering.esc
@@ -0,0 +1,10 @@
+:talk
+ global show_first = false
+
+ ?! "-" 5 3
+ - "First option" [show_first]
+ break
+ - "Second option"
+ break
+ - "Third option"
+ break
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc
new file mode 100644
index 0000000..770224a
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc
@@ -0,0 +1,11 @@
+:test
+ global result = "start"
+ ?!
+ - "Outer"
+ ?!
+ - "Done"
+ done
+ result = "after_nested"
+ - "Exit outer"
+ break
+ result = result + "-done"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc.uid
new file mode 100644
index 0000000..9b3eaf5
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_exits_entire_nested_dialog.esc.uid
@@ -0,0 +1 @@
+uid://catfpqk5u8vj0
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc
new file mode 100644
index 0000000..a37b050
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc
@@ -0,0 +1,7 @@
+:test
+ global result = 0
+ ?!
+ - "Outer"
+ if true:
+ done
+ result = 1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc.uid
new file mode 100644
index 0000000..8e5c281
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/done_nested_block_in_dialog.esc.uid
@@ -0,0 +1 @@
+uid://bgxmdpje356jd
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc
new file mode 100644
index 0000000..8461cff
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc
@@ -0,0 +1,5 @@
+:use "wrench"
+ test_set_global("result", "first")
+
+:use "wrench"
+ test_set_global("result", "second")
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc.uid
new file mode 100644
index 0000000..600a3bf
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/duplicate_exact_target_event_overwrites.esc.uid
@@ -0,0 +1 @@
+uid://cfpnxq6v1bg1t
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc
new file mode 100644
index 0000000..e11c818
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc
@@ -0,0 +1,15 @@
+:front_running
+ global global_interrupt_front_result = "start"
+ test_interruptible_set_global("global_interrupt_front_result", "front-cmd", "global_interrupt_front_was_interrupted", 0.2)
+ global_interrupt_front_result = global_interrupt_front_result + "-after"
+
+:front_queued
+ global_interrupt_front_result = "front-queued"
+
+:background_running
+ global global_interrupt_background_result = "start"
+ test_interruptible_set_global("global_interrupt_background_result", "bg-cmd", "global_interrupt_background_was_interrupted", 0.2)
+ global_interrupt_background_result = global_interrupt_background_result + "-after"
+
+:background_queued
+ global_interrupt_background_result = "bg-queued"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc.uid
new file mode 100644
index 0000000..5547f25
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_clears_all_channels.esc.uid
@@ -0,0 +1 @@
+uid://cje3uy4r8lh1x
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc
new file mode 100644
index 0000000..a9ffa26
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc
@@ -0,0 +1,17 @@
+:front_keep
+ global interrupt_except_front_result = "start"
+ test_interruptible_set_global("interrupt_except_front_result", "front-cmd", "interrupt_except_front_interrupted", 0.2)
+ interrupt_except_front_result = interrupt_except_front_result + "-after"
+
+:front_drop
+ interrupt_except_front_result = "front-drop"
+
+:background_drop
+ global interrupt_except_background_result = "start"
+ test_interruptible_set_global("interrupt_except_background_result", "bg-drop", "interrupt_except_background_interrupted", 0.2)
+ interrupt_except_background_result = interrupt_except_background_result + "-after"
+
+:background_keep
+ global interrupt_except_background_keep_result = "start"
+ test_delayed_set_global("interrupt_except_background_keep_result", "bg-keep", 0.01)
+ interrupt_except_background_keep_result = interrupt_except_background_keep_result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc.uid
new file mode 100644
index 0000000..dc38433
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/global_interrupt_preserves_exceptions.esc.uid
@@ -0,0 +1 @@
+uid://ceu2x1lbwfxby
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc
new file mode 100644
index 0000000..7d39c5f
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc
@@ -0,0 +1,4 @@
+:test
+ global result = "unset"
+ test_set_global("result", "cmd")
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc.uid
new file mode 100644
index 0000000..16cf665
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/immediate_command_ordering.esc.uid
@@ -0,0 +1 @@
+uid://dmf5wqf50nfnr
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc
new file mode 100644
index 0000000..fd1ba03
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc
@@ -0,0 +1,11 @@
+:first
+ global interpreter_runtime_result = "first"
+
+:second
+ interpreter_runtime_result = interpreter_runtime_result + "-second"
+
+:background_seed
+ global interpreter_runtime_background_result = "first"
+
+:background_after
+ interpreter_runtime_background_result = interpreter_runtime_background_result + "-background"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc.uid
new file mode 100644
index 0000000..cafce06
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/interpreter_runtime_state_persists_between_events.esc.uid
@@ -0,0 +1 @@
+uid://clqs3e5r4yem1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc
new file mode 100644
index 0000000..4d92d26
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc
@@ -0,0 +1,4 @@
+:test
+ global result = "start"
+ missing_test_command()
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_does_not_continue.esc.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc
new file mode 100644
index 0000000..2d59fb8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc
@@ -0,0 +1,6 @@
+:test
+ global result = "start"
+ ?!
+ - "Break it"
+ missing_command()
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc.uid
new file mode 100644
index 0000000..77a66d7
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_dialog_stops_event.esc.uid
@@ -0,0 +1 @@
+uid://d4esrhlw7qhri
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc
new file mode 100644
index 0000000..4ecf824
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc
@@ -0,0 +1,7 @@
+:test
+ global result = "start"
+ global count = 0
+ while count < 1:
+ missing_command()
+ count = count + 1
+ result = result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc.uid
new file mode 100644
index 0000000..ec48741
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_command_in_loop_stops_event.esc.uid
@@ -0,0 +1 @@
+uid://ci5h4hpy7mjav
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc
new file mode 100644
index 0000000..a81c4c0
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc
@@ -0,0 +1,3 @@
+:test
+ global result = "unset"
+ result = "a" > 1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc.uid
new file mode 100644
index 0000000..67b9e76
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/invalid_numeric_comparison.esc.uid
@@ -0,0 +1 @@
+uid://bo4clnuxdy778
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc
new file mode 100644
index 0000000..10b036b
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc
@@ -0,0 +1,8 @@
+:look
+ say("player", "It's a leaking pipe.")
+
+:use "r5_wrench"
+ say("player", "The leak's now fixed.")
+
+:use "r5_pen"
+ say("player", "I don't see how I can fix the pipe with a pen.")
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc.uid
new file mode 100644
index 0000000..f77b460
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/multi_target_use_events.esc.uid
@@ -0,0 +1 @@
+uid://cpdc2ha8v6svh
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc
new file mode 100644
index 0000000..82d638d
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc
@@ -0,0 +1,11 @@
+:test
+ global result = "start"
+ ?!
+ - "Outer"
+ ?!
+ - "Break nested"
+ break 1
+ result = "after_nested"
+ - "Exit outer"
+ break
+ result = result + "-done"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc.uid
new file mode 100644
index 0000000..8d392b8
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/nested_dialog_break_one.esc.uid
@@ -0,0 +1 @@
+uid://bfwg7dby8q4g3
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc
new file mode 100644
index 0000000..07b5ccd
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc
@@ -0,0 +1,2 @@
+:test
+ print("a", "b")
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc.uid
new file mode 100644
index 0000000..cd16f5f
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_two_args.esc.uid
@@ -0,0 +1 @@
+uid://d0cv0a5fo7hdh
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc
new file mode 100644
index 0000000..3ff88bf
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc
@@ -0,0 +1,2 @@
+:test
+ print()
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc.uid
new file mode 100644
index 0000000..8dd21c3
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/print_zero_args.esc.uid
@@ -0,0 +1 @@
+uid://cc274jba2egui
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc
new file mode 100644
index 0000000..853a985
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc
@@ -0,0 +1,10 @@
+:background_first
+ global block_wait_result = "start"
+ test_delayed_set_global("block_wait_result", "first", 0.05)
+
+:background_target
+ test_delayed_set_global("block_wait_result", "first-target", 0.2)
+
+:front_event
+ global block_wait_front_result = "start"
+ test_delayed_set_global("block_wait_front_result", "front", 0.01)
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc.uid
new file mode 100644
index 0000000..928dcef
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_background_event.esc.uid
@@ -0,0 +1 @@
+uid://4g0g2m5j3ve7u
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc
new file mode 100644
index 0000000..9ea46b0
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc
@@ -0,0 +1,10 @@
+:front_first
+ global front_block_wait_result = "start"
+ test_delayed_set_global("front_block_wait_result", "first", 0.05)
+
+:front_target
+ test_delayed_set_global("front_block_wait_result", "first-target", 0.2)
+
+:background_event
+ global front_block_wait_background_result = "start"
+ test_delayed_set_global("front_block_wait_background_result", "background", 0.01)
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc.uid
new file mode 100644
index 0000000..b725b6c
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/queue_event_block_waits_for_correct_front_event.esc.uid
@@ -0,0 +1 @@
+uid://c5q5hi1sp5u4h
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc
new file mode 100644
index 0000000..475c523
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc
@@ -0,0 +1,7 @@
+:front_running
+ global scheduled_interrupt_result = "start"
+ test_interruptible_set_global("scheduled_interrupt_result", "front-running", "scheduled_interrupt_was_interrupted", 0.2)
+ scheduled_interrupt_result = scheduled_interrupt_result + "-after"
+
+:scheduled_front
+ scheduled_interrupt_result = scheduled_interrupt_result + "-scheduled"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc.uid
new file mode 100644
index 0000000..1ca4eb5
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_event_interrupted_before_dispatch.esc.uid
@@ -0,0 +1 @@
+uid://dd1tktxwp4t0s
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc
new file mode 100644
index 0000000..1f88f71
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc
@@ -0,0 +1,12 @@
+:front_running
+ global scheduled_front_result = "start"
+ test_interruptible_set_global("scheduled_front_result", "front-running", "scheduled_front_interrupted", 0.2)
+ scheduled_front_result = scheduled_front_result + "-after"
+
+:scheduled_front
+ scheduled_front_result = scheduled_front_result + "-scheduled"
+
+:background_running
+ global scheduled_background_result = "start"
+ test_interruptible_set_global("scheduled_background_result", "background-running", "scheduled_background_interrupted", 0.2)
+ scheduled_background_result = scheduled_background_result + "-after"
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc.uid
new file mode 100644
index 0000000..235d8c4
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/scheduled_front_event_with_busy_background.esc.uid
@@ -0,0 +1 @@
+uid://cqt1mxg5k7q6m
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc
new file mode 100644
index 0000000..7be3f85
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc
@@ -0,0 +1,6 @@
+:test
+ global result = 0
+ ?!
+ - "Stop"
+ stop
+ result = 1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc.uid
new file mode 100644
index 0000000..d979742
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_in_dialog_option.esc.uid
@@ -0,0 +1 @@
+uid://148e3f0660144f8a9e00065bdde07cde
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc
new file mode 100644
index 0000000..52ea799
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc
@@ -0,0 +1,5 @@
+:test
+ global result = 0
+ if true:
+ stop
+ result = 1
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc.uid
new file mode 100644
index 0000000..f005816
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/stop_nested_block.esc.uid
@@ -0,0 +1 @@
+uid://ccw3t8r0s6n6r
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc b/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc
new file mode 100644
index 0000000..728ca59
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc
@@ -0,0 +1,11 @@
+:trigger_in "player"
+ test_set_global("trigger_in_result", "player-in")
+
+:trigger_in
+ test_set_global("trigger_in_result", "fallback-in")
+
+:trigger_out "player"
+ test_set_global("trigger_out_result", "player-out")
+
+:trigger_out
+ test_set_global("trigger_out_result", "fallback-out")
diff --git a/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc.uid b/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc.uid
new file mode 100644
index 0000000..46d6dcb
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/fixtures/trigger_targeted_events_use_object_id.esc.uid
@@ -0,0 +1 @@
+uid://ciepdqqwhcgtf
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd
new file mode 100644
index 0000000..c0463b2
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd
@@ -0,0 +1,16 @@
+class_name TestDelayedSetGlobalCommand
+extends ESCBaseCommand
+
+
+func configure() -> ESCCommandArgumentDescriptor:
+ return ESCCommandArgumentDescriptor.new(
+ 3,
+ [[TYPE_STRING], [TYPE_STRING], [TYPE_FLOAT, TYPE_INT]],
+ [null, null, null]
+ )
+
+
+func run(command_params: Array) -> int:
+ await escoria.get_tree().create_timer(float(command_params[2])).timeout
+ escoria.globals_manager.set_global(command_params[0], command_params[1])
+ return ESCExecution.RC_OK
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd.uid b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd.uid
new file mode 100644
index 0000000..f844e2a
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_delayed_set_global.gd.uid
@@ -0,0 +1 @@
+uid://b21sl821n61w4
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd
new file mode 100644
index 0000000..2ea34a9
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd
@@ -0,0 +1,32 @@
+class_name TestInterruptibleSetGlobalCommand
+extends ESCBaseCommand
+
+
+var _interrupted := false
+
+
+func configure() -> ESCCommandArgumentDescriptor:
+ return ESCCommandArgumentDescriptor.new(
+ 4,
+ [[TYPE_STRING], [TYPE_STRING], [TYPE_STRING], [TYPE_FLOAT, TYPE_INT]],
+ [null, null, null, null]
+ )
+
+
+func run(command_params: Array) -> int:
+ _interrupted = false
+
+ var deadline_usec := Time.get_ticks_usec() + int(float(command_params[3]) * 1000000.0)
+ while Time.get_ticks_usec() < deadline_usec and not _interrupted:
+ await escoria.get_tree().create_timer(0.01).timeout
+
+ if _interrupted:
+ escoria.globals_manager.set_global(command_params[2], true)
+ return ESCExecution.RC_OK
+
+ escoria.globals_manager.set_global(command_params[0], command_params[1])
+ return ESCExecution.RC_OK
+
+
+func interrupt():
+ _interrupted = true
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd.uid b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd.uid
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_interruptible_set_global.gd.uid
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd
new file mode 100644
index 0000000..41bd624
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd
@@ -0,0 +1,15 @@
+class_name TestSetGlobalCommand
+extends ESCBaseCommand
+
+
+func configure() -> ESCCommandArgumentDescriptor:
+ return ESCCommandArgumentDescriptor.new(
+ 2,
+ [[TYPE_STRING], [TYPE_STRING]],
+ [null, null]
+ )
+
+
+func run(command_params: Array) -> int:
+ escoria.globals_manager.set_global(command_params[0], command_params[1])
+ return ESCExecution.RC_OK
diff --git a/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd.uid b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd.uid
new file mode 100644
index 0000000..b273d48
--- /dev/null
+++ b/addons/escoria-core/testing/ashes/interpreter/support/commands/test_set_global.gd.uid
@@ -0,0 +1 @@
+uid://chb08gv4i2nlw