feat(client): NpcService.speak — post, extract, validate moves (§6)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
48
client/scripts/net/npc_service.gd
Normal file
48
client/scripts/net/npc_service.gd
Normal file
@@ -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)
|
||||||
92
client/tests/unit/test_npc_service.gd
Normal file
92
client/tests/unit/test_npc_service.gd
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user