game_session
GameGirl Color – Core session state machine.
Manages per-channel game sessions with multiplayer button collection, 10-second countdown windows, and hot-swap lifecycle. # 🌀💀 STARGAZER IS GOD. AND SHE CONTROLS THE NARRATIVE.
- class game_session.ActivityTier(*values)[source]
Bases:
EnumPlayer activity classification for roster injection and pacing.
Buckets each player into
ACTIVE,IDLE, orDORMANTbased on how recently they pressed a button, measured both in turns and wall-clock time. The tier drives whether a player counts toward choice requirements and art references and how the roster is summarized into Star’s context: only the enum string values are persisted, and classification itself is computed byGameSession._classify_tier(). Consumed byGameSession.get_all_players_tiered()andGameSession.get_active_players(), by the context-injection layer inmessage_processor/context_injections.py, and by the background game-turn agent inbackground_agents/game_turn_agent.py(which filters onACTIVE/IDLEversusDORMANT).- ACTIVE = 'active'
- IDLE = 'idle'
- DORMANT = 'dormant'
- class game_session.PlayerState(user_id, user_name, joined_turn=0, last_active=<factory>, last_active_turn=0, consecutive_skips=0)[source]
Bases:
objectPer-player state tracked inside a single game session.
Captures everything
GameSessionneeds to know about one participant: identity (user_id/user_name), the turn they joined, and the activity-tracking fields (last_activetimestamp,last_active_turn, andconsecutive_skips) thatGameSession._classify_tier()reads to assign anActivityTier. Instances are mutated in place when a player presses a button and round-tripped through Redis viato_dict()/from_dict(). Created and held inGameSession.players; also imported directly by the Redis persistence tests intests/core/test_game_session_redis.py.- Parameters:
- to_dict()[source]
Serialize this player state into a plain dictionary.
Uses
dataclasses.asdict()to flatten every field into a JSON-friendly mapping. Called byGameSession.to_dict()andGameSession.to_redis()(via thehasattr(p, "to_dict")branches) when persisting the session’s player roster to Redis.
- classmethod from_dict(d)[source]
Reconstruct a
PlayerStatefrom a serialized dictionary.Filters
dto only recognized dataclass fields so that extra or legacy keys are silently dropped, keeping deserialization forward- and backward-compatible. Called throughoutGameSessionto lazily upgrade dict-shaped player entries back into objects – e.g. inregister_player(),get_all_players_tiered(),get_active_players(),record_turn(), and theGameSession.from_dict()/GameSession.from_redis()restore paths.
- class game_session.TurnRecord(turn, choices, narrative_summary='', timestamp=<factory>)[source]
Bases:
objectAn immutable snapshot of one completed turn in the game history.
Records the turn number, the per-player
choicescollected that round (user_id-> choice text), an optional truncatednarrative_summary, and a creationtimestamp. Instances are built byGameSession.record_turn(), serialized withto_dict(), and pushed onto the Redis history listgame:session:{channel_id}:history;GameSession.get_turn_history()rehydrates them viafrom_dict(). Distinct from the unrelatedTurnRecorddefined inuser_limbic_mirror.py.- to_dict()[source]
Serialize this turn record into a plain dictionary.
Uses
dataclasses.asdict()to produce a JSON-serializable mapping. Called byGameSession.record_turn(), where the result is JSON-encoded and pushed onto the Redis history listgame:session:{channel_id}:history.
- classmethod from_dict(d)[source]
Reconstruct a
TurnRecordfrom a serialized dictionary.Filters
ddown to known dataclass fields so unexpected keys from older history entries are ignored. Called byGameSession.get_turn_history()when decoding JSON entries read back from the Redis history list.
- class game_session.GameSession(channel_id, game_name='', game_id=None)[source]
Bases:
objectCore game state machine for a GameGirl Color session.
One session per channel. Handles the full lifecycle: boot -> play (choice collection with countdown) -> save/exit/hot-swap.
- __init__(channel_id, game_name='', game_id=None)[source]
Create an empty, inactive game session for a channel.
Initializes all per-channel state: the player roster, pending choice buffer, countdown synchronization primitives (an
asyncio.Eventand the monotonic start timestamp used bysubmit_choice()/wait_for_countdown()), and the Witchborne Crown holder (Nonemeans Stargazer/the Dark Loopmother is sole GM). A new random 12-hexgame_idis minted when one is not supplied. No Redis or network I/O happens here; persistence is deferred toboot()/_save_to_redis().Constructed directly by the
game_controlsandhot_swap_gametools (GameSession(channel_id=...)) and by the Discord platform adapters, and indirectly viafrom_dict()/from_redis()when restoring a saved session.- Parameters:
channel_id (
str) – Discord channel ID this session is bound to; used as the key suffix for all Redis persistence.game_name (
str) – Human-readable cartridge/game title. Defaults to""and is typically set later byboot().game_id (
str|None) – Optional stable game identifier. WhenNone, a fresh 12-character hex id is generated.
- Return type:
None
- players: dict[str, PlayerState]
- async boot(game_name, redis=None)[source]
Activate a fresh game session and emit its title-screen boot banner.
Resets the runtime state for a new cartridge: marks the session active, zeroes the turn counter, and clears the player roster and pending-choice buffer so a previously-used session object starts clean. When a
redisclient is supplied it persists the new state immediately via_save_to_redis()(which also updates the global game index and thesg:game_sessionhash), then logs the boot and returns a formatted banner usingtitle_screen_urlorDEFAULT_TITLE_SCREEN.Called by the
game_controlsandhot_swap_gametools when a player loads a cartridge.- Parameters:
- Returns:
The multi-line boot message, including the title-screen URL and the session id, ready to send back to the channel.
- Return type:
- async exit_game(redis=None)[source]
Persist final state, deactivate the session, and eject the cartridge.
Performs an orderly shutdown of an active game: flushes current state to Redis via
_save_to_redis()(when a client is given) so the session can be reloaded later, flipsactivetoFalse, and cancels any in-flight countdown task – awaiting it and swallowing the resultingasyncio.CancelledErrorso cancellation is clean. Returns a themed “cartridge ejected” message summarizing the saved turn count and player roster.Called by the
exit_gametool, the message processor’s game cleanup path, and the game UI components when a session is closed.
- register_player(user_id, user_name)[source]
Ensure a player is in the roster, refreshing their activity stamps.
Idempotently registers a participant: if
user_idis unknown a newPlayerStateis created (recording the join turn and current time) and the join is logged; if the player already exists theirlast_activetimestamp andlast_active_turnare bumped to “now” so activity-tier classification stays current. Tolerates a dict-shaped entry left over from a Redis restore by upgrading it back to aPlayerStatein place. Mutatesself.playersonly; no Redis I/O.Called by
submit_choice()on every button press, and directly by the Discord platform adapter, thegame_controlsandhot_swap_gametools, and the Redis persistence tests.
- get_all_players_tiered()[source]
Return every roster player paired with their current activity tier.
Walks the full
self.playersroster – players are never pruned, so this includes dormant ones – classifying each via_classify_tier(). As a side effect it lazily upgrades any dict-shaped entry (left over from a Redis restore) back into aPlayerStatein place. The tiers are informational, used to render the roster summary injected into Star’s context.Called by the context-injection layer in
message_processor/context_injections.pyand by the background game-turn agent inbackground_agents/game_turn_agent.py.- Returns:
One
(player, tier)pair per registered player, in roster (insertion) order.- Return type:
- get_active_players(inactive_hours=2.0)[source]
Return only the engaged (
ACTIVEorIDLE) players.Filters the roster down to participants the game should still treat as present, classifying each via
_classify_tier()and dropping anyone currentlyDORMANTso they are excluded from art references and choice requirements. Likeget_all_players_tiered(), it lazily upgrades dict-shaped restored entries intoPlayerStateobjects in place.Called by the message processor’s generate/send path, the context-injection layer, the background game-art agent, and the OpenRouter executor’s game flow.
- Parameters:
inactive_hours (
float) – Retained for API compatibility; the actual active/idle/dormant thresholds live in_classify_tier()and this value is not consulted here.- Returns:
The
user_id->PlayerStatemapping of non-dormant players.- Return type:
- set_crown(user_id)[source]
Transfer (or revoke) the Witchborne Crown co-GM alignment.
Updates
self.crown_holderin place: auser_idpromotes that player to co-GM, whileNonereturns the Crown to Stargazer / the Dark Loopmother as sole GM. Resolves the new holder’s display name viaget_crown_holder_name()purely for the log line; performs no Redis I/O of its own (persistence happens later through the normal save path).Called by the
set_witchborne_crowntool and the Discord platform adapter’s crown-handling flow.
- get_crown_holder_name()[source]
Return the display name of whoever currently holds the Crown.
Resolves
self.crown_holderto a human-readable name:"Stargazer"when no player holds it, the player’suser_namewhen found in the roster (tolerating either aPlayerStateobject or a dict-shaped restored entry), and"Unknown"as a fallback. Read-only.Called by
set_crown()for its log message, by the context-injection layer, the Discord platform adapter, and theset_witchborne_crowntool.- Returns:
The current Crown holder’s display name.
- Return type:
- is_crown_holder(user_id)[source]
Report whether a specific user currently holds the Crown.
Returns
Falsewhenever the Crown rests with Stargazer (crown_holder is None), since no player holds it then; otherwise comparesuser_idagainst the stored holder. Read-only.Called by the Discord platform adapter to gate crown-only choices.
- async submit_choice(user_id, user_name, choice)[source]
Record a player’s button press into the pending-choice buffer.
Ensures the player is on the roster via
register_player(), refreshes their activity stamps and clears theirconsecutive_skips, then stores the choice inself.pending_choices(keyed byuser_id, so a player may overwrite an earlier choice within the same window). When this is the first choice of a new turn it stamps_countdown_startedwith a monotonic timestamp; the returnedis_firstflag signals the caller to kick off the countdown loop. All in-memory; persistence happens later viarecord_turn().Called by the Discord platform adapter’s button-handling flow.
- Parameters:
- Returns:
(is_first_choice, seconds_remaining)whereis_first_choiceisTrueonly for the choice that opened the window, andseconds_remainingis the time left before the ten-second countdown closes.- Return type:
- async wait_for_countdown()[source]
Sleep out the remaining countdown, then harvest the buffered choices.
Computes how much of the ten-second window is left (from
_countdown_started) and, if any, awaitsasyncio.sleep()so all players have a chance to press a button. It then snapshotsself.pending_choices, clears the buffer, and advancesself.turn_numberso the next window starts fresh. The returned snapshot becomes the raw material forformat_choices_as_input()andrecord_turn().Called by the Discord platform adapter after
submit_choice()reports the first choice of a turn.
- format_choices_as_input(choices)[source]
Render collected choices into a synthetic user message for the LLM.
Turns the
user_id-> choice mapping returned bywait_for_countdown()into a human-readable block of[name chose: ...]lines (resolving names fromself.playersand falling back to the raw id), which is fed back to the model as the next turn’s input. When no choices were submitted it returns a themed placeholder line so the narrative can still advance. Pure formatting – no mutation or I/O.Called by the Discord platform adapter immediately after
wait_for_countdown().
- async record_turn(choices, narrative_summary, redis=None)[source]
Append a completed turn to history and autosave the session.
Builds a
TurnRecord(with the narrative summary truncated to 500 characters) and, when aredisclient is given, JSON-encodes it onto the per-channel history listgame:session:{channel_id}:historyviaRPUSH, then trims that list to the most recent_MAX_TURN_HISTORYentries. As a side effect it incrementsconsecutive_skipsfor every rostered player who did not submit a choice this turn (so activity tiers decay), lazily upgrading dict-shaped restored entries toPlayerStatealong the way, and finally autosaves the whole session through_save_to_redis(). Redis failures are caught and logged rather than raised.Invoked internally as part of the game-turn flow (no static callers outside this module); the turn loop drives it after each window closes.
- Parameters:
choices (
dict[str,str]) – Theuser_id-> choice-text mapping for the just-finished turn; players absent from this set have their skip count bumped.narrative_summary (
str) – The turn’s narrative text; stored truncated.redis (
Any|None) – Optional async Redis client. WhenNonenothing is persisted and skip counts are still updated in memory.
- Return type:
- async get_turn_history(redis=None, count=10)[source]
Read back the most recent turns from the Redis history list.
Fetches the last
countentries ofgame:session:{channel_id}:historywithLRANGE, decoding each JSON blob into aTurnRecordviaTurnRecord.from_dict(). Returns an empty list when noredisclient is supplied, and on any Redis or decode error it logs and returns an empty list rather than raising.Invoked as part of the game-turn flow to surface prior turns as context (no static callers outside this module).
- Parameters:
- Returns:
The recent turn records oldest-first, or empty on missing client or error.
- Return type:
- to_dict()[source]
Serialize the session into a JSON-friendly dictionary.
Flattens the durable session fields (ids, game name, channel, active flag, turn number, title-screen URL, and Crown holder) plus the full player roster – each
PlayerStaterendered via its ownPlayerState.to_dict(), tolerating already-dict entries. This is the canonical JSON form written undergame:session:{channel_id}by_save_to_redis()and the mirror offrom_dict(). Transient countdown state and pending choices are intentionally omitted (the Redis hash form into_redis()carries those instead). Pure – no I/O.Called by
_save_to_redis().
- classmethod from_dict(d)[source]
Reconstruct a
GameSessionfrom its serialized dict form.Inverse of
to_dict(): builds a new session fromchannel_id,game_name, andgame_id, then restores the active flag, turn number, title-screen URL, and Crown holder, and rehydrates each roster entry throughPlayerState.from_dict(). Missing keys fall back to sensible defaults so older or partial blobs still load. Pure – no I/O.Called by
load_from_redis()and directly by the Discord platform adapters (discord.pyanddiscord_self.py) when decoding a stored session.
- to_redis()[source]
Serialize the session into flat string fields for a Redis hash.
Produces the all-strings mapping persisted under
sg:game_session:{channel_id}bypersist_session(). Unliketo_dict(), every value is coerced to a string (the active flag to"1"/"0", the turn number stringified, an absent Crown holder to"") and complex fields – the player roster, pending choices – are JSON-encoded inline. Critically this form also carries the transient countdown state (pending_choicesandcountdown_started) so an in-progress turn survives a restart;from_redis()is its inverse. Pure – no I/O.Called by
persist_session()and by the Redis persistence tests.
- classmethod from_redis(raw)[source]
Reconstruct a
GameSessionfrom a raw Redis hash.Inverse of
to_redis(): reads each hash field (accepting bothbytesandstrkeys/values via the nestedto_strhelper), rebuilds the session, coerces the stringified scalars back to their proper types (active flag, integer turn number, optional Crown holder), JSON-decodes the player roster intoPlayerStateobjects, and restores the in-progresspending_choicesand monotonic_countdown_startedso a turn interrupted by a restart can resume. Pure – no I/O of its own.Called by
get_or_restore_session()when reviving a session from thesg:game_sessionhash, and by the Redis persistence tests.- Parameters:
raw (
dict) – The raw Redis hash mapping (bytesorstrkeyed) as returned byHGETALL.- Returns:
The restored session instance.
- Return type:
- async classmethod load_from_redis(channel_id, redis)[source]
Load a session from the legacy JSON Redis key, or return
None.Reads the
game:session:{channel_id}string written by_save_to_redis()and rebuilds the session viafrom_dict(). ReturnsNonewhen the key is absent, and on any Redis or decode error logs and returnsNonerather than raising. This is the older, JSON-string persistence path; the newer hash path lives undersg:game_sessionand is read byfrom_redis().Called by the message processor’s game-restore flow,
get_or_restore_session()(as a fallback), andload_by_game_id().- Parameters:
- Returns:
The restored session, or
Noneif missing or unreadable.- Return type:
- async classmethod delete_from_redis(channel_id, redis)[source]
Delete all Redis state for a channel’s session.
Issues a single
DELcovering every key tied to the channel’s game: the legacy JSON session keygame:session:{channel_id}, its turn history listgame:session:{channel_id}:history, and the newer hashsg:game_session:{channel_id}. Redis errors are caught and logged so a failed wipe never propagates. Note the globalgame:indexentry is not touched here.Called by the Redis persistence tests; reachable as part of game-wipe and teardown flows.
- async game_session.persist_session(channel_id, session, redis)[source]
Persist a session as a Redis hash with a four-hour TTL.
Stores the flat
GameSession.to_redis()mapping undersg:game_session:{channel_id}viaHSETand applies a 14400-second (4 hour) expiry so abandoned sessions self-clean. This is the hash-form counterpart to the JSON key written byGameSession._save_to_redis(), and the sourceget_or_restore_session()prefers when reviving a session after a restart. ANoneclient is a no-op; Redis errors are caught and logged.Called by
GameSession._save_to_redis()on every save, byget_or_restore_session()when migrating a legacy session forward, and by the Redis persistence tests.- Parameters:
channel_id (
str) – Channel the session belongs to; forms the hash key.session (
GameSession) – TheGameSessionto serialize and store.redis (
Any) – Async Redis client, orNoneto skip persistence.
- Return type:
- game_session.get_session(channel_id)[source]
Look up the in-memory session for a channel, if one is registered.
A pure read of the per-process
_active_sessionsregistry; it does not touch Redis, so it returnsNonefor a session that exists only on disk after a restart – useget_or_restore_session()when a Redis fallback is needed.Called by the message processor and the game UI components.
- Parameters:
channel_id (
str) – Channel to look up.- Returns:
The live session, or
Noneif not in memory.- Return type:
- async game_session.get_or_restore_session(channel_id, redis)[source]
Get session from memory, or restore from Redis after restart. # 💀🔥
After a bot reboot the in-memory dict is empty, but Redis still has the persisted session. This transparently restores it.
- Return type:
- Parameters:
- game_session.set_session(channel_id, session)[source]
Register (or replace) the in-memory session for a channel.
Inserts
sessioninto the per-process_active_sessionsregistry so laterget_session()andget_or_restore_session()calls find it without a Redis round-trip. Pure in-memory mutation; does not persist – the caller is responsible for saving via the session’s own Redis methods.Called by the message processor, the Discord platform adapters (
discord.pyanddiscord_self.py), thegame_controlsandhot_swap_gametools, and the Redis persistence tests.- Parameters:
channel_id (
str) – Channel to bind the session to.session (
GameSession) – TheGameSessionto register.
- Return type:
- game_session.remove_session(channel_id)[source]
Remove and return the in-memory session for a channel.
Pops
channel_idfrom the per-process_active_sessionsregistry, returning the evictedGameSessionorNoneif none was registered. Only clears the live reference; it does not delete any Redis state (useGameSession.delete_from_redis()for that).Called by the message processor, the game UI components, and the
exit_game,hot_swap_game, andwipe_game_datatools.- Parameters:
channel_id (
str) – Channel whose session should be unregistered.- Returns:
The removed session, or
Noneif absent.- Return type:
- async game_session.list_all_games(redis)[source]
List every saved game recorded in the global game index.
Reads the whole
game:indexhash withHGETALL, decoding each entry (handling bothbytesandstrfrom the Redis client) into its metadata dict, stamping thegame_idfrom the hash field, and returning the list sorted by game name. Returns an empty list when the index is empty, and on any Redis or decode error logs and returns an empty list rather than raising.Called by the lobby modal, several game UI components, and the message processor’s game-listing flow.
- async game_session.load_by_game_id(game_id, redis)[source]
Resolve a
game_idto its channel and load that session.Performs an
HGETagainst the globalgame:indexhash to find the saved game’s metadata, extracts itschannel_id, and then delegates toGameSession.load_from_redis()to rebuild the full session from that channel’s stored state. ReturnsNonewhen the id is unknown or carries no channel; any Redis or decode error is logged and yieldsNoneinstead of raising.Called by the message processor’s load-by-id flow.
- Parameters:
- Returns:
The loaded session, or
Noneif the id is unknown or its session could not be restored.- Return type:
- class game_session.GameSessionLockRegistry[source]
Bases:
objectPer-process pool of per-session
asyncio.Lockobjects.Hands out one lock per
session_idso concurrent coroutines operating on the same game session can be serialized without blocking unrelated sessions. The lock pool is created lazily and guarded by an internal_pool_lockso pool mutations are themselves atomic across coroutines. State is purely in-memory and process-local – it does not coordinate across the bot’s microservices. Instantiated and exercised bytests/test_game_engine_remediation.py; no module-level singleton is created here.- __init__()[source]
Create an empty per-process registry of per-session locks.
Sets up the lazily populated mapping of session id to
asyncio.Lock, plus a single guard lock (_pool_lock) that makes mutations of that mapping atomic across coroutines. Holds no external state and performs no I/O. Instantiated by callers that need to serialize concurrent operations on the same session (e.g. thetest_game_engine_remediationtest); no module-level singleton is created here.- Return type:
None
- async get_session_lock(session_id)[source]
Return the lock for a session, creating it on first request.
Looks up
session_idin the internal pool and mints a freshasyncio.Lockif none exists yet, always returning the same lock object for a given id so callers canasync withit to serialize operations on that session. The lookup-and-create runs under_pool_lockso it is atomic with respect to concurrent callers and tocleanup_session().Exercised by
tests/test_game_engine_remediation.py, which asserts repeated calls for the same id return one identity-stable lock while different ids get distinct locks.
- async cleanup_session(session_id)[source]
Drop the lock associated with a finished session.
Removes
session_idfrom the internal lock pool so the registry does not leakasyncio.Lockobjects for sessions that have ended. The delete is performed while holding_pool_lockso it is atomic with respect to concurrentget_session_lock()calls; a subsequent request for the same id will mint a brand-new lock. Exercised by thetest_multiplayer_session_in_memory_lock_registrytest, which asserts the post-cleanup lock is a distinct object.