1package cmd23import (4 "fmt"5 "strings"67 "github.com/FelineStateMachine/puzzletea/config"8 "github.com/FelineStateMachine/puzzletea/game"9 "github.com/FelineStateMachine/puzzletea/registry"10 "github.com/FelineStateMachine/puzzletea/resolve"11 sessionflow "github.com/FelineStateMachine/puzzletea/session"12 "github.com/FelineStateMachine/puzzletea/store"1314 "github.com/spf13/cobra"15)1617var (18 flagSetSeed string19 flagWithSeed string20 flagExport int21 flagOutput string22)2324var newCmd = &cobra.Command{25 Use: "new <game> [mode]",26 Short: "Start a new puzzle game",27 Long: fmt.Sprintf("Start a new puzzle game, optionally specifying the difficulty mode.\n"+28 "Use --set-seed to generate a deterministic puzzle from a seed string.\n\nAvailable games:\n %s",29 strings.Join(registry.Names(), "\n ")),30 Args: cobra.RangeArgs(0, 2),31 RunE: func(cmd *cobra.Command, args []string) error {32 if flagOutput != "" || cmd.Flags().Changed("export") {33 return runNewExport(cmd, args)34 }3536 cfg := loadActiveConfig()3738 if flagSetSeed != "" {39 if len(args) > 0 {40 return fmt.Errorf("cannot specify game/mode arguments with --set-seed; the seed determines the puzzle type")41 }42 return launchSeededGameFn(flagSetSeed, cfg)43 }44 if len(args) == 0 {45 return fmt.Errorf("requires at least 1 arg(s), only received 0")46 }47 gameArg := args[0]48 var modeArg string49 if len(args) > 1 {50 modeArg = args[1]51 }52 return launchNewGameFn(gameArg, modeArg, flagWithSeed, cfg)53 },54}5556func init() {57 newCmd.Flags().StringVar(&flagSetSeed, "set-seed", "", "seed string for deterministic puzzle selection and generation")58 newCmd.Flags().StringVar(&flagWithSeed, "with-seed", "", "seed string for deterministic puzzle generation within the selected game/mode")59 newCmd.Flags().IntVarP(&flagExport, "export", "e", 1, "number of puzzles to export")60 newCmd.Flags().StringVarP(&flagOutput, "output", "o", "", "write puzzles to a jsonl file (defaults to stdout)")61}6263// launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.64func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {65 entry, ok := registry.Resolve(gameArg)66 if !ok {67 err := fmt.Errorf("unknown game %q", gameArg)68 return err69 }7071 spawner, modeTitle, err := resolve.Mode(entry, modeArg)72 if err != nil {73 return err74 }7576 g, err := spawnFromMode(spawner, seed)77 if err != nil {78 return fmt.Errorf("failed to spawn game: %w", err)79 }8081 s, err := openStoreFn(cfg.DBPath)82 if err != nil {83 return err84 }85 defer s.Close()8687 name := sessionflow.GenerateUniqueName(s)88 g = g.SetTitle(name)8990 rec, err := sessionflow.CreateRecord(s, g, name, entry.Definition.Name, modeTitle, store.NormalRunMetadata())91 if err != nil {92 return err93 }9495 return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, false)96}9798func spawnFromMode(spawner game.Spawner, seed string) (game.Gamer, error) {99 if seed == "" {100 return spawner.Spawn()101 }102103 seeded, ok := spawner.(game.SeededSpawner)104 if !ok {105 return nil, fmt.Errorf("mode does not support deterministic spawning")106 }107 return seeded.SpawnSeeded(resolve.RNGFromString(seed))108}109110// launchSeededGame uses an arbitrary seed string to deterministically select111// a game type, mode, and puzzle. The same seed always produces the same puzzle.112// If a game with the same seed-derived name already exists, it is resumed.113//114// Name generation and mode selection each use independent hashing so that115// changes to namegen word lists or mode lists don't cascade. The shared116// RNG is reserved purely for puzzle content generation.117func launchSeededGame(seed string, cfg *config.Config) error {118 seed = sessionflow.NormalizeSeed(seed)119 name := sessionflow.SeededName(seed)120121 s, err := openStoreFn(cfg.DBPath)122 if err != nil {123 return err124 }125 defer s.Close()126127 // If a game with this name already exists, resume it (including128 // abandoned games — seeded puzzles are deterministic and should129 // always be resumable rather than duplicated).130 if rec, err := s.GetDailyGame(name); err == nil && rec != nil {131 g, err := sessionflow.ImportRecord(rec)132 if err != nil {133 return err134 }135 completed := rec.Status == store.StatusCompleted136137 if err := sessionflow.ResumeAbandonedDeterministicRecord(s, rec); err != nil {138 return err139 }140141 return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, completed)142 }143144 // Mode selection uses rendezvous hashing (independent of RNG).145 spawner, gameType, modeTitle, err := resolve.SeededMode(seed, registry.Entries())146 if err != nil {147 return err148 }149150 // RNG is reserved purely for puzzle content generation.151 rng := resolve.RNGFromString(seed)152 g, err := spawner.SpawnSeeded(rng)153 if err != nil {154 return fmt.Errorf("failed to spawn game: %w", err)155 }156 g = g.SetTitle(name)157158 rec, err := sessionflow.CreateRecord(s, g, name, gameType, modeTitle, store.SeededRunMetadata(seed))159 if err != nil {160 return err161 }162163 return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, false)164}