module

GameLoop

Constants

DEBUG = (ENV.fetch("CRYSTAL_QUEST_DEBUG", "0")) == "1"

Debug mode - set via CRYSTAL_QUEST_DEBUG=1 environment variable

DM_MODEL = Anthropic::Model::CLAUDE_SONNET_5

Model used for the Dungeon Master. Sonnet 5 is the latest Sonnet shipped with anthropic-cr 0.8.0 — strong at tool use and following the strict "call tools before narrating" contract this game relies on.

HEALTH_QUERY_PATTERN = /^(how('?s| is)?( my)?|check( my)?|what('?s| is)?( my)?)\s*(health|hp|hit points|condition|wounds)/
INVENTORY_QUERY_PATTERN = /^(what('?s| is| do i have)?( in)?( my)?|check( my)?|show( me)?( my)?|list( my)?)\s*(inventory|items|possessions|belongings|pack|bag|stuff|equipment)/

Regex patterns for intercepting state queries locally (avoids asking Claude, which prevents it from inventing or misremembering game state).

LOCATION_QUERY_PATTERN = /^(where am i|what room (am i in|is this)|which room (am i in|is this)|current location|where is this place|what is this place)/

Tightened to avoid hijacking "where is this goblin from?" / "what room has the boss?" — only phrases clearly about the player's OWN location match.

MIN_THINKING_BUDGET = 1024

Minimum valid extended-thinking budget enforced by the Anthropic API.

MUTATING_TOOLS = Set {"modify_hp", "add_to_inventory", "remove_from_inventory", "move_to_room", "add_companion", "update_companion", "remove_companion", "remember"}

Tools that mutate GameState. The marker fallback runs only when NONE of these fired this turn — calling a read-only tool (e.g. get_inventory) must not silently disable the fallback, otherwise narrated-but-uncalled state changes (damage, movement, ...) would be lost.

THINKING_BUDGET = 2000

Token budget for the DM's extended thinking when enabled (see CRYSTAL_QUEST_THINKING). Extended thinking lets the DM reason about combat math, name consistency, and tool ordering before it narrates — which improves adherence to the tool-first rules at the cost of some latency.

Class methods

build_system_prompt(state : GameState) : String

Build the system prompt with DM instructions and current state

Source
cache_hit_percent(usage : UsageTotals) : Int32

Percentage of input tokens served from the prompt cache. Pure function so it can be unit-tested. Measures how effective the tools/system-prompt caching is across an autopilot run — a high rate means Anthropic is reusing the cached tool schemas instead of reprocessing them each turn.

Source
handle_load_command(state : GameState, arg : String | Nil) : Nil
Source
handle_meta_command(state : GameState, input : String) : Symbol | Nil

Handle built-in slash/keyword commands. Returns :quit to exit the loop, :handled to skip to the next iteration, or nil if the input was not a meta command.

Source
handle_save_command(state : GameState, arg : String | Nil) : Nil
Source
handle_state_query(state : GameState, input : String) : Bool

Intercept natural-language state queries (inventory/location/health) and answer them from the authoritative game state. Returns true if handled.

Source
health_condition(hp : Int32, max_hp : Int32) : String

Map current HP to a descriptive condition label based on percentage of max_hp. Pure function so the thresholds can be unit-tested without capturing stdout.

Source
parse_command(input : String) : Tuple(String, String | Nil)

Split input into a downcased command word and an optional remainder arg. Exposed for unit testing of save/load argument handling.

Source
process_response(state : GameState, response : String) : String

Marker safety-net: delegates to MarkerParser so parsing logic lives in one focused module. Kept as a GameLoop method for existing call sites/specs.

Source
query_dungeon_master(state : GameState, player_action : String) : TurnResult
Source
resolve_thinking_budget

Resolve the extended-thinking budget from CRYSTAL_QUEST_THINKING, or nil if thinking is disabled. Accepts "1"/"on"/"yes" (→ default budget) or a token count (>= MIN_THINKING_BUDGET). Values below the API minimum are clamped up. Exposed for unit testing.

Source
run(state : GameState, driver : InputDriver = StdinDriver.new, *, autopilot : Bool = false, max_turns : Int32 | Nil = nil, resume : Bool = false) : Nil

Run the main game loop. driver supplies player actions (STDIN for humans, PlayerAgentDriver for autopilot). When autopilot is true the loop runs the stress harness: caps turns at max_turns, checks state invariants each turn, and prints a report at the end. When resume is true (loaded save), skip the opening DM scene-setting call.

Source
show_companions_meta(state : GameState) : Nil
Source
show_health_query(state : GameState) : Nil
Source
show_inventory_meta(state : GameState) : Nil
Source
show_inventory_query(state : GameState) : Nil
Source
show_journal_meta(state : GameState) : Nil
Source
show_location_query(state : GameState) : Nil
Source
static_dm_instructions

The large, static portion of the system prompt — DM role, the tool-first contract, and response-format rules. This text is identical every turn, so it benefits from Anthropic prompt caching (the tools array carries the cache breakpoint; the system prompt is sent as a stable prefix alongside it). Keeping it in its own method makes the static/dynamic split explicit.

Source

Nested types