package main import ( "context" "database/sql" "flag" "fmt" "log" "os" "os/signal" "syscall" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" "github.com/cosmic-clash/cosmic-clash/server/matcher" "github.com/cosmic-clash/cosmic-clash/server/migrations" "github.com/cosmic-clash/cosmic-clash/server/store" _ "github.com/jackc/pgx/v5/stdlib" "github.com/redis/go-redis/v9" ) func main() { dsn := flag.String("dsn", os.Getenv("COSMIC_CLASH_POSTGRES_DSN"), "PostgreSQL connection string") migrationDir := flag.String("migrations", "migrations", "directory containing numbered SQL migrations") playlist := flag.String("playlist", string(domain.Casual), "playlist to match") size := flag.Int("size", 4, "players per match") interval := flag.Duration("interval", time.Second, "poll interval") rankedRandomArena := flag.Bool("ranked-random-arena", false, "enable ranked matching only when the selected arena is random and non-elevated") redisAddr := flag.String("redis-addr", os.Getenv("COSMIC_CLASH_REDIS_ADDR"), "optional Redis candidate projection address") redisPrefix := flag.String("redis-prefix", envOrDefault("COSMIC_CLASH_REDIS_PREFIX", "cosmic-clash"), "Redis key prefix") redisTTL := flag.Duration("redis-ttl", 60*time.Second, "TTL for transient candidate projection entries") flag.Parse() if *dsn == "" { fatalf("--dsn or COSMIC_CLASH_POSTGRES_DSN is required") } if *playlist != string(domain.Casual) && *playlist != string(domain.Ranked) { fatalf("unsupported playlist %q", *playlist) } selectedPlaylist := domain.Playlist(*playlist) if selectedPlaylist == domain.Ranked && *size != 6 { fatalf("ranked matching requires --size=6") } if selectedPlaylist == domain.Casual && *size < 2 || selectedPlaylist == domain.Casual && *size > 6 { fatalf("casual matching requires --size between 2 and 6") } if *redisTTL <= 0 { fatalf("--redis-ttl must be positive") } db, err := sql.Open("pgx", *dsn) if err != nil { fatalf("open PostgreSQL: %v", err) } defer db.Close() startupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := db.PingContext(startupCtx); err != nil { fatalf("ping PostgreSQL: %v", err) } if err := migrations.Apply(startupCtx, db, *migrationDir); err != nil { fatalf("apply migrations: %v", err) } now := func() time.Time { return time.Now().UTC() } var redisClient *redis.Client var projection *store.CandidateProjection if *redisAddr != "" { redisClient = redis.NewClient(&redis.Options{Addr: *redisAddr}) defer redisClient.Close() candidateProjection := store.CandidateProjection{ Index: store.RedisCandidateIndex{Client: redisClient, Prefix: *redisPrefix, TTL: *redisTTL}, Source: func(ctx context.Context, at time.Time) ([]domain.Candidate, error) { return store.ListQueuedCandidates(ctx, db, selectedPlaylist, at, 1000) }, } projection = &candidateProjection } worker := matcher.Worker{ Source: func(ctx context.Context, at time.Time, playlist domain.Playlist, limit int) ([]domain.Candidate, error) { if projection != nil { candidates, err := projection.Snapshot(ctx, at) if err != nil { return nil, err } if len(candidates) > limit { candidates = candidates[:limit] } filtered := make([]domain.Candidate, 0, len(candidates)) for _, candidate := range candidates { if candidate.Playlist == playlist { filtered = append(filtered, candidate) } } return filtered, nil } return store.ListQueuedCandidates(ctx, db, playlist, at, limit) }, Creator: matcher.ProposalCreatorFunc(func(ctx context.Context, proposal domain.Proposal, ticketIDs map[string]string, at time.Time) error { return store.CreateProposal(ctx, db, proposal, ticketIDs, at) }), Playlist: selectedPlaylist, Size: *size, Now: now, NextID: func() string { return fmt.Sprintf("proposal-%d", time.Now().UnixNano()) }, Prepare: func(id string, playlist domain.Playlist, formation domain.MatchFormation, at time.Time) (domain.PreparedProposal, error) { if playlist == domain.Ranked { playerIDs := make([]string, 0, len(formation.Selection.Players)) for _, player := range formation.Selection.Players { playerIDs = append(playerIDs, player.PlayerID) } participants, err := store.LoadRankedParticipants(context.Background(), db, playerIDs) if err != nil { return domain.PreparedProposal{}, err } return domain.PrepareProposal(id, playlist, formation, participants, domain.RankedArena{RandomEnabled: *rankedRandomArena}, at) } return domain.PrepareProposal(id, playlist, formation, nil, domain.RankedArena{}, at) }, OnError: func(err error) { log.Printf("matcher pass: %v", err) }, } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := worker.Run(ctx, *interval); err != nil && ctx.Err() == nil { fatalf("matcher stopped: %v", err) } } func envOrDefault(name, fallback string) string { if value := os.Getenv(name); value != "" { return value } return fallback } func fatalf(format string, args ...any) { log.Printf("matcher: "+format, args...) os.Exit(1) }