When implementing staged boss encounters in a card‑based roguelike, a dedicated interaction loop becomes essential to orchestrate player decisions, enforce rules, and manage auxiliary services such as hints. This article examines the architecture of a reusable boss fight loop that parses multi‑language combat commands, limits paid API calls, and seamlessly integrates with existing game state.
Action Parsing with Enum‑Based Dispatch
All player input passes through a normalising parser that accepts English shortcuts, full phrases, or localised aliases. The parser maps the first token against a configurable set of keywords and extracts optional parameters for card counts and claimed essence types.
from enum import Enum
from typing import Optional, Dict, Any
class Essence(Enum):
RIGHTEOUS = "righteous"
WICKED = "wicked"
NEUTRAL = "neutral"
# Keyword aliases can be extended for different languages
KEYWORD_MAP = {
"play": "follow", "p": "follow", "place": "follow",
"chal": "challenge", "c": "challenge", "dispute": "challenge",
"quit": "fold", "f": "fold", "surrender": "fold",
}
ESSENCE_ALIASES = {
"righteous": Essence.RIGHTEOUS, "good": Essence.RIGHTEOUS,
"wicked": Essence.WICKED, "evil": Essence.WICKED,
"neutral": Essence.NEUTRAL, "balanced": Essence.NEUTRAL,
}
def parse_action(user_input: str) -> Optional[Dict[str, Any]]:
"""Convert raw player input into a structured action dictionary."""
clean = user_input.strip().lower()
if not clean:
return None
tokens = clean.split()
root = KEYWORD_MAP.get(tokens[0], tokens[0])
if root == "follow":
if len(tokens) != 3:
return None
try:
count = int(tokens[1])
except ValueError:
return None
if not 1 <= count <= 3:
return None
essence = ESSENCE_ALIASES.get(tokens[2])
if essence is None:
return None
return {"action": "follow", "card_count": count, "claimed_essence": essence}
if root in ("challenge", "fold"):
if len(tokens) != 1:
return None
return {"action": root}
return None
The parser focuses on clarity and extensibility: new aliases can be registered without modifying branching logic. The returned dictionary contains only primitive types and enumeration members, making it straightforward to validate in the main loop.
The Encounter Loop
The core function run_encounter accepts a fully initialised BossEncounter instance and a session state dictionary. It maintains a local hint counter and delegates system commands (inventory, help, status) to a pre‑existing dispatcher, while intercepting the hint command to enforce the per‑fight usage cap of three.
def run_encounter(encounter: BossEncounter, session: dict) -> str:
"""
Execute a full boss fight loop.
Returns:
'player' if the boss is defeated, 'boss' if the player loses,
or 'exit' if the player quits manually.
"""
hint_usage = 0
MAX_HINTS = 3
while not encounter.is_finished:
snapshot = encounter.get_snapshot()
_display_table(snapshot)
if encounter.current_actor == "player":
raw = input("> ").strip()
if not raw:
continue
# ---- intercept hint to count usage ----
if _is_hint_command(raw):
if hint_usage >= MAX_HINTS:
print("Hint limit reached (3). You cannot ask for more hints in this fight.")
continue
response = dispatch_system("hint", session, encounter)
print(response["message"])
if response["should_exit"]:
return "exit"
hint_usage += 1
continue
# ---- other system commands ----
sys_resp = dispatch_system(raw, session, encounter)
if sys_resp["should_exit"]:
return "exit"
if sys_resp["message"]:
print(sys_resp["message"])
continue
# ---- battle action ----
action = parse_action(raw)
if action is None:
print("Invalid command. Use play/challenge/fold or a system command (help, pack, status, hint, quit).")
continue
outcome = encounter.perform_player_action(**action)
print(outcome["message"])
if outcome.get("narration"):
print(outcome["narration"])
if outcome["game_over"]:
break
else:
outcome = encounter.perform_boss_turn()
print(outcome["message"])
if outcome.get("narration"):
print(outcome["narration"])
if outcome["game_over"]:
break
return encounter.winner
The loop delegates turn management to the encounter object; after each valid player action or boss move the game_over flag is checked immediately. System commands are processed without advancing the turn, while a hint only consumes the usage counter. The _display_table helper renders player hand size, boss hand size, last claim, and the active pile count.
Integrating with External Modules
The dispatch_system function mirrors the existing handle_system_command from the project’s command module but is adapted to accept the encounter context directly. When a hint is requested, the dispatcher calls the LLM‑powered hint module using the encounter’s snapshot as prompt context. The session dictionary carries the in_boss_fight flag sothat hints know to pull relevant boss‑specific data.
To guarantee the per‑encounter hint limit, the loop pre‑empts the dispatcher for hint commands. This separation keeps the limit logic inside the loop and avoids modifying the general command handler.
Testing Strategy
Unit tests for the parser cover every alias combination, boundary values (card count 0 or 4), and malformed input. Integration tests simulate a full fight by mocking the BossEncounter methods to return controlled snapshots and outcomes. The hint limit test calls the hint path four times and asserts that the fourth invocation is blocked while the fight continues. Exit tests verify that quit returns 'exit' immediately without affecting the encounter result.
All test doubles inherit from a lightweight interface, making them easy to reuse across both loop and parser tests. The mock setup also exercises the narration and display functions to catch rendering regressions early.