203 lines
8.2 KiB
Python
203 lines
8.2 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
|
|
from .llm import call_llm
|
|
from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH
|
|
from . import state
|
|
|
|
|
|
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.
|
|
6. **Journal Progress**: Are TODO items being addressed? If the narrative resolves an open TODO, the turn must call `modify_journal` with operation `done` to mark it done. Unchecked items left stale too long may need prompting.
|
|
7. **Player Speech**: If the player action contains direct speech (quoted text like `"Hello"` or `'Hello'`), the narrative MUST include the player character speaking those words or equivalent dialogue. If the player's speech can be incorporated given the context, the turn should reflect it. Only skip if the speech is completely impossible given the situation.
|
|
|
|
## Character (before changes)
|
|
{character}
|
|
|
|
## World
|
|
{world}
|
|
|
|
## Recent Story
|
|
{story}
|
|
|
|
## Session Log
|
|
{log}
|
|
|
|
## Journal
|
|
{journal}
|
|
|
|
## 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 `modify_inventory` with operation `remove`
|
|
- **Item acquired** → must have `modify_inventory` with operation `add` or `replace`
|
|
- **HP changed** → must have `modify_vitals`
|
|
- **Cash changed** → must have `modify_cash`
|
|
- **World changed** → must have `modify_world`
|
|
- **NPC/location/thread changes** → must have `modify_world` or `modify_note`
|
|
- **TODO resolved** → must have `modify_journal` with operation `done`
|
|
|
|
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 changes follow logically from the narrative
|
|
- Cash changes follow logically from the narrative (spending → deduct, earning → add)
|
|
- 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 ```tool block. Examples:
|
|
|
|
Valid:
|
|
```tool
|
|
{{"tool": "validate", "args": {{"valid": true, "reason": "ok", "action": "ok"}}}}
|
|
```
|
|
|
|
Reject (player action itself was impossible or nonsensical):
|
|
```tool
|
|
{{"tool": "validate", "args": {{"valid": false, "reason": "explain why the action is impossible", "action": "reject"}}}}
|
|
```
|
|
|
|
Regenerate (turn had fixable issues like wrong state changes or minor inconsistencies):
|
|
```tool
|
|
{{"tool": "validate", "args": {{"valid": false, "reason": "describe what the LLM should fix", "action": "regenerate"}}}}
|
|
```
|
|
"""
|
|
|
|
META_VALIDATION_PROMPT = """You are validating a meta (out-of-character) DM response. The player's action starts with `>` — they are talking to the DM, not to a character.
|
|
|
|
## Player Action (Meta)
|
|
{action}
|
|
|
|
## Generated Meta Response
|
|
{narrative}
|
|
|
|
## Instructions
|
|
1. **Meta Format**: The entire response must start with `>` and use meta language (DM addressing the player directly).
|
|
2. **State Changes**: There MUST be no state changes. This is a meta conversation, not story progression.
|
|
3. **Answer Quality**: The response should address the player's meta question and be helpful.
|
|
4. **No Story Advancement**: The response must not advance the game narrative.
|
|
|
|
Reply with ONLY a ```tool block. Examples:
|
|
|
|
Valid:
|
|
```tool
|
|
{{"tool": "validate", "args": {{"valid": true, "reason": "ok", "action": "ok"}}}}
|
|
```
|
|
|
|
Regenerate (response didn't start with `>` or tried to change state):
|
|
```tool
|
|
{{"tool": "validate", "args": {{"valid": false, "reason": "describe the issue", "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 = "",
|
|
meta: bool = False,
|
|
) -> 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.*"
|
|
journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*"
|
|
change_summary = _format_changes(changes or [])
|
|
|
|
if meta:
|
|
prompt = META_VALIDATION_PROMPT.format(
|
|
action=player_action,
|
|
narrative=narrative,
|
|
)
|
|
else:
|
|
prompt = TURN_VALIDATION_PROMPT.format(
|
|
character=char, world=world, story=recent,
|
|
log=log_entries, journal=journal, action=player_action,
|
|
narrative=narrative, log_entry=log_entry or "*No log entry provided.*",
|
|
changes=change_summary,
|
|
)
|
|
|
|
messages = [{"role": "system", "content": prompt}]
|
|
|
|
for attempt in range(2):
|
|
text = call_llm(
|
|
messages,
|
|
max_tokens=1024,
|
|
temperature=0.2,
|
|
label="Turn validation",
|
|
)
|
|
|
|
if not text:
|
|
return False, "Not sure", "reject"
|
|
|
|
m = re.search(r"```tool\s*\n?(.*?)```", text, re.DOTALL)
|
|
if m:
|
|
cleaned = m.group(1).strip()
|
|
else:
|
|
cleaned = text.strip()
|
|
try:
|
|
data = json.loads(cleaned)
|
|
if data.get("tool") != "validate":
|
|
return False, "Unrecognized", "reject"
|
|
args = data.get("args", {})
|
|
valid = args.get("valid", True)
|
|
reason = args.get("reason", "")
|
|
action = args.get("action", "ok")
|
|
if action not in ("ok", "reject", "regenerate"):
|
|
action = "ok" if valid else "reject"
|
|
return valid, reason, action
|
|
except (json.JSONDecodeError, ValueError):
|
|
if attempt == 0:
|
|
messages.append({
|
|
"role": "system",
|
|
"content": f"Your previous response was NOT valid. Do NOT include any reasoning or explanation. Reply with EXACTLY ONE of these three ```tool blocks and nothing else:\n\n```tool\n{{\"tool\": \"validate\", \"args\": {{\"valid\": true, \"reason\": \"ok\", \"action\": \"ok\"}}}}\n```\n```tool\n{{\"tool\": \"validate\", \"args\": {{\"valid\": false, \"reason\": \"explain why the action is impossible\", \"action\": \"reject\"}}}}\n```\n```tool\n{{\"tool\": \"validate\", \"args\": {{\"valid\": false, \"reason\": \"describe what the LLM should fix\", \"action\": \"regenerate\"}}}}\n```"
|
|
})
|
|
|
|
return False, "Unrecognized", "reject"
|