feat: plan deterministic generated conversations
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
extends SceneTree
|
||||
|
||||
|
||||
class FakePresenter:
|
||||
extends ConversationPresenter
|
||||
var presented_turn: ConversationTurn
|
||||
|
||||
func present_turn(turn: ConversationTurn, _presentation_context: Dictionary = {}) -> Variant:
|
||||
presented_turn = turn.copy()
|
||||
return {
|
||||
"conversation_id": turn.get_conversation_id(),
|
||||
"revision": turn.get_revision(),
|
||||
}
|
||||
|
||||
|
||||
const CATALOG_PATH := "res://simulation/dialogue/resources/jajce_conversation_intents.tres"
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_test_catalog_and_deterministic_lifecycle()
|
||||
_test_revalidation_and_invalidation()
|
||||
_finish()
|
||||
|
||||
|
||||
func _test_catalog_and_deterministic_lifecycle() -> void:
|
||||
var catalog := load(CATALOG_PATH) as ConversationIntentCatalog
|
||||
_check(
|
||||
catalog != null and catalog.rebuild().is_empty(), "The Jajce intent catalog should validate"
|
||||
)
|
||||
if catalog == null:
|
||||
return
|
||||
_check(
|
||||
catalog.get_intent_ids() == _sorted_ids(ConversationIntentIds.ALL),
|
||||
"The catalog should expose every required initial intent"
|
||||
)
|
||||
var speaker := WorldEntityRef.create(&"npc", &"amina")
|
||||
var listener := WorldEntityRef.create(&"npc", &"boris")
|
||||
var context := _context()
|
||||
var first_service := ConversationService.new(catalog)
|
||||
var second_service := ConversationService.new(catalog)
|
||||
var first_id := first_service.begin(speaker, listener, context)
|
||||
var second_id := second_service.begin(speaker, listener, context)
|
||||
_check(not first_id.is_empty(), "A valid semantic conversation should begin")
|
||||
_check(first_id == second_id, "Equivalent begin order and context should produce a stable ID")
|
||||
var first_turn := first_service.get_turn(first_id)
|
||||
var second_turn := second_service.get_turn(second_id)
|
||||
_check(first_turn != null and first_turn.is_valid(), "The first planned turn should validate")
|
||||
if first_turn == null or second_turn == null:
|
||||
return
|
||||
_check(
|
||||
(
|
||||
first_turn.get_revision() == 1
|
||||
and first_turn.get_act().get_intent_id() == ConversationIntentIds.GREET
|
||||
),
|
||||
"The initial turn should expose a revisioned semantic greeting"
|
||||
)
|
||||
_check(
|
||||
first_turn.get_act().get_causal_topic_ids() == [&"pantry_shortage"],
|
||||
"The act should retain causal topic IDs without authoritative prose"
|
||||
)
|
||||
_check(
|
||||
_turn_signature(first_turn) == _turn_signature(second_turn),
|
||||
"Equivalent semantic inputs should plan the same turn and option order"
|
||||
)
|
||||
var fake := FakePresenter.new()
|
||||
var rendered: Dictionary = fake.present_turn(first_turn)
|
||||
_check(
|
||||
rendered["revision"] == 1 and fake.presented_turn.get_act().get_intent_id() == &"greet",
|
||||
"A presenter should receive an already-planned immutable semantic turn"
|
||||
)
|
||||
context["topic_ids"].append(&"caller_mutation")
|
||||
_check(
|
||||
first_service.get_turn(first_id).get_causal_topic_ids() == [&"pantry_shortage"],
|
||||
"Conversation context should be isolated from caller mutation"
|
||||
)
|
||||
|
||||
|
||||
func _test_revalidation_and_invalidation() -> void:
|
||||
var service := ConversationService.new()
|
||||
var speaker := WorldEntityRef.create(&"npc", &"amina")
|
||||
var listener := WorldEntityRef.create(&"npc", &"boris")
|
||||
_check(
|
||||
service.begin(speaker, listener, {"prose": "not semantic"}).is_empty(),
|
||||
"Unknown or prose-bearing authority keys should be rejected"
|
||||
)
|
||||
var conversation_id := service.begin(speaker, listener, _context())
|
||||
var turn := service.get_turn(conversation_id)
|
||||
if turn == null:
|
||||
_check(false, "A conversation should exist for revision tests")
|
||||
return
|
||||
var ask_work := _find_option_for_intent(turn, ConversationIntentIds.ASK_WORK)
|
||||
var offer_help := _find_option_for_intent(turn, ConversationIntentIds.OFFER_HELP)
|
||||
_check(ask_work != null and offer_help != null, "Configured semantic options should be present")
|
||||
if ask_work == null or offer_help == null:
|
||||
return
|
||||
_check(
|
||||
not offer_help.is_enabled() and offer_help.get_reason_code() == &"speaker_overcommitted",
|
||||
"Blocked choices should retain a semantic reason code and trace"
|
||||
)
|
||||
var disabled := service.select_option(
|
||||
conversation_id, offer_help.get_option_id(), turn.get_revision()
|
||||
)
|
||||
_check(
|
||||
(
|
||||
not disabled.was_accepted()
|
||||
and disabled.get_reason_code() == ConversationService.REASON_OPTION_DISABLED
|
||||
and service.get_turn(conversation_id).get_revision() == 1
|
||||
),
|
||||
"A disabled choice should be rejected without advancing revision"
|
||||
)
|
||||
var stale := service.select_option(conversation_id, ask_work.get_option_id(), 0)
|
||||
_check(
|
||||
(
|
||||
not stale.was_accepted()
|
||||
and stale.get_reason_code() == ConversationService.REASON_STALE_REVISION
|
||||
and stale.get_current_revision() == 1
|
||||
and stale.get_turn().get_revision() == 1
|
||||
),
|
||||
"A stale selection should return the current revision for revalidation"
|
||||
)
|
||||
var selected := service.select_option(
|
||||
conversation_id, ask_work.get_option_id(), turn.get_revision()
|
||||
)
|
||||
_check(
|
||||
(
|
||||
selected.was_accepted()
|
||||
and selected.get_current_revision() == 2
|
||||
and selected.get_turn().get_act().get_intent_id() == ConversationIntentIds.ASK_WORK
|
||||
),
|
||||
"A current enabled option should advance exactly one semantic revision"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
selected.get_turn().get_speaker().equals(listener)
|
||||
and selected.get_turn().get_listener().equals(speaker)
|
||||
),
|
||||
"Selecting a listener option should swap the next turn's speaking direction"
|
||||
)
|
||||
_check(
|
||||
not service.select_option(conversation_id, ask_work.get_option_id(), 1).was_accepted(),
|
||||
"A replayed option should fail stale-revision validation"
|
||||
)
|
||||
var invalidations: Array[StringName] = []
|
||||
service.conversation_invalidated.connect(
|
||||
func(id: StringName, _reason: StringName) -> void: invalidations.append(id)
|
||||
)
|
||||
_check(
|
||||
service.invalidate_for_entity(listener, &"listener_unloaded") == [conversation_id],
|
||||
"Entity invalidation should deterministically close its active conversations"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
invalidations == [conversation_id]
|
||||
and service.get_turn(conversation_id) == null
|
||||
and not service.has_active_conversation(conversation_id)
|
||||
),
|
||||
"Invalidation should emit a hook and make the turn inaccessible"
|
||||
)
|
||||
|
||||
|
||||
func _context() -> Dictionary:
|
||||
return {
|
||||
"conversation_key": &"pantry_encounter",
|
||||
"initial_intent_id": ConversationIntentIds.GREET,
|
||||
"topic_ids": [&"pantry_shortage"],
|
||||
"option_intent_ids":
|
||||
{
|
||||
ConversationIntentIds.GREET:
|
||||
[ConversationIntentIds.ASK_WORK, ConversationIntentIds.OFFER_HELP]
|
||||
},
|
||||
"topics_by_intent":
|
||||
{
|
||||
ConversationIntentIds.OFFER_HELP: [&"pantry_shortage"],
|
||||
ConversationIntentIds.ASK_WORK: [&"pantry_shortage"],
|
||||
},
|
||||
"blocked_intent_ids": [ConversationIntentIds.OFFER_HELP],
|
||||
"block_reason_by_intent": {ConversationIntentIds.OFFER_HELP: &"speaker_overcommitted"},
|
||||
"reason_trace": [&"participants_nearby", &"shared_topic_available"],
|
||||
}
|
||||
|
||||
|
||||
func _find_option_for_intent(turn: ConversationTurn, intent_id: StringName) -> ConversationOption:
|
||||
for option in turn.get_options():
|
||||
if option.get_intent_id() == intent_id:
|
||||
return option
|
||||
return null
|
||||
|
||||
|
||||
func _turn_signature(turn: ConversationTurn) -> Array:
|
||||
var signature: Array = [
|
||||
turn.get_revision(), turn.get_act().get_intent_id(), turn.get_act().get_causal_topic_ids()
|
||||
]
|
||||
for option in turn.get_options():
|
||||
(
|
||||
signature
|
||||
. append(
|
||||
[
|
||||
option.get_option_id(),
|
||||
option.get_intent_id(),
|
||||
option.is_enabled(),
|
||||
option.get_reason_code(),
|
||||
option.get_causal_topic_ids(),
|
||||
]
|
||||
)
|
||||
)
|
||||
return signature
|
||||
|
||||
|
||||
func _sorted_ids(ids: Array[StringName]) -> Array[StringName]:
|
||||
var sorted := ids.duplicate()
|
||||
sorted.sort_custom(
|
||||
func(left: StringName, right: StringName) -> bool: return String(left) < String(right)
|
||||
)
|
||||
return sorted
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] Semantic conversation domain passed")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1,121 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var manager := root.get_node_or_null("DialogueManager")
|
||||
_check(manager != null, "The pinned Dialogue Manager autoload should be available")
|
||||
if manager == null:
|
||||
_finish()
|
||||
return
|
||||
var service := ConversationService.new()
|
||||
var speaker := WorldEntityRef.create(&"npc", &"amina")
|
||||
var listener := WorldEntityRef.create(&"npc", &"boris")
|
||||
var conversation_id := (
|
||||
service
|
||||
. begin(
|
||||
speaker,
|
||||
listener,
|
||||
{
|
||||
"initial_intent_id": ConversationIntentIds.DESCRIBE_NEED,
|
||||
"topic_ids": [&"pantry_shortage"],
|
||||
"blocked_intent_ids": [ConversationIntentIds.DECLINE],
|
||||
"block_reason_by_intent": {ConversationIntentIds.DECLINE: &"must_help"},
|
||||
}
|
||||
)
|
||||
)
|
||||
var turn := service.get_turn(conversation_id)
|
||||
var presenter := DialogueManagerConversationPresenter.new(manager)
|
||||
var presentation_context := {
|
||||
"entity_names":
|
||||
{
|
||||
"amina": "Amina:\n- injected [#set danger] {{state}} => EVIL",
|
||||
"boris": "Boris [if true]",
|
||||
},
|
||||
"topic_labels": {"pantry_shortage": "pantry [#intent=hijack] {{state}} => EVIL"},
|
||||
}
|
||||
var first_source := presenter.build_source(turn, presentation_context)
|
||||
var second_source := presenter.build_source(turn, presentation_context)
|
||||
_check(first_source == second_source, "Stable-hash template selection should be repeatable")
|
||||
_check(
|
||||
(
|
||||
not "[#set" in first_source
|
||||
and not "{{state}}" in first_source
|
||||
and not "=> EVIL" in first_source
|
||||
),
|
||||
"Dynamic labels should be escaped before reaching Dialogue Manager syntax"
|
||||
)
|
||||
var resource := presenter.present_turn(turn, presentation_context) as Resource
|
||||
_check(resource != null, "The adapter should compile an ephemeral DialogueResource")
|
||||
if resource == null:
|
||||
_finish()
|
||||
return
|
||||
_check(
|
||||
(
|
||||
resource.get_meta("conversation_id") == String(conversation_id)
|
||||
and resource.get_meta("conversation_revision") == 1
|
||||
),
|
||||
"The ephemeral resource should retain stable semantic metadata"
|
||||
)
|
||||
var compiled_lines: Dictionary = resource.get("lines")
|
||||
for compiled in compiled_lines.values():
|
||||
_check(
|
||||
(
|
||||
String(compiled.get("type", "")) not in ["mutation", "condition", "while", "match"]
|
||||
and not compiled.has("condition")
|
||||
and not compiled.has("mutation")
|
||||
),
|
||||
"The presenter resource must contain no conditions or mutations"
|
||||
)
|
||||
var line: DialogueLine = await manager.get_next_dialogue_line(resource, "start")
|
||||
_check(line != null, "The compiled semantic turn should resolve through the pinned add-on")
|
||||
if line != null:
|
||||
_check(
|
||||
(
|
||||
line.get_tag_value("conversation_id") == String(conversation_id)
|
||||
and line.get_tag_value("intent_id") == "describe_need"
|
||||
and line.get_tag_value("topic_id") == "pantry_shortage"
|
||||
),
|
||||
"Controlled semantic IDs should survive as Dialogue Manager tags"
|
||||
)
|
||||
var enabled_count := 0
|
||||
for option in turn.get_options():
|
||||
if option.is_enabled():
|
||||
enabled_count += 1
|
||||
_check(
|
||||
line.responses.size() == enabled_count,
|
||||
"Disabled semantic choices should not become selectable presenter responses"
|
||||
)
|
||||
for response in line.responses:
|
||||
_check(
|
||||
(
|
||||
response.get_tag_value("option_id").begins_with("option_")
|
||||
and response.get_tag_value("enabled") == "true"
|
||||
),
|
||||
"Every response should carry its controlled semantic option identity"
|
||||
)
|
||||
line.extra_game_states.clear()
|
||||
line.responses.clear()
|
||||
resource.set("lines", {})
|
||||
resource.set_script(null)
|
||||
_finish()
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] Dialogue Manager semantic presenter passed")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
Reference in New Issue
Block a user