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: # ONE RULE: a move applies AT MOST ONCE PER REPLY. The tag extractor returns # every occurrence and the validator is a membership test, so without this a # model repeating a tag N times picks an amount in unary — the AI choosing a # number, which is the §2 breach the bounded vocabulary exists to prevent. # It is worth the same for real items: three give_item(amulet) tags are one # amulet, not three. var applied := {} # "name(id)" -> true var dispo_total := 0 # the reply's NET disposition swing, capped at MAX_DELTA for move in moves: var name: String = move.get("name", "") var args: Array = move.get("args", []) # MoveValidator.ID_MOVES is the one list of moves that name a thing — those # are the ones a (name, id) key can dedupe. Reused, not restated. if name in MoveValidator.ID_MOVES and not args.is_empty(): var key := "%s(%s)" % [name, str(args[0])] if applied.has(key): continue applied[key] = true 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.grant(i, 1) if not Currency.is_currency(i): game_state.mark_gift_given(i) # the one-shot gate is for real items only "accept_item": game_state.take(str(args[0]), 1) "adjust_disposition": # MAX_DELTA caps the reply's NET swing, not each tag — clamping per # tag let three +15s move standing by 45, which is exactly the # "wholesale swing" the constant is here to forbid. Applied inside # the loop (not batched at the end) so emission order still holds # and a later become_hostile floors what came before it. var delta: int = clampi(int(str(args[0])), -MAX_DELTA, MAX_DELTA) var capped: int = clampi(dispo_total + delta, -MAX_DELTA, MAX_DELTA) var allowed: int = capped - dispo_total dispo_total = capped var cur: int = int(game_state.npc_dispositions.get(npc_id, 0)) game_state.set_npc_disposition(npc_id, cur + allowed) "become_hostile": game_state.set_npc_disposition(npc_id, HOSTILE_FLOOR) _: pass # refuse, end_conversation, and anything unknown: no state