From d01a27e899ef2fa9db966f7a2abb3019a4c5994b Mon Sep 17 00:00:00 2001 From: PtiCalin <143633151+PtiCalin@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:27:23 -0400 Subject: [PATCH 1/2] 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" From ec3a350eb7b716958f1d090f1b9ec332f2f82689 Mon Sep 17 00:00:00 2001 From: PtiCalin <143633151+PtiCalin@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:24:16 -0500 Subject: [PATCH 2/2] Implement core RPG components: Campaign, Character, Combat, Inventory, NPC, Quest, and Spell Managers --- engine/rpg/campaign_manager.py | 40 ++++++++++++++++++++++++++ engine/rpg/character_manager.py | 51 +++++++++++++++++++++++++++++++++ engine/rpg/combat_engine.py | 28 ++++++++++++++++++ engine/rpg/dice_roller.py | 20 +++++++++++++ engine/rpg/inventory_helper.py | 32 +++++++++++++++++++++ engine/rpg/npc_helper.py | 33 +++++++++++++++++++++ engine/rpg/quest_manager.py | 38 ++++++++++++++++++++++++ engine/rpg/spell_manager.py | 36 +++++++++++++++++++++++ 8 files changed, 278 insertions(+) create mode 100644 engine/rpg/campaign_manager.py create mode 100644 engine/rpg/character_manager.py create mode 100644 engine/rpg/combat_engine.py create mode 100644 engine/rpg/dice_roller.py create mode 100644 engine/rpg/inventory_helper.py create mode 100644 engine/rpg/npc_helper.py create mode 100644 engine/rpg/quest_manager.py create mode 100644 engine/rpg/spell_manager.py diff --git a/engine/rpg/campaign_manager.py b/engine/rpg/campaign_manager.py new file mode 100644 index 0000000..5f1b2ab --- /dev/null +++ b/engine/rpg/campaign_manager.py @@ -0,0 +1,40 @@ +""" +Campaign Manager for DnD-style RPG +""" +class Campaign: + def __init__(self, title, description, scenes=None, npcs=None, quests=None): + self.title = title + self.description = description + self.scenes = scenes or [] + self.npcs = npcs or [] + self.quests = quests or [] + + def add_scene(self, scene): + self.scenes.append(scene) + + def add_npc(self, npc): + self.npcs.append(npc) + + def add_quest(self, quest): + self.quests.append(quest) + + def __repr__(self): + return f"" + +class CampaignManager: + def __init__(self): + self.campaigns = [] + + def create_campaign(self, title, description, **kwargs): + campaign = Campaign(title, description, **kwargs) + self.campaigns.append(campaign) + return campaign + + def get_campaign(self, title): + for campaign in self.campaigns: + if campaign.title == title: + return campaign + return None + + def list_campaigns(self): + return self.campaigns diff --git a/engine/rpg/character_manager.py b/engine/rpg/character_manager.py new file mode 100644 index 0000000..efb72fd --- /dev/null +++ b/engine/rpg/character_manager.py @@ -0,0 +1,51 @@ +""" +DnD-style Character Manager for PtiCalin Game Engine +""" + +class Character: + def __init__(self, name, race, char_class, level=1, stats=None, inventory=None, spells=None, quests=None): + self.name = name + self.race = race + self.char_class = char_class + self.level = level + self.stats = stats or { + 'STR': 10, 'DEX': 10, 'CON': 10, 'INT': 10, 'WIS': 10, 'CHA': 10 + } + self.inventory = inventory or [] + self.spells = spells or [] + self.quests = quests or [] + self.hp = self.max_hp() + + def max_hp(self): + # Example: 10 + CON modifier per level + return 10 + (self.stats['CON'] - 10) // 2 * self.level + + def add_item(self, item): + self.inventory.append(item) + + def add_spell(self, spell): + self.spells.append(spell) + + def add_quest(self, quest): + self.quests.append(quest) + + def __repr__(self): + return f"" + +class CharacterManager: + def __init__(self): + self.characters = [] + + def create_character(self, name, race, char_class, **kwargs): + char = Character(name, race, char_class, **kwargs) + self.characters.append(char) + return char + + def get_character(self, name): + for char in self.characters: + if char.name == name: + return char + return None + + def list_characters(self): + return self.characters diff --git a/engine/rpg/combat_engine.py b/engine/rpg/combat_engine.py new file mode 100644 index 0000000..7744166 --- /dev/null +++ b/engine/rpg/combat_engine.py @@ -0,0 +1,28 @@ +""" +Simple DnD-style Combat Engine +""" +from .character_manager import Character +from .dice_roller import roll_dice + +class CombatEngine: + def __init__(self, party, enemies): + self.party = party # list of Character + self.enemies = enemies # list of Character + self.turn_order = self.determine_initiative() + + def determine_initiative(self): + # Sort by DEX stat, descending + all_combatants = self.party + self.enemies + return sorted(all_combatants, key=lambda c: c.stats['DEX'], reverse=True) + + def attack(self, attacker, defender): + # Roll d20 + STR for melee, DEX for ranged + attack_roll, _, _ = roll_dice('1d20') + attack_mod = attacker.stats['STR'] if attacker.char_class in ['Fighter', 'Barbarian'] else attacker.stats['DEX'] + total_attack = attack_roll + (attack_mod - 10) // 2 + # Simple AC check + if total_attack >= defender.stats.get('AC', 10): + damage, _, _ = roll_dice('1d8') + defender.hp -= damage + return True, damage + return False, 0 diff --git a/engine/rpg/dice_roller.py b/engine/rpg/dice_roller.py new file mode 100644 index 0000000..d555b70 --- /dev/null +++ b/engine/rpg/dice_roller.py @@ -0,0 +1,20 @@ +""" +Dice Roller for DnD-style RPG +""" +import random + +def roll_dice(dice_str): + """ + Roll dice in NdM format, e.g. '2d6+1' + Returns total and breakdown. + """ + import re + match = re.match(r"(\d+)d(\d+)([+-]\d+)?", dice_str) + if not match: + raise ValueError("Invalid dice format") + n, m, mod = match.groups() + n, m = int(n), int(m) + mod = int(mod) if mod else 0 + rolls = [random.randint(1, m) for _ in range(n)] + total = sum(rolls) + mod + return total, rolls, mod diff --git a/engine/rpg/inventory_helper.py b/engine/rpg/inventory_helper.py new file mode 100644 index 0000000..17ff4c9 --- /dev/null +++ b/engine/rpg/inventory_helper.py @@ -0,0 +1,32 @@ +""" +Inventory Helper for DnD-style RPG +""" +class Item: + def __init__(self, name, item_type, description, properties=None): + self.name = name + self.item_type = item_type # e.g. weapon, armor, potion + self.description = description + self.properties = properties or {} + + def __repr__(self): + return f"" + +class InventoryHelper: + def __init__(self): + self.items = [] + + def add_item(self, name, item_type, description, properties=None): + item = Item(name, item_type, description, properties) + self.items.append(item) + return item + + def get_item(self, name): + for item in self.items: + if item.name == name: + return item + return None + + def list_items(self, item_type=None): + if item_type: + return [i for i in self.items if i.item_type == item_type] + return self.items diff --git a/engine/rpg/npc_helper.py b/engine/rpg/npc_helper.py new file mode 100644 index 0000000..eee7d44 --- /dev/null +++ b/engine/rpg/npc_helper.py @@ -0,0 +1,33 @@ +""" +NPC Helper for DnD-style RPG +""" +class NPC: + def __init__(self, name, traits=None, personality=None, dialogue=None): + self.name = name + self.traits = traits or {} + self.personality = personality or "Neutral" + self.dialogue = dialogue or [] + + def add_dialogue(self, line): + self.dialogue.append(line) + + def __repr__(self): + return f"" + +class NPCHelper: + def __init__(self): + self.npcs = [] + + def add_npc(self, name, traits=None, personality=None, dialogue=None): + npc = NPC(name, traits, personality, dialogue) + self.npcs.append(npc) + return npc + + def get_npc(self, name): + for npc in self.npcs: + if npc.name == name: + return npc + return None + + def list_npcs(self): + return self.npcs diff --git a/engine/rpg/quest_manager.py b/engine/rpg/quest_manager.py new file mode 100644 index 0000000..927612b --- /dev/null +++ b/engine/rpg/quest_manager.py @@ -0,0 +1,38 @@ +""" +Quest Manager for DnD-style RPG +""" +class Quest: + def __init__(self, title, description, status='active', rewards=None): + self.title = title + self.description = description + self.status = status # active, completed, failed + self.rewards = rewards or [] + + def complete(self): + self.status = 'completed' + + def fail(self): + self.status = 'failed' + + def __repr__(self): + return f"" + +class QuestManager: + def __init__(self): + self.quests = [] + + def add_quest(self, title, description, **kwargs): + quest = Quest(title, description, **kwargs) + self.quests.append(quest) + return quest + + def get_quest(self, title): + for quest in self.quests: + if quest.title == title: + return quest + return None + + def list_quests(self, status=None): + if status: + return [q for q in self.quests if q.status == status] + return self.quests diff --git a/engine/rpg/spell_manager.py b/engine/rpg/spell_manager.py new file mode 100644 index 0000000..018d1af --- /dev/null +++ b/engine/rpg/spell_manager.py @@ -0,0 +1,36 @@ +""" +Spell Manager for DnD-style RPG +""" +class Spell: + def __init__(self, name, level, school, description, effect=None): + self.name = name + self.level = level + self.school = school + self.description = description + self.effect = effect or {} + + def __repr__(self): + return f"" + +class SpellManager: + def __init__(self): + self.spells = [] + + def add_spell(self, name, level, school, description, effect=None): + spell = Spell(name, level, school, description, effect) + self.spells.append(spell) + return spell + + def get_spell(self, name): + for spell in self.spells: + if spell.name == name: + return spell + return None + + def list_spells(self, school=None, level=None): + result = self.spells + if school: + result = [s for s in result if s.school == school] + if level: + result = [s for s in result if s.level == level] + return result