Game rules injected along with tool

This commit is contained in:
Dejvino 2026-07-04 21:15:12 +02:00
parent e002bafbc8
commit 49df5a67e8
5 changed files with 56 additions and 13 deletions

View File

@ -16,6 +16,7 @@ from engine_lib.tools_handler import execute_tool, describe_change, extract_tool
from engine_lib.parsing import log_turn_details from engine_lib.parsing import log_turn_details
from engine_lib import state from engine_lib import state
from engine_lib.llm import call_llm from engine_lib.llm import call_llm
from engine_lib.paths import CHARACTER_CREATION_PATH, RULES_INJECTION_PATH
class GameEngine: class GameEngine:
@ -48,7 +49,13 @@ class GameEngine:
if on_action: if on_action:
on_action("DM is preparing a response") on_action("DM is preparing a response")
is_new_game = not player_action and not recent_narrative
system = build_system_prompt(recent_narrative=recent_narrative, recent_log=session_log) system = build_system_prompt(recent_narrative=recent_narrative, recent_log=session_log)
if is_new_game:
cc = state.read_file(CHARACTER_CREATION_PATH)
if cc:
system += f"\n\n## Character Creation Reference\n{cc}"
state.append_llm_log(f"\n[NEW GAME] injected character_creation.md ({len(cc)} chars)")
is_meta = bool(player_action and player_action.strip().startswith(">")) is_meta = bool(player_action and player_action.strip().startswith(">"))
@ -62,7 +69,7 @@ class GameEngine:
"Do NOT advance the story. Respond as the DM in meta language, starting the response with `>`. " "Do NOT advance the story. Respond as the DM in meta language, starting the response with `>`. "
"Use the `narrative` tool to output your meta response. Do NOT call any other tools (no journal_update, no finalize_turn, no rolls, no state changes)." "Use the `narrative` tool to output your meta response. Do NOT call any other tools (no journal_update, no finalize_turn, no rolls, no state changes)."
) )
elif not player_action and not recent_narrative: elif is_new_game:
base_parts.append( base_parts.append(
"## Instructions\n" "## Instructions\n"
"This is a new story. Welcome the player and guide them through the game setup." "This is a new story. Welcome the player and guide them through the game setup."
@ -140,6 +147,11 @@ class GameEngine:
ambience = None ambience = None
if args.get("log_entry"): if args.get("log_entry"):
log_entry = args["log_entry"] log_entry = args["log_entry"]
elif name == "read_rules":
result = execute_tool("read_rules", {})
state.append_llm_log(f"\n[READ RULES] loaded {len(result)} chars")
RULES_INJECTION_PATH.parent.mkdir(parents=True, exist_ok=True)
RULES_INJECTION_PATH.write_text(result)
else: else:
state_changes.append(tc) state_changes.append(tc)
@ -154,6 +166,17 @@ class GameEngine:
continue continue
state.append_llm_log(f"\n[TURN META EXCEEDED] accepting despite state changes") state.append_llm_log(f"\n[TURN META EXCEEDED] accepting despite state changes")
# Narrative check — reject if finalized with log_entry but no narrative
if not is_meta and log_entry and not book_log:
state.append_llm_log(f"\n[TURN NO NARRATIVE] finalized with log_entry but no narrative")
if attempt < MAX_RETRIES:
feedback = "You called finalize_turn with a log_entry but produced no narrative. Every turn must include a `narrative` tool block with the story. Regenerate with both narrative and log_entry."
state.append_llm_log(f"\n[TURN REGENERATE] (no narrative) attempt {attempt + 2}")
if on_action:
on_action("DM is weaving the tale...")
continue
state.append_llm_log(f"\n[TURN NO NARRATIVE EXCEEDED] accepting despite missing narrative")
# Duplicate check — reject if narrative is 80%+ similar to last book entry # Duplicate check — reject if narrative is 80%+ similar to last book entry
if not is_meta and book_log: if not is_meta and book_log:
prev = state.read_recent_book(1) prev = state.read_recent_book(1)
@ -236,12 +259,12 @@ class GameEngine:
name = tc.get("tool", "") name = tc.get("tool", "")
args = tc.get("args", {}) args = tc.get("args", {})
if name == "narrative": if name in ("narrative", "read_rules"):
pass pass
else: else:
result = execute_tool(name, args) result = execute_tool(name, args)
if name not in ("narrative", "finalize_turn", "player_roll"): if name not in ("narrative", "finalize_turn", "player_roll", "read_rules"):
if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"): if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"):
errors.append(f"{name}: {result}") errors.append(f"{name}: {result}")
else: else:

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH, CORE_RULES_PATH, RULES_INJECTION_PATH
from .prompts import SYSTEM_PROMPT from .prompts import SYSTEM_PROMPT
from . import state from . import state
@ -12,6 +12,9 @@ def build_system_prompt(recent_narrative: str | None = None, recent_log: str | N
log = recent_log if recent_log is not None else state.read_recent_log() log = recent_log if recent_log is not None else state.read_recent_log()
journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*" journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*"
story = recent_narrative if recent_narrative is not None else state.read_recent_book(2) story = recent_narrative if recent_narrative is not None else state.read_recent_book(2)
core_rules = state.read_file(CORE_RULES_PATH) or "*No core rules file.*"
extra = state.read_file(RULES_INJECTION_PATH)
extra_section = f"\n\n## Full Mechanics Reference\n{extra}" if extra else ""
return SYSTEM_PROMPT.substitute( return SYSTEM_PROMPT.substitute(
character=char, world=world, log=log, journal=journal, story=story, character=char, world=world, log=log, journal=journal, story=story, core_rules=core_rules,
) ) + extra_section

View File

@ -9,6 +9,10 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
RULES_DIR = BASE_DIR / 'rules'
CORE_RULES_PATH = RULES_DIR / 'core_mechanics.md'
MECHANICS_PATH = RULES_DIR / 'mechanics.md'
CHARACTER_CREATION_PATH = RULES_DIR / 'character_creation.md'
SESSION_DIR = BASE_DIR / 'session' SESSION_DIR = BASE_DIR / 'session'
CONFIG_PATH = SESSION_DIR / 'config.json' CONFIG_PATH = SESSION_DIR / 'config.json'
CHAR_PATH = SESSION_DIR / 'character.md' CHAR_PATH = SESSION_DIR / 'character.md'
@ -20,4 +24,5 @@ LOG_PATH = SESSION_DIR / 'session_log.md'
LLM_LOG_PATH = SESSION_DIR / 'llm.log' LLM_LOG_PATH = SESSION_DIR / 'llm.log'
AMBIENCE_OPTIONS_PATH = SESSION_DIR / "ambience_options.md" AMBIENCE_OPTIONS_PATH = SESSION_DIR / "ambience_options.md"
CHANGES_PATH = SESSION_DIR / "changes.md" CHANGES_PATH = SESSION_DIR / "changes.md"
RULES_INJECTION_PATH = SESSION_DIR / "rules_injection.md"
AUDIO_DIR = SESSION_DIR / "audio" AUDIO_DIR = SESSION_DIR / "audio"

View File

@ -3,12 +3,10 @@ from string import Template
SYSTEM_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 3rd person, vivid but concise. Use the player's name (Dillion) and NPC names explicitly — everything must be parseable on its own without relying on "you" or implied subjects. SYSTEM_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 3rd person, vivid but concise. Use the player's name (Dillion) and NPC names explicitly — everything must be parseable on its own without relying on "you" or implied subjects.
## Rules ## Core Rules
- **Odds**: 1d6, 4+ favourable, 3- trouble. $core_rules
- **Traits**: 3d6, roll UNDER trait.
- **Combat**: 1d6, 4+ hits. Damage: 1d6 + mod - armour. If you need the full mechanics reference (exploration, deck tables, grit tables, etc.), call the `read_rules` tool to load it.
- **Wounds at 0 HP**: 1d6 1-2 die, 3-4 -1 max HP, 5-6 -1 all rolls until healed.
- **Modifiers**: Favourable +1, Risky -1, Desperate -2.
## Output Format ## Output Format
Output ONLY ```tool blocks no prose, no reasoning, no explanation outside tool blocks. Every piece of output must be in a tool block. Output ONLY ```tool blocks no prose, no reasoning, no explanation outside tool blocks. Every piece of output must be in a tool block.
@ -50,6 +48,10 @@ Wrap each action in its own ```tool block:
{"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Dillion explored the dungeon, found a hidden passage, and was ambushed by goblins."}} {"tool": "finalize_turn", "args": {"ambience": "dungeon", "log_entry": "Dillion explored the dungeon, found a hidden passage, and was ambushed by goblins."}}
``` ```
```tool
{"tool": "read_rules", "args": {}}
```
**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. **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.
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. 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.

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
import re import re
from .paths import AMBIENCE_PATH, CHAR_PATH, WORLD_PATH from .paths import AMBIENCE_PATH, CHAR_PATH, WORLD_PATH, MECHANICS_PATH
from .state import read_file, validate_update_size, update_journal, append_llm_log, get_valid_ambiences from .state import read_file, validate_update_size, update_journal, append_llm_log, get_valid_ambiences
@ -18,6 +18,7 @@ TOOL_REGISTRY: dict[str, dict] = {
"world_update": {"description": "Replace world state.", "args": {"content": "full world markdown"}}, "world_update": {"description": "Replace world state.", "args": {"content": "full world markdown"}},
"journal_update": {"description": "Update TODO/DONE.", "args": {"add": "[...]", "done": "[...]"}}, "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"}},
"read_rules": {"description": "Read the full mechanics reference (exploration, deck tables, grit, healing, etc.). Call when you need details beyond the Core Rules in the prompt.", "args": {}},
} }
@ -158,6 +159,14 @@ def tool_finalize_turn(args: dict) -> str:
return f"Ambience set to {raw}." return f"Ambience set to {raw}."
def tool_read_rules(args: dict) -> str:
"""Read the full mechanics.md and return its content."""
content = read_file(MECHANICS_PATH)
if not content:
return "**Error:** rules/mechanics.md not found."
return content
def execute_tool(tool_name: str, args: dict) -> str: def execute_tool(tool_name: str, args: dict) -> str:
"""Execute a tool by name. Returns result string.""" """Execute a tool by name. Returns result string."""
fn_map = { fn_map = {
@ -171,6 +180,7 @@ def execute_tool(tool_name: str, args: dict) -> str:
"world_update": tool_world_update, "world_update": tool_world_update,
"journal_update": tool_journal_update, "journal_update": tool_journal_update,
"finalize_turn": tool_finalize_turn, "finalize_turn": tool_finalize_turn,
"read_rules": tool_read_rules,
} }
fn = fn_map.get(tool_name) fn = fn_map.get(tool_name)
if not fn: if not fn: