253 lines
8.8 KiB
Python
253 lines
8.8 KiB
Python
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"
|