Files
code_of_conquest_dnd/client/scripts/harness/npc_harness.gd
Phillip Tarrant 5149e817f6 fix(contract): repair 3-arg LogPlayer callers + digest article bug
Three callers (main_window_shell.gd, narrate_harness.gd, npc_harness.gd)
still built LogPlayer with the OLD 3-arg shape (name, class_id,
luck_descriptor) after LogPlayer.new() was migrated to the 4-arg
(name, race_id, calling_id, luck_descriptor) shape. This was invisible
to both grep and the test suite: every ctor param carries a default, so
GDScript compiles a 3-arg call against a 4-arg signature without error —
it just silently mis-binds positionally. "sellsword" landed in the
race_id slot (rejected — not a race), the luck descriptor landed in the
calling_id slot (rejected — not a calling), and luck_descriptor itself
fell back to its default "". Every field in the emitted dict came out
empty, which 422s against the canon-log schema's minLength/enum
constraints. Grepping for class_id can't see this, because there is no
class_id token left anywhere — the bug is purely positional.

Also fixes prompts.py's _article(""), which returned "an" because
Python's "" in "aeiou" is True, producing a doubled-space/wrong-article
digest line when race_id is empty. _describe_player now builds its
descriptor from whichever of race/calling are present and omits the
clause entirely when both are absent.

Adds a schema-parity guard for origin.schema.json's inlined
allowed_callings enum, which duplicated the calling roster with no
runtime check tying it to Callings.IDS. Renames leftover "class"
vocabulary in test names/comments to "calling".

Regression coverage:
- client/tests/unit/test_entities.gd: ctor populates both race_id and
  calling_id; a calling passed into the race_id slot (the exact shape
  of the three broken call sites) yields an all-empty row.
- api/tests/test_prompts.py: empty-race and empty-race-and-calling
  digest rendering, asserting no doubled space and no dangling article.
- client/tests/unit/test_schema_parity.gd: origin.schema.json's
  allowed_callings enum matches Callings.IDS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QYa9u7Kdxv5gX4AnwWexy8
2026-07-12 20:34:05 -05:00

108 lines
3.6 KiB
GDScript

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 ConsideringIndicator = preload("res://scripts/ui/considering_indicator.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
var _status: Label
var _indicator: ConsideringIndicator
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")
# Build the player through LogPlayer so luck_descriptor is non-empty — the
# canon-log schema requires it (minLength 1), and it's the §7 fortune line
# the digest surfaces to the NPC. An empty string here 422s the request.
_canon_log.player = LogPlayer.new("Aldric", "human", "sellsword", "the dice are kind today")
# 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)
_status = Label.new()
vbox.add_child(_status)
_indicator = ConsideringIndicator.new()
add_child(_indicator)
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)
_indicator.start(_status)
var r: NpcResult = await _service.speak(
NPC_ID, utterance, _canon_log, disposition, available)
_indicator.stop()
# 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")