DM tool loop, player roll modal, free-form input
- Add tool-use loop in generate_with_tools(): LLM can call read_file, roll, player_roll, and think tools via tool fenced blocks before producing the final json response. Tool results fed back for multi-round reasoning. - Add player_roll tool: shows a modal overlay asking the player to physically roll dice and type the result. Blocks the worker thread via threading.Event until the player responds. - Add RollModal Screen with dice label, reason, input, and submit. - Every tool call updates status bar with a specific DM action message (e.g. "DM is rolling dice", "DM is reading the character sheet") instead of the generic "weaving the narrative". The thinking-dot animation uses the last-reported action as its base. - Replace all user-facing "LLM" references with "DM". - Remove predefined choices: no more choice buttons or choice container. Player types freely. System prompt says "never present predefined choices" and all instruction templates say "let the player decide". - Add book story context to system prompt — last 3 turns from book.md injected as "Recent Story" section. - Fix build_user_message: detects existing book content; if story exists, sends "continue from where it left off" instead of "establish the opening scene". - Improve parse_response: add JSON-only fallback when no json fenced block is found.
This commit is contained in:
parent
7f69bf6349
commit
d265dfc7f7
313
tools/engine.py
313
tools/engine.py
@ -55,7 +55,7 @@ SYSTEM_PROMPT = Template("""You are the Dungeon Master for **The Chaos**, a solo
|
|||||||
- Keep narration tight and cinematic. No monologues.
|
- Keep narration tight and cinematic. No monologues.
|
||||||
- Use **bold** for emphasis, *italic* for thoughts/sounds.
|
- Use **bold** for emphasis, *italic* for thoughts/sounds.
|
||||||
- NPC dialogue goes in **"quotes with bold names."**
|
- NPC dialogue goes in **"quotes with bold names."**
|
||||||
- Present **2-4 clear choices** at the end of each scene.
|
- Never present predefined choices — the player decides freely what to do.
|
||||||
- Each turn should advance the story meaningfully.
|
- Each turn should advance the story meaningfully.
|
||||||
|
|
||||||
## Game Rules (Quick Reference)
|
## Game Rules (Quick Reference)
|
||||||
@ -88,7 +88,6 @@ IMPORTANT: End every response with a JSON fenced code block:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"choices": ["Choice 1", "Choice 2", "Choice 3"],
|
|
||||||
"log_entry": "- **time of day** — brief description of what happened.",
|
"log_entry": "- **time of day** — brief description of what happened.",
|
||||||
"ambience": "ambience_name_or_null",
|
"ambience": "ambience_name_or_null",
|
||||||
"character_updates": null,
|
"character_updates": null,
|
||||||
@ -99,7 +98,6 @@ IMPORTANT: End every response with a JSON fenced code block:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Rules for the JSON block:
|
Rules for the JSON block:
|
||||||
- **choices**: 2-4 brief action options presented to the player.
|
|
||||||
- **log_entry**: One-line log entry summarizing this turn's action.
|
- **log_entry**: One-line log entry summarizing this turn's action.
|
||||||
- **ambience**: One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds. Set to null to keep current.
|
- **ambience**: One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds. Set to null to keep current.
|
||||||
- **character_updates**: ONLY include if HP, cash, gear, or stats changed. Provide the FULL updated character sheet markdown. Otherwise null.
|
- **character_updates**: ONLY include if HP, cash, gear, or stats changed. Provide the FULL updated character sheet markdown. Otherwise null.
|
||||||
@ -107,6 +105,29 @@ Rules for the JSON block:
|
|||||||
- **journal_add**: New TODO items to add.
|
- **journal_add**: New TODO items to add.
|
||||||
- **journal_done**: TODO items that are now completed.
|
- **journal_done**: TODO items that are now completed.
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
You may use these tools before writing your final JSON block. Tool calls go in their own fenced code block:
|
||||||
|
|
||||||
|
```tool
|
||||||
|
{"tool": "tool_name", "args": {...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can call tools any number of times, one per block. Available tools:
|
||||||
|
|
||||||
|
- **read_file** — Read a game state file. `{"file": "character|world|book|log|journal"}`
|
||||||
|
- **roll** — Roll dice (outcome is shown to player). `{"dice": "2d6", "modifier": "-1"}`
|
||||||
|
- **think** — Internal reasoning (shown in game status bar). `{"thought": "Your reasoning here"}`
|
||||||
|
- **player_roll** — Ask the player to roll physical dice. **Only use when the player's action has an uncertain outcome that the dice should decide.** `{"dice": "2d6", "reason": "why"}`
|
||||||
|
|
||||||
|
You may also show reasoning inline with a separate fence:
|
||||||
|
|
||||||
|
```thought
|
||||||
|
Your reasoning here
|
||||||
|
```
|
||||||
|
|
||||||
|
Call tools as needed, then end with the final JSON block.
|
||||||
|
|
||||||
When the player makes a choice, resolve it with the dice mechanics above. Describe the action, roll dice implicitly (describe the outcome, don't say "rolling dice"), apply damage/effects, and update state.
|
When the player makes a choice, resolve it with the dice mechanics above. Describe the action, roll dice implicitly (describe the outcome, don't say "rolling dice"), apply damage/effects, and update state.
|
||||||
|
|
||||||
## Current Game State
|
## Current Game State
|
||||||
@ -117,8 +138,11 @@ $character
|
|||||||
### World
|
### World
|
||||||
$world
|
$world
|
||||||
|
|
||||||
### Recent Events
|
### Recent Log
|
||||||
$log""")
|
$log
|
||||||
|
|
||||||
|
### Recent Story (last turns from the book)
|
||||||
|
$story""")
|
||||||
# trailing """ is intentional — the template ends here
|
# trailing """ is intentional — the template ends here
|
||||||
|
|
||||||
|
|
||||||
@ -211,7 +235,10 @@ class GameEngine:
|
|||||||
char = self._read_file(CHAR_PATH) or "*No character sheet.*"
|
char = self._read_file(CHAR_PATH) or "*No character sheet.*"
|
||||||
world = self._read_file(WORLD_PATH) or "*No world state.*"
|
world = self._read_file(WORLD_PATH) or "*No world state.*"
|
||||||
log = self._read_recent_log()
|
log = self._read_recent_log()
|
||||||
return SYSTEM_PROMPT.substitute(character=char, world=world, log=log)
|
story = self._read_recent_book()
|
||||||
|
return SYSTEM_PROMPT.substitute(
|
||||||
|
character=char, world=world, log=log, story=story
|
||||||
|
)
|
||||||
|
|
||||||
def build_user_message(
|
def build_user_message(
|
||||||
self,
|
self,
|
||||||
@ -224,19 +251,34 @@ class GameEngine:
|
|||||||
parts.append(f"## Previously\n{last_narrative}")
|
parts.append(f"## Previously\n{last_narrative}")
|
||||||
if player_action:
|
if player_action:
|
||||||
parts.append(f"## Player Action\n{player_action}")
|
parts.append(f"## Player Action\n{player_action}")
|
||||||
|
|
||||||
|
has_existing_story = bool(
|
||||||
|
self._read_file(BOOK_PATH).strip()
|
||||||
|
) if not last_narrative else True
|
||||||
|
|
||||||
if not player_action and not last_narrative:
|
if not player_action and not last_narrative:
|
||||||
|
if has_existing_story:
|
||||||
parts.append(
|
parts.append(
|
||||||
"## Instructions\n"
|
"## Instructions\n"
|
||||||
"Establish the opening scene. Dillion is at the Splintered "
|
"Continue the story from where it left off. Describe "
|
||||||
"Tankard in the Keep. Describe the setting and present "
|
"what happens next based on the current situation. "
|
||||||
"choices for what he might do. End with a JSON block."
|
"Use tools as needed, then end with a JSON block."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parts.append(
|
||||||
|
"## Instructions\n"
|
||||||
|
"Establish the opening scene. Dillion is at the "
|
||||||
|
"Splintered Tankard in the Keep. Describe the "
|
||||||
|
"setting and let the player decide what to do. "
|
||||||
|
"Use tools as needed, then end with a JSON block."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
parts.append(
|
parts.append(
|
||||||
"## Instructions\n"
|
"## Instructions\n"
|
||||||
"Describe the outcome of the player's action using game "
|
"Describe the outcome of the player's action using game "
|
||||||
"mechanics where appropriate. Then present new choices. "
|
"mechanics where appropriate. Let the player decide their "
|
||||||
"End with a JSON block."
|
"next move freely. Use tools as needed, then end with a "
|
||||||
|
"JSON block."
|
||||||
)
|
)
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
@ -354,6 +396,242 @@ class GameEngine:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
yield json.dumps({"error": f"LLM call failed: {e}"})
|
yield json.dumps({"error": f"LLM call failed: {e}"})
|
||||||
|
|
||||||
|
# ── Tool Infrastructure ────────────────────────────────────────────
|
||||||
|
|
||||||
|
TOOL_REGISTRY: dict[str, dict] = {
|
||||||
|
"read_file": {
|
||||||
|
"description": "Read a game state file.",
|
||||||
|
"args": {"file": "character | world | book | log | journal"},
|
||||||
|
},
|
||||||
|
"roll": {
|
||||||
|
"description": "Roll dice and return the outcome.",
|
||||||
|
"args": {"dice": "e.g. 1d6, 2d6", "modifier": "optional +N or -N"},
|
||||||
|
},
|
||||||
|
"think": {
|
||||||
|
"description": "Internal reasoning shown in the game status bar.",
|
||||||
|
"args": {"thought": "Your reasoning."},
|
||||||
|
},
|
||||||
|
"player_roll": {
|
||||||
|
"description": "Ask the player to physically roll dice and enter the result.",
|
||||||
|
"args": {"dice": "e.g. 2d6+1", "reason": "Why the roll is needed (shown to player)"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _tool_read_file(self, args: dict) -> str:
|
||||||
|
filename = (args or {}).get("file", "")
|
||||||
|
paths = {
|
||||||
|
"character": CHAR_PATH,
|
||||||
|
"world": WORLD_PATH,
|
||||||
|
"book": BOOK_PATH,
|
||||||
|
"log": LOG_DIR / f"{TODAY}.md",
|
||||||
|
"journal": JOURNAL_PATH,
|
||||||
|
}
|
||||||
|
path = paths.get(filename)
|
||||||
|
if not path:
|
||||||
|
return f"Unknown file: {filename}. Choose from: {', '.join(paths)}"
|
||||||
|
return self._read_file(path) or f"*{filename} is empty.*"
|
||||||
|
|
||||||
|
def _tool_roll(self, args: dict) -> str:
|
||||||
|
import random
|
||||||
|
dice_str = (args or {}).get("dice", "1d6")
|
||||||
|
modifier_str = (args or {}).get("modifier", "0")
|
||||||
|
try:
|
||||||
|
count, sides = dice_str.lower().split("d")
|
||||||
|
count = int(count) if count else 1
|
||||||
|
sides = int(sides)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return f"Invalid dice: {dice_str}. Use format like '2d6'."
|
||||||
|
mod = 0
|
||||||
|
if modifier_str:
|
||||||
|
try:
|
||||||
|
mod = int(modifier_str)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
rolls = [random.randint(1, sides) for _ in range(count)]
|
||||||
|
total = sum(rolls) + mod
|
||||||
|
mod_str = f" {'+' if mod >= 0 else ''}{mod}" if mod != 0 else ""
|
||||||
|
return f"Roll: {dice_str}{mod_str} → [{', '.join(str(r) for r in rolls)}] = {total}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _describe_tool_action(tool_name: str, args: dict) -> str:
|
||||||
|
"""Return a user-facing status message for a tool call."""
|
||||||
|
read_descriptions = {
|
||||||
|
"character": "reading the character sheet",
|
||||||
|
"world": "consulting the world map",
|
||||||
|
"book": "reviewing the story so far",
|
||||||
|
"log": "checking the session log",
|
||||||
|
"journal": "scanning the journal",
|
||||||
|
}
|
||||||
|
if tool_name == "read_file":
|
||||||
|
file = (args or {}).get("file", "")
|
||||||
|
desc = read_descriptions.get(file, f"reading {file}")
|
||||||
|
elif tool_name == "roll":
|
||||||
|
dice = (args or {}).get("dice", "1d6")
|
||||||
|
mod = (args or {}).get("modifier")
|
||||||
|
desc = f"rolling {dice}"
|
||||||
|
if mod:
|
||||||
|
desc += f" {mod}"
|
||||||
|
elif tool_name == "player_roll":
|
||||||
|
dice = (args or {}).get("dice", "1d6")
|
||||||
|
desc = f"asking you to roll {dice}"
|
||||||
|
elif tool_name == "think":
|
||||||
|
desc = "pausing to think"
|
||||||
|
else:
|
||||||
|
desc = f"using {tool_name}"
|
||||||
|
return f"DM is {desc}..."
|
||||||
|
|
||||||
|
def _execute_tool(self, tool_name: str, args: dict) -> str:
|
||||||
|
fn_map = {
|
||||||
|
"read_file": self._tool_read_file,
|
||||||
|
"roll": self._tool_roll,
|
||||||
|
}
|
||||||
|
fn = fn_map.get(tool_name)
|
||||||
|
if not fn:
|
||||||
|
return f"Unknown tool: {tool_name}"
|
||||||
|
try:
|
||||||
|
return fn(args)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Tool error ({tool_name}): {e}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_thoughts(text: str) -> list[str]:
|
||||||
|
pattern = r"```thought\s*\n?(.*?)```"
|
||||||
|
return re.findall(pattern, text, re.DOTALL)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_tool_calls(text: str) -> list[dict]:
|
||||||
|
pattern = r"```tool\s*\n?(.*?)```"
|
||||||
|
blocks = re.findall(pattern, text, re.DOTALL)
|
||||||
|
calls = []
|
||||||
|
for block in blocks:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(block.strip())
|
||||||
|
if isinstance(parsed, dict) and "tool" in parsed:
|
||||||
|
calls.append(parsed)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return calls
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_final_json(text: str) -> dict | None:
|
||||||
|
pattern = r"```json\s*\n?(.*?)```"
|
||||||
|
matches = re.findall(pattern, text, re.DOTALL)
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(matches[-1].strip())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_with_tools(
|
||||||
|
self,
|
||||||
|
player_action: str | None = None,
|
||||||
|
last_narrative: str | None = None,
|
||||||
|
on_thought: callable = None,
|
||||||
|
on_action: callable = None,
|
||||||
|
on_player_roll: callable = None,
|
||||||
|
) -> GenerationResult:
|
||||||
|
"""
|
||||||
|
Multi-turn generation with tool-use loop.
|
||||||
|
|
||||||
|
The LLM can output ```thought blocks (reasoning), ```tool blocks
|
||||||
|
(tool calls), and a final ```json block. If any tool is called,
|
||||||
|
the result is fed back and the LLM is re-invoked. The loop ends
|
||||||
|
when the LLM outputs a ```json block with no tool calls.
|
||||||
|
|
||||||
|
`on_thought` is called for each thought block (may be from a
|
||||||
|
worker thread — use call_from_thread in the TUI).
|
||||||
|
"""
|
||||||
|
system = self.build_system_prompt()
|
||||||
|
user = self.build_user_message(
|
||||||
|
player_action=player_action,
|
||||||
|
last_narrative=last_narrative,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages: list[dict] = [
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import litellm
|
||||||
|
except ImportError:
|
||||||
|
return GenerationResult(narrative="", error="litellm not installed")
|
||||||
|
|
||||||
|
if self.api_key:
|
||||||
|
os_env_key = f"{self.model.split('/')[0]}_API_KEY".upper()
|
||||||
|
import os
|
||||||
|
os.environ[os_env_key] = self.api_key
|
||||||
|
if self.api_base:
|
||||||
|
os_env_base = f"{self.model.split('/')[0]}_API_BASE".upper()
|
||||||
|
import os
|
||||||
|
os.environ[os_env_base] = self.api_base
|
||||||
|
|
||||||
|
max_rounds = 10
|
||||||
|
|
||||||
|
for round_idx in range(max_rounds):
|
||||||
|
try:
|
||||||
|
response = litellm.completion(
|
||||||
|
model=self.model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=self.temperature,
|
||||||
|
stream=False,
|
||||||
|
)
|
||||||
|
text = response.choices[0].message.content or ""
|
||||||
|
except Exception as e:
|
||||||
|
return GenerationResult(narrative="", error=f"LLM call failed: {e}")
|
||||||
|
|
||||||
|
thoughts = self._extract_thoughts(text)
|
||||||
|
for t in thoughts:
|
||||||
|
if on_thought:
|
||||||
|
on_thought(t.strip())
|
||||||
|
|
||||||
|
tool_calls = self._extract_tool_calls(text)
|
||||||
|
final_data = self._extract_final_json(text)
|
||||||
|
|
||||||
|
if tool_calls:
|
||||||
|
results = []
|
||||||
|
for tc in tool_calls:
|
||||||
|
name = tc.get("tool", "?")
|
||||||
|
args = tc.get("args", {})
|
||||||
|
if on_action:
|
||||||
|
on_action(self._describe_tool_action(name, args))
|
||||||
|
if name == "player_roll" and on_player_roll:
|
||||||
|
dice = args.get("dice", "1d6")
|
||||||
|
reason = args.get("reason", "a check")
|
||||||
|
roll_val = on_player_roll(dice, reason)
|
||||||
|
result = f"Player rolled {dice} for '{reason}': {roll_val}"
|
||||||
|
else:
|
||||||
|
result = self._execute_tool(name, args)
|
||||||
|
results.append(f"**Tool:** {name}\n**Args:** {json.dumps(args)}\n**Result:** {result}")
|
||||||
|
messages.append({"role": "assistant", "content": text})
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": "## Tool Results\n\n" + "\n\n".join(results),
|
||||||
|
})
|
||||||
|
continue # Another round
|
||||||
|
|
||||||
|
# No tool calls → parse the final JSON
|
||||||
|
if final_data:
|
||||||
|
return GenerationResult(
|
||||||
|
narrative=text[: text.rfind("```json")].strip(),
|
||||||
|
choices=final_data.get("choices", []),
|
||||||
|
log_entry=final_data.get("log_entry"),
|
||||||
|
ambience=final_data.get("ambience"),
|
||||||
|
character_updates=final_data.get("character_updates"),
|
||||||
|
world_updates=final_data.get("world_updates"),
|
||||||
|
journal_add=final_data.get("journal_add", []),
|
||||||
|
journal_done=final_data.get("journal_done", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback: no tool calls, no JSON → normal parse
|
||||||
|
return self.parse_response(text)
|
||||||
|
|
||||||
|
return GenerationResult(
|
||||||
|
narrative="",
|
||||||
|
error="Tool loop exceeded max rounds (10)",
|
||||||
|
)
|
||||||
|
|
||||||
# ── Response Parsing ────────────────────────────────────────────────
|
# ── Response Parsing ────────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -370,7 +648,7 @@ class GameEngine:
|
|||||||
err = "Unknown error"
|
err = "Unknown error"
|
||||||
return GenerationResult(narrative="", error=err)
|
return GenerationResult(narrative="", error=err)
|
||||||
|
|
||||||
# Extract JSON block — find the last ```json ... ``` block
|
# Try to find a ```json ... ``` block
|
||||||
json_pattern = r"```json\s*\n?(.*?)\n?```"
|
json_pattern = r"```json\s*\n?(.*?)\n?```"
|
||||||
matches = re.findall(json_pattern, text, re.DOTALL)
|
matches = re.findall(json_pattern, text, re.DOTALL)
|
||||||
|
|
||||||
@ -379,13 +657,20 @@ class GameEngine:
|
|||||||
|
|
||||||
if matches:
|
if matches:
|
||||||
json_str = matches[-1].strip()
|
json_str = matches[-1].strip()
|
||||||
# Remove the json block from the narrative
|
|
||||||
narrative = text[: text.rfind("```json")]
|
narrative = text[: text.rfind("```json")]
|
||||||
narrative = narrative.strip()
|
narrative = narrative.strip()
|
||||||
try:
|
try:
|
||||||
data = json.loads(json_str)
|
data = json.loads(json_str)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
# Try to salvage partial JSON
|
pass
|
||||||
|
else:
|
||||||
|
# Fallback: maybe the entire response is JSON (no fence)
|
||||||
|
text_stripped = text.strip()
|
||||||
|
if text_stripped.startswith("{") and text_stripped.endswith("}"):
|
||||||
|
try:
|
||||||
|
data = json.loads(text_stripped)
|
||||||
|
narrative = data.get("narrative", "")
|
||||||
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return GenerationResult(
|
return GenerationResult(
|
||||||
|
|||||||
171
tools/run.py
171
tools/run.py
@ -17,6 +17,7 @@ from pathlib import Path
|
|||||||
from textual import on
|
from textual import on
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||||
|
from textual.screen import Screen
|
||||||
from textual.widgets import Button, Input, Static, TabbedContent, TabPane
|
from textual.widgets import Button, Input, Static, TabbedContent, TabPane
|
||||||
from rich.markdown import Markdown as RichMarkdown
|
from rich.markdown import Markdown as RichMarkdown
|
||||||
from rich.theme import Theme
|
from rich.theme import Theme
|
||||||
@ -241,6 +242,76 @@ class AmbiencePlayer:
|
|||||||
app_ambience_player = None
|
app_ambience_player = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Roll Modal ───────────────────────────────────────────
|
||||||
|
class RollModal(Screen):
|
||||||
|
"""Overlay asking the player to roll physical dice and enter the result."""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
RollModal {
|
||||||
|
align: center middle;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
#roll-dialog {
|
||||||
|
width: 44;
|
||||||
|
height: auto;
|
||||||
|
padding: 2 3;
|
||||||
|
background: #2a2a3a;
|
||||||
|
border: thick #e0ad4c;
|
||||||
|
}
|
||||||
|
#roll-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #ffd93d;
|
||||||
|
text-align: center;
|
||||||
|
height: 3;
|
||||||
|
}
|
||||||
|
#roll-reason {
|
||||||
|
color: #c0b090;
|
||||||
|
text-align: center;
|
||||||
|
height: 3;
|
||||||
|
}
|
||||||
|
#roll-input {
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
#roll-submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#roll-hint {
|
||||||
|
color: #888888;
|
||||||
|
text-align: center;
|
||||||
|
height: 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dice: str, reason: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.dice = dice
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="roll-dialog"):
|
||||||
|
yield Static(f"[bold]🎲 ROLL {self.dice}[/bold]", id="roll-title")
|
||||||
|
yield Static(f"Reason: {self.reason}", id="roll-reason")
|
||||||
|
yield Input(
|
||||||
|
placeholder="Enter the number you rolled...",
|
||||||
|
id="roll-input",
|
||||||
|
)
|
||||||
|
yield Button("Submit", id="roll-submit", variant="primary")
|
||||||
|
yield Static("(or press Enter)", id="roll-hint")
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
self._submit(event.value)
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "roll-submit":
|
||||||
|
inp = self.query_one("#roll-input", Input)
|
||||||
|
self._submit(inp.value)
|
||||||
|
|
||||||
|
def _submit(self, value: str) -> None:
|
||||||
|
val = value.strip()
|
||||||
|
if val:
|
||||||
|
self.dismiss(val)
|
||||||
|
|
||||||
|
|
||||||
# ── Auto-refreshing panels ───────────────────────────────
|
# ── Auto-refreshing panels ───────────────────────────────
|
||||||
class AutoStatic(Static):
|
class AutoStatic(Static):
|
||||||
def load(self):
|
def load(self):
|
||||||
@ -358,17 +429,6 @@ class ChaosTUI(App):
|
|||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
#play-choices {
|
|
||||||
height: auto;
|
|
||||||
min-height: 3;
|
|
||||||
background: #1e1e2a;
|
|
||||||
padding: 0 1;
|
|
||||||
align: center middle;
|
|
||||||
}
|
|
||||||
#play-choices Button {
|
|
||||||
margin: 0 1;
|
|
||||||
min-width: 12;
|
|
||||||
}
|
|
||||||
#play-status {
|
#play-status {
|
||||||
background: #1a1a2a;
|
background: #1a1a2a;
|
||||||
color: #e0b060;
|
color: #e0b060;
|
||||||
@ -480,6 +540,11 @@ class ChaosTUI(App):
|
|||||||
# Thinking animation
|
# Thinking animation
|
||||||
self._thinking_dots = 0
|
self._thinking_dots = 0
|
||||||
self._thinking_timer_handle = None
|
self._thinking_timer_handle = None
|
||||||
|
self._dm_action = "DM is weaving the narrative"
|
||||||
|
|
||||||
|
# Player roll state (thread-safe)
|
||||||
|
self._roll_event = threading.Event()
|
||||||
|
self._roll_result: str | None = None
|
||||||
|
|
||||||
# Book viewer state
|
# Book viewer state
|
||||||
self._book_page = 0
|
self._book_page = 0
|
||||||
@ -496,7 +561,6 @@ class ChaosTUI(App):
|
|||||||
with TabPane("PLAY", id="play-tab"):
|
with TabPane("PLAY", id="play-tab"):
|
||||||
with VerticalScroll(id="play-scroll"):
|
with VerticalScroll(id="play-scroll"):
|
||||||
yield Static("*Awaiting the fates...*", id="play-narrative")
|
yield Static("*Awaiting the fates...*", id="play-narrative")
|
||||||
yield Horizontal(id="play-choices")
|
|
||||||
yield Static("", id="play-status")
|
yield Static("", id="play-status")
|
||||||
yield Input(
|
yield Input(
|
||||||
placeholder="Type your action and press Enter...",
|
placeholder="Type your action and press Enter...",
|
||||||
@ -548,9 +612,8 @@ class ChaosTUI(App):
|
|||||||
|
|
||||||
input_widget = self.query_one("#play-input", Input)
|
input_widget = self.query_one("#play-input", Input)
|
||||||
input_widget.disabled = True
|
input_widget.disabled = True
|
||||||
input_widget.placeholder = "LLM is thinking..."
|
input_widget.placeholder = "DM is at work..."
|
||||||
|
|
||||||
self._clear_choices()
|
|
||||||
self._show_thinking()
|
self._show_thinking()
|
||||||
|
|
||||||
# Run generation in a daemon thread so it doesn't block the UI
|
# Run generation in a daemon thread so it doesn't block the UI
|
||||||
@ -562,23 +625,32 @@ class ChaosTUI(App):
|
|||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
def _run_generation(self, player_action: str | None) -> None:
|
def _run_generation(self, player_action: str | None) -> None:
|
||||||
"""Worker thread: calls engine.generate() and posts result back."""
|
"""Worker thread: calls engine.generate_with_tools() and posts result back."""
|
||||||
# Provide previous narrative as context on subsequent calls
|
|
||||||
last_narrative = self._last_narrative if self._last_narrative else None
|
last_narrative = self._last_narrative if self._last_narrative else None
|
||||||
|
|
||||||
result = self.engine.generate(
|
def on_thought(thought: str) -> None:
|
||||||
|
self.call_from_thread(self._on_thought, thought)
|
||||||
|
|
||||||
|
def on_action(action: str) -> None:
|
||||||
|
self.call_from_thread(self._on_action, action)
|
||||||
|
|
||||||
|
result = self.engine.generate_with_tools(
|
||||||
player_action=player_action,
|
player_action=player_action,
|
||||||
last_narrative=last_narrative,
|
last_narrative=last_narrative,
|
||||||
|
on_thought=on_thought,
|
||||||
|
on_action=on_action,
|
||||||
|
on_player_roll=self._on_player_roll,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.call_from_thread(self._on_generation_done, result, player_action)
|
self.call_from_thread(self._on_generation_done, result, player_action)
|
||||||
|
|
||||||
def _show_thinking(self) -> None:
|
def _show_thinking(self) -> None:
|
||||||
"""Show the thinking indicator and start the animation timer."""
|
"""Show the thinking indicator and start the animation timer."""
|
||||||
|
self._dm_action = "DM is weaving the narrative"
|
||||||
self._thinking_dots = 0
|
self._thinking_dots = 0
|
||||||
status = self.query_one("#play-status", Static)
|
status = self.query_one("#play-status", Static)
|
||||||
status.add_class("processing")
|
status.add_class("processing")
|
||||||
status.update("✦ LLM is weaving the narrative ✦")
|
status.update(f"✦ {self._dm_action} ✦")
|
||||||
self._thinking_timer_handle = self.set_interval(
|
self._thinking_timer_handle = self.set_interval(
|
||||||
0.5, self._tick_thinking
|
0.5, self._tick_thinking
|
||||||
)
|
)
|
||||||
@ -592,14 +664,51 @@ class ChaosTUI(App):
|
|||||||
status.remove_class("processing")
|
status.remove_class("processing")
|
||||||
status.update("")
|
status.update("")
|
||||||
|
|
||||||
|
def _on_thought(self, thought: str) -> None:
|
||||||
|
"""Display a thought from the DM in the status bar."""
|
||||||
|
display = thought[:60] + "…" if len(thought) > 60 else thought
|
||||||
|
self._dm_action = display
|
||||||
|
self._thinking_dots = 0
|
||||||
|
status = self.query_one("#play-status", Static)
|
||||||
|
status.add_class("processing")
|
||||||
|
status.update(f"✦ {display} ✦")
|
||||||
|
|
||||||
|
def _on_action(self, action: str) -> None:
|
||||||
|
"""Display a DM action (tool call) in the status bar."""
|
||||||
|
self._dm_action = action
|
||||||
|
self._thinking_dots = 0
|
||||||
|
status = self.query_one("#play-status", Static)
|
||||||
|
status.add_class("processing")
|
||||||
|
status.update(f"✦ {action} ✦")
|
||||||
|
|
||||||
|
def _on_player_roll(self, dice: str, reason: str) -> str:
|
||||||
|
"""Called from worker thread. Shows roll popup, blocks until player responds."""
|
||||||
|
self.call_from_thread(self._show_roll_modal, dice, reason)
|
||||||
|
self._roll_event.wait()
|
||||||
|
self._roll_event.clear()
|
||||||
|
result = self._roll_result
|
||||||
|
self._roll_result = None
|
||||||
|
return result or "0"
|
||||||
|
|
||||||
|
def _show_roll_modal(self, dice: str, reason: str) -> None:
|
||||||
|
"""Push the RollModal screen (runs on main thread)."""
|
||||||
|
self._roll_event.clear()
|
||||||
|
self._roll_result = None
|
||||||
|
|
||||||
|
def on_dismiss(value: str) -> None:
|
||||||
|
self._roll_result = value
|
||||||
|
self._roll_event.set()
|
||||||
|
|
||||||
|
self.push_screen(RollModal(dice, reason), on_dismiss)
|
||||||
|
|
||||||
def _tick_thinking(self) -> None:
|
def _tick_thinking(self) -> None:
|
||||||
"""Animate the thinking dots."""
|
"""Animate the thinking dots on the current DM action."""
|
||||||
if not self._is_processing:
|
if not self._is_processing:
|
||||||
return
|
return
|
||||||
self._thinking_dots = (self._thinking_dots + 1) % 4
|
self._thinking_dots = (self._thinking_dots + 1) % 4
|
||||||
dots = "." * self._thinking_dots
|
dots = "." * self._thinking_dots
|
||||||
status = self.query_one("#play-status", Static)
|
status = self.query_one("#play-status", Static)
|
||||||
status.update(f"✦ LLM is weaving the narrative{dots} ✦")
|
status.update(f"✦ {self._dm_action}{dots} ✦")
|
||||||
|
|
||||||
def _on_generation_done(
|
def _on_generation_done(
|
||||||
self, result: GenerationResult, player_action: str | None
|
self, result: GenerationResult, player_action: str | None
|
||||||
@ -636,7 +745,6 @@ class ChaosTUI(App):
|
|||||||
def _display_scene(self, result: GenerationResult) -> None:
|
def _display_scene(self, result: GenerationResult) -> None:
|
||||||
"""Update the UI with a new scene."""
|
"""Update the UI with a new scene."""
|
||||||
self._set_narrative(result.narrative)
|
self._set_narrative(result.narrative)
|
||||||
self._set_choices(result.choices)
|
|
||||||
self._enable_input()
|
self._enable_input()
|
||||||
|
|
||||||
def _enable_input(self) -> None:
|
def _enable_input(self) -> None:
|
||||||
@ -653,17 +761,6 @@ class ChaosTUI(App):
|
|||||||
scroll = self.query_one("#play-scroll", VerticalScroll)
|
scroll = self.query_one("#play-scroll", VerticalScroll)
|
||||||
scroll.scroll_home(animate=False)
|
scroll.scroll_home(animate=False)
|
||||||
|
|
||||||
def _clear_choices(self) -> None:
|
|
||||||
container = self.query_one("#play-choices", Horizontal)
|
|
||||||
container.remove_children()
|
|
||||||
|
|
||||||
def _set_choices(self, choices: list[str]) -> None:
|
|
||||||
container = self.query_one("#play-choices", Horizontal)
|
|
||||||
container.remove_children()
|
|
||||||
for choice in choices:
|
|
||||||
btn = Button(choice, classes="choice-btn")
|
|
||||||
container.mount(btn)
|
|
||||||
|
|
||||||
def _show_error(self, error: str) -> None:
|
def _show_error(self, error: str) -> None:
|
||||||
self._set_narrative(
|
self._set_narrative(
|
||||||
f"**Error:** {error}\n\n"
|
f"**Error:** {error}\n\n"
|
||||||
@ -681,16 +778,8 @@ class ChaosTUI(App):
|
|||||||
event.stop()
|
event.stop()
|
||||||
self._handle_player_action(action)
|
self._handle_player_action(action)
|
||||||
|
|
||||||
@on(Button.Pressed, ".choice-btn")
|
|
||||||
def on_choice_clicked(self, event: Button.Pressed) -> None:
|
|
||||||
"""Player clicked a choice button."""
|
|
||||||
if self._is_processing:
|
|
||||||
return
|
|
||||||
action = event.button.label
|
|
||||||
self._handle_player_action(str(action))
|
|
||||||
|
|
||||||
def _handle_player_action(self, action: str) -> None:
|
def _handle_player_action(self, action: str) -> None:
|
||||||
"""Common handler for player actions from input or buttons."""
|
"""Handle a player action typed in the input."""
|
||||||
# Log the action
|
# Log the action
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
timestamp = datetime.now().strftime("%H:%M")
|
timestamp = datetime.now().strftime("%H:%M")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user