from __future__ import annotations import json import re from .llm import call_llm from .paths import CHAR_PATH, WORLD_PATH from . import state VALIDATION_PROMPT = """You are a strict RPG game master validating whether a player's action is possible given the game state. Be thorough — check inventory, stats, location, NPCs, story context, and story logic. ## Character {character} ## World {world} ## Session Log *Written in 3rd person with explicit actor names.* {log} ## Recent Story *Written in 3rd person with explicit actor names.* {story} ## Player Action {action} ## Instructions - Is the player trying to use an item they don't have? -> invalid - Are they asserting something that contradicts the state? -> invalid - Is the action nonsensical given the situation? -> invalid - Does the action make sense given the character's abilities and resources? -> valid - Pay close attention to the Recent Story section — entities like monsters, NPCs, and hazards currently present in the scene ARE valid targets for action. - If valid, also check: if they're using a consumable item, note that it must be removed from inventory. Reply with ONLY the JSON object. Examples: ``` {{"valid": true, "reason": "ok"}} ``` or ``` {{"valid": false, "reason": "brief explanation of why the action is impossible"}} ``` """ def validate_action( player_action: str, *, story: str = "", log: str = "", on_debug: callable = None, ) -> tuple[bool, str]: """Ask the LLM whether a player action is valid given the game state. Returns (valid, reason).""" if not player_action: return True, "" char = state.read_file(CHAR_PATH) or "*No character sheet.*" world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*" recent = story.strip() or state.read_recent_book() or "*No prior story.*" log_entries = log.strip() or state.read_recent_log() or "*No recent events.*" prompt = VALIDATION_PROMPT.format(character=char, world=world, log=log_entries, story=recent, action=player_action) messages = [{"role": "user", "content": prompt}] for attempt in range(2): text = call_llm( messages, max_tokens=1024, temperature=0.2, label="Action validation", on_debug=on_debug, ) if not text: return False, "Not sure" cleaned = text.strip() m = re.search(r"```(?:json)?\s*\n?(.*?)```", cleaned, re.DOTALL) if m: cleaned = m.group(1).strip() try: data = json.loads(cleaned) valid = data.get("valid", True) reason = data.get("reason", "") if on_debug: on_debug("action_validation", {"valid": valid, "reason": reason, "action": player_action}) return valid, reason except (json.JSONDecodeError, ValueError): if on_debug: on_debug("action_validation", {"valid": True, "reason": "parse_failed", "raw": text[:200]}) if attempt == 0: messages.append({ "role": "system", "content": "Your previous response was not valid JSON. Reply with ONLY a JSON object in exactly this format, nothing else:\n\n```json\n{\"valid\": true, \"reason\": \"ok\"}\n```\nor\n```json\n{\"valid\": false, \"reason\": \"brief explanation\"}\n```" }) return False, "Unrecognized" TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a generated turn. Check: 1. **Action Sense**: Did the player's request make sense given the character, inventory, and world state? 2. **Story Coherence**: Is the story evolution coherent, non-contradictory, and within the game world's logic? 3. **State Correctness**: Do the planned state changes match the narrative? Are they valid given current state? 4. **Log Entry**: Does the log entry accurately summarise the narrative in 1-2 short, dense sentences? Should be specific, factual, and immediately readable. ## Character (before changes) {character} ## World {world} ## Recent Story {story} ## Session Log {log} ## Player Action {action} ## Generated Narrative {narrative} ## Proposed Log Entry {log_entry} ## Planned State Changes {changes} ## Instructions Check all criteria. **Completeness** is critical — scan the narrative for every event that should change state and verify it has a corresponding tool call: - **Item used** → must have `remove_from_inventory` - **Item acquired** → must have `add_to_inventory` or `replace_gear` - **HP changed** → must have `modify_vitals` - **Cash changed** → must have `modify_vitals` - **World changed** → must have `world_update` - **NPC/location/thread changes** → must have `world_update` or `add_note` Missing tool calls = regenerate. Also check that: - Items removed were actually in inventory - Items added are reasonable and don't duplicate existing items - HP/cash changes follow logically from the narrative - No impossible modifications For log entry: must be a tight summary of the narrative's key events — specific entities, actions, outcomes. Vague, rambling, or mismatched log entries should be flagged for regenerate. Reply with ONLY a JSON object using one of these formats: Valid: ```json {{"valid": true, "reason": "ok", "action": "ok"}} ``` Reject (player action itself was impossible or nonsensical): ```json {{"valid": false, "reason": "explain why the action is impossible", "action": "reject"}} ``` Regenerate (turn had fixable issues like wrong state changes or minor inconsistencies): ```json {{"valid": false, "reason": "describe what the LLM should fix", "action": "regenerate"}} ``` """ def _format_changes(changes: list[dict]) -> str: """Format tool calls into a readable change list for the validation prompt.""" if not changes: return "*No state changes planned.*" lines = [] for tc in changes: tool = tc.get("tool", "?") args = {k: v for k, v in tc.get("args", {}).items() if v is not None} parts = ", ".join(f"{k}={v}" for k, v in args.items()) lines.append(f"- {tool}: {parts}" if parts else f"- {tool}") return "\n".join(lines) def validate_turn( player_action: str, *, narrative: str = "", log_entry: str = "", changes: list[dict] | None = None, story: str = "", log: str = "", on_debug: callable = None, ) -> tuple[bool, str, str]: """Validate a complete generated turn. Returns (valid, reason, action) where action is "ok", "reject", or "regenerate". """ if not player_action and not narrative: return True, "", "ok" char = state.read_file(CHAR_PATH) or "*No character sheet.*" world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*" recent = story.strip() or state.read_recent_book() or "*No prior story.*" log_entries = log.strip() or state.read_recent_log() or "*No recent events.*" change_summary = _format_changes(changes or []) prompt = TURN_VALIDATION_PROMPT.format( character=char, world=world, story=recent, log=log_entries, action=player_action, narrative=narrative, log_entry=log_entry or "*No log entry provided.*", changes=change_summary, ) messages = [{"role": "user", "content": prompt}] for attempt in range(2): text = call_llm( messages, max_tokens=1024, temperature=0.2, label="Turn validation", on_debug=on_debug, ) if not text: return False, "Not sure", "reject" cleaned = text.strip() m = re.search(r"```(?:json)?\s*\n?(.*?)```", cleaned, re.DOTALL) if m: cleaned = m.group(1).strip() try: data = json.loads(cleaned) valid = data.get("valid", True) reason = data.get("reason", "") action = data.get("action", "ok") if action not in ("ok", "reject", "regenerate"): action = "ok" if valid else "reject" if on_debug: on_debug("turn_validation", {"valid": valid, "reason": reason, "action": action}) return valid, reason, action except (json.JSONDecodeError, ValueError): if on_debug: on_debug("turn_validation", {"valid": True, "reason": "parse_failed", "raw": text[:200]}) if attempt == 0: messages.append({ "role": "system", "content": "Your previous response was not valid JSON. Reply with ONLY a JSON object:\n\n```json\n{\"valid\": true, \"reason\": \"ok\", \"action\": \"ok\"}\n```\nor\n```json\n{\"valid\": false, \"reason\": \"...\", \"action\": \"reject\"}\n```\nor\n```json\n{\"valid\": false, \"reason\": \"...\", \"action\": \"regenerate\"}\n```" }) return False, "Unrecognized", "reject"