package companion import ( "context" "fmt" "sort" "strings" "sync" "time" ) // SCUMPlayerLogProjection is plugin-owned business logic. It consumes the // opaque log channel after the platform has forwarded it and publishes a typed // players snapshot; Run and Platform never inspect the source text. type SCUMPlayerLogProjection struct { Client *Client ServerInstanceID string KeepForSeconds int MaxRecords int Now func() time.Time SequenceStore SnapshotSequenceStore mu sync.Mutex players map[string]scumPlayerProjection } type scumPlayerProjection struct { PlayerID string PlayerName string Status string LastSeenAt time.Time } func NewSCUMPlayerLogProjection(client *Client, serverInstanceID string) *SCUMPlayerLogProjection { return &SCUMPlayerLogProjection{Client: client, ServerInstanceID: serverInstanceID, KeepForSeconds: 86400, MaxRecords: 1000, Now: time.Now, SequenceStore: NewMemorySnapshotSequenceStore(), players: map[string]scumPlayerProjection{}} } func (projection *SCUMPlayerLogProjection) Handle(ctx context.Context, batch SemanticEventBatch) error { if projection == nil || projection.Client == nil || strings.TrimSpace(projection.ServerInstanceID) == "" { return fmt.Errorf("SCUM player log projection is not configured") } if batch.ServerID != projection.ServerInstanceID { return fmt.Errorf("SCUM player log projection server scope mismatch") } now := time.Now if projection.Now != nil { now = projection.Now } observedAt := now().UTC() projection.mu.Lock() for _, event := range batch.Events { playerID := strings.TrimSpace(event.PlayerID) if playerID == "" { continue } name := strings.TrimSpace(event.DisplayName) if name == "" { name = playerID } player := projection.players[playerID] player.PlayerID = playerID player.PlayerName = name player.LastSeenAt = event.OccurredAt.UTC() if player.LastSeenAt.IsZero() { player.LastSeenAt = now().UTC() } switch event.Type { case "scum.login": player.Status = "online" case "scum.logout": player.Status = "offline" default: continue } projection.players[playerID] = player } if len(batch.Events) == 0 { projection.mu.Unlock() return nil } players := make([]scumPlayerProjection, 0, len(projection.players)) for _, player := range projection.players { players = append(players, player) } store := projection.SequenceStore if store == nil { store = NewMemorySnapshotSequenceStore() projection.SequenceStore = store } sequence, err := store.Next("players", "current", snapshotSequenceFloor(observedAt)) if err != nil { projection.mu.Unlock() return err } projection.mu.Unlock() sort.Slice(players, func(i, j int) bool { return players[i].PlayerID < players[j].PlayerID }) payloadPlayers := make([]map[string]any, 0, len(players)) for _, player := range players { payloadPlayers = append(payloadPlayers, map[string]any{ "playerId": player.PlayerID, "playerName": player.PlayerName, "status": player.Status, "lastSeenAt": player.LastSeenAt.Format(time.RFC3339Nano), }) } _, err = projection.Client.UploadSnapshot(ctx, Snapshot{ Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, ObservedAt: observedAt, Payload: map[string]any{"observedAt": observedAt.Format(time.RFC3339Nano), "players": payloadPlayers}, KeepForSeconds: projection.KeepForSeconds, MaxRecords: projection.MaxRecords, }) return err } type noopConsoleLogStore struct{} func (noopConsoleLogStore) EnsureSchema(context.Context) error { return nil } func (noopConsoleLogStore) StoreConsoleRecords(context.Context, []ConsoleRecord) (int, error) { return 0, nil } func (noopConsoleLogStore) StoreSemanticEventBatch(_ context.Context, batch SemanticEventBatch) (int, error) { return len(batch.Events), nil }