diff --git a/client/scripts/npc/npc_content.gd b/client/scripts/npc/npc_content.gd new file mode 100644 index 0000000..6c5bc4a --- /dev/null +++ b/client/scripts/npc/npc_content.gd @@ -0,0 +1,32 @@ +class_name NpcContent +extends RefCounted +## Computes available_moves — the single home of move legality (§6). Reads the +## NPC's non-spoiler capability block and filters by live game state so the model +## is shown exactly what it may legally do this turn. persona/knowledge on the +## same file are server-only and ignored here. + +const UNIVERSAL := ["adjust_disposition", "refuse", "end_conversation", "become_hostile"] + + +static func available_moves(npc_dict: Dictionary, game_state, canon_log) -> Array: + var moves: Array = [] + var caps: Dictionary = npc_dict.get("capabilities", {}) + + for q in caps.get("offerable_quests", []): + if not canon_log.has_quest(q): + moves.append("offer_quest(%s)" % q) + + for t in caps.get("revealable_topics", []): + if not game_state.is_revealed(t): + moves.append("reveal(%s)" % t) + + for i in caps.get("giveable_items", []): + if not game_state.is_gift_given(i): + moves.append("give_item(%s)" % i) + + for item_id in game_state.inventory.keys(): + if int(game_state.inventory[item_id]) > 0: + moves.append("accept_item(%s)" % item_id) + + moves.append_array(UNIVERSAL) + return moves diff --git a/client/tests/unit/test_npc_content.gd b/client/tests/unit/test_npc_content.gd new file mode 100644 index 0000000..e4f7bf1 --- /dev/null +++ b/client/tests/unit/test_npc_content.gd @@ -0,0 +1,44 @@ +extends "res://addons/gut/test.gd" + +const NpcContent = preload("res://scripts/npc/npc_content.gd") + +func _fenn() -> Dictionary: + return {"id": "fenn", "capabilities": { + "offerable_quests": ["find_the_ledger"], + "giveable_items": [], + "revealable_topics": ["varrell_twins", "fenns_debt"]}} + + +func test_universal_moves_always_present(): + var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new()) + for m in ["adjust_disposition", "refuse", "end_conversation", "become_hostile"]: + assert_true(m in moves, "missing universal move %s" % m) + + +func test_offers_unstarted_quest_and_unrevealed_topics(): + var moves = NpcContent.available_moves(_fenn(), GameState.new(), CanonLog.new()) + assert_true("offer_quest(find_the_ledger)" in moves) + assert_true("reveal(varrell_twins)" in moves) + assert_true("reveal(fenns_debt)" in moves) + + +func test_active_quest_is_not_offered_again(): + var log = CanonLog.new() + log.add_quest("find_the_ledger", "x", "y") + var moves = NpcContent.available_moves(_fenn(), GameState.new(), log) + assert_false("offer_quest(find_the_ledger)" in moves) + + +func test_revealed_topic_drops_out(): + var gs = GameState.new() + gs.mark_revealed("varrell_twins") + var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new()) + assert_false("reveal(varrell_twins)" in moves) + assert_true("reveal(fenns_debt)" in moves) + + +func test_accept_item_offered_per_held_item(): + var gs = GameState.new() + gs.add_item("coin", 2) + var moves = NpcContent.available_moves(_fenn(), gs, CanonLog.new()) + assert_true("accept_item(coin)" in moves)