package service import ( "browser.local/platform/domain" "browser.local/platform/repo" "crypto/hmac" "crypto/sha256" "encoding/hex" "sort" "strconv" "strings" "time" ) const gameAccessRetention = 30 * 24 * time.Hour const failedAccessWindow = 15 * time.Minute const failedAccessThreshold = 5 func (svc *CoreService) ListGamePlayersForSession(sessionID string, filter domain.GamePlayerFilter) ([]domain.GamePlayer, error) { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { return nil, err } if err := svc.pruneGamePlayerEvidence(filter.ServerInstanceID); err != nil { return nil, err } values, err := svc.store.GamePlayers().List(filter) if err != nil { return nil, err } sort.Slice(values, func(i, j int) bool { return values[i].LastSeenAt.After(values[j].LastSeenAt) }) if filter.Limit > 0 && len(values) > filter.Limit { values = values[:filter.Limit] } return values, nil } func (svc *CoreService) GetGamePlayerProfileForSession(sessionID, playerRecordID string) (domain.GamePlayerProfile, error) { player, err := svc.store.GamePlayers().Get(playerRecordID) if err != nil { return domain.GamePlayerProfile{}, err } if err := svc.authorizeServerLifecycle(sessionID, player.ServerInstanceID); err != nil { return domain.GamePlayerProfile{}, err } if err := svc.pruneGamePlayerEvidence(player.ServerInstanceID); err != nil { return domain.GamePlayerProfile{}, err } aliases, err := svc.store.GamePlayerAliases().List(domain.GamePlayerAliasFilter{GamePlayerRecordID: player.ID}) if err != nil { return domain.GamePlayerProfile{}, err } sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: player.ID}) if err != nil { return domain.GamePlayerProfile{}, err } attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{GamePlayerRecordID: player.ID}) if err != nil { return domain.GamePlayerProfile{}, err } signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{GamePlayerRecordID: player.ID}) if err != nil { return domain.GamePlayerProfile{}, err } return domain.GamePlayerProfile{Player: player, Aliases: aliases, Sessions: sessions, AccessAttempts: attempts, SecuritySignals: signals}, nil } func (svc *CoreService) projectGamePlayerEvents(batch domain.LogBatchIngest) error { for _, entry := range batch.Entries { if err := svc.projectGamePlayerEvent(batch, entry); err != nil { return err } } return svc.pruneGamePlayerEvidence(batch.ServerInstanceID) } func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entry domain.LogEntry) error { fields := entry.Fields if fields == nil { return nil } eventType := strings.TrimSpace(fields["eventType"]) if eventType != "scum.login" && eventType != "scum.logout" { return nil } gameID, name := strings.TrimSpace(fields["playerId"]), strings.TrimSpace(fields["playerName"]) if gameID == "" || name == "" { return nil } occurred := entry.Timestamp if raw := strings.TrimSpace(fields["occurredAt"]); raw != "" { if parsed, err := time.Parse(time.RFC3339, raw); err == nil { occurred = parsed } } if occurred.IsZero() { occurred = svc.now() } recordID := gamePlayerRecordID(batch.ServerInstanceID, gameID) player, err := svc.store.GamePlayers().Get(recordID) if err == repo.ErrNotFound { player = domain.GamePlayer{ID: recordID, ServerInstanceID: batch.ServerInstanceID, GamePlayerID: gameID, DisplayName: name, FirstSeenAt: occurred, LastSeenAt: occurred, LastEventAt: occurred, CreatedAt: svc.now(), UpdatedAt: svc.now()} if err = svc.store.GamePlayers().Create(player); err != nil { return err } } else if err != nil { return err } if occurred.After(player.LastEventAt) || occurred.Equal(player.LastEventAt) { if name != player.DisplayName { player.DisplayName = name } player.LastSeenAt = maxTime(player.LastSeenAt, occurred) player.LastEventAt = occurred player.UpdatedAt = svc.now() if err := svc.store.GamePlayers().Update(player); err != nil { return err } } if err := svc.upsertGamePlayerAlias(player, name, occurred); err != nil { return err } sourceSession := strings.TrimSpace(fields["sessionId"]) if sourceSession == "" { sourceSession = "event-" + entryID(batch.LogStreamID, entry.Seq) } if eventType == "scum.login" { outcome := strings.TrimSpace(fields["outcome"]) if outcome == "accepted" { if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, true, ""); err != nil { return err } if err := svc.recordSuccessfulGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])); err != nil { return err } return svc.openGamePlayerSession(player, sourceSession, occurred) } return svc.recordFailedGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])) } if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, false, strings.TrimSpace(fields["reason"])); err != nil { return err } return svc.closeGamePlayerSession(player, sourceSession, occurred, strings.TrimSpace(fields["reason"])) } func (svc *CoreService) recordSuccessfulGameAccess(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, at time.Time, raw string) error { id := "attempt-" + entryID(batch.LogStreamID, entry.Seq) if _, err := svc.store.GameAccessAttempts().Get(id); err == nil { return nil } else if err != repo.ErrNotFound { return err } key := svc.networkCorrelation(batch.ServerInstanceID, raw) if err := svc.store.GameAccessAttempts().Create(domain.GameAccessAttempt{ID: id, ServerInstanceID: batch.ServerInstanceID, GamePlayerRecordID: player.ID, EventID: entryID(batch.LogStreamID, entry.Seq), OccurredAt: at, Outcome: "accepted", Reason: "login-accepted", NetworkCorrelationKey: key, ExpiresAt: at.Add(gameAccessRetention)}); err != nil { return err } if key != "" { return svc.refreshPossibleAltSignal(player, key, at) } return nil } func (svc *CoreService) refreshPossibleAltSignal(player domain.GamePlayer, key string, at time.Time) error { attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: player.ServerInstanceID}) if err != nil { return err } players := map[string]struct{}{} for _, attempt := range attempts { if attempt.Outcome == "accepted" && attempt.NetworkCorrelationKey == key && !attempt.OccurredAt.Before(at.Add(-gameAccessRetention)) { players[attempt.GamePlayerRecordID] = struct{}{} } } if len(players) < 2 { return nil } id := "signal-alt-" + fingerprintID(player.ServerInstanceID, key) signal, err := svc.store.GameSecuritySignals().Get(id) if err == repo.ErrNotFound { return svc.store.GameSecuritySignals().Create(domain.GameSecuritySignal{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, RuleKey: "possible-alt-account", Status: domain.GameSecuritySignalReviewRequired, EvidenceCount: len(players), Summary: "Multiple game identities share server-local access evidence; manual review required", FirstObservedAt: at, LastObservedAt: at, ExpiresAt: at.Add(gameAccessRetention)}) } if err != nil { return err } signal.EvidenceCount = len(players) signal.LastObservedAt = at signal.ExpiresAt = at.Add(gameAccessRetention) return svc.store.GameSecuritySignals().Update(signal) } func (svc *CoreService) upsertGamePlayerAlias(player domain.GamePlayer, name string, at time.Time) error { id := gamePlayerAliasID(player.ID, name) item, err := svc.store.GamePlayerAliases().Get(id) if err == repo.ErrNotFound { return svc.store.GamePlayerAliases().Create(domain.GamePlayerAlias{ID: id, GamePlayerRecordID: player.ID, ServerInstanceID: player.ServerInstanceID, Alias: name, FirstSeenAt: at, LastSeenAt: at}) } if err != nil { return err } if at.After(item.LastSeenAt) { item.LastSeenAt = at return svc.store.GamePlayerAliases().Update(item) } return nil } func (svc *CoreService) openGamePlayerSession(player domain.GamePlayer, source string, at time.Time) error { id := gamePlayerSessionID(player.ID, source) item, err := svc.store.GamePlayerSessions().Get(id) if err == repo.ErrNotFound { return svc.store.GamePlayerSessions().Create(domain.GamePlayerSession{ID: id, GamePlayerRecordID: player.ID, ServerInstanceID: player.ServerInstanceID, SourceSessionID: source, StartedAt: at, LastEventAt: at}) } if err != nil { return err } if at.After(item.LastEventAt) { item.LastEventAt = at return svc.store.GamePlayerSessions().Update(item) } return nil } func (svc *CoreService) closeGamePlayerSession(player domain.GamePlayer, source string, at time.Time, reason string) error { id := gamePlayerSessionID(player.ID, source) item, err := svc.store.GamePlayerSessions().Get(id) if err == repo.ErrNotFound { return nil } if err != nil { return err } if item.StartedAt.After(at) || (!item.EndedAt.IsZero() && !at.After(item.EndedAt)) { return nil } item.EndedAt = at item.EndReason = bounded(reason, 40) item.LastEventAt = maxTime(item.LastEventAt, at) return svc.store.GamePlayerSessions().Update(item) } func (svc *CoreService) recordFailedGameAccess(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, at time.Time, raw string) error { id := "attempt-" + entryID(batch.LogStreamID, entry.Seq) if _, err := svc.store.GameAccessAttempts().Get(id); err == nil { return nil } else if err != repo.ErrNotFound { return err } key := svc.networkCorrelation(batch.ServerInstanceID, raw) attempt := domain.GameAccessAttempt{ID: id, ServerInstanceID: batch.ServerInstanceID, GamePlayerRecordID: player.ID, EventID: entryID(batch.LogStreamID, entry.Seq), OccurredAt: at, Outcome: "rejected", Reason: "login-rejected", NetworkCorrelationKey: key, ExpiresAt: at.Add(gameAccessRetention)} if err := svc.store.GameAccessAttempts().Create(attempt); err != nil { return err } if key != "" { return svc.refreshFailedAccessSignal(player, key, at) } return nil } func (svc *CoreService) refreshFailedAccessSignal(player domain.GamePlayer, key string, at time.Time) error { attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: player.ServerInstanceID}) if err != nil { return err } count := 0 for _, a := range attempts { if a.NetworkCorrelationKey == key && !a.OccurredAt.Before(at.Add(-failedAccessWindow)) { count++ } } if count < failedAccessThreshold { return nil } id := "signal-failed-" + fingerprintID(player.ServerInstanceID, key) s, err := svc.store.GameSecuritySignals().Get(id) if err == repo.ErrNotFound { return svc.store.GameSecuritySignals().Create(domain.GameSecuritySignal{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, RuleKey: "excessive-failed-access", Status: domain.GameSecuritySignalReviewRequired, EvidenceCount: count, Summary: "Repeated failed access requires manual review", FirstObservedAt: at, LastObservedAt: at, ExpiresAt: at.Add(gameAccessRetention)}) } if err != nil { return err } s.EvidenceCount = count s.LastObservedAt = at s.ExpiresAt = at.Add(gameAccessRetention) return svc.store.GameSecuritySignals().Update(s) } func (svc *CoreService) networkCorrelation(serverID, raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } mac := hmac.New(sha256.New, svc.networkFingerprintKey) mac.Write([]byte(serverID)) mac.Write([]byte{0}) mac.Write([]byte(raw)) return hex.EncodeToString(mac.Sum(nil)) } func (svc *CoreService) pruneGamePlayerEvidence(serverID string) error { now := svc.now() attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: serverID}) if err != nil { return err } for _, a := range attempts { if !a.ExpiresAt.After(now) { if err := svc.store.GameAccessAttempts().Delete(a.ID); err != nil { return err } } } signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{ServerInstanceID: serverID}) if err != nil { return err } for _, s := range signals { if !s.ExpiresAt.After(now) { if err := svc.store.GameSecuritySignals().Delete(s.ID); err != nil { return err } } } return nil } func gamePlayerRecordID(server, id string) string { return "game-player-" + fingerprintID(server, id) } func gamePlayerAliasID(player, name string) string { return "game-player-alias-" + fingerprintID(player, name) } func gamePlayerSessionID(player, session string) string { return "game-player-session-" + fingerprintID(player, session) } func entryID(stream string, seq uint64) string { return stream + "-" + itoa(seq) } func fingerprintID(a, b string) string { sum := sha256.Sum256([]byte(a + "\x00" + b)) return hex.EncodeToString(sum[:])[:24] } func itoa(v uint64) string { return strconv.FormatUint(v, 10) } func maxTime(a, b time.Time) time.Time { if b.After(a) { return b } return a } func bounded(v string, n int) string { v = strings.TrimSpace(v) if len(v) > n { return v[:n] } return v }