Update engine.py with AMBIENCE_OPTIONS_PATH and AUDIO_DIR, and adjust related functions
This commit is contained in:
parent
d78aad6ce4
commit
52f590d432
@ -30,6 +30,8 @@ JOURNAL_PATH = SESSION_DIR / 'journal.md'
|
|||||||
AMBIENCE_PATH = SESSION_DIR / 'ambience.md'
|
AMBIENCE_PATH = SESSION_DIR / 'ambience.md'
|
||||||
LOG_DIR = SESSION_DIR / 'log'
|
LOG_DIR = SESSION_DIR / 'log'
|
||||||
LLM_LOG_PATH = SESSION_DIR / 'llm.log'
|
LLM_LOG_PATH = SESSION_DIR / 'llm.log'
|
||||||
|
AMBIENCE_OPTIONS_PATH = SESSION_DIR / "ambience_options.md"
|
||||||
|
AUDIO_DIR = SESSION_DIR / "audio"
|
||||||
TODAY = date.today().isoformat()
|
TODAY = date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
@ -105,8 +107,8 @@ Each turn follows this sequence:
|
|||||||
3. **You MUST call `finalize_turn` to end the turn.** There is no other way to complete a turn. The loop will keep calling you until you do.
|
3. **You MUST call `finalize_turn` to end the turn.** There is no other way to complete a turn. The loop will keep calling you until you do.
|
||||||
|
|
||||||
The **finalize_turn** tool produces all data for this turn:
|
The **finalize_turn** tool produces all data for this turn:
|
||||||
- **book_log** `[Required]` — **Everything that happens this turn, narrated in full.** This is appended to the story book and forms the permanent record of the adventure. Include sensory details, dialogue, outcomes — the whole scene.
|
- **book_log** `[Required]` — **The complete self-contained narrative of this turn.** Describe what the player did (based on their action input) and what happened as a result, with all sensory/dialogue/mechanical details. This is the permanent story record — it must stand alone without the player's input text. The player's action is implicit in the narrative, not quoted.
|
||||||
- **user_prompt** `[Required]` — **Short prompt for the player only, NOT recorded in the book.** Ask what they do next. 1-3 sentences. Don't put important narrative details here — they belong in `book_log`.
|
- **user_prompt** `[Required]` — **Short prompt for the player only, NOT recorded in the book.** Ask what they do next. 1-3 sentences. Do NOT recap the action — that belongs in `book_log`.
|
||||||
- **log_entry** `[Optional]` — One-sentence summary of what happened (action + outcome). Keep it tight.
|
- **log_entry** `[Optional]` — One-sentence summary of what happened (action + outcome). Keep it tight.
|
||||||
- **ambience** `[Optional]` — One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds.
|
- **ambience** `[Optional]` — One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds.
|
||||||
|
|
||||||
@ -188,7 +190,7 @@ Tool reference (`[R]` = required, `[O]` = optional):
|
|||||||
`[O] done`: ["completed item", ...]
|
`[O] done`: ["completed item", ...]
|
||||||
`[R] dm_status`: "..."
|
`[R] dm_status`: "..."
|
||||||
- **finalize_turn** — **REQUIRED to end the turn.** The loop will NOT stop without it. Call this ALONE — do not mix with get tools.
|
- **finalize_turn** — **REQUIRED to end the turn.** The loop will NOT stop without it. Call this ALONE — do not mix with get tools.
|
||||||
`[R] book_log`: "full narrative of what happened this turn — appended to story book (permanent record)"
|
`[R] book_log`: "self-contained narrative of what the player did this turn — permanent story record, must stand alone"
|
||||||
`[R] user_prompt`: "short prompt for the player — NOT recorded, 1-3 sentences"
|
`[R] user_prompt`: "short prompt for the player — NOT recorded, 1-3 sentences"
|
||||||
`[O] log_entry`: "one-sentence summary (action + outcome)"
|
`[O] log_entry`: "one-sentence summary (action + outcome)"
|
||||||
`[O] ambience`: "soundscape name: silence|calm|combat|dungeon|forest|tavern|tension|town|wilds"
|
`[O] ambience`: "soundscape name: silence|calm|combat|dungeon|forest|tavern|tension|town|wilds"
|
||||||
@ -281,7 +283,7 @@ class GameEngine:
|
|||||||
def _read_file(self, path: Path) -> str:
|
def _read_file(self, path: Path) -> str:
|
||||||
return path.read_text().strip() if path.exists() else ""
|
return path.read_text().strip() if path.exists() else ""
|
||||||
|
|
||||||
def _read_recent_log(self, max_entries: int = 15) -> str:
|
def _read_recent_log(self, max_entries: int = 5) -> str:
|
||||||
"""Read the latest log file and return the last N entries."""
|
"""Read the latest log file and return the last N entries."""
|
||||||
log_path = LOG_DIR / f"{TODAY}.md"
|
log_path = LOG_DIR / f"{TODAY}.md"
|
||||||
if not log_path.exists():
|
if not log_path.exists():
|
||||||
@ -295,7 +297,7 @@ class GameEngine:
|
|||||||
entries = [l for l in lines if l.strip().startswith("- ")]
|
entries = [l for l in lines if l.strip().startswith("- ")]
|
||||||
return "\n".join(entries[-max_entries:]) or "*No recent events.*"
|
return "\n".join(entries[-max_entries:]) or "*No recent events.*"
|
||||||
|
|
||||||
def _read_recent_book(self, max_turns: int = 3) -> str:
|
def _read_recent_book(self, max_turns: int = 1) -> str:
|
||||||
"""Return the last N turns from the book as context."""
|
"""Return the last N turns from the book as context."""
|
||||||
text = self._read_file(BOOK_PATH)
|
text = self._read_file(BOOK_PATH)
|
||||||
if not text:
|
if not text:
|
||||||
@ -304,6 +306,34 @@ class GameEngine:
|
|||||||
recent = turns[-max_turns:]
|
recent = turns[-max_turns:]
|
||||||
return "\n## ".join(recent) if len(turns) > 1 else recent[0]
|
return "\n## ".join(recent) if len(turns) > 1 else recent[0]
|
||||||
|
|
||||||
|
def _get_valid_ambiences(self) -> set[str]:
|
||||||
|
"""Parse ambience_options.md and return set of valid ambience names with associated audio files."""
|
||||||
|
valid = {"silence"} # silence always valid (stops music)
|
||||||
|
if not AMBIENCE_OPTIONS_PATH.exists():
|
||||||
|
return valid
|
||||||
|
in_table = False
|
||||||
|
for line in AMBIENCE_OPTIONS_PATH.read_text().splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if not s.startswith("|") or not s.endswith("|"):
|
||||||
|
in_table = False
|
||||||
|
continue
|
||||||
|
if in_table and all(c in "-:| " for c in s):
|
||||||
|
continue
|
||||||
|
parts = [p.strip() for p in s.split("|") if p.strip()]
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
if not in_table:
|
||||||
|
in_table = True
|
||||||
|
continue
|
||||||
|
name = parts[0].lower()
|
||||||
|
files_str = parts[1] if len(parts) > 1 else ""
|
||||||
|
files = [f.strip() for f in files_str.split(",")]
|
||||||
|
# Only add if at least one file exists (or is listed)
|
||||||
|
has_files = any((AUDIO_DIR / f).exists() or f for f in files)
|
||||||
|
if has_files:
|
||||||
|
valid.add(name)
|
||||||
|
return valid
|
||||||
|
|
||||||
def build_system_prompt(self) -> str:
|
def build_system_prompt(self) -> str:
|
||||||
"""Assemble the system prompt with current game state."""
|
"""Assemble the system prompt with current game state."""
|
||||||
char = self._read_file(CHAR_PATH) or "*No character sheet.*"
|
char = self._read_file(CHAR_PATH) or "*No character sheet.*"
|
||||||
@ -405,7 +435,7 @@ class GameEngine:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=self.temperature,
|
temperature=self.temperature,
|
||||||
stream=False,
|
stream=False,
|
||||||
timeout=30,
|
timeout=60,
|
||||||
)
|
)
|
||||||
text = response.choices[0].message.content or ""
|
text = response.choices[0].message.content or ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -451,7 +481,7 @@ class GameEngine:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=self.temperature,
|
temperature=self.temperature,
|
||||||
stream=True,
|
stream=True,
|
||||||
timeout=30,
|
timeout=60,
|
||||||
)
|
)
|
||||||
full_text = ""
|
full_text = ""
|
||||||
for chunk in response:
|
for chunk in response:
|
||||||
@ -765,7 +795,7 @@ class GameEngine:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
return TurnResult(error="litellm not installed")
|
return TurnResult(error="litellm not installed")
|
||||||
|
|
||||||
max_rounds = 10
|
max_rounds = 30
|
||||||
debug_entries: list[str] = []
|
debug_entries: list[str] = []
|
||||||
attempt = 0
|
attempt = 0
|
||||||
round_used = 0
|
round_used = 0
|
||||||
@ -790,7 +820,8 @@ class GameEngine:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=self.temperature,
|
temperature=self.temperature,
|
||||||
stream=False,
|
stream=False,
|
||||||
timeout=30,
|
timeout=60,
|
||||||
|
max_tokens=512,
|
||||||
)
|
)
|
||||||
text = response.choices[0].message.content or ""
|
text = response.choices[0].message.content or ""
|
||||||
self._append_llm_log(
|
self._append_llm_log(
|
||||||
@ -835,16 +866,42 @@ class GameEngine:
|
|||||||
names = [tc.get("tool", "?") for tc in tool_calls]
|
names = [tc.get("tool", "?") for tc in tool_calls]
|
||||||
round_log.append(f" tools: {', '.join(names)}")
|
round_log.append(f" tools: {', '.join(names)}")
|
||||||
|
|
||||||
# Guard: no get tools alongside finalize_turn
|
# Guard: mixed get tools + finalize_turn → execute get tools, reject finalize
|
||||||
get_tools = {"read_file", "character_get", "world_get", "journal_get"}
|
get_tools = {"read_file", "character_get", "world_get", "journal_get"}
|
||||||
if finalize_call and any(tc.get("tool") in get_tools for tc in other_calls):
|
if finalize_call and any(tc.get("tool") in get_tools for tc in other_calls):
|
||||||
round_log.append(" mixed get + finalize — rejected")
|
# Execute only the get tools, drop finalize_turn
|
||||||
|
results = []
|
||||||
|
for tc in other_calls:
|
||||||
|
if tc.get("tool") not in get_tools:
|
||||||
|
continue
|
||||||
|
name = tc.get("tool", "?")
|
||||||
|
args = tc.get("args", {})
|
||||||
|
if not args.get("dm_status"):
|
||||||
|
err_msg = (
|
||||||
|
f"**Validation Error:** Tool `{name}` missing required `dm_status`. "
|
||||||
|
f"Add `\"dm_status\": \"what the DM is doing\"` to the args."
|
||||||
|
)
|
||||||
|
results.append(err_msg)
|
||||||
|
round_log.append(f" {name}: MISSING dm_status")
|
||||||
|
if on_debug:
|
||||||
|
on_debug("validation_error", {"round": attempt, "type": "tool", "tool": name, "error": "missing dm_status"})
|
||||||
|
continue
|
||||||
|
if on_action:
|
||||||
|
on_action(self._describe_tool_action(name, args))
|
||||||
|
if on_debug:
|
||||||
|
on_debug("tool_call", {"round": attempt, "tool": name, "args": args})
|
||||||
|
result = self._execute_tool(name, args)
|
||||||
|
results.append(f"**Tool:** {name}\n**Args:** {json.dumps(args)}\n**Result:** {result}")
|
||||||
|
round_log.append(f" {name}: OK")
|
||||||
|
if on_debug:
|
||||||
|
on_debug("tool_result", {"round": attempt, "tool": name, "result": result})
|
||||||
|
round_log.append(" finalize_turn ignored (mixed with get tools)")
|
||||||
debug_entries.append("\n".join(round_log))
|
debug_entries.append("\n".join(round_log))
|
||||||
messages = messages[:2]
|
messages = messages[:2]
|
||||||
messages.append({"role": "assistant", "content": text})
|
messages.append({"role": "assistant", "content": text})
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": "## Validation Error\nYou used a get tool (`read_file`, `character_get`, `world_get`, `journal_get`) and `finalize_turn` in the same round. Decide: either gather information (use get tools, then stop), or finalize the turn (call `finalize_turn` alone with all data). Do not mix them."
|
"content": "## Tool Results\n\n" + "\n\n".join(results) + "\n\n**Note:** `finalize_turn` was ignored because you called get tools in the same round. Call `finalize_turn` alone in the next round to complete the turn."
|
||||||
})
|
})
|
||||||
if on_debug:
|
if on_debug:
|
||||||
on_debug("validation_error", {"round": attempt, "type": "mixed_get_finalize", "tools": [tc.get("tool") for tc in other_calls]})
|
on_debug("validation_error", {"round": attempt, "type": "mixed_get_finalize", "tools": [tc.get("tool") for tc in other_calls]})
|
||||||
@ -859,6 +916,14 @@ class GameEngine:
|
|||||||
errs.append("book_log [Required]")
|
errs.append("book_log [Required]")
|
||||||
if not args.get("user_prompt"):
|
if not args.get("user_prompt"):
|
||||||
errs.append("user_prompt [Required]")
|
errs.append("user_prompt [Required]")
|
||||||
|
|
||||||
|
# Validate ambience
|
||||||
|
ambience_name = args.get("ambience")
|
||||||
|
if ambience_name and ambience_name != "silence":
|
||||||
|
valid_ambiences = self._get_valid_ambiences()
|
||||||
|
if not valid_ambiences or ambience_name not in valid_ambiences:
|
||||||
|
errs.append(f"ambience '{ambience_name}' is invalid or has no associated audio files.")
|
||||||
|
|
||||||
if errs:
|
if errs:
|
||||||
hint = (
|
hint = (
|
||||||
f"Expected:\n"
|
f"Expected:\n"
|
||||||
@ -868,6 +933,7 @@ class GameEngine:
|
|||||||
f'"log_entry": "...", '
|
f'"log_entry": "...", '
|
||||||
f'"ambience": "..."'
|
f'"ambience": "..."'
|
||||||
f"}}}}\n"
|
f"}}}}\n"
|
||||||
|
f"Valid ambiences: {', '.join(valid_ambiences)}"
|
||||||
)
|
)
|
||||||
round_log.append(f" finalize_turn validation errors: {', '.join(errs)}")
|
round_log.append(f" finalize_turn validation errors: {', '.join(errs)}")
|
||||||
debug_entries.append("\n".join(round_log))
|
debug_entries.append("\n".join(round_log))
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user