1package cmd23import (4 "fmt"5 "strings"67 "github.com/FelineStateMachine/puzzletea/config"8 "github.com/FelineStateMachine/puzzletea/difficulty"9 "github.com/FelineStateMachine/puzzletea/game"10 "github.com/FelineStateMachine/puzzletea/registry"11 "github.com/FelineStateMachine/puzzletea/resolve"12 sessionflow "github.com/FelineStateMachine/puzzletea/session"13 "github.com/FelineStateMachine/puzzletea/store"1415 "github.com/spf13/cobra"16)1718var (19 flagSetSeed string20 flagWithSeed string21 flagDifficulty int22 flagDifficultySet bool23 flagExport int24 flagOutput string25)2627var newCmd = &cobra.Command{28 Use: "new <game> [mode]",29 Short: "Start a new puzzle game",30 Long: fmt.Sprintf("Start a new puzzle game, optionally specifying the difficulty mode.\n"+31 "Use --set-seed to generate a deterministic puzzle from a seed string.\n\nAvailable games:\n %s",32 strings.Join(registry.Names(), "\n ")),33 Args: cobra.RangeArgs(0, 2),34 RunE: func(cmd *cobra.Command, args []string) error {35 flagDifficultySet = cmd.Flags().Changed("difficulty")36 if flagOutput != "" || cmd.Flags().Changed("export") {37 return runNewExport(cmd, args)38 }3940 cfg := loadActiveConfig()4142 if flagSetSeed != "" {43 if len(args) > 0 {44 return fmt.Errorf("cannot specify game/mode arguments with --set-seed; the seed determines the puzzle type")45 }46 return launchSeededGameFn(flagSetSeed, cfg)47 }48 if len(args) == 0 {49 return fmt.Errorf("requires at least 1 arg(s), only received 0")50 }51 gameArg := args[0]52 var modeArg string53 if len(args) > 1 {54 modeArg = args[1]55 }56 return launchNewGameFn(gameArg, modeArg, flagWithSeed, cfg)57 },58}5960func init() {61 newCmd.Flags().StringVar(&flagSetSeed, "set-seed", "", "seed string for deterministic puzzle selection and generation")62 newCmd.Flags().StringVar(&flagWithSeed, "with-seed", "", "seed string for deterministic puzzle generation within the selected game/mode")63 newCmd.Flags().IntVar(&flagDifficulty, "difficulty", -1, "target Elo difficulty for Elo-capable generation (0..3000)")64 newCmd.Flags().IntVarP(&flagExport, "export", "e", 1, "number of puzzles to export")65 newCmd.Flags().StringVarP(&flagOutput, "output", "o", "", "write puzzles to a jsonl file (defaults to stdout)")66}6768// launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.69func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {70 entry, ok := registry.Resolve(gameArg)71 if !ok {72 err := fmt.Errorf("unknown game %q", gameArg)73 return err74 }7576 selection, err := resolve.VariantEntry(entry, modeArg)77 if err != nil {78 return err79 }8081 targetElo, err := difficultyFlag()82 if err != nil {83 return err84 }85 if targetElo == nil {86 targetElo = selection.ExplicitElo87 }8889 s, err := openStoreFn(cfg.DBPath)90 if err != nil {91 return err92 }93 defer s.Close()9495 name := sessionflow.GenerateUniqueName(s)9697 g, report, err := spawnFromVariant(selection.Variant, seed, targetElo, name)98 if err != nil {99 return fmt.Errorf("failed to spawn game: %w", err)100 }101 g = g.SetTitle(name)102103 meta := difficultyMetadataFromReport(report)104 rec, err := sessionflow.CreateRecordWithDifficulty(s, g, name, entry.Definition.Name, selection.DisplayTitle, store.NormalRunMetadata(), meta)105 if err != nil {106 return err107 }108109 return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, false)110}111112func spawnFromVariant(variant registry.VariantEntry, seed string, targetElo *difficulty.Elo, fallbackEloSeed string) (game.Gamer, difficulty.Report, error) {113 effectiveElo := targetElo114 if effectiveElo == nil {115 elo := variant.Definition.DefaultElo116 effectiveElo = &elo117 }118 if variant.Elo == nil {119 return nil, difficulty.Report{}, fmt.Errorf("variant does not support Elo difficulty")120 }121 eloSeed := seed122 if eloSeed == "" {123 eloSeed = fallbackEloSeed124 }125 g, report, err := variant.Elo.SpawnElo(eloSeed, *effectiveElo)126 if err != nil {127 return nil, difficulty.Report{}, err128 }129 return g, report, nil130}131132func spawnFromMode(mode registry.ModeEntry, seed string, targetElo *difficulty.Elo, fallbackEloSeed string) (game.Gamer, difficulty.Report, error) {133 effectiveElo := targetElo134 if effectiveElo == nil {135 effectiveElo = mode.Definition.PresetElo136 }137138 if effectiveElo != nil {139 if mode.Elo == nil {140 return nil, difficulty.Report{}, fmt.Errorf("mode does not support Elo difficulty")141 }142 eloSeed := seed143 if eloSeed == "" {144 eloSeed = fallbackEloSeed145 }146 g, report, err := mode.Elo.SpawnElo(eloSeed, *effectiveElo)147 if err != nil {148 return nil, difficulty.Report{}, err149 }150 return g, report, nil151 }152153 if seed == "" {154 g, err := mode.Spawner.Spawn()155 return g, difficulty.Report{}, err156 }157158 if mode.Seeded == nil {159 return nil, difficulty.Report{}, fmt.Errorf("mode does not support deterministic spawning")160 }161 g, err := mode.Seeded.SpawnSeeded(resolve.RNGFromString(seed))162 return g, difficulty.Report{}, err163}164165func difficultyFlag() (*difficulty.Elo, error) {166 if !flagDifficultySet {167 return nil, nil168 }169 elo := difficulty.Elo(flagDifficulty)170 if err := difficulty.ValidateElo(elo); err != nil {171 return nil, err172 }173 return &elo, nil174}175176func difficultyMetadataFromReport(report difficulty.Report) sessionflow.DifficultyMetadata {177 if report.Confidence == "" {178 return sessionflow.DifficultyMetadata{}179 }180 target := report.TargetElo181 actual := report.ActualElo182 return sessionflow.DifficultyMetadata{183 TargetElo: &target,184 ActualElo: &actual,185 Confidence: report.Confidence,186 }187}188189// launchSeededGame uses an arbitrary seed string to deterministically select190// a game type, mode, and puzzle. The same seed always produces the same puzzle.191// If a game with the same seed-derived name already exists, it is resumed.192//193// Name generation and mode selection each use independent hashing so that194// changes to namegen word lists or mode lists don't cascade. The shared195// RNG is reserved purely for puzzle content generation.196func launchSeededGame(seed string, cfg *config.Config) error {197 seed = sessionflow.NormalizeSeed(seed)198 name := sessionflow.SeededName(seed)199200 s, err := openStoreFn(cfg.DBPath)201 if err != nil {202 return err203 }204 defer s.Close()205206 // If a game with this name already exists, resume it (including207 // abandoned games — seeded puzzles are deterministic and should208 // always be resumable rather than duplicated).209 if rec, err := s.GetDailyGame(name); err == nil && rec != nil {210 g, err := sessionflow.ImportRecord(rec)211 if err != nil {212 return err213 }214 completed := rec.Status == store.StatusCompleted215216 if err := sessionflow.ResumeAbandonedDeterministicRecord(s, rec); err != nil {217 return err218 }219220 return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, completed)221 }222223 // Mode selection uses rendezvous hashing (independent of RNG).224 spawner, gameType, modeTitle, err := resolve.SeededMode(seed, registry.Entries())225 if err != nil {226 return err227 }228229 // RNG is reserved purely for puzzle content generation.230 rng := resolve.RNGFromString(seed)231 g, err := spawner.SpawnSeeded(rng)232 if err != nil {233 return fmt.Errorf("failed to spawn game: %w", err)234 }235 g = g.SetTitle(name)236237 rec, err := sessionflow.CreateRecord(s, g, name, gameType, modeTitle, store.SeededRunMetadata(seed))238 if err != nil {239 return err240 }241242 return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, false)243}