diff --git a/client/scripts/npc/move_applier.gd b/client/scripts/npc/move_applier.gd new file mode 100644 index 0000000..1aeaa0c --- /dev/null +++ b/client/scripts/npc/move_applier.gd @@ -0,0 +1,37 @@ +class_name MoveApplier +extends RefCounted +## Writes validated §6 moves to game state. Town-NPC disposition lives in +## GameState.npc_dispositions (NOT CanonLog.party — that is companions only). +## Called by the conversation caller after NpcService.speak, so state writes +## stay explicit (§2). Unknown move names are ignored (they never pass the +## validator, but this stays defensive). + +const MAX_DELTA := 15 # one line cannot swing standing wholesale (§7 spirit) +const HOSTILE_FLOOR := -100 + + +static func apply(moves: Array, game_state, canon_log, content_db, npc_id: String) -> void: + for move in moves: + var name: String = move.get("name", "") + var args: Array = move.get("args", []) + match name: + "offer_quest": + var q: String = str(args[0]) + var qd: Dictionary = content_db.quest(q) + canon_log.add_quest(q, qd.get("name", ""), qd.get("objective", "")) + "reveal": + game_state.mark_revealed(str(args[0])) + "give_item": + var i: String = str(args[0]) + game_state.add_item(i, 1) + game_state.mark_gift_given(i) + "accept_item": + game_state.remove_item(str(args[0]), 1) + "adjust_disposition": + var delta: int = clampi(int(str(args[0])), -MAX_DELTA, MAX_DELTA) + var cur: int = int(game_state.npc_dispositions.get(npc_id, 0)) + game_state.set_npc_disposition(npc_id, cur + delta) + "become_hostile": + game_state.set_npc_disposition(npc_id, HOSTILE_FLOOR) + _: + pass # refuse, end_conversation, and anything unknown: no state diff --git a/client/tests/unit/test_move_applier.gd b/client/tests/unit/test_move_applier.gd new file mode 100644 index 0000000..2a653c9 --- /dev/null +++ b/client/tests/unit/test_move_applier.gd @@ -0,0 +1,55 @@ +# client/tests/unit/test_move_applier.gd +extends "res://addons/gut/test.gd" + +const MoveApplier = preload("res://scripts/npc/move_applier.gd") +const ContentDB = preload("res://scripts/content/content_db.gd") + + +func _m(name, args := []) -> Dictionary: + return {"name": name, "args": args} + + +func _content() -> ContentDB: + var c = ContentDB.new() + c.quests = {"find_the_ledger": {"id": "find_the_ledger", + "name": "The Missing Ledger", "objective": "Find who took it"}} + return c + + +func test_offer_quest_adds_active_quest(): + var log = CanonLog.new() + MoveApplier.apply([_m("offer_quest", ["find_the_ledger"])], + GameState.new(), log, _content(), "fenn") + assert_true(log.has_quest("find_the_ledger")) + assert_eq(log.active_quests[0].name, "The Missing Ledger") + + +func test_reveal_marks_topic(): + var gs = GameState.new() + MoveApplier.apply([_m("reveal", ["varrell_twins"])], gs, CanonLog.new(), _content(), "fenn") + assert_true(gs.is_revealed("varrell_twins")) + + +func test_adjust_disposition_clamps_to_max_delta(): + var gs = GameState.new() + MoveApplier.apply([_m("adjust_disposition", ["+50"])], gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.npc_dispositions.get("fenn", 0), MoveApplier.MAX_DELTA) + + +func test_become_hostile_sets_floor(): + var gs = GameState.new() + MoveApplier.apply([_m("become_hostile")], gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.npc_dispositions.get("fenn", 0), MoveApplier.HOSTILE_FLOOR) + + +func test_accept_item_removes_from_inventory(): + var gs = GameState.new() + gs.add_item("coin", 2) + MoveApplier.apply([_m("accept_item", ["coin"])], gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.inventory.get("coin", 0), 1) + + +func test_refuse_and_end_are_noops(): + var gs = GameState.new() + MoveApplier.apply([_m("refuse"), _m("end_conversation")], gs, CanonLog.new(), _content(), "fenn") + assert_eq(gs.npc_dispositions.size(), 0)