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:
Dejvino 2026-06-25 15:14:35 +02:00
parent 7f69bf6349
commit d265dfc7f7
2 changed files with 432 additions and 58 deletions

View File

@ -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:
parts.append( if has_existing_story:
"## Instructions\n" parts.append(
"Establish the opening scene. Dillion is at the Splintered " "## Instructions\n"
"Tankard in the Keep. Describe the setting and present " "Continue the story from where it left off. Describe "
"choices for what he might do. End with a JSON block." "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: 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,14 +657,21 @@ 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 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( return GenerationResult(
narrative=narrative or text, narrative=narrative or text,

View File

@ -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")