From 4c0b4e41c3e0db79c0b1dbd842d925b8aa580320 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 12:41:45 -0500 Subject: [PATCH] =?UTF-8?q?feat(client):=20pure=20MoveValidator=20?= =?UTF-8?q?=E2=80=94=20membership=20is=20the=20whole=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/scripts/npc/move_validator.gd | 29 ++++++++++++++++++ client/tests/unit/test_move_validator.gd | 38 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 client/scripts/npc/move_validator.gd create mode 100644 client/tests/unit/test_move_validator.gd diff --git a/client/scripts/npc/move_validator.gd b/client/scripts/npc/move_validator.gd new file mode 100644 index 0000000..45afc92 --- /dev/null +++ b/client/scripts/npc/move_validator.gd @@ -0,0 +1,29 @@ +class_name MoveValidator +extends RefCounted +## Pure §6 validation: an emitted move is legal iff its canonical key is in the +## turn's available_moves (which NpcContent already computed from live state). +## No state reads here — membership is the whole test. Invalid moves are not an +## error; they are dropped and returned separately so the caller can log them. + +const ID_MOVES := ["reveal", "offer_quest", "give_item", "accept_item"] +const BARE_MOVES := ["adjust_disposition", "refuse", "end_conversation", "become_hostile"] + + +static func _key(move: Dictionary) -> String: + var name: String = move.get("name", "") + var args: Array = move.get("args", []) + if name in ID_MOVES: + return "%s(%s)" % [name, str(args[0])] if not args.is_empty() else name + return name # bare/value moves key on the name alone + + +static func validate(moves: Array, available_moves: Array) -> Dictionary: + var valid: Array = [] + var dropped: Array = [] + for move in moves: + var name: String = move.get("name", "") + if (name in ID_MOVES or name in BARE_MOVES) and _key(move) in available_moves: + valid.append(move) + else: + dropped.append(move) + return {"valid": valid, "dropped": dropped} diff --git a/client/tests/unit/test_move_validator.gd b/client/tests/unit/test_move_validator.gd new file mode 100644 index 0000000..869eff5 --- /dev/null +++ b/client/tests/unit/test_move_validator.gd @@ -0,0 +1,38 @@ +extends "res://addons/gut/test.gd" + +const MoveValidator = preload("res://scripts/npc/move_validator.gd") + + +func _m(name, args := []) -> Dictionary: + return {"name": name, "args": args} + + +func test_id_move_in_available_is_valid(): + var res = MoveValidator.validate( + [_m("reveal", ["varrell_twins"])], ["reveal(varrell_twins)", "refuse"]) + assert_eq(res["valid"].size(), 1) + assert_eq(res["dropped"].size(), 0) + + +func test_id_move_not_in_available_is_dropped(): + var res = MoveValidator.validate( + [_m("offer_quest", ["some_other_quest"])], ["offer_quest(find_the_ledger)"]) + assert_eq(res["valid"].size(), 0) + assert_eq(res["dropped"].size(), 1) + + +func test_bare_move_keys_on_name(): + var res = MoveValidator.validate([_m("refuse")], ["refuse"]) + assert_eq(res["valid"].size(), 1) + + +func test_adjust_disposition_keys_on_name_keeps_delta(): + var res = MoveValidator.validate( + [_m("adjust_disposition", ["+5"])], ["adjust_disposition"]) + assert_eq(res["valid"].size(), 1) + assert_eq(res["valid"][0]["args"], ["+5"]) + + +func test_unknown_move_name_is_dropped(): + var res = MoveValidator.validate([_m("cast_fireball", ["9"])], ["refuse"]) + assert_eq(res["dropped"].size(), 1)