clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

feat/elo-difficulty
puzzletea / cmd / new_export.go
new_export.go 6.6K
1package cmd23import (4 "encoding/json"5 "fmt"6 "math/rand/v2"7 "path/filepath"8 "strings"9 "time"1011 "github.com/FelineStateMachine/puzzletea/difficulty"12 "github.com/FelineStateMachine/puzzletea/export/builtinprint"13 "github.com/FelineStateMachine/puzzletea/export/pack"14 "github.com/FelineStateMachine/puzzletea/export/pdf"15 "github.com/FelineStateMachine/puzzletea/game"16 "github.com/FelineStateMachine/puzzletea/namegen"17 "github.com/FelineStateMachine/puzzletea/registry"18 "github.com/FelineStateMachine/puzzletea/resolve"1920 "github.com/spf13/cobra"21)2223var exportNow = time.Now2425type exportModeEntry struct {26 elo game.EloSpawner27 defaultElo difficulty.Elo28 mode string29}3031func runNewExport(cmd *cobra.Command, args []string) error {32 builtinprint.Register()33 flagDifficultySet = cmd.Flags().Changed("difficulty")3435 if err := validateNewExportFlags(cmd, args); err != nil {36 return err37 }3839 entry, ok := registry.Resolve(args[0])40 if !ok {41 return fmt.Errorf("unknown game %q", args[0])42 }43 if !pdfexport.HasPrintAdapter(entry.Definition.Name) {44 return fmt.Errorf("game %q does not support export", entry.Definition.Name)45 }4647 modeArg := ""48 if len(args) > 1 {49 modeArg = args[1]50 }5152 entries, modeSelection, err := collectExportModes(entry, modeArg)53 if err != nil {54 return err55 }5657 targetElo, err := difficultyFlag()58 if err != nil {59 return err60 }6162 generatedAt := exportNow()63 records, err := buildExportRecords(entry.Definition.Name, modeSelection, entries, flagExport, flagWithSeed, targetElo, generatedAt)64 if err != nil {65 return err66 }6768 if err := writeExportJSONL(cmd, flagOutput, records); err != nil {69 return err70 }7172 return nil73}7475func validateNewExportFlags(_ *cobra.Command, args []string) error {76 if flagExport < 1 {77 return fmt.Errorf("--export must be at least 1")78 }79 if strings.TrimSpace(flagOutput) != "" && !strings.EqualFold(filepath.Ext(flagOutput), ".jsonl") {80 return fmt.Errorf("--output must use a .jsonl extension")81 }82 if flagSetSeed != "" {83 return fmt.Errorf("--set-seed cannot be combined with export (--export/--output)")84 }85 if len(args) == 0 {86 return fmt.Errorf("requires at least 1 arg(s), only received 0")87 }88 if len(args) > 2 {89 return fmt.Errorf("accepts at most 2 arg(s), received %d", len(args))90 }91 return nil92}9394func collectExportModes(entry registry.Entry, modeArg string) ([]exportModeEntry, string, error) {95 if modeArg != "" {96 selection, err := resolve.VariantEntry(entry, modeArg)97 if err != nil {98 return nil, "", err99 }100 defaultElo := selection.Variant.Definition.DefaultElo101 if selection.ExplicitElo != nil {102 defaultElo = *selection.ExplicitElo103 }104 return []exportModeEntry{{105 elo: selection.Variant.Elo,106 defaultElo: defaultElo,107 mode: selection.Variant.Definition.Title,108 }}, selection.Variant.Definition.Title, nil109 }110111 entries := make([]exportModeEntry, 0, len(entry.Variants))112 for _, variant := range entry.Variants {113 entries = append(entries, exportModeEntry{114 elo: variant.Elo,115 defaultElo: variant.Definition.DefaultElo,116 mode: variant.Definition.Title,117 })118 }119 if len(entries) == 0 {120 return nil, "", fmt.Errorf("game %q has no exportable variants", entry.Definition.Name)121 }122123 return entries, "mixed variants", nil124}125126func buildExportRecords(127 gameType, modeSelection string,128 entries []exportModeEntry,129 count int,130 seed string,131 targetElo *difficulty.Elo,132 generatedAt time.Time,133) ([]pdfexport.JSONLRecord, error) {134 var rng *rand.Rand135 if seed != "" {136 rng = resolve.RNGFromString(seed)137 }138139 nameSeed := seed140 if strings.TrimSpace(nameSeed) == "" {141 nameSeed = generatedAt.Format(time.RFC3339Nano)142 }143 nameRNG := resolve.RNGFromString("export-names:" + nameSeed)144145 records := make([]pdfexport.JSONLRecord, 0, count)146 for i := range count {147 entry := entries[0]148 if len(entries) > 1 {149 var modeIndex int150 if rng != nil {151 modeIndex = rng.IntN(len(entries))152 } else {153 modeIndex = rand.IntN(len(entries))154 }155 entry = entries[modeIndex]156 }157158 eloSeed := scopedExportEloSeed(seed, generatedAt, gameType, entry.mode, i+1)159 puzzle, report, err := spawnExportPuzzle(entry, rng, eloSeed, targetElo)160 if err != nil {161 return nil, fmt.Errorf("generate puzzle %d: %w", i+1, err)162 }163164 save, err := puzzle.GetSave()165 if err != nil {166 return nil, fmt.Errorf("serialize puzzle %d: %w", i+1, err)167 }168 if !json.Valid(save) {169 return nil, fmt.Errorf("serialize puzzle %d: save payload is not valid JSON", i+1)170 }171172 records = append(records, pdfexport.JSONLRecord{173 Schema: pdfexport.ExportSchemaV2,174 Pack: pdfexport.JSONLPackMeta{175 Generated: generatedAt.Format(time.RFC3339),176 Version: Version,177 Category: gameType,178 ModeSelection: modeSelection,179 Count: count,180 Seed: seed,181 },182 Puzzle: pdfexport.JSONLPuzzle{183 Index: i + 1,184 Name: namegen.GenerateSeeded(nameRNG),185 Game: gameType,186 Mode: entry.mode,187 TargetDifficultyElo: intPtrFromEloReport(report.TargetElo, report.Confidence),188 ActualDifficultyElo: intPtrFromEloReport(report.ActualElo, report.Confidence),189 DifficultyConfidence: string(report.Confidence),190 Save: json.RawMessage(save),191 },192 })193 }194195 return records, nil196}197198func scopedExportEloSeed(seed string, generatedAt time.Time, gameType, mode string, index int) string {199 base := strings.TrimSpace(seed)200 if base == "" {201 base = generatedAt.Format(time.RFC3339Nano)202 }203 return fmt.Sprintf("export:%s:%s:%s:%d", base, gameType, mode, index)204}205206func spawnExportPuzzle(entry exportModeEntry, rng *rand.Rand, seed string, targetElo *difficulty.Elo) (game.Gamer, difficulty.Report, error) {207 effectiveElo := targetElo208 if effectiveElo == nil {209 effectiveElo = &entry.defaultElo210 }211 if entry.elo == nil {212 return nil, difficulty.Report{}, fmt.Errorf("variant does not support Elo difficulty")213 }214 if rng != nil && strings.TrimSpace(seed) == "" {215 seed = fmt.Sprintf("export-seeded:%016x:%016x", rng.Uint64(), rng.Uint64())216 }217 g, report, err := entry.elo.SpawnElo(seed, *effectiveElo)218 if err != nil {219 return nil, difficulty.Report{}, err220 }221 return g, report, nil222}223224func intPtrFromEloReport(elo difficulty.Elo, confidence difficulty.Confidence) *int {225 if confidence == "" {226 return nil227 }228 v := int(elo)229 return &v230}231232func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error {233 content, err := packexport.EncodeJSONL(records)234 if err != nil {235 return err236 }237 if strings.TrimSpace(path) == "" {238 if _, err := cmd.OutOrStdout().Write(content); err != nil {239 return fmt.Errorf("write export jsonl to stdout: %w", err)240 }241 return nil242 }243 return packexport.WriteJSONL(path, records)244}