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)