package redisstate import ( "encoding/base64" "time" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/racename" ) // defaultPrefix is the mandatory `lobby:` namespace prefix shared by every // Game Lobby Redis key. const defaultPrefix = "lobby:" // GameRecordTTL is the Redis retention applied to game records. The // value is zero (no expiry); a future stage will revisit this // choice when the platform locks in archival/GDPR policy. const GameRecordTTL time.Duration = 0 // ApplicationRecordTTL is the Redis retention applied to application // records. uses zero (no expiry) to match game records; the // archival policy will be revisited when the platform locks it in. const ApplicationRecordTTL time.Duration = 0 // InviteRecordTTL is the Redis retention applied to invite records. // uses zero (no expiry); the `expires_at` field is a business // deadline enforced by the service layer, not a Redis TTL. const InviteRecordTTL time.Duration = 0 // MembershipRecordTTL is the Redis retention applied to membership // records. uses zero (no expiry) to match the other participant // entities. const MembershipRecordTTL time.Duration = 0 // Keyspace builds the frozen Game Lobby Redis keys. All dynamic key // segments are encoded with base64url so raw key structure does not // depend on user-provided or caller-provided characters. type Keyspace struct{} // Game returns the primary Redis key for one game record. func (Keyspace) Game(gameID common.GameID) string { return defaultPrefix + "games:" + encodeKeyComponent(gameID.String()) } // GamesByStatus returns the sorted-set key that stores game identifiers // indexed by their current status. func (Keyspace) GamesByStatus(status game.Status) string { return defaultPrefix + "games_by_status:" + encodeKeyComponent(string(status)) } // GamesByOwner returns the set key that stores game identifiers owned // by one user. The set is maintained for private games whose // OwnerUserID is non-empty (public games are admin-owned and carry an // empty OwnerUserID, so they never enter the index). func (Keyspace) GamesByOwner(userID string) string { return defaultPrefix + "games_by_owner:" + encodeKeyComponent(userID) } // Application returns the primary Redis key for one application record. func (Keyspace) Application(applicationID common.ApplicationID) string { return defaultPrefix + "applications:" + encodeKeyComponent(applicationID.String()) } // ApplicationsByGame returns the set key that stores application // identifiers attached to one game. func (Keyspace) ApplicationsByGame(gameID common.GameID) string { return defaultPrefix + "game_applications:" + encodeKeyComponent(gameID.String()) } // ApplicationsByUser returns the set key that stores application // identifiers submitted by one applicant. func (Keyspace) ApplicationsByUser(applicantUserID string) string { return defaultPrefix + "user_applications:" + encodeKeyComponent(applicantUserID) } // UserGameApplication returns the lookup key that stores the single // non-rejected application identifier for one (user, game) pair. Presence // of this key blocks a second submitted/approved application for the // same user and game. func (Keyspace) UserGameApplication(applicantUserID string, gameID common.GameID) string { return defaultPrefix + "user_game_application:" + encodeKeyComponent(applicantUserID) + ":" + encodeKeyComponent(gameID.String()) } // Invite returns the primary Redis key for one invite record. func (Keyspace) Invite(inviteID common.InviteID) string { return defaultPrefix + "invites:" + encodeKeyComponent(inviteID.String()) } // InvitesByGame returns the set key that stores invite identifiers // attached to one game. func (Keyspace) InvitesByGame(gameID common.GameID) string { return defaultPrefix + "game_invites:" + encodeKeyComponent(gameID.String()) } // InvitesByUser returns the set key that stores invite identifiers // addressed to one invitee. func (Keyspace) InvitesByUser(inviteeUserID string) string { return defaultPrefix + "user_invites:" + encodeKeyComponent(inviteeUserID) } // InvitesByInviter returns the set key that stores invite identifiers // created by one inviter (private-game owner). The set retains // invite_ids regardless of subsequent status transitions; callers // filter by status when needed. func (Keyspace) InvitesByInviter(inviterUserID string) string { return defaultPrefix + "user_inviter_invites:" + encodeKeyComponent(inviterUserID) } // Membership returns the primary Redis key for one membership record. func (Keyspace) Membership(membershipID common.MembershipID) string { return defaultPrefix + "memberships:" + encodeKeyComponent(membershipID.String()) } // MembershipsByGame returns the set key that stores membership // identifiers attached to one game. func (Keyspace) MembershipsByGame(gameID common.GameID) string { return defaultPrefix + "game_memberships:" + encodeKeyComponent(gameID.String()) } // MembershipsByUser returns the set key that stores membership // identifiers held by one user. func (Keyspace) MembershipsByUser(userID string) string { return defaultPrefix + "user_memberships:" + encodeKeyComponent(userID) } // RegisteredRaceName returns the Redis key that stores the registered // race name bound to canonical. func (Keyspace) RegisteredRaceName(canonical racename.CanonicalKey) string { return defaultPrefix + "race_names:registered:" + encodeKeyComponent(canonical.String()) } // UserRegisteredRaceNames returns the set key that stores canonical keys // of every registered race name owned by userID. func (Keyspace) UserRegisteredRaceNames(userID string) string { return defaultPrefix + "race_names:user_registered:" + encodeKeyComponent(userID) } // RaceNameReservation returns the Redis key that stores the per-game race // name reservation bound to (gameID, canonical). func (Keyspace) RaceNameReservation(gameID common.GameID, canonical racename.CanonicalKey) string { return defaultPrefix + "race_names:reservations:" + encodeKeyComponent(gameID.String()) + ":" + encodeKeyComponent(canonical.String()) } // UserRaceNameReservations returns the set key that stores // `:` tuples of every active reservation // (including pending_registration) owned by userID. func (Keyspace) UserRaceNameReservations(userID string) string { return defaultPrefix + "race_names:user_reservations:" + encodeKeyComponent(userID) } // RaceNameCanonicalLookup returns the Redis key that stores the eager // canonical-lookup cache entry for canonical. The cache surfaces the // strongest existing binding (registered > pending_registration > // reservation) so Check remains an O(1) read. func (Keyspace) RaceNameCanonicalLookup(canonical racename.CanonicalKey) string { return defaultPrefix + "race_names:canonical_lookup:" + encodeKeyComponent(canonical.String()) } // PendingRaceNameIndex returns the singleton sorted-set key that indexes // pending registrations by eligible_until_ms for the expiration worker. func (Keyspace) PendingRaceNameIndex() string { return defaultPrefix + "race_names:pending_index" } // RaceNameReservationMember returns the canonical member representation // stored inside UserRaceNameReservations and PendingRaceNameIndex for // (gameID, canonical). func (Keyspace) RaceNameReservationMember(gameID common.GameID, canonical racename.CanonicalKey) string { return encodeKeyComponent(gameID.String()) + ":" + encodeKeyComponent(canonical.String()) } // GapActivatedAt returns the Redis key that stores the gap-window // activation timestamp for one game. func (Keyspace) GapActivatedAt(gameID common.GameID) string { return defaultPrefix + "gap_activated_at:" + encodeKeyComponent(gameID.String()) } // StreamOffset returns the Redis key that stores the last successfully // processed entry id for one Redis Stream consumer. The streamLabel is // the short logical identifier of the consumer (e.g. `runtime_results`, // `gm_events`, `user_lifecycle`), not the full stream name; it stays // stable when the underlying stream key is renamed. func (Keyspace) StreamOffset(streamLabel string) string { return defaultPrefix + "stream_offsets:" + encodeKeyComponent(streamLabel) } // GameTurnStat returns the per-user Redis key that stores the // initial/max stats aggregate for one game. keeps one key per // user so the Lua-backed SaveInitial and UpdateMax scripts can operate // on a single primary key without a secondary index. func (Keyspace) GameTurnStat(gameID common.GameID, userID string) string { return defaultPrefix + "game_turn_stats:" + encodeKeyComponent(gameID.String()) + ":" + encodeKeyComponent(userID) } // GameTurnStatsByGame returns the set key that stores every userID for // which a GameTurnStat key exists for gameID. The set is the lookup // index used by Load and Delete so they avoid a Redis SCAN over the // whole keyspace. func (Keyspace) GameTurnStatsByGame(gameID common.GameID) string { return defaultPrefix + "game_turn_stats_by_game:" + encodeKeyComponent(gameID.String()) } // CapabilityEvaluationGuard returns the Redis key whose presence marks // gameID as already evaluated by the The capability evaluator // uses SETNX on this key to make replayed `game_finished` events safe. func (Keyspace) CapabilityEvaluationGuard(gameID common.GameID) string { return defaultPrefix + "capability_evaluation:done:" + encodeKeyComponent(gameID.String()) } // CreatedAtScore returns the frozen sorted-set score representation for // game creation timestamps stored in the status index. func CreatedAtScore(createdAt time.Time) float64 { return float64(createdAt.UTC().UnixMilli()) } func encodeKeyComponent(value string) string { return base64.RawURLEncoding.EncodeToString([]byte(value)) }