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. **Self-Contained Narrative**: The narrative must read clearly on its own — explicitly describe what the character did in response to the action. Do not skip the character's action and jump straight to consequences. Each turn must make sense without referencing the player action line. 5. **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: - The narrative explicitly describes the character acting — not just the world reacting - A reader should understand what happened without seeing the "Player Action" line above - 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"