283 lines
9.0 KiB
GDScript
283 lines
9.0 KiB
GDScript
extends SceneTree
|
|
|
|
|
|
class ForgedResponse:
|
|
extends RefCounted
|
|
|
|
var text := "Forged authority"
|
|
|
|
func get_tag_value(tag_name: String) -> String:
|
|
if tag_name == "option_id":
|
|
return "option_not_in_turn"
|
|
if tag_name == "enabled":
|
|
return "true"
|
|
return ""
|
|
|
|
|
|
class ForgedLine:
|
|
extends RefCounted
|
|
|
|
var responses: Array = [ForgedResponse.new()]
|
|
|
|
|
|
var failures: Array[String] = []
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
var main_scene: Node = load("res://main.tscn").instantiate()
|
|
root.add_child(main_scene)
|
|
await process_frame
|
|
for _frame in 4:
|
|
await physics_frame
|
|
|
|
var manager: Node = main_scene.get_node("SimulationManager")
|
|
var world_view: Node = main_scene.get_node("WorldViewManager")
|
|
var player := main_scene.get_node("Player") as CharacterBody3D
|
|
var combat := player.get_node("PlayerCombatController") as PlayerCombatController
|
|
var controller := player.get_node_or_null("DialogueModeController") as DialogueModeController
|
|
manager.set_process(false)
|
|
player.set_physics_process(false)
|
|
combat.set_physics_process(false)
|
|
_freeze_and_place_population(manager, world_view, player.global_position)
|
|
|
|
_check(controller != null, "The player should install one autoload-free dialogue controller")
|
|
if controller == null:
|
|
_finish()
|
|
return
|
|
var nearest_id: int = world_view.find_nearest_active_npc_id(
|
|
player.global_position, player.villager_inspection_range
|
|
)
|
|
var context := player.get_interaction_context() as PlayerInteractionResult
|
|
_check(
|
|
(
|
|
nearest_id >= 0
|
|
and context != null
|
|
and context.kind == PlayerInteractionResult.KIND_DIALOGUE
|
|
and context.target_id == StringName(str(nearest_id))
|
|
and "Talk" in context.prompt_text
|
|
),
|
|
"The E interaction should prefer the nearest loaded living villager",
|
|
)
|
|
|
|
var mouse_mode_before_dialogue := Input.mouse_mode
|
|
var gamepad_accept := InputEventAction.new()
|
|
gamepad_accept.action = &"ui_accept"
|
|
gamepad_accept.pressed = true
|
|
player.call("_unhandled_input", gamepad_accept)
|
|
await _wait_for_presentation(controller)
|
|
_check(
|
|
(
|
|
manager.is_dialogue_active()
|
|
and controller.is_dialogue_active()
|
|
and player.is_dialogue_input_locked()
|
|
and controller.has_valid_option_mapping()
|
|
and not controller.get_presented_speaker().is_empty()
|
|
and not controller.get_presented_line().is_empty()
|
|
and not controller.get_presented_option_ids().is_empty()
|
|
),
|
|
"A manager-planned turn should render through the pinned Dialogue Manager adapter",
|
|
)
|
|
_check(
|
|
player.get_interaction_context() == null,
|
|
"Ordinary world interactions should be unavailable while dialogue owns input",
|
|
)
|
|
var horizontal_speed_before := 5.0
|
|
player.velocity = Vector3(horizontal_speed_before, 0.0, 0.0)
|
|
player.call("_physics_process", 0.1)
|
|
_check(
|
|
absf(player.velocity.x) < horizontal_speed_before,
|
|
"Dialogue mode should decelerate the player instead of reading movement input",
|
|
)
|
|
_check_combat_lock(combat)
|
|
_check_navigation(controller)
|
|
_check_responsive_layout(controller)
|
|
|
|
var displayed_revision := controller.get_presented_revision()
|
|
var authoritative_turn: ConversationTurn = manager.conversation_service.get_turn(
|
|
controller.get_conversation_id()
|
|
)
|
|
var forged_mapping: Array = controller.call(
|
|
"_copy_mapped_responses", ForgedLine.new(), authoritative_turn
|
|
)
|
|
_check(
|
|
forged_mapping.is_empty(),
|
|
"Presenter copy must reject response tags that are absent from the planned turn",
|
|
)
|
|
var advance_option := _first_non_goodbye_option(authoritative_turn)
|
|
_check(advance_option != null, "The opening turn should offer a non-terminal response")
|
|
if advance_option != null:
|
|
var advanced: ConversationSelectionResult = manager.conversation_service.select_option(
|
|
controller.get_conversation_id(),
|
|
advance_option.get_option_id(),
|
|
authoritative_turn.get_revision()
|
|
)
|
|
_check(advanced.was_accepted(), "The stale-revision setup should advance domain state")
|
|
var stale_option_id := controller.get_presented_option_ids()[0]
|
|
_check(
|
|
not controller.select_option(stale_option_id),
|
|
"A displayed response must not apply against a newer authoritative revision",
|
|
)
|
|
await _wait_for_presentation(controller)
|
|
_check(
|
|
(
|
|
controller.get_presented_revision() == displayed_revision + 1
|
|
and controller.has_valid_option_mapping()
|
|
),
|
|
"A stale selection should refresh from the rejected result's authoritative turn",
|
|
)
|
|
|
|
_check(controller.close_conversation(), "Esc/close should end the active manager conversation")
|
|
await process_frame
|
|
_check(
|
|
(
|
|
not manager.is_dialogue_active()
|
|
and not controller.is_dialogue_active()
|
|
and not player.is_dialogue_input_locked()
|
|
and not controller.get_node("DialogueBalloon").visible
|
|
and Input.mouse_mode == mouse_mode_before_dialogue
|
|
),
|
|
"Closing should release presentation and gameplay input without one-way UI state",
|
|
)
|
|
|
|
_check(
|
|
controller.begin_conversation(nearest_id),
|
|
"The same nearby villager should be talkable again"
|
|
)
|
|
await _wait_for_presentation(controller)
|
|
var goodbye_id := _option_id_for_intent(
|
|
manager.conversation_service.get_turn(controller.get_conversation_id()),
|
|
ConversationIntentIds.GOODBYE
|
|
)
|
|
_check(
|
|
not goodbye_id.is_empty() and controller.get_presented_option_ids().has(goodbye_id),
|
|
"The semantic goodbye option should retain its controlled presenter mapping",
|
|
)
|
|
if not goodbye_id.is_empty():
|
|
_check(controller.select_option(goodbye_id), "Selecting goodbye should be accepted")
|
|
await process_frame
|
|
_check(
|
|
not manager.is_dialogue_active() and not controller.is_dialogue_active(),
|
|
"A terminal goodbye should close through the manager's conversation-ended signal",
|
|
)
|
|
|
|
main_scene.queue_free()
|
|
await process_frame
|
|
_finish()
|
|
|
|
|
|
func _wait_for_presentation(controller: DialogueModeController) -> void:
|
|
for _frame in 12:
|
|
if controller.has_valid_option_mapping() or not controller.is_dialogue_active():
|
|
return
|
|
await process_frame
|
|
|
|
|
|
func _freeze_and_place_population(manager: Node, world_view: Node, origin: Vector3) -> void:
|
|
for index in manager.npcs.size():
|
|
var npc: SimNPC = manager.npcs[index]
|
|
var position := origin + Vector3(40.0 + index * 3.0, 0.0, 30.0)
|
|
if index == 0:
|
|
position = origin + Vector3(1.0, 0.0, 0.0)
|
|
manager.synchronize_npc_position(npc.id, position)
|
|
var visual := world_view.active_npc_visuals.get(npc.id) as Node3D
|
|
if visual != null:
|
|
visual.set_physics_process(false)
|
|
if visual.has_method("stop_travel"):
|
|
visual.stop_travel()
|
|
visual.global_position = position
|
|
|
|
|
|
func _check_combat_lock(combat: PlayerCombatController) -> void:
|
|
combat.dashing = true
|
|
combat.dash_velocity = Vector3(18.0, 0.0, 0.0)
|
|
var attack_timer_before := combat.attack_timer
|
|
combat.call("_perform_attack")
|
|
_check(
|
|
(
|
|
combat.get_dash_velocity() == Vector3.ZERO
|
|
and is_equal_approx(combat.attack_timer, attack_timer_before)
|
|
),
|
|
"Dialogue input ownership should suppress active dashes and new attacks",
|
|
)
|
|
|
|
|
|
func _check_navigation(controller: DialogueModeController) -> void:
|
|
var option_ids := controller.get_presented_option_ids()
|
|
if option_ids.size() < 2:
|
|
_check(false, "The navigation proof needs at least two presented responses")
|
|
return
|
|
var first_id := controller.get_focused_option_id()
|
|
var down := InputEventAction.new()
|
|
down.action = &"ui_down"
|
|
down.pressed = true
|
|
controller.call("_input", down)
|
|
_check(
|
|
controller.get_focused_option_id() != first_id,
|
|
"Keyboard/gamepad down should move deterministic option focus",
|
|
)
|
|
var up := InputEventAction.new()
|
|
up.action = &"ui_up"
|
|
up.pressed = true
|
|
controller.call("_input", up)
|
|
_check(
|
|
controller.get_focused_option_id() == first_id,
|
|
"Keyboard/gamepad up should restore deterministic option focus",
|
|
)
|
|
|
|
|
|
func _check_responsive_layout(controller: DialogueModeController) -> void:
|
|
var original_size := root.size
|
|
root.size = Vector2i(480, 270)
|
|
controller.call("_layout_balloon")
|
|
var panel := controller.get_node("DialogueBalloon/Panel") as PanelContainer
|
|
var viewport_size := controller.get_viewport().get_visible_rect().size
|
|
_check(
|
|
(
|
|
panel.size.x <= minf(760.0, viewport_size.x)
|
|
and panel.size.y <= viewport_size.y
|
|
and panel.position.x >= 0.0
|
|
and panel.position.y >= 0.0
|
|
and panel.position.x + panel.size.x <= viewport_size.x + 0.01
|
|
and panel.position.y + panel.size.y <= viewport_size.y + 0.01
|
|
),
|
|
"The custom balloon should remain inside the low-resolution viewport bounds",
|
|
)
|
|
root.size = original_size
|
|
|
|
|
|
func _first_non_goodbye_option(turn: ConversationTurn) -> ConversationOption:
|
|
if turn == null:
|
|
return null
|
|
for option in turn.get_options():
|
|
if option.is_enabled() and option.get_intent_id() != ConversationIntentIds.GOODBYE:
|
|
return option
|
|
return null
|
|
|
|
|
|
func _option_id_for_intent(turn: ConversationTurn, intent_id: StringName) -> StringName:
|
|
if turn == null:
|
|
return &""
|
|
for option in turn.get_options():
|
|
if option.is_enabled() and option.get_intent_id() == intent_id:
|
|
return option.get_option_id()
|
|
return &""
|
|
|
|
|
|
func _check(condition: bool, message: String) -> void:
|
|
if not condition:
|
|
failures.append(message)
|
|
|
|
|
|
func _finish() -> void:
|
|
if failures.is_empty():
|
|
print("[TEST] Playable Dialogue Manager presentation passed")
|
|
quit(0)
|
|
return
|
|
for failure in failures:
|
|
push_error("[TEST] " + failure)
|
|
quit(1)
|