clone

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

refactor/root-tui-model
puzzletea / export / pack / spec.go
spec.go 13.8K
1package packexport23import (4 "context"5 "encoding/json"6 "fmt"7 "os"8 "path/filepath"9 "slices"10 "strings"11 "time"1213 "github.com/FelineStateMachine/puzzletea/difficulty"14 "github.com/FelineStateMachine/puzzletea/export/builtinprint"15 "github.com/FelineStateMachine/puzzletea/export/pdf"16 "github.com/FelineStateMachine/puzzletea/game"17 "github.com/FelineStateMachine/puzzletea/namegen"18 "github.com/FelineStateMachine/puzzletea/puzzle"19 "github.com/FelineStateMachine/puzzletea/registry"20 "github.com/FelineStateMachine/puzzletea/resolve"21)2223// Version is embedded at build time.24var Version = "dev"2526var nowFn = time.Now2728const PackSpecSchemaV2 = "puzzletea.pack.v2"2930const (31 DefaultTitle = "PuzzleTea Sampler"32 DefaultHeader = "A small mixed pack that highlights a few PuzzleTea puzzle styles."33 DefaultAdvert = "Find more puzzles at github.com/FelineStateMachine/puzzletea"34 DefaultSheetLayout = "half-letter"35)3637type Spec struct {38 Schema string `json:"schema,omitempty"`39 Title string `json:"title,omitempty"`40 Header string `json:"header,omitempty"`41 Advert string `json:"advert,omitempty"`42 Volume int `json:"volume,omitempty"`43 SheetLayout string `json:"sheet_layout,omitempty"`44 Seed string `json:"seed,omitempty"`45 PDFOutputPath string `json:"pdf_output_path"`46 JSONLOutputPath string `json:"jsonl_output_path,omitempty"`47 Counts map[puzzle.GameID]map[puzzle.ModeID]int `json:"counts"`48}4950type Result struct {51 TotalCount int52 PDFOutputPath string53 JSONLOutputPath string54}5556type GameCatalog struct {57 GameID puzzle.GameID58 GameType string59 Modes []ModeCatalog60}6162type ModeCatalog struct {63 GameID puzzle.GameID64 GameType string65 ModeID puzzle.ModeID66 ModeTitle string67 Seeded bool68 PresetElo *int69}7071type modeSelection struct {72 gameID puzzle.GameID73 gameType string74 modeID puzzle.ModeID75 modeTitle string76 count int77 spawner game.Spawner78 seededSpawner game.SeededSpawner79 eloSpawner game.EloSpawner80 targetElo *difficulty.Elo81}8283type runPlan struct {84 spec Spec85 selections []modeSelection86}8788func DefaultSpec(cwd string) Spec {89 if strings.TrimSpace(cwd) == "" {90 cwd = "."91 }9293 spec := Spec{94 Schema: PackSpecSchemaV2,95 Title: DefaultTitle,96 Header: DefaultHeader,97 Advert: DefaultAdvert,98 Volume: 1,99 SheetLayout: DefaultSheetLayout,100 PDFOutputPath: filepath.Join(cwd, "out", "puzzletea-export.pdf"),101 Counts: zeroCounts(),102 }103104 setDefaultCount(spec.Counts, "Sudoku", "Sudoku", 2)105 setDefaultCount(spec.Counts, "Word Search", "Word Search", 2)106 setDefaultCount(spec.Counts, "Hitori", "Hitori", 1)107 setDefaultCount(spec.Counts, "Nonogram", "Nonogram", 1)108 setDefaultCount(spec.Counts, "Nurikabe", "Nurikabe", 1)109 setDefaultCount(spec.Counts, "Takuzu", "Takuzu", 1)110 setDefaultCount(spec.Counts, "Shikaku", "Shikaku", 1)111 setDefaultCount(spec.Counts, "Spell Puzzle", "Spell Puzzle", 1)112113 return spec114}115116func ExportCatalog() []GameCatalog {117 entries := registry.Entries()118 catalog := make([]GameCatalog, 0, len(entries))119 for _, entry := range entries {120 if entry.Print == nil {121 continue122 }123124 gameModes := make([]ModeCatalog, 0, len(entry.Variants))125 for _, variant := range entry.Variants {126 elo := int(variant.Definition.DefaultElo)127 gameModes = append(gameModes, ModeCatalog{128 GameID: entry.Definition.ID,129 GameType: entry.Definition.Name,130 ModeID: puzzle.ModeID(variant.Definition.ID),131 ModeTitle: variant.Definition.Title,132 Seeded: variant.Seeded != nil,133 PresetElo: &elo,134 })135 }136137 catalog = append(catalog, GameCatalog{138 GameID: entry.Definition.ID,139 GameType: entry.Definition.Name,140 Modes: gameModes,141 })142 }143 return catalog144}145146func LoadSpecFile(path string) (Spec, error) {147 data, err := os.ReadFile(path)148 if err != nil {149 return Spec{}, fmt.Errorf("read export spec: %w", err)150 }151152 var spec Spec153 if err := json.Unmarshal(data, &spec); err != nil {154 return Spec{}, fmt.Errorf("decode export spec: %w", err)155 }156157 return spec, nil158}159160func ValidateSpec(spec Spec) error {161 _, err := buildRunPlan(spec)162 return err163}164165func Run(ctx context.Context, spec Spec) (Result, error) {166 if ctx == nil {167 ctx = context.Background()168 }169170 builtinprint.Register()171172 plan, err := buildRunPlan(spec)173 if err != nil {174 return Result{}, err175 }176177 generatedAt := nowFn()178 records, err := generateRecords(ctx, plan.selections, strings.TrimSpace(plan.spec.Seed), generatedAt)179 if err != nil {180 return Result{}, err181 }182183 result := Result{184 TotalCount: len(records),185 }186 if err := ctx.Err(); err != nil {187 return Result{}, err188 }189190 var stagedJSONL stagedOutput191 hasJSONL := strings.TrimSpace(plan.spec.JSONLOutputPath) != ""192 if hasJSONL {193 stagedJSONL, err = stageJSONL(plan.spec.JSONLOutputPath, records)194 if err != nil {195 return Result{}, err196 }197 defer stagedJSONL.Cleanup()198 }199200 if err := ctx.Err(); err != nil {201 return Result{}, err202 }203204 doc, err := pdfexport.ParseJSONLRecords("packexport-generated.jsonl", records)205 if err != nil {206 return Result{}, err207 }208209 docs := []pdfexport.PackDocument{doc}210 puzzles := flattenPuzzles(docs)211 AnnotatePuzzlesForPrint(puzzles, registry.Definitions())212213 shuffleSeed := strings.TrimSpace(plan.spec.Seed)214 if shuffleSeed == "" {215 shuffleSeed = generatedAt.Format(time.RFC3339Nano)216 }217 ordered := pdfexport.OrderPuzzlesForPrint(puzzles, shuffleSeed)218219 cfg, err := BuildRenderConfig(RenderOptions{220 Title: plan.spec.Title,221 Header: plan.spec.Header,222 Advert: plan.spec.Advert,223 Volume: plan.spec.Volume,224 SheetLayout: plan.spec.SheetLayout,225 ShuffleSeed: shuffleSeed,226 GeneratedAt: generatedAt,227 }, docs)228 if err != nil {229 return Result{}, err230 }231232 stagedPDF, err := stagePDF(plan.spec.PDFOutputPath, docs, ordered, cfg)233 if err != nil {234 return Result{}, err235 }236 defer stagedPDF.Cleanup()237238 if err := ctx.Err(); err != nil {239 return Result{}, err240 }241 if err := stagedPDF.Publish(); err != nil {242 return Result{}, err243 }244 result.PDFOutputPath = plan.spec.PDFOutputPath245246 if hasJSONL {247 if err := stagedJSONL.Publish(); err != nil {248 return Result{}, err249 }250 result.JSONLOutputPath = plan.spec.JSONLOutputPath251 }252253 return result, nil254}255256func zeroCounts() map[puzzle.GameID]map[puzzle.ModeID]int {257 counts := make(map[puzzle.GameID]map[puzzle.ModeID]int)258 for _, game := range ExportCatalog() {259 gameCounts := make(map[puzzle.ModeID]int, len(game.Modes))260 for _, mode := range game.Modes {261 gameCounts[mode.ModeID] = 0262 }263 counts[game.GameID] = gameCounts264 }265 return counts266}267268func setDefaultCount(counts map[puzzle.GameID]map[puzzle.ModeID]int, gameName, modeTitle string, count int) {269 gameID := puzzle.CanonicalGameID(gameName)270 modeID := puzzle.CanonicalModeID(modeTitle)271 gameCounts, ok := counts[gameID]272 if !ok {273 return274 }275 if _, ok := gameCounts[modeID]; !ok {276 return277 }278 gameCounts[modeID] = count279}280281func buildRunPlan(spec Spec) (runPlan, error) {282 spec = normalizeSpec(spec)283284 if spec.Schema != PackSpecSchemaV2 {285 return runPlan{}, fmt.Errorf("unsupported pack spec schema %q", spec.Schema)286 }287 if !strings.EqualFold(filepath.Ext(spec.PDFOutputPath), ".pdf") {288 return runPlan{}, fmt.Errorf("pdf output path must use a .pdf extension")289 }290 if strings.TrimSpace(spec.JSONLOutputPath) != "" && !strings.EqualFold(filepath.Ext(spec.JSONLOutputPath), ".jsonl") {291 return runPlan{}, fmt.Errorf("jsonl output path must use a .jsonl extension")292 }293 if spec.Volume < 1 {294 return runPlan{}, fmt.Errorf("volume must be >= 1")295 }296297 entries := registry.Entries()298 entryByID := make(map[puzzle.GameID]registry.Entry, len(entries))299 for _, entry := range entries {300 entryByID[entry.Definition.ID] = entry301 }302303 selections := []modeSelection{}304 total := 0305 for gameID, modes := range spec.Counts {306 entry, ok := entryByID[gameID]307 if !ok {308 for modeID, count := range modes {309 if count != 0 {310 return runPlan{}, fmt.Errorf("%s / %s is not a known export target", gameID, modeID)311 }312 }313 continue314 }315 if entry.Print == nil {316 for modeID, count := range modes {317 if count != 0 {318 return runPlan{}, fmt.Errorf("%s / %s does not support export", entry.Definition.Name, modeID)319 }320 }321 continue322 }323324 variantByID := make(map[puzzle.ModeID]registry.VariantEntry, len(entry.Variants))325 for _, variant := range entry.Variants {326 variantByID[puzzle.ModeID(variant.Definition.ID)] = variant327 }328 legacyByID := make(map[puzzle.ModeID]puzzle.LegacyModeAlias, len(entry.LegacyModes))329 for _, alias := range entry.LegacyModes {330 legacyByID[alias.ID] = alias331 }332333 for modeID, count := range modes {334 if count < 0 {335 return runPlan{}, fmt.Errorf("%s / %s count must be >= 0", entry.Definition.Name, modeID)336 }337 if count == 0 {338 continue339 }340341 variant, targetElo, ok := exportVariantForModeID(entry, variantByID, legacyByID, modeID)342 if !ok {343 return runPlan{}, fmt.Errorf("%s / %s is not a known export variant", entry.Definition.Name, modeID)344 }345 if strings.TrimSpace(spec.Seed) != "" && variant.Seeded == nil {346 return runPlan{}, fmt.Errorf("%s / %s does not support seeded export", entry.Definition.Name, variant.Definition.Title)347 }348349 total += count350 selections = append(selections, modeSelection{351 gameID: entry.Definition.ID,352 gameType: entry.Definition.Name,353 modeID: puzzle.ModeID(variant.Definition.ID),354 modeTitle: variant.Definition.Title,355 count: count,356 spawner: variant.Seeded,357 seededSpawner: variant.Seeded,358 eloSpawner: variant.Elo,359 targetElo: &targetElo,360 })361 }362 }363364 if total == 0 {365 return runPlan{}, fmt.Errorf("total exported puzzle count must be > 0")366 }367368 slices.SortFunc(selections, func(a, b modeSelection) int {369 if c := strings.Compare(puzzle.NormalizeName(a.gameType), puzzle.NormalizeName(b.gameType)); c != 0 {370 return c371 }372 return strings.Compare(puzzle.NormalizeName(a.modeTitle), puzzle.NormalizeName(b.modeTitle))373 })374375 return runPlan{376 spec: spec,377 selections: selections,378 }, nil379}380381func exportVariantForModeID(382 entry registry.Entry,383 variantByID map[puzzle.ModeID]registry.VariantEntry,384 legacyByID map[puzzle.ModeID]puzzle.LegacyModeAlias,385 modeID puzzle.ModeID,386) (registry.VariantEntry, difficulty.Elo, bool) {387 if variant, ok := variantByID[modeID]; ok {388 return variant, variant.Definition.DefaultElo, true389 }390 alias, ok := legacyByID[modeID]391 if !ok {392 return registry.VariantEntry{}, 0, false393 }394 for _, variant := range entry.Variants {395 if variant.Definition.ID == alias.TargetVariantID {396 return variant, alias.PresetElo, true397 }398 }399 return registry.VariantEntry{}, 0, false400}401402func normalizeSpec(spec Spec) Spec {403 spec.Schema = strings.TrimSpace(spec.Schema)404 spec.Title = strings.TrimSpace(spec.Title)405 spec.Header = strings.TrimSpace(spec.Header)406 spec.Advert = strings.TrimSpace(spec.Advert)407 spec.Seed = strings.TrimSpace(spec.Seed)408 spec.PDFOutputPath = strings.TrimSpace(spec.PDFOutputPath)409 spec.JSONLOutputPath = strings.TrimSpace(spec.JSONLOutputPath)410411 if spec.Schema == "" {412 spec.Schema = PackSpecSchemaV2413 }414 if spec.Volume == 0 {415 spec.Volume = 1416 }417 if spec.SheetLayout == "" {418 spec.SheetLayout = DefaultSheetLayout419 }420 if spec.Advert == "" {421 spec.Advert = DefaultAdvert422 }423 if spec.Counts == nil {424 spec.Counts = zeroCounts()425 }426 return spec427}428429func generateRecords(430 ctx context.Context,431 selections []modeSelection,432 seed string,433 generatedAt time.Time,434) ([]pdfexport.JSONLRecord, error) {435 var rng *randState436 if seed != "" {437 rng = newRandState(seed)438 }439440 nameSeed := seed441 if strings.TrimSpace(nameSeed) == "" {442 nameSeed = generatedAt.Format(time.RFC3339Nano)443 }444 nameRNG := resolve.RNGFromString("export-names:" + nameSeed)445446 totalCount := 0447 for _, selection := range selections {448 totalCount += selection.count449 }450451 records := make([]pdfexport.JSONLRecord, 0, totalCount)452 recordIndex := 0453 for _, selection := range selections {454 for i := 0; i < selection.count; i++ {455 if err := ctx.Err(); err != nil {456 return nil, err457 }458 recordIndex++459460 eloSeed := scopedPackEloSeed(seed, generatedAt, selection.gameType, selection.modeTitle, recordIndex)461 generated, err := spawnSaveData(ctx, selection, rng, eloSeed)462 if err != nil {463 return nil, fmt.Errorf("generate %s / %s puzzle %d: %w", selection.gameType, selection.modeTitle, i+1, err)464 }465466 records = append(records, pdfexport.JSONLRecord{467 Schema: pdfexport.ExportSchemaV2,468 Pack: pdfexport.JSONLPackMeta{469 Generated: generatedAt.Format(time.RFC3339),470 Version: Version,471 Category: selection.gameType,472 ModeSelection: selection.modeTitle,473 Count: totalCount,474 Seed: seed,475 },476 Puzzle: pdfexport.JSONLPuzzle{477 Index: len(records) + 1,478 Name: namegen.GenerateSeeded(nameRNG),479 Game: selection.gameType,480 Mode: selection.modeTitle,481 TargetDifficultyElo: intPtrFromReportElo(generated.Report.TargetElo, generated.Report.Confidence),482 ActualDifficultyElo: intPtrFromReportElo(generated.Report.ActualElo, generated.Report.Confidence),483 DifficultyConfidence: string(generated.Report.Confidence),484 Save: generated.SaveData,485 },486 })487 }488 }489490 return records, nil491}492493func intPtrFromReportElo(elo difficulty.Elo, confidence difficulty.Confidence) *int {494 if confidence == "" {495 return nil496 }497 v := int(elo)498 return &v499}500501func scopedPackEloSeed(seed string, generatedAt time.Time, gameType, mode string, index int) string {502 base := strings.TrimSpace(seed)503 if base == "" {504 base = generatedAt.Format(time.RFC3339Nano)505 }506 return fmt.Sprintf("packexport:%s:%s:%s:%d", base, gameType, mode, index)507}