diff --git a/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/__init__.py b/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/catalog/__init__.py b/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/catalog/__init__.py new file mode 100644 index 0000000..11dd8e6 --- /dev/null +++ b/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/catalog/__init__.py @@ -0,0 +1,10 @@ +from mastermind_overlay.boss_mutators.catalog.color_ban import ColorBan +from mastermind_overlay.boss_mutators.catalog.white_peg_blind import WhitePegBlind +from mastermind_overlay.boss_mutators.mutator import BossMutator + + +BOSS_MUTATORS: list[type[BossMutator]] = [ + ColorBan, + WhitePegBlind, +] + diff --git a/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/catalog/color_ban.py b/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/catalog/color_ban.py new file mode 100644 index 0000000..c49e1cc --- /dev/null +++ b/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/catalog/color_ban.py @@ -0,0 +1,26 @@ +import random +from typing import override + +from mastermind_kernel.colors import PegColor + +from mastermind_overlay.boss_mutators.mutator import BossMutator + + +class ColorBan(BossMutator): + def __init__(self) -> None: + self._banned_color: PegColor = random.choice(list(PegColor)) + + @property + def banned_color(self) -> PegColor: + return self._banned_color + + @property + @override + def description(self) -> str: + return f"{self._banned_color.name.title()} is banned this round" + + + def allowed_colors(self) -> set[PegColor] | None: + return set(PegColor) - {self._banned_color} + + diff --git a/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/catalog/white_peg_blind.py b/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/catalog/white_peg_blind.py new file mode 100644 index 0000000..8b2309a --- /dev/null +++ b/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/catalog/white_peg_blind.py @@ -0,0 +1,17 @@ +from typing import override + +from mastermind_kernel.feedback import CodeFeedback + +from mastermind_overlay.boss_mutators.mutator import BossMutator + + +class WhitePegBlind(BossMutator): + @property + @override + def description(self) -> str: + return "White pegs are hidden this round" + + + @override + def transform_feedback(self, feedback: CodeFeedback) -> CodeFeedback: + return feedback._replace(white_pegs=0) diff --git a/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/mutator.py b/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/mutator.py new file mode 100644 index 0000000..f15c97f --- /dev/null +++ b/packages/mastermind-overlay/src/mastermind_overlay/boss_mutators/mutator.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from mastermind_kernel.colors import PegColor +from mastermind_kernel.feedback import CodeFeedback + +if TYPE_CHECKING: + from mastermind_overlay.run.gamestate import GameState + + +class BossMutator(ABC): + @property + @abstractmethod + def description(self) -> str: ... + + + def transform_feedback(self, feedback: CodeFeedback) -> CodeFeedback: + return feedback + + + def allowed_colors(self) -> set[PegColor] | None: + return None + + + def on_round_start(self, state: GameState) -> None: + pass + diff --git a/packages/mastermind-overlay/src/mastermind_overlay/events/__init__.py b/packages/mastermind-overlay/src/mastermind_overlay/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/mastermind-overlay/src/mastermind_overlay/events/bus.py b/packages/mastermind-overlay/src/mastermind_overlay/events/bus.py new file mode 100644 index 0000000..e6bcce6 --- /dev/null +++ b/packages/mastermind-overlay/src/mastermind_overlay/events/bus.py @@ -0,0 +1,19 @@ +from collections.abc import Callable +from typing import Any, TypeVar + + +E = TypeVar("E") + +class EventBus: + def __init__(self) -> None: + self._handlers: dict[type, list[Callable[[Any], None]]] = {} + + + def subscribe(self, event_type: type[E], handler: Callable[[E], None]) -> None: + self._handlers.setdefault(event_type, []).append(handler) + + + def publish(self, event: object) -> None: + for handler in self._handlers.get(type(event), []): + handler(event) + diff --git a/packages/mastermind-overlay/src/mastermind_overlay/events/catalog/__init__.py b/packages/mastermind-overlay/src/mastermind_overlay/events/catalog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/mastermind-overlay/src/mastermind_overlay/events/catalog/round_started.py b/packages/mastermind-overlay/src/mastermind_overlay/events/catalog/round_started.py new file mode 100644 index 0000000..02e0c3c --- /dev/null +++ b/packages/mastermind-overlay/src/mastermind_overlay/events/catalog/round_started.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from mastermind_overlay.run.round_tier import RoundTier + + +@dataclass(frozen=True) +class RoundStarted: + tier: RoundTier diff --git a/packages/mastermind-overlay/src/mastermind_overlay/events/catalog/shop_entered.py b/packages/mastermind-overlay/src/mastermind_overlay/events/catalog/shop_entered.py new file mode 100644 index 0000000..4b0b775 --- /dev/null +++ b/packages/mastermind-overlay/src/mastermind_overlay/events/catalog/shop_entered.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ShopEntered: + pass diff --git a/packages/mastermind-overlay/src/mastermind_overlay/run/gamestate.py b/packages/mastermind-overlay/src/mastermind_overlay/run/gamestate.py index e748b43..1efbac2 100644 --- a/packages/mastermind-overlay/src/mastermind_overlay/run/gamestate.py +++ b/packages/mastermind-overlay/src/mastermind_overlay/run/gamestate.py @@ -2,21 +2,26 @@ from mastermind_kernel.feedback import CodeFeedback from mastermind_overlay.relics.relic import Relic -from mastermind_overlay.scoring.points import Points, apply_multiplier, compute_points +from mastermind_overlay.scoring.pipeline import ScoringPipeline +from mastermind_overlay.scoring.points import apply_multiplier, compute_points +from mastermind_overlay.boss_mutators.mutator import BossMutator class GameState: - def __init__(self, max_turns: int, target_score: int, relics: list[Relic]) -> None: + def __init__(self, max_turns: int, target_score: int, relics: list[Relic], mutator: BossMutator | None = None) -> None: self._max_turns: int = max_turns self._target_score: int = target_score self._current_turn: int = 0 self._current_peg: int = 0 + self._mutator: BossMutator | None = mutator self._current_code: Code = Code.blank() + self._sanitize_current_code() self._secret_code: Code = Code.random() self._guessed_codes: list[Code] = [] self._guessed_feedback: list[CodeFeedback] = [] self._score: int = 0 self._relics: list[Relic] = relics + self._pipeline: ScoringPipeline = ScoringPipeline(relics) @property def current_peg(self) -> int: @@ -62,15 +67,20 @@ def game_over(self) -> bool: def target_score(self) -> int: return self._target_score + @property + def mutator(self) -> BossMutator | None: + return self._mutator + + def submit_guess(self) -> None: self._guessed_codes.append(self._current_code) feedback: CodeFeedback = self._secret_code.feedback(self._current_code) - self._guessed_feedback.append(feedback) + displayed_feedback = feedback + if self._mutator is not None: + displayed_feedback = self._mutator.transform_feedback(feedback) + self._guessed_feedback.append(displayed_feedback) - mult: int = 1 - points: Points = compute_points(feedback) - for relic in self._relics: - points, mult = relic.on_guess(points, mult, feedback) + points, mult = self._pipeline.run(compute_points(feedback), 1, feedback) self._score += apply_multiplier(points, mult) self._current_turn += 1 @@ -81,6 +91,25 @@ def add_turn(self) -> None: def cycle_color(self, direction: int) -> None: self._current_code.cycle(self._current_peg, direction) + self._enforce_allowed_color(self._current_peg, direction) + + + def _enforce_allowed_color(self, peg: int, direction: int = 1) -> None: + if self._mutator is None: + return + + allowed = self._mutator.allowed_colors() + if allowed is None: + return + + while self._current_code[peg] not in allowed: + self._current_code.cycle(peg, direction) + + + def _sanitize_current_code(self) -> None: + for peg in range(len(self._current_code)): + self._enforce_allowed_color(peg) + def cycle_current_square(self, direction: int) -> None: if direction > 0: @@ -92,6 +121,7 @@ def cycle_current_square(self, direction: int) -> None: def reset_current_guess(self) -> None: self._current_code = Code.blank() + self._sanitize_current_code() self._current_peg = 0 def reset(self) -> None: diff --git a/packages/mastermind-overlay/src/mastermind_overlay/run/run_state.py b/packages/mastermind-overlay/src/mastermind_overlay/run/run_state.py index bfde852..4bdb0c2 100644 --- a/packages/mastermind-overlay/src/mastermind_overlay/run/run_state.py +++ b/packages/mastermind-overlay/src/mastermind_overlay/run/run_state.py @@ -1,7 +1,13 @@ +import random + from mastermind_overlay.economy.currency import reward_for +from mastermind_overlay.events.bus import EventBus +from mastermind_overlay.events.catalog.round_started import RoundStarted from mastermind_overlay.relics.relic import Relic from mastermind_overlay.run.gamestate import GameState from mastermind_overlay.run.round_tier import RoundTier +from mastermind_overlay.boss_mutators.catalog import BOSS_MUTATORS +from mastermind_overlay.boss_mutators.mutator import BossMutator class RunState: @@ -17,6 +23,7 @@ def __init__(self) -> None: self._max_relics: int = 5 self._currency: int = 0 self._tier_idx: int = 0 + self._events: EventBus = EventBus() @property @@ -35,6 +42,10 @@ def relics(self) -> list[Relic]: def tier(self) -> RoundTier: return self._TIER_ORDER[self._tier_idx] + @property + def events(self) -> EventBus: + return self._events + @property def max_relics(self) -> int: return self._max_relics @@ -55,7 +66,13 @@ def reward_round(self, state: GameState) -> None: def new_round(self) -> GameState: stage_base = self._BASE_TARGET_SCORE * self._TARGET_SCORE_GROWTH ** (self._stage - 1) target = round(stage_base * self.tier.multiplier) - return GameState(self._BASE_MAX_TURNS, target, self._relics) + mutator: BossMutator | None = self._pick_mutator() if self.tier.is_final else None + self._events.publish(RoundStarted(self.tier)) + return GameState(self._BASE_MAX_TURNS, target, self._relics, mutator) + + + def _pick_mutator(self) -> BossMutator: + return random.choice(BOSS_MUTATORS)() def spend(self, amount: int) -> None: diff --git a/packages/mastermind-overlay/src/mastermind_overlay/scoring/pipeline.py b/packages/mastermind-overlay/src/mastermind_overlay/scoring/pipeline.py new file mode 100644 index 0000000..f8eb573 --- /dev/null +++ b/packages/mastermind-overlay/src/mastermind_overlay/scoring/pipeline.py @@ -0,0 +1,19 @@ +from typing import Protocol, Sequence + +from mastermind_kernel.feedback import CodeFeedback +from mastermind_overlay.scoring.points import Points + + +class ScoringStage(Protocol): + def on_guess(self, points: Points, mult: int, feedback: CodeFeedback) -> tuple[Points, int]: ... + + +class ScoringPipeline: + def __init__(self, stages: Sequence[ScoringStage]) -> None: + self._stages: Sequence[ScoringStage] = stages + + + def run(self, points: Points, mult: int, feedback: CodeFeedback) -> tuple[Points, int]: + for stage in self._stages: + points, mult = stage.on_guess(points, mult, feedback) + return points, mult diff --git a/packages/mastermind-shell/src/mastermind_shell/curses_backend/renderer.py b/packages/mastermind-shell/src/mastermind_shell/curses_backend/renderer.py index 816416d..998cf12 100644 --- a/packages/mastermind-shell/src/mastermind_shell/curses_backend/renderer.py +++ b/packages/mastermind-shell/src/mastermind_shell/curses_backend/renderer.py @@ -242,3 +242,18 @@ def _draw_shop_cards(self, items: list[tuple[str, str, int]], selected: int, rel self._stdscr.addstr(y_pos + card_height - 2, x + (card_width - len(price_str)) // 2, price_str, text_attr) x += card_width + 1 + + + def render_boss_announcement(self, title: str, description: str) -> None: + self._stdscr.clear() + self._stdscr.addstr( + curses.LINES // 2, + (curses.COLS - len(title)) // 2, + title, + curses.A_STANDOUT) + self._stdscr.addstr( + curses.LINES // 2 + 1, + (curses.COLS - len(description)) // 2, + description, + curses.A_STANDOUT) + self._stdscr.refresh() diff --git a/packages/mastermind-shell/src/mastermind_shell/engine/renderer.py b/packages/mastermind-shell/src/mastermind_shell/engine/renderer.py index da48ab3..9a2ee46 100644 --- a/packages/mastermind-shell/src/mastermind_shell/engine/renderer.py +++ b/packages/mastermind-shell/src/mastermind_shell/engine/renderer.py @@ -13,3 +13,4 @@ def render_gameplay(self, state: GameState, stage: int, tier_label: str, relics: def render_win(self, correct_guess_str: str) -> None: ... def render_loose(self, correct_guess_str: str) -> None: ... def render_shop(self, currency: int, offer: list[Relic], extra_guess_price: int, selected: int, owned_relics: list[Relic], max_relics: int) -> None: ... + def render_boss_announcement(self, title: str, description: str) -> None: ... diff --git a/packages/mastermind-shell/src/mastermind_shell/screens/boss_announcement.py b/packages/mastermind-shell/src/mastermind_shell/screens/boss_announcement.py new file mode 100644 index 0000000..6a2e1fd --- /dev/null +++ b/packages/mastermind-shell/src/mastermind_shell/screens/boss_announcement.py @@ -0,0 +1,30 @@ +from typing import override + +from mastermind_overlay.boss_mutators.mutator import BossMutator +from mastermind_shell.engine.input_event import InputEvent +from mastermind_shell.engine.renderer import Renderer +from mastermind_shell.engine.screen import Screen +from mastermind_shell.engine.screen_queue import ScreenQueue +from mastermind_shell.engine.transition import ScreenTransition + + +class BossAnnouncementScreen(Screen): + def __init__(self, queue: ScreenQueue, renderer: Renderer, mutator: BossMutator) -> None: + super().__init__(queue, True) + self._renderer: Renderer = renderer + self._mutator: BossMutator = mutator + + + @override + def handle_input(self, event: InputEvent) -> None: + self._queue.push(ScreenTransition.pop()) + + + @override + def update(self) -> None: + pass + + + @override + def render(self) -> None: + self._renderer.render_boss_announcement("BOSS ROUND", self._mutator.description) diff --git a/packages/mastermind-shell/src/mastermind_shell/screens/shop.py b/packages/mastermind-shell/src/mastermind_shell/screens/shop.py index 2ab144b..f41e30a 100644 --- a/packages/mastermind-shell/src/mastermind_shell/screens/shop.py +++ b/packages/mastermind-shell/src/mastermind_shell/screens/shop.py @@ -1,6 +1,7 @@ import random from typing import override +from mastermind_overlay.events.catalog.shop_entered import ShopEntered from mastermind_overlay.relics.catalog import RELICS from mastermind_overlay.relics.relic import Relic from mastermind_overlay.run.gamestate import GameState @@ -11,6 +12,7 @@ from mastermind_shell.engine.screen import Screen from mastermind_shell.engine.screen_queue import ScreenQueue from mastermind_shell.engine.transition import ScreenTransition +from mastermind_shell.screens.boss_announcement import BossAnnouncementScreen _OFFER_SIZE: int = 3 _EXTRA_GUESS_PRICE: int = 8 @@ -30,6 +32,7 @@ def __init__(self, queue: ScreenQueue, renderer: Renderer, run_state: RunState, self._next_state: GameState = next_state self._offer: list[Relic] = generate_offer(run_state.relics) self._cursor: int = 0 + run_state.events.publish(ShopEntered()) @override @@ -51,6 +54,10 @@ def handle_input(self, event: InputEvent) -> None: self._buy_extra_guess() else: self._queue.push(ScreenTransition.pop()) + if self._next_state.mutator is not None: + self._queue.push(ScreenTransition.push( + BossAnnouncementScreen(self._queue, self._renderer, self._next_state.mutator) + )) if event == InputEvent.QUIT: self._queue.push(ScreenTransition.pop())