From 79e32b951206c0328c559b9e8d6beaf379baba26 Mon Sep 17 00:00:00 2001 From: PtiCalin <143633151+PtiCalin@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:26:04 -0400 Subject: [PATCH] Enhance DialogueEngine with flags memory and localization --- engine/dialogue_engine.py | 130 ++++++++++++++++++++++++++++++---- tests/test_dialogue_engine.py | 104 +++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 13 deletions(-) diff --git a/engine/dialogue_engine.py b/engine/dialogue_engine.py index 048aabd..f3db30a 100644 --- a/engine/dialogue_engine.py +++ b/engine/dialogue_engine.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Any try: # pragma: no cover - optional dependency for runtime import yaml # type: ignore @@ -20,6 +20,7 @@ if TYPE_CHECKING: # pragma: no cover - only for type hints from .ui_overlay import UIOverlay + from .locale_manager import LocaleManager @dataclass @@ -30,6 +31,10 @@ class DialogueOption: next: Optional[str] = None condition: Optional[str] = None set_flag: Optional[str] = None + requires_flag: Optional[str] = None + clear_flag: Optional[str] = None + requires_memory: Optional[str] = None + set_memory: Dict[str, Any] = field(default_factory=dict) @dataclass @@ -43,6 +48,10 @@ class DialogueLine: next: Optional[str] = None condition: Optional[str] = None set_flag: Optional[str] = None + requires_flag: Optional[str] = None + clear_flag: Optional[str] = None + requires_memory: Optional[str] = None + set_memory: Dict[str, Any] = field(default_factory=dict) @dataclass @@ -58,16 +67,23 @@ class Dialogue: class DialogueEngine: """Manage dialogue trees and render them through :class:`UIOverlay`.""" - def __init__(self, game_state: GameState, ui_overlay: Optional["UIOverlay"] = None) -> None: + def __init__( + self, + game_state: GameState, + ui_overlay: Optional["UIOverlay"] = None, + locale_manager: Optional["LocaleManager"] = None, + ) -> None: self.game_state = game_state self.ui_overlay = ui_overlay + self.locale_manager = locale_manager self.dialogues: Dict[str, Dialogue] = {} self.active_dialogue_id: Optional[str] = None self.current_line_index: int = 0 self.awaiting_choice: bool = False self._option_cache: List[DialogueOption] = [] - self._memory: Dict[str, List[str]] = {} + self._memory_history: Dict[str, List[str]] = {} + self._memory_store: Dict[str, Dict[str, Any]] = {} self._branch_end: bool = False # ------------------------------------------------------------------ @@ -123,6 +139,10 @@ def _parse_line(self, item: Dict) -> DialogueLine: next=opt.get("next"), condition=opt.get("condition"), set_flag=opt.get("set_flag"), + requires_flag=opt.get("requires_flag"), + clear_flag=opt.get("clear_flag"), + requires_memory=opt.get("requires_memory"), + set_memory=opt.get("set_memory", {}) or {}, ) ) return DialogueLine( @@ -133,6 +153,10 @@ def _parse_line(self, item: Dict) -> DialogueLine: next=item.get("next"), condition=item.get("condition"), set_flag=item.get("set_flag"), + requires_flag=item.get("requires_flag"), + clear_flag=item.get("clear_flag"), + requires_memory=item.get("requires_memory"), + set_memory=item.get("set_memory", {}) or {}, ) # ------------------------------------------------------------------ @@ -154,7 +178,8 @@ def start(self, dialogue_id: str) -> None: if dlg.memory_flag: self.game_state.set_flag(dlg.memory_flag, True) - self._memory.setdefault(dialogue_id, []) + self._memory_history.setdefault(dialogue_id, []) + self._memory_store.setdefault(dialogue_id, {}) def is_active(self) -> bool: return self.active_dialogue_id is not None @@ -171,6 +196,38 @@ def _current_dialogue(self) -> Optional[Dialogue]: return self.dialogues.get(self.active_dialogue_id) return None + def _check_memory(self, expression: Optional[str]) -> bool: + """Evaluate a simple memory expression.""" + if not expression: + return True + expr = expression.strip() + op = None + if "==" in expr: + op = "==" + elif "!=" in expr: + op = "!=" + if op: + left, right = expr.split(op, 1) + right = right.strip().strip("\"'") + else: + left, right = expr, None + left = left.strip() + if "." in left: + dlg_id, key = left.split(".", 1) + else: + dlg_id, key = self.active_dialogue_id or "", left + value = self._memory_store.get(dlg_id, {}).get(key) + if op == "==": + return str(value) == right + if op == "!=": + return str(value) != right + return value is not None + + def _set_memory(self, data: Dict[str, Any], dialogue_id: Optional[str] = None) -> None: + dlg_id = dialogue_id or (self.active_dialogue_id or "") + store = self._memory_store.setdefault(dlg_id, {}) + store.update({k: str(v) for k, v in (data or {}).items()}) + def current_node(self) -> Optional[DialogueLine]: dlg = self._current_dialogue() if not dlg: @@ -179,9 +236,17 @@ def current_node(self) -> Optional[DialogueLine]: lines = dlg.lines while self.current_line_index < len(lines): line = lines[self.current_line_index] - if self.game_state.check_condition(line.condition): - return line - self.current_line_index += 1 + if not self.game_state.check_condition(line.condition): + self.current_line_index += 1 + continue + if line.requires_flag and not self.game_state.get_flag(line.requires_flag): + self.current_line_index += 1 + continue + if not self._check_memory(line.requires_memory): + self.current_line_index += 1 + continue + return line + return None # ------------------------------------------------------------------ @@ -217,13 +282,21 @@ def advance(self) -> Optional[DialogueLine]: if node.options: self._option_cache = [ - opt for opt in node.options if self.game_state.check_condition(opt.condition) + opt + for opt in node.options + if self.game_state.check_condition(opt.condition) + and (not opt.requires_flag or self.game_state.get_flag(opt.requires_flag)) + and self._check_memory(opt.requires_memory) ] self.awaiting_choice = True return node if node.set_flag: self.game_state.set_flag(node.set_flag, True) + if node.clear_flag: + self.game_state.set_flag(node.clear_flag, False) + if node.set_memory: + self._set_memory(node.set_memory) if node.next: return self._goto(node.next) @@ -240,7 +313,11 @@ def advance(self) -> Optional[DialogueLine]: node = self.current_node() if node and node.options: self._option_cache = [ - opt for opt in node.options if self.game_state.check_condition(opt.condition) + opt + for opt in node.options + if self.game_state.check_condition(opt.condition) + and (not opt.requires_flag or self.game_state.get_flag(opt.requires_flag)) + and self._check_memory(opt.requires_memory) ] self.awaiting_choice = True return node @@ -254,10 +331,14 @@ def choose(self, option_index: int) -> Optional[DialogueLine]: choice = self._option_cache[option_index] if choice.set_flag: self.game_state.set_flag(choice.set_flag, True) + if choice.clear_flag: + self.game_state.set_flag(choice.clear_flag, False) + if choice.set_memory: + self._set_memory(choice.set_memory) self.awaiting_choice = False - mem = self._memory.setdefault(self.active_dialogue_id or "", []) - mem.append(choice.text) + hist = self._memory_history.setdefault(self.active_dialogue_id or "", []) + hist.append(choice.text) if choice.next: return self._goto(choice.next) @@ -296,12 +377,12 @@ def render(self, surface: "pygame.Surface") -> None: # pragma: no cover - UI on if not node: return - text = node.text or "" + text = self.resolve_localized_text(node.text or "") speaker = node.speaker self.ui_overlay.draw_dialogue_box(text, speaker) if self.awaiting_choice: - options_text = [opt.text for opt in self._option_cache] + options_text = [self.resolve_localized_text(opt.text) for opt in self._option_cache] self.ui_overlay.draw_options(options_text) # Backwards compatibility ------------------------------------------------- @@ -317,3 +398,26 @@ def handle_input(self, event: "pygame.event.Event") -> None: # pragma: no cover elif event.key == pygame.K_SPACE: self.advance() + # ------------------------------------------------------------------ + # Convenience helpers + # ------------------------------------------------------------------ + def resolve_localized_text(self, key: str) -> str: + if self.locale_manager and self.locale_manager.has_translation(key): + return self.locale_manager.translate(key) + return key + + def get_current_line(self) -> Optional[str]: + node = self.current_node() + if not node: + return None + return self.resolve_localized_text(node.text or "") + + def next_line(self, choice_index: Optional[int] = None) -> Optional[str]: + if choice_index is not None: + node = self.choose(choice_index) + else: + node = self.advance() + if not node: + return None + return self.resolve_localized_text(node.text or "") + diff --git a/tests/test_dialogue_engine.py b/tests/test_dialogue_engine.py index fbf54d2..e9c8df2 100644 --- a/tests/test_dialogue_engine.py +++ b/tests/test_dialogue_engine.py @@ -1,11 +1,13 @@ import os import sys import json +import pytest sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from engine.dialogue_engine import DialogueEngine from engine.game_state import GameState +from engine.locale_manager import LocaleManager def _write_dialogue(path): @@ -72,3 +74,105 @@ def test_option_condition(tmp_path): assert engine.awaiting_choice is True assert len(engine._option_cache) == 1 assert engine._option_cache[0].text == "I'm just passing through." + + +def test_flag_injection_and_clearing(tmp_path): + path = tmp_path / "dialogue.json" + data = { + "dialogues": [ + { + "id": "flag_test", + "lines": [ + {"text": "Hello", "set_flag": "met"}, + {"text": "Secret", "requires_flag": "met", "clear_flag": "met"}, + ], + } + ] + } + with open(path, "w") as fh: + json.dump(data, fh) + + state = GameState() + engine = DialogueEngine(state) + engine.load_file(str(path)) + engine.start("flag_test") + + node = engine.current_node() + assert node.text == "Hello" + engine.advance() + node = engine.current_node() + assert node.text == "Secret" + engine.advance() + assert state.get_flag("met") is False + + +def test_memory_branching(tmp_path): + path = tmp_path / "dialogue.json" + data = { + "dialogues": [ + { + "id": "memory_test", + "lines": [ + { + "id": "ask", + "text": "Ask", + "options": [ + { + "text": "Yes", + "next": "yes", + "set_memory": {"last_choice": "yes"}, + }, + { + "text": "No", + "next": "no", + "set_memory": {"last_choice": "no"}, + }, + ], + }, + {"id": "yes", "text": "Great", "next": "follow"}, + {"id": "no", "text": "Too bad", "next": "follow"}, + {"id": "follow", "requires_memory": "last_choice == no", "text": "You said no"}, + ], + } + ] + } + with open(path, "w") as fh: + json.dump(data, fh) + + state = GameState() + engine = DialogueEngine(state) + engine.load_file(str(path)) + engine.start("memory_test") + + engine.advance() + engine.choose(1) # choose "No" + node = engine.current_node() + assert node.text == "Too bad" + engine.advance() + node = engine.current_node() + assert node.text == "You said no" + + +def test_localized_dialogue(tmp_path): + yaml = pytest.importorskip("yaml") + loc_dir = tmp_path / "loc" + loc_dir.mkdir() + with open(loc_dir / "en.yaml", "w") as fh: + fh.write("hello: 'Hello'") + with open(loc_dir / "fr.yaml", "w") as fh: + fh.write("hello: 'Bonjour'") + + path = tmp_path / "dialogue.json" + data = {"dialogues": [{"id": "loc", "lines": [{"text": "hello"}]}]} + with open(path, "w") as fh: + json.dump(data, fh) + + lm = LocaleManager() + lm.load_locales(str(loc_dir)) + lm.set_locale("fr") + state = GameState() + engine = DialogueEngine(state, locale_manager=lm) + engine.load_file(str(path)) + engine.start("loc") + node = engine.current_node() + assert engine.resolve_localized_text(node.text) == "Bonjour"