diff --git a/client/scenes/npc_harness.tscn b/client/scenes/npc_harness.tscn new file mode 100644 index 0000000..200b353 --- /dev/null +++ b/client/scenes/npc_harness.tscn @@ -0,0 +1,10 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/harness/npc_harness.gd" id="1_harness"] + +[node name="NpcHarness" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_harness") diff --git a/client/scripts/harness/npc_harness.gd b/client/scripts/harness/npc_harness.gd new file mode 100644 index 0000000..a9de9d6 --- /dev/null +++ b/client/scripts/harness/npc_harness.gd @@ -0,0 +1,94 @@ +extends Control +## Throwaway harness — the bounded-NPC loop on screen, not shipped UI. Type an +## utterance to Fenn, see his voiced prose, watch moves apply and disposition +## change, until he ends the conversation. Mirrors narrate_harness. Run this +## scene directly (F6) with the proxy + Ollama up. + +const NpcService = preload("res://scripts/net/npc_service.gd") +const NpcContent = preload("res://scripts/npc/npc_content.gd") +const MoveApplier = preload("res://scripts/npc/move_applier.gd") +const ContentDB = preload("res://scripts/content/content_db.gd") +const HttpTransport = preload("res://scripts/net/http_transport.gd") +const FallbackLibrary = preload("res://scripts/net/fallback_library.gd") + +const NPC_ID := "fenn" + +var _service: NpcService +var _content: ContentDB +var _game_state: GameState +var _canon_log: CanonLog +var _output: RichTextLabel +var _entry: LineEdit +var _speak_btn: Button + + +func _ready() -> void: + var http := HTTPRequest.new() + add_child(http) + _service = NpcService.new(HttpTransport.new(http), FallbackLibrary.new()) + + _content = ContentDB.new() + _content.load_from(ContentDB.default_content_root()) + + _game_state = GameState.new() + _canon_log = CanonLog.new() + _canon_log.set_location("greywater_docks", "Greywater Docks") + _canon_log.player.name = "Aldric" + _canon_log.player.class_id = "sellsword" + # Deliberately no find_the_ledger quest here — leave it un-started so Fenn + # can offer_quest it during the conversation. + + var vbox := VBoxContainer.new() + vbox.set_anchors_preset(Control.PRESET_FULL_RECT) + vbox.add_theme_constant_override("separation", 12) + add_child(vbox) + + _output = RichTextLabel.new() + _output.bbcode_enabled = true + _output.fit_content = true + _output.custom_minimum_size = Vector2(600, 400) + vbox.add_child(_output) + + _entry = LineEdit.new() + _entry.placeholder_text = "Say something to Fenn…" + vbox.add_child(_entry) + + _speak_btn = Button.new() + _speak_btn.text = "Speak" + _speak_btn.pressed.connect(_on_speak) + vbox.add_child(_speak_btn) + + +func _on_speak() -> void: + var utterance := _entry.text + if utterance.strip_edges() == "": + return + _entry.text = "" + _speak_btn.disabled = true + _append("[b]You:[/b] %s" % utterance) + + var disposition := int(_game_state.npc_dispositions.get(NPC_ID, 0)) + var available := NpcContent.available_moves(_content.npc(NPC_ID), _game_state, _canon_log) + + var r: NpcResult = await _service.speak( + NPC_ID, utterance, _canon_log, disposition, available) + + # Apply state explicitly (§2) — the service applied nothing. + MoveApplier.apply(r.valid_moves, _game_state, _canon_log, _content, NPC_ID) + for f in r.facts: + _canon_log.add_fact(f) + + _append("[b]Fenn:[/b] %s" % r.display_text) + _append("[i]moves: %d applied, %d dropped · disposition %d%s[/i]" % [ + r.valid_moves.size(), r.dropped_moves.size(), + int(_game_state.npc_dispositions.get(NPC_ID, 0)), + " (degraded)" if r.degraded else ""]) + + if r.ends_conversation: + _append("[i]— Fenn ends the conversation. —[/i]") + else: + _speak_btn.disabled = false + + +func _append(bbcode: String) -> void: + _output.append_text(bbcode + "\n\n") diff --git a/client/scripts/harness/npc_harness.gd.uid b/client/scripts/harness/npc_harness.gd.uid new file mode 100644 index 0000000..e85093d --- /dev/null +++ b/client/scripts/harness/npc_harness.gd.uid @@ -0,0 +1 @@ +uid://cbmr6caohwsxb