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.
|
||||
- Use **bold** for emphasis, *italic* for thoughts/sounds.
|
||||
- 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.
|
||||
|
||||
## Game Rules (Quick Reference)
|
||||
@ -88,7 +88,6 @@ IMPORTANT: End every response with a JSON fenced code block:
|
||||
|
||||
```json
|
||||
{
|
||||
"choices": ["Choice 1", "Choice 2", "Choice 3"],
|
||||
"log_entry": "- **time of day** — brief description of what happened.",
|
||||
"ambience": "ambience_name_or_null",
|
||||
"character_updates": null,
|
||||
@ -99,7 +98,6 @@ IMPORTANT: End every response with a JSON fenced code 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.
|
||||
- **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.
|
||||
@ -107,6 +105,29 @@ Rules for the JSON block:
|
||||
- **journal_add**: New TODO items to add.
|
||||
- **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.
|
||||
|
||||
## Current Game State
|
||||
@ -117,8 +138,11 @@ $character
|
||||
### World
|
||||
$world
|
||||
|
||||
### Recent Events
|
||||
$log""")
|
||||
### Recent Log
|
||||
$log
|
||||
|
||||
### Recent Story (last turns from the book)
|
||||
$story""")
|
||||
# trailing """ is intentional — the template ends here
|
||||
|
||||
|
||||
@ -211,7 +235,10 @@ class GameEngine:
|
||||
char = self._read_file(CHAR_PATH) or "*No character sheet.*"
|
||||
world = self._read_file(WORLD_PATH) or "*No world state.*"
|
||||
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(
|
||||
self,
|
||||
@ -224,19 +251,34 @@ class GameEngine:
|
||||
parts.append(f"## Previously\n{last_narrative}")
|
||||
if 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 has_existing_story:
|
||||
parts.append(
|
||||
"## Instructions\n"
|
||||
"Establish the opening scene. Dillion is at the Splintered "
|
||||
"Tankard in the Keep. Describe the setting and present "
|
||||
"choices for what he might do. End with a JSON block."
|
||||
"Continue the story from where it left off. Describe "
|
||||
"what happens next based on the current situation. "
|
||||
"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:
|
||||
parts.append(
|
||||
"## Instructions\n"
|
||||
"Describe the outcome of the player's action using game "
|
||||
"mechanics where appropriate. Then present new choices. "
|
||||
"End with a JSON block."
|
||||
"mechanics where appropriate. Let the player decide their "
|
||||
"next move freely. Use tools as needed, then end with a "
|
||||
"JSON block."
|
||||
)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
@ -354,6 +396,242 @@ class GameEngine:
|
||||
except Exception as 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 ────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
@ -370,7 +648,7 @@ class GameEngine:
|
||||
err = "Unknown error"
|
||||
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?```"
|
||||
matches = re.findall(json_pattern, text, re.DOTALL)
|
||||
|
||||
@ -379,13 +657,20 @@ class GameEngine:
|
||||
|
||||
if matches:
|
||||
json_str = matches[-1].strip()
|
||||
# Remove the json block from the narrative
|
||||
narrative = text[: text.rfind("```json")]
|
||||
narrative = narrative.strip()
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
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
|
||||
|
||||
return GenerationResult(
|
||||
|
||||
171
tools/run.py
171
tools/run.py
@ -17,6 +17,7 @@ from pathlib import Path
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Button, Input, Static, TabbedContent, TabPane
|
||||
from rich.markdown import Markdown as RichMarkdown
|
||||
from rich.theme import Theme
|
||||
@ -241,6 +242,76 @@ class AmbiencePlayer:
|
||||
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 ───────────────────────────────
|
||||
class AutoStatic(Static):
|
||||
def load(self):
|
||||
@ -358,17 +429,6 @@ class ChaosTUI(App):
|
||||
padding: 1 2;
|
||||
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 {
|
||||
background: #1a1a2a;
|
||||
color: #e0b060;
|
||||
@ -480,6 +540,11 @@ class ChaosTUI(App):
|
||||
# Thinking animation
|
||||
self._thinking_dots = 0
|
||||
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
|
||||
self._book_page = 0
|
||||
@ -496,7 +561,6 @@ class ChaosTUI(App):
|
||||
with TabPane("PLAY", id="play-tab"):
|
||||
with VerticalScroll(id="play-scroll"):
|
||||
yield Static("*Awaiting the fates...*", id="play-narrative")
|
||||
yield Horizontal(id="play-choices")
|
||||
yield Static("", id="play-status")
|
||||
yield Input(
|
||||
placeholder="Type your action and press Enter...",
|
||||
@ -548,9 +612,8 @@ class ChaosTUI(App):
|
||||
|
||||
input_widget = self.query_one("#play-input", Input)
|
||||
input_widget.disabled = True
|
||||
input_widget.placeholder = "LLM is thinking..."
|
||||
input_widget.placeholder = "DM is at work..."
|
||||
|
||||
self._clear_choices()
|
||||
self._show_thinking()
|
||||
|
||||
# Run generation in a daemon thread so it doesn't block the UI
|
||||
@ -562,23 +625,32 @@ class ChaosTUI(App):
|
||||
t.start()
|
||||
|
||||
def _run_generation(self, player_action: str | None) -> None:
|
||||
"""Worker thread: calls engine.generate() and posts result back."""
|
||||
# Provide previous narrative as context on subsequent calls
|
||||
"""Worker thread: calls engine.generate_with_tools() and posts result back."""
|
||||
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,
|
||||
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)
|
||||
|
||||
def _show_thinking(self) -> None:
|
||||
"""Show the thinking indicator and start the animation timer."""
|
||||
self._dm_action = "DM is weaving the narrative"
|
||||
self._thinking_dots = 0
|
||||
status = self.query_one("#play-status", Static)
|
||||
status.add_class("processing")
|
||||
status.update("✦ LLM is weaving the narrative ✦")
|
||||
status.update(f"✦ {self._dm_action} ✦")
|
||||
self._thinking_timer_handle = self.set_interval(
|
||||
0.5, self._tick_thinking
|
||||
)
|
||||
@ -592,14 +664,51 @@ class ChaosTUI(App):
|
||||
status.remove_class("processing")
|
||||
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:
|
||||
"""Animate the thinking dots."""
|
||||
"""Animate the thinking dots on the current DM action."""
|
||||
if not self._is_processing:
|
||||
return
|
||||
self._thinking_dots = (self._thinking_dots + 1) % 4
|
||||
dots = "." * self._thinking_dots
|
||||
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(
|
||||
self, result: GenerationResult, player_action: str | None
|
||||
@ -636,7 +745,6 @@ class ChaosTUI(App):
|
||||
def _display_scene(self, result: GenerationResult) -> None:
|
||||
"""Update the UI with a new scene."""
|
||||
self._set_narrative(result.narrative)
|
||||
self._set_choices(result.choices)
|
||||
self._enable_input()
|
||||
|
||||
def _enable_input(self) -> None:
|
||||
@ -653,17 +761,6 @@ class ChaosTUI(App):
|
||||
scroll = self.query_one("#play-scroll", VerticalScroll)
|
||||
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:
|
||||
self._set_narrative(
|
||||
f"**Error:** {error}\n\n"
|
||||
@ -681,16 +778,8 @@ class ChaosTUI(App):
|
||||
event.stop()
|
||||
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:
|
||||
"""Common handler for player actions from input or buttons."""
|
||||
"""Handle a player action typed in the input."""
|
||||
# Log the action
|
||||
from datetime import datetime
|
||||
timestamp = datetime.now().strftime("%H:%M")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user