diff --git a/client/scripts/net/npc_service.gd b/client/scripts/net/npc_service.gd new file mode 100644 index 0000000..0feea5b --- /dev/null +++ b/client/scripts/net/npc_service.gd @@ -0,0 +1,48 @@ +class_name NpcService +extends RefCounted +## The client /npc/speak loop (§6). Posts persona-free request (npc_id + +## disposition + client-computed available_moves + utterance) through an injected +## transport, degrades to an authored NPC fallback on ANY failure, extracts tags +## with TagExtractor, validates moves against available_moves (MoveValidator), +## and returns an NpcResult. It applies NO state — the caller runs MoveApplier, +## keeping state writes explicit (§2). + +const ENDING_MOVES := ["end_conversation", "become_hostile"] + +var _transport: DmTransport +var _fallback: FallbackLibrary + + +func _init(transport: DmTransport, fallback: FallbackLibrary) -> void: + _transport = transport + _fallback = fallback + + +func speak(npc_id: String, utterance: String, canon_log: CanonLog, + disposition: int, available_moves: Array) -> NpcResult: + var resp: DmResponse = await _transport.post_json("/npc/speak", { + "canon_log": canon_log.to_dict(), + "npc_id": npc_id, + "disposition": disposition, + "available_moves": available_moves, + "utterance": utterance, + }) + if not resp.transport_ok or resp.status != 200 \ + or typeof(resp.body) != TYPE_DICTIONARY or not resp.body.has("prose"): + return NpcResult.fallback(_fallback.npc_line()) + + var extracted := TagExtractor.extract(str(resp.body["prose"])) + var moves: Array = extracted["moves"].duplicate() + # Fold the [ADJUST_DISPOSITION:n] alias into the move stream so the validator + # sees one uniform shape (§ tag convention). + for delta in extracted["dispositions"]: + moves.append({"name": "adjust_disposition", "args": [str(delta)]}) + + var checked := MoveValidator.validate(moves, available_moves) + var ends := false + for m in checked["valid"]: + if m["name"] in ENDING_MOVES: + ends = true + return NpcResult.new( + extracted["clean_text"], extracted["facts"], + checked["valid"], checked["dropped"], ends, false) diff --git a/client/tests/unit/test_npc_service.gd b/client/tests/unit/test_npc_service.gd new file mode 100644 index 0000000..e47578b --- /dev/null +++ b/client/tests/unit/test_npc_service.gd @@ -0,0 +1,92 @@ +# client/tests/unit/test_npc_service.gd +extends "res://addons/gut/test.gd" + +const NpcService = preload("res://scripts/net/npc_service.gd") +const DmResponse = preload("res://scripts/net/dm_response.gd") +const FallbackLibrary = preload("res://scripts/net/fallback_library.gd") +const FakeDmTransport = preload("res://tests/doubles/fake_transport.gd") + +var _fallback + + +func before_each(): + _fallback = FallbackLibrary.new( + "res://content/fallback/missing.json", "res://content/fallback/missing.json") + + +func _svc(resp) -> Dictionary: + var t = FakeDmTransport.new() + t.set_response(resp) + return {"svc": NpcService.new(t, _fallback), "transport": t} + + +func _speak(pair, available): + return await pair["svc"].speak("fenn", "what happened?", CanonLog.new(), -10, available) + + +func test_posts_full_request_body(): + var pair = _svc(DmResponse.ok(200, {"prose": "Aye."})) + await _speak(pair, ["refuse"]) + assert_eq(pair["transport"].last_path, "/npc/speak") + var body = pair["transport"].last_body + assert_eq(body["npc_id"], "fenn") + assert_eq(body["disposition"], -10) + assert_eq(body["utterance"], "what happened?") + assert_true(body.has("available_moves")) + assert_true(body.has("canon_log")) + + +func test_valid_move_kept_prose_cleaned(): + var prose = "Aye, I knew them. [MOVE: reveal(varrell_twins)]" + var pair = _svc(DmResponse.ok(200, {"prose": prose})) + var r = await _speak(pair, ["reveal(varrell_twins)", "refuse"]) + assert_false(r.degraded) + assert_eq(r.display_text, "Aye, I knew them.") + assert_eq(r.valid_moves.size(), 1) + assert_eq(r.dropped_moves.size(), 0) + + +func test_illegal_move_dropped_prose_kept(): + var prose = "No. [MOVE: offer_quest(some_other)]" + var pair = _svc(DmResponse.ok(200, {"prose": prose})) + var r = await _speak(pair, ["offer_quest(find_the_ledger)", "refuse"]) + assert_eq(r.display_text, "No.") + assert_eq(r.valid_moves.size(), 0) + assert_eq(r.dropped_moves.size(), 1) + + +func test_adjust_disposition_alias_is_validated(): + var prose = "Fine. [ADJUST_DISPOSITION: +5]" + var pair = _svc(DmResponse.ok(200, {"prose": prose})) + var r = await _speak(pair, ["adjust_disposition"]) + assert_eq(r.valid_moves.size(), 1) + assert_eq(r.valid_moves[0]["name"], "adjust_disposition") + assert_eq(r.valid_moves[0]["args"], ["5"]) + + +func test_end_conversation_sets_flag(): + var prose = "We're done. [MOVE: end_conversation()]" + var pair = _svc(DmResponse.ok(200, {"prose": prose})) + var r = await _speak(pair, ["end_conversation"]) + assert_true(r.ends_conversation) + + +func test_facts_harvested(): + var prose = "The bridge fell. [FACT: the eastern bridge is out]" + var pair = _svc(DmResponse.ok(200, {"prose": prose})) + var r = await _speak(pair, ["refuse"]) + assert_eq(r.facts, ["the eastern bridge is out"]) + + +func test_502_degrades_no_moves(): + var pair = _svc(DmResponse.ok(502, {"detail": {"model_error": "boom"}})) + var r = await _speak(pair, ["refuse"]) + assert_true(r.degraded) + assert_eq(r.display_text, FallbackLibrary.NPC_LAST_RESORT) + assert_eq(r.valid_moves, []) + + +func test_transport_failure_degrades(): + var pair = _svc(DmResponse.failed("refused")) + var r = await _speak(pair, ["refuse"]) + assert_true(r.degraded)