diff --git a/tools/engine.py b/tools/engine.py index 87fa89f..cca773e 100644 --- a/tools/engine.py +++ b/tools/engine.py @@ -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 import state from engine_lib.llm import call_llm +from engine_lib.paths import CHARACTER_CREATION_PATH, RULES_INJECTION_PATH class GameEngine: @@ -48,7 +49,13 @@ class GameEngine: if on_action: 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) + 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(">")) @@ -62,7 +69,7 @@ class GameEngine: "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)." ) - elif not player_action and not recent_narrative: + elif is_new_game: base_parts.append( "## Instructions\n" "This is a new story. Welcome the player and guide them through the game setup." @@ -140,6 +147,11 @@ class GameEngine: ambience = None if args.get("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: state_changes.append(tc) @@ -154,6 +166,17 @@ class GameEngine: continue 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 if not is_meta and book_log: prev = state.read_recent_book(1) @@ -236,12 +259,12 @@ class GameEngine: name = tc.get("tool", "") args = tc.get("args", {}) - if name == "narrative": + if name in ("narrative", "read_rules"): pass else: 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"): errors.append(f"{name}: {result}") else: diff --git a/tools/engine_lib/context.py b/tools/engine_lib/context.py index cf14b0b..d2c7ca8 100644 --- a/tools/engine_lib/context.py +++ b/tools/engine_lib/context.py @@ -1,6 +1,6 @@ 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 . 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() 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) + 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( - 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 diff --git a/tools/engine_lib/paths.py b/tools/engine_lib/paths.py index f3c419f..6a916e3 100644 --- a/tools/engine_lib/paths.py +++ b/tools/engine_lib/paths.py @@ -9,6 +9,10 @@ from pathlib import Path 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' CONFIG_PATH = SESSION_DIR / 'config.json' CHAR_PATH = SESSION_DIR / 'character.md' @@ -20,4 +24,5 @@ LOG_PATH = SESSION_DIR / 'session_log.md' 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" AUDIO_DIR = SESSION_DIR / "audio" diff --git a/tools/engine_lib/prompts.py b/tools/engine_lib/prompts.py index 7e610ca..1343db9 100644 --- a/tools/engine_lib/prompts.py +++ b/tools/engine_lib/prompts.py @@ -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. -## Rules -- **Odds**: 1d6, 4+ favourable, 3- trouble. -- **Traits**: 3d6, roll UNDER trait. -- **Combat**: 1d6, 4+ hits. Damage: 1d6 + mod - armour. -- **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. +## Core Rules +$core_rules + +If you need the full mechanics reference (exploration, deck tables, grit tables, etc.), call the `read_rules` tool to load it. ## 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. @@ -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 +{"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. 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. diff --git a/tools/engine_lib/tools_handler.py b/tools/engine_lib/tools_handler.py index c79fc8e..2afe0df 100644 --- a/tools/engine_lib/tools_handler.py +++ b/tools/engine_lib/tools_handler.py @@ -3,7 +3,7 @@ from __future__ import annotations import json 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 @@ -18,6 +18,7 @@ TOOL_REGISTRY: dict[str, dict] = { "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"}}, + "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}." +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: """Execute a tool by name. Returns result string.""" fn_map = { @@ -171,6 +180,7 @@ def execute_tool(tool_name: str, args: dict) -> str: "world_update": tool_world_update, "journal_update": tool_journal_update, "finalize_turn": tool_finalize_turn, + "read_rules": tool_read_rules, } fn = fn_map.get(tool_name) if not fn: