clone

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

feat/app-decomposition
puzzletea / export / pack / spec.go
spec.go 11.3K
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/export/builtinprint"14 "github.com/FelineStateMachine/puzzletea/export/pdf"15 "github.com/FelineStateMachine/puzzletea/game"16 "github.com/FelineStateMachine/puzzletea/namegen"17 "github.com/FelineStateMachine/puzzletea/puzzle"18 "github.com/FelineStateMachine/puzzletea/registry"19 "github.com/FelineStateMachine/puzzletea/resolve"20)2122// Version is embedded at build time.23var Version = "dev"2425var nowFn = time.Now2627const (28 DefaultTitle = "PuzzleTea Sampler"29 DefaultHeader = "A small mixed pack that highlights a few PuzzleTea puzzle styles."30 DefaultAdvert = "Find more puzzles at github.com/FelineStateMachine/puzzletea"31 DefaultSheetLayout = "half-letter"32)3334type Spec struct {35 Title string `json:"title,omitempty"`36 Header string `json:"header,omitempty"`37 Advert string `json:"advert,omitempty"`38 Volume int `json:"volume,omitempty"`39 SheetLayout string `json:"sheet_layout,omitempty"`40 Seed string `json:"seed,omitempty"`41 PDFOutputPath string `json:"pdf_output_path"`42 JSONLOutputPath string `json:"jsonl_output_path,omitempty"`43 Counts map[puzzle.GameID]map[puzzle.ModeID]int `json:"counts"`44}4546type Result struct {47 TotalCount int48 PDFOutputPath string49 JSONLOutputPath string50}5152type GameCatalog struct {53 GameID puzzle.GameID54 GameType string55 Modes []ModeCatalog56}5758type ModeCatalog struct {59 GameID puzzle.GameID60 GameType string61 ModeID puzzle.ModeID62 ModeTitle string63 Seeded bool64}6566type modeSelection struct {67 gameID puzzle.GameID68 gameType string69 modeID puzzle.ModeID70 modeTitle string71 count int72 spawner game.Spawner73 seededSpawner game.SeededSpawner74}7576type runPlan struct {77 spec Spec78 selections []modeSelection79}8081func DefaultSpec(cwd string) Spec {82 if strings.TrimSpace(cwd) == "" {83 cwd = "."84 }8586 spec := Spec{87 Title: DefaultTitle,88 Header: DefaultHeader,89 Advert: DefaultAdvert,90 Volume: 1,91 SheetLayout: DefaultSheetLayout,92 PDFOutputPath: filepath.Join(cwd, "out", "puzzletea-export.pdf"),93 Counts: zeroCounts(),94 }9596 setDefaultCount(spec.Counts, "Sudoku", "Easy", 2)97 setDefaultCount(spec.Counts, "Word Search", "Easy 10x10", 2)98 setDefaultCount(spec.Counts, "Hitori", "Easy", 1)99 setDefaultCount(spec.Counts, "Nonogram", "Standard", 1)100 setDefaultCount(spec.Counts, "Nurikabe", "Easy", 1)101 setDefaultCount(spec.Counts, "Takuzu", "Easy", 1)102 setDefaultCount(spec.Counts, "Shikaku", "Easy 7x7", 1)103 setDefaultCount(spec.Counts, "Spell Puzzle", "Easy", 1)104105 return spec106}107108func ExportCatalog() []GameCatalog {109 entries := registry.Entries()110 catalog := make([]GameCatalog, 0, len(entries))111 for _, entry := range entries {112 if entry.Print == nil {113 continue114 }115116 gameModes := make([]ModeCatalog, 0, len(entry.Modes))117 for _, mode := range entry.Modes {118 gameModes = append(gameModes, ModeCatalog{119 GameID: entry.Definition.ID,120 GameType: entry.Definition.Name,121 ModeID: mode.Definition.ID,122 ModeTitle: mode.Definition.Title,123 Seeded: mode.Seeded != nil,124 })125 }126127 catalog = append(catalog, GameCatalog{128 GameID: entry.Definition.ID,129 GameType: entry.Definition.Name,130 Modes: gameModes,131 })132 }133 return catalog134}135136func LoadSpecFile(path string) (Spec, error) {137 data, err := os.ReadFile(path)138 if err != nil {139 return Spec{}, fmt.Errorf("read export spec: %w", err)140 }141142 var spec Spec143 if err := json.Unmarshal(data, &spec); err != nil {144 return Spec{}, fmt.Errorf("decode export spec: %w", err)145 }146147 return spec, nil148}149150func ValidateSpec(spec Spec) error {151 _, err := buildRunPlan(spec)152 return err153}154155func Run(ctx context.Context, spec Spec) (Result, error) {156 if ctx == nil {157 ctx = context.Background()158 }159160 builtinprint.Register()161162 plan, err := buildRunPlan(spec)163 if err != nil {164 return Result{}, err165 }166167 generatedAt := nowFn()168 records, err := generateRecords(ctx, plan.selections, strings.TrimSpace(plan.spec.Seed), generatedAt)169 if err != nil {170 return Result{}, err171 }172173 result := Result{174 TotalCount: len(records),175 }176 if err := ctx.Err(); err != nil {177 return Result{}, err178 }179180 var stagedJSONL stagedOutput181 hasJSONL := strings.TrimSpace(plan.spec.JSONLOutputPath) != ""182 if hasJSONL {183 stagedJSONL, err = stageJSONL(plan.spec.JSONLOutputPath, records)184 if err != nil {185 return Result{}, err186 }187 defer stagedJSONL.Cleanup()188 }189190 if err := ctx.Err(); err != nil {191 return Result{}, err192 }193194 doc, err := pdfexport.ParseJSONLRecords("packexport-generated.jsonl", records)195 if err != nil {196 return Result{}, err197 }198199 docs := []pdfexport.PackDocument{doc}200 puzzles := flattenPuzzles(docs)201 AnnotatePuzzlesForPrint(puzzles, registry.Definitions())202203 shuffleSeed := strings.TrimSpace(plan.spec.Seed)204 if shuffleSeed == "" {205 shuffleSeed = generatedAt.Format(time.RFC3339Nano)206 }207 ordered := pdfexport.OrderPuzzlesForPrint(puzzles, shuffleSeed)208209 cfg, err := BuildRenderConfig(RenderOptions{210 Title: plan.spec.Title,211 Header: plan.spec.Header,212 Advert: plan.spec.Advert,213 Volume: plan.spec.Volume,214 SheetLayout: plan.spec.SheetLayout,215 ShuffleSeed: shuffleSeed,216 GeneratedAt: generatedAt,217 }, docs)218 if err != nil {219 return Result{}, err220 }221222 stagedPDF, err := stagePDF(plan.spec.PDFOutputPath, docs, ordered, cfg)223 if err != nil {224 return Result{}, err225 }226 defer stagedPDF.Cleanup()227228 if err := ctx.Err(); err != nil {229 return Result{}, err230 }231 if err := stagedPDF.Publish(); err != nil {232 return Result{}, err233 }234 result.PDFOutputPath = plan.spec.PDFOutputPath235236 if hasJSONL {237 if err := stagedJSONL.Publish(); err != nil {238 return Result{}, err239 }240 result.JSONLOutputPath = plan.spec.JSONLOutputPath241 }242243 return result, nil244}245246func zeroCounts() map[puzzle.GameID]map[puzzle.ModeID]int {247 counts := make(map[puzzle.GameID]map[puzzle.ModeID]int)248 for _, game := range ExportCatalog() {249 gameCounts := make(map[puzzle.ModeID]int, len(game.Modes))250 for _, mode := range game.Modes {251 gameCounts[mode.ModeID] = 0252 }253 counts[game.GameID] = gameCounts254 }255 return counts256}257258func setDefaultCount(counts map[puzzle.GameID]map[puzzle.ModeID]int, gameName, modeTitle string, count int) {259 gameID := puzzle.CanonicalGameID(gameName)260 modeID := puzzle.CanonicalModeID(modeTitle)261 gameCounts, ok := counts[gameID]262 if !ok {263 return264 }265 if _, ok := gameCounts[modeID]; !ok {266 return267 }268 gameCounts[modeID] = count269}270271func buildRunPlan(spec Spec) (runPlan, error) {272 spec = normalizeSpec(spec)273274 if !strings.EqualFold(filepath.Ext(spec.PDFOutputPath), ".pdf") {275 return runPlan{}, fmt.Errorf("pdf output path must use a .pdf extension")276 }277 if strings.TrimSpace(spec.JSONLOutputPath) != "" && !strings.EqualFold(filepath.Ext(spec.JSONLOutputPath), ".jsonl") {278 return runPlan{}, fmt.Errorf("jsonl output path must use a .jsonl extension")279 }280 if spec.Volume < 1 {281 return runPlan{}, fmt.Errorf("volume must be >= 1")282 }283284 entries := registry.Entries()285 entryByID := make(map[puzzle.GameID]registry.Entry, len(entries))286 for _, entry := range entries {287 entryByID[entry.Definition.ID] = entry288 }289290 selections := []modeSelection{}291 total := 0292 for gameID, modes := range spec.Counts {293 entry, ok := entryByID[gameID]294 if !ok {295 for modeID, count := range modes {296 if count != 0 {297 return runPlan{}, fmt.Errorf("%s / %s is not a known export target", gameID, modeID)298 }299 }300 continue301 }302 if entry.Print == nil {303 for modeID, count := range modes {304 if count != 0 {305 return runPlan{}, fmt.Errorf("%s / %s does not support export", entry.Definition.Name, modeID)306 }307 }308 continue309 }310311 modeByID := make(map[puzzle.ModeID]registry.ModeEntry, len(entry.Modes))312 for _, mode := range entry.Modes {313 modeByID[mode.Definition.ID] = mode314 }315316 for modeID, count := range modes {317 if count < 0 {318 return runPlan{}, fmt.Errorf("%s / %s count must be >= 0", entry.Definition.Name, modeID)319 }320 if count == 0 {321 continue322 }323324 mode, ok := modeByID[modeID]325 if !ok {326 return runPlan{}, fmt.Errorf("%s / %s is not a known export mode", entry.Definition.Name, modeID)327 }328 if strings.TrimSpace(spec.Seed) != "" && mode.Seeded == nil {329 return runPlan{}, fmt.Errorf("%s / %s does not support seeded export", entry.Definition.Name, mode.Definition.Title)330 }331332 total += count333 selections = append(selections, modeSelection{334 gameID: entry.Definition.ID,335 gameType: entry.Definition.Name,336 modeID: mode.Definition.ID,337 modeTitle: mode.Definition.Title,338 count: count,339 spawner: mode.Spawner,340 seededSpawner: mode.Seeded,341 })342 }343 }344345 if total == 0 {346 return runPlan{}, fmt.Errorf("total exported puzzle count must be > 0")347 }348349 slices.SortFunc(selections, func(a, b modeSelection) int {350 if c := strings.Compare(puzzle.NormalizeName(a.gameType), puzzle.NormalizeName(b.gameType)); c != 0 {351 return c352 }353 return strings.Compare(puzzle.NormalizeName(a.modeTitle), puzzle.NormalizeName(b.modeTitle))354 })355356 return runPlan{357 spec: spec,358 selections: selections,359 }, nil360}361362func normalizeSpec(spec Spec) Spec {363 spec.Title = strings.TrimSpace(spec.Title)364 spec.Header = strings.TrimSpace(spec.Header)365 spec.Advert = strings.TrimSpace(spec.Advert)366 spec.Seed = strings.TrimSpace(spec.Seed)367 spec.PDFOutputPath = strings.TrimSpace(spec.PDFOutputPath)368 spec.JSONLOutputPath = strings.TrimSpace(spec.JSONLOutputPath)369370 if spec.Volume == 0 {371 spec.Volume = 1372 }373 if spec.SheetLayout == "" {374 spec.SheetLayout = DefaultSheetLayout375 }376 if spec.Advert == "" {377 spec.Advert = DefaultAdvert378 }379 if spec.Counts == nil {380 spec.Counts = zeroCounts()381 }382 return spec383}384385func generateRecords(386 ctx context.Context,387 selections []modeSelection,388 seed string,389 generatedAt time.Time,390) ([]pdfexport.JSONLRecord, error) {391 var rng *randState392 if seed != "" {393 rng = newRandState(seed)394 }395396 nameSeed := seed397 if strings.TrimSpace(nameSeed) == "" {398 nameSeed = generatedAt.Format(time.RFC3339Nano)399 }400 nameRNG := resolve.RNGFromString("export-names:" + nameSeed)401402 totalCount := 0403 for _, selection := range selections {404 totalCount += selection.count405 }406407 records := make([]pdfexport.JSONLRecord, 0, totalCount)408 for _, selection := range selections {409 for i := 0; i < selection.count; i++ {410 if err := ctx.Err(); err != nil {411 return nil, err412 }413414 saveData, err := spawnSaveData(ctx, selection, rng)415 if err != nil {416 return nil, fmt.Errorf("generate %s / %s puzzle %d: %w", selection.gameType, selection.modeTitle, i+1, err)417 }418419 records = append(records, pdfexport.JSONLRecord{420 Schema: pdfexport.ExportSchemaV1,421 Pack: pdfexport.JSONLPackMeta{422 Generated: generatedAt.Format(time.RFC3339),423 Version: Version,424 Category: selection.gameType,425 ModeSelection: selection.modeTitle,426 Count: totalCount,427 Seed: seed,428 },429 Puzzle: pdfexport.JSONLPuzzle{430 Index: len(records) + 1,431 Name: namegen.GenerateSeeded(nameRNG),432 Game: selection.gameType,433 Mode: selection.modeTitle,434 Save: saveData,435 },436 })437 }438 }439440 return records, nil441}