Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 79 additions & 13 deletions engine/puzzle_engine.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional, Type
from importlib import import_module
from pathlib import Path
from typing import Any, Dict, Optional, Type

try:
import yaml # type: ignore
Expand All @@ -26,8 +28,9 @@ class PuzzleMeta:
solved: bool = False
description: str = ""
data: dict[str, Any] = field(default_factory=dict)
solution: Any = None
condition: Optional[str] = None
on_solve: dict[str, Any] = field(default_factory=dict)
on_solve: Any = field(default_factory=dict)


class PuzzleBase:
Expand Down Expand Up @@ -61,21 +64,34 @@ def mark_solved(self) -> None:
def is_solved(self) -> bool:
return self.solved

def check_solution(self, player_input: Any) -> bool:
"""Check ``player_input`` against ``meta.solution``."""
if player_input == self.meta.solution:
self.mark_solved()
return True
return False


class LogicPuzzle(PuzzleBase):
"""Simple sequence/logic puzzle."""

def submit_answer(self, answer: Any) -> None:
if answer == self.meta.data.get("solution"):
def check_solution(self, player_input: Any) -> bool:
solution = self.meta.solution or self.meta.data.get("solution")
if player_input == solution:
self.mark_solved()
return True
return False


class ManipulationPuzzle(PuzzleBase):
"""Drag or arrange items into the correct order."""

def set_state(self, state: Any) -> None:
if state == self.meta.data.get("solution"):
def check_solution(self, player_input: Any) -> bool:
solution = self.meta.solution or self.meta.data.get("solution")
if player_input == solution:
self.mark_solved()
return True
return False


class DeductionPuzzle(PuzzleBase):
Expand All @@ -87,7 +103,7 @@ def __init__(self, meta: PuzzleMeta, engine: "PuzzleEngine") -> None:

def add_clue(self, clue: Any) -> None:
self.clues.add(clue)
required = set(self.meta.data.get("solution", []))
required = set(self.meta.solution or self.meta.data.get("solution", []))
if required and required.issubset(self.clues):
self.mark_solved()

Expand All @@ -112,10 +128,23 @@ def __init__(
"manipulation": ManipulationPuzzle,
"deduction": DeductionPuzzle,
}
self._load_builtin_types()
self.active_puzzle: Optional[PuzzleBase] = None
self.ui_overlay = ui_overlay
self.scene_manager = scene_manager

def _load_builtin_types(self) -> None:
"""Import puzzle modules from ``engine.puzzle_types`` if available."""
pkg_path = Path(__file__).with_name("puzzle_types")
if not pkg_path.exists():
return
try:
loader = import_module("engine.puzzle_types").load_types
except Exception:
return
for name, cls in loader().items():
self.handlers[name] = cls

# ------------------------------------------------------------------
# Loading
# ------------------------------------------------------------------
Expand Down Expand Up @@ -145,11 +174,33 @@ def load_file(self, path: str) -> None:
solved=bool(entry.get("solved", False)),
description=entry.get("description", ""),
data=entry.get("data", {}) or {},
solution=entry.get("solution"),
condition=entry.get("condition"),
on_solve=entry.get("on_solve", {}) or {},
)
self.registry[puzzle.id] = puzzle

def load_from_yaml(self, scene_id: str, data: Dict[str, Any]) -> None:
"""Load puzzle definitions from already parsed scene YAML."""
entries = data.get("puzzles", [])
if not isinstance(entries, list):
return
for entry in entries:
if not isinstance(entry, dict):
continue
puzzle = PuzzleMeta(
id=entry.get("id", ""),
type=entry.get("type", "logic"),
scene=scene_id,
solved=bool(entry.get("solved", False)),
description=entry.get("description", ""),
data=entry.get("data", {}) or {},
solution=entry.get("solution"),
condition=entry.get("conditions") or entry.get("condition"),
on_solve=entry.get("on_solve", {}) or {},
)
self.registry[puzzle.id] = puzzle

def register_type(self, name: str, cls: Type[PuzzleBase]) -> None:
"""Register a new puzzle handler class."""
self.handlers[name] = cls
Expand All @@ -163,6 +214,7 @@ def load_puzzle(self, puzzle_data: dict[str, Any]) -> PuzzleBase:
solved=bool(puzzle_data.get("solved", False)),
description=puzzle_data.get("description", ""),
data=puzzle_data.get("data", {}) or {},
solution=puzzle_data.get("solution"),
condition=puzzle_data.get("condition"),
on_solve=puzzle_data.get("on_solve", {}) or {},
)
Expand All @@ -185,12 +237,19 @@ def mark_solved(self, puzzle_id: str) -> None:
actions = meta.on_solve
else:
actions = {}
if actions.get("set_flag"):
self.state.set_flag(actions["set_flag"], True)
if actions.get("go_to_scene") and self.scene_manager:
self.scene_manager.open_scene(actions["go_to_scene"])
if actions.get("show_dialogue") and self.ui_overlay:
self.ui_overlay.draw_dialogue_box(actions["show_dialogue"])
if isinstance(actions, list):
acts = actions
else:
acts = [actions]
for act in acts:
if not isinstance(act, dict):
continue
if act.get("set_flag"):
self.state.set_flag(act["set_flag"], True)
if act.get("go_to_scene") and self.scene_manager:
self.scene_manager.open_scene(act["go_to_scene"])
if act.get("show_dialogue") and self.ui_overlay:
self.ui_overlay.draw_dialogue_box(act["show_dialogue"])

def is_solved(self, puzzle_id: str) -> bool:
"""Return True if ``puzzle_id`` is marked solved."""
Expand Down Expand Up @@ -223,3 +282,10 @@ def create(self, puzzle_id: str) -> Optional[PuzzleBase]:
return None
cls = self.handlers.get(meta.type, PuzzleBase)
return cls(meta, self)

def check(self, puzzle_id: str, player_input: Any) -> bool:
"""Validate ``player_input`` for ``puzzle_id`` and mark solved if correct."""
puzzle = self.create(puzzle_id)
if not puzzle:
return False
return puzzle.check_solution(player_input)
8 changes: 8 additions & 0 deletions engine/puzzle_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ def load_file(self, path: str) -> None:
"""Load puzzle registry from ``path``."""
self.engine.load_file(path)

def load_from_yaml(self, scene_id: str, data: dict) -> None:
"""Load puzzle definitions from parsed YAML ``data``."""
self.engine.load_from_yaml(scene_id, data)

# ------------------------------------------------------------------
# Activation
# ------------------------------------------------------------------
Expand Down Expand Up @@ -69,3 +73,7 @@ def draw(self, screen) -> None: # pragma: no cover - UI only
def update(self) -> None:
if self.active:
self.active.update()

def check(self, puzzle_id: str, player_input) -> bool:
"""Validate ``player_input`` for puzzle and mark solved if correct."""
return self.engine.check(puzzle_id, player_input)
21 changes: 21 additions & 0 deletions engine/puzzle_types/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Built-in puzzle type registry."""
from importlib import import_module
from pathlib import Path
from typing import Dict, Type

from ..puzzle_engine import PuzzleBase


def load_types() -> Dict[str, Type[PuzzleBase]]:
"""Dynamically import puzzle type modules and return a registry."""
registry: Dict[str, Type[PuzzleBase]] = {}
pkg_path = Path(__file__).parent
for py in pkg_path.glob("*.py"):
if py.stem.startswith("_") or py.stem == "__init__":
continue
module = import_module(f"engine.puzzle_types.{py.stem}")
cls = getattr(module, "PuzzleType", None)
name = getattr(module, "PUZZLE_TYPE", py.stem)
if cls:
registry[name] = cls
return registry
15 changes: 15 additions & 0 deletions engine/puzzle_types/lockbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Simple lockbox puzzle type."""
from ..puzzle_engine import PuzzleBase

PUZZLE_TYPE = "lockbox"


class PuzzleType(PuzzleBase):
"""A numeric lock that opens with the correct code."""

def check_solution(self, player_input):
code = str(self.meta.solution or self.meta.data.get("code", ""))
if str(player_input) == code:
self.mark_solved()
return True
return False
15 changes: 15 additions & 0 deletions engine/puzzle_types/match_items.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Item matching puzzle."""
from ..puzzle_engine import PuzzleBase

PUZZLE_TYPE = "match_items"


class PuzzleType(PuzzleBase):
"""Match items to their correct slots."""

def check_solution(self, player_input):
expected = self.meta.solution or self.meta.data.get("matches", {})
if player_input == expected:
self.mark_solved()
return True
return False
15 changes: 15 additions & 0 deletions engine/puzzle_types/sequence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Sequence puzzle type."""
from ..puzzle_engine import PuzzleBase

PUZZLE_TYPE = "sequence"


class PuzzleType(PuzzleBase):
"""Validate a sequence of inputs."""

def check_solution(self, player_input):
expected = self.meta.solution or self.meta.data.get("sequence", [])
if player_input == expected:
self.mark_solved()
return True
return False
Loading