Add meta log
This commit is contained in:
@@ -18,3 +18,4 @@ class TurnResult:
|
||||
changes: list[str] = field(default_factory=list)
|
||||
is_meta: bool = False
|
||||
game_over: bool = False
|
||||
meta_log: str = ""
|
||||
|
||||
@@ -18,6 +18,7 @@ def log_turn_details(
|
||||
log_entry: str,
|
||||
ambience: Optional[str],
|
||||
tool_calls: list,
|
||||
meta_log: str = "",
|
||||
) -> None:
|
||||
"""Write structured turn summary to llm.log and fire TUI debug event."""
|
||||
ts = datetime.now().isoformat()
|
||||
@@ -34,6 +35,7 @@ def log_turn_details(
|
||||
state.append_llm_log(f"├─ Output: {output_chars} chars ({output_words} words)")
|
||||
state.append_llm_log(f"├─ Log Entry: {log_entry}")
|
||||
state.append_llm_log(f"├─ Ambience: {ambience or 'None'}")
|
||||
state.append_llm_log(f"├─ Meta Log: {(meta_log or '')[:80]}")
|
||||
tools_preview = ", ".join(tc.get("tool", "?") for tc in tool_calls)
|
||||
state.append_llm_log(f"├─ Tool Calls: {len(tool_calls)} ({tools_preview})")
|
||||
state.append_llm_log(
|
||||
|
||||
@@ -25,6 +25,7 @@ LLM_LOG_PATH = SESSION_DIR / 'llm.log'
|
||||
AMBIENCE_OPTIONS_PATH = SESSION_DIR / "ambience_options.md"
|
||||
CHANGES_PATH = SESSION_DIR / "changes.md"
|
||||
RULES_INJECTION_PATH = SESSION_DIR / "rules_injection.md"
|
||||
META_LOG_PATH = SESSION_DIR / "meta_log.md"
|
||||
AUDIO_DIR = SESSION_DIR / "audio"
|
||||
|
||||
END_GAME_PATH = RULES_DIR / 'end_game.md'
|
||||
|
||||
@@ -45,7 +45,7 @@ Wrap each action in its own ```tool block:
|
||||
{"tool": "journal_update", "args": {"add": ["Investigate the mine"], "done": ["Defeat the demon"]}}
|
||||
```
|
||||
```tool
|
||||
{"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Kael explored the dungeon, found a hidden passage, and was ambushed by goblins."}}
|
||||
{"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Kael explored the dungeon, found a hidden passage, and was ambushed by goblins.", "meta_log": "Kael rolled a 4 (1d6) for perception — success, spotted the hidden door. HP lowered to 5 after goblin ambush."}}
|
||||
```
|
||||
|
||||
```tool
|
||||
@@ -59,6 +59,8 @@ or with a category:
|
||||
|
||||
**log_entry**: Provide a short, dense summary (1-2 sentences) of the turn's main events. This becomes the session log — be specific, factual, and concise.
|
||||
|
||||
**meta_log**: (Optional) Provide a behind-the-scenes explanation of the mechanics — what dice were rolled, what rules triggered, what changed and why. This is shown to the player at the bottom of the screen for insight into the game mechanics. Be specific: "rolled X (1d6) for Y — result: Z".
|
||||
|
||||
You are the sole authority over the game state. The player's action is a **proposal**, not a fact. If their action contradicts the character sheet (e.g. using an item they don't have, spending cash they don't have, claiming stats they don't have), narrate the failure and do NOT call any state-changing tools.
|
||||
|
||||
**Inventory rule**: If the player wants to use an item, you must first verify it's on their character sheet. If it is, you MUST call `remove_from_inventory` for that item AND apply the effects (e.g. `modify_vitals` for HP potions). If it's not on the sheet, reject the action — do not let them use items they don't have.
|
||||
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
from .paths import (
|
||||
CHAR_PATH, WORLD_PATH, BOOK_PATH, JOURNAL_PATH, AMBIENCE_PATH,
|
||||
LOG_PATH, LLM_LOG_PATH, AMBIENCE_OPTIONS_PATH, CHANGES_PATH,
|
||||
AUDIO_DIR, SESSION_DIR, ARCHIVE_DIR,
|
||||
META_LOG_PATH, AUDIO_DIR, SESSION_DIR, ARCHIVE_DIR,
|
||||
)
|
||||
from .models import TurnResult
|
||||
|
||||
@@ -168,6 +168,21 @@ def append_llm_log(text: str) -> None:
|
||||
f.write(text + "\n")
|
||||
|
||||
|
||||
def append_meta_log(turn_num: int, entry: str) -> None:
|
||||
"""Append a meta_log entry to meta_log.md with turn number."""
|
||||
META_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(META_LOG_PATH, "a") as f:
|
||||
f.write(f"- **Turn {turn_num}** — {entry.strip()}\n")
|
||||
|
||||
|
||||
def read_last_meta_log() -> str:
|
||||
"""Return the last meta_log entry, or empty string if none."""
|
||||
if not META_LOG_PATH.exists():
|
||||
return ""
|
||||
lines = [l.strip() for l in META_LOG_PATH.read_text().splitlines() if l.strip()]
|
||||
return lines[-1] if lines else ""
|
||||
|
||||
|
||||
def update_journal(add: list[str] | None = None, done: list[str] | None = None) -> None:
|
||||
"""Add or complete TODO items in journal.md."""
|
||||
if not JOURNAL_PATH.exists():
|
||||
|
||||
@@ -20,7 +20,7 @@ TOOL_REGISTRY: dict[str, dict] = {
|
||||
"replace_note": {"description": "Replace note by exact match.", "args": {"before": "exact text", "after": "new text"}},
|
||||
"world_update": {"description": "Replace world state.", "args": {"content": "full world markdown"}},
|
||||
"journal_update": {"description": "Update TODO/DONE.", "args": {"add": "[...]", "done": "[...]"}},
|
||||
"finalize_turn": {"description": "End turn.", "args": {"ambience": "soundscape name", "log_entry": "one-line summary of what happened"}},
|
||||
"finalize_turn": {"description": "End turn.", "args": {"ambience": "soundscape name", "log_entry": "one-line summary of what happened", "meta_log": "optional behind-the-scenes mechanics explanation"}},
|
||||
"read_rules": {"description": "Read a rules file by category. Categories: mechanics (full mechanics reference), core (core mechanics), character_creation, end_game (end-game closure rules). Call when you need details beyond the Core Rules in the prompt.", "args": {"category": "optional — one of: mechanics, core, character_creation, end_game (default: mechanics)"}},
|
||||
}
|
||||
|
||||
|
||||
@@ -8,95 +8,6 @@ from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_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
|
||||
- Is the player's action or intention unclear or ambiguous? -> invalid (explain what is unclear and why)
|
||||
- If you are uncertain whether the action is valid, reject it and describe exactly why you are unsure.
|
||||
- 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 = "",
|
||||
) -> 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",
|
||||
)
|
||||
|
||||
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", "")
|
||||
return valid, reason
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
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?
|
||||
@@ -251,7 +162,7 @@ def validate_turn(
|
||||
changes=change_summary,
|
||||
)
|
||||
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
messages = [{"role": "system", "content": prompt}]
|
||||
|
||||
for attempt in range(2):
|
||||
text = call_llm(
|
||||
@@ -284,7 +195,7 @@ def validate_turn(
|
||||
if attempt == 0:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": "Your previous response was not valid. Reply with ONLY a ```tool block:\n\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": true, \"reason\": \"ok\", \"action\": \"ok\"}}\n```\nor\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": false, \"reason\": \"...\", \"action\": \"reject\"}}\n```\nor\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": false, \"reason\": \"...\", \"action\": \"regenerate\"}}\n```"
|
||||
"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"
|
||||
|
||||
Reference in New Issue
Block a user