clone

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

feat/app-decomposition
puzzletea / export / pack / runtime.go
runtime.go 4.8K
1package packexport23import (4 "context"5 "encoding/json"6 "errors"7 "fmt"8 "math/rand/v2"9 "os"10 "path/filepath"11 "strings"1213 "github.com/FelineStateMachine/puzzletea/export/pdf"14 "github.com/FelineStateMachine/puzzletea/game"15 "github.com/FelineStateMachine/puzzletea/puzzle"16 "github.com/FelineStateMachine/puzzletea/resolve"17)1819var writePDFFn = pdfexport.WritePDF2021type randState struct {22 rng *rand.Rand23}2425func newRandState(seed string) *randState {26 return &randState{rng: resolve.RNGFromString(seed)}27}2829func (r *randState) RNG() *rand.Rand {30 if r == nil {31 return nil32 }33 return r.rng34}3536func spawnSaveData(ctx context.Context, selection modeSelection, state *randState) (json.RawMessage, error) {37 var (38 puzzleGame game.Gamer39 err error40 )4142 if state != nil {43 if selection.seededSpawner == nil {44 return nil, fmt.Errorf("mode does not support deterministic spawning")45 }46 rng := state.RNG()47 if cancellable, ok := selection.seededSpawner.(game.CancellableSeededSpawner); ok {48 puzzleGame, err = cancellable.SpawnSeededContext(ctx, rng)49 } else {50 puzzleGame, err = selection.seededSpawner.SpawnSeeded(rng)51 }52 } else if cancellable, ok := selection.spawner.(game.CancellableSpawner); ok {53 puzzleGame, err = cancellable.SpawnContext(ctx)54 } else {55 puzzleGame, err = selection.spawner.Spawn()56 }57 if err != nil {58 return nil, err59 }6061 save, err := puzzleGame.GetSave()62 if err != nil {63 return nil, fmt.Errorf("serialize puzzle: %w", err)64 }65 if !json.Valid(save) {66 return nil, fmt.Errorf("save payload is not valid json")67 }6869 return append(json.RawMessage(nil), save...), nil70}7172func WriteJSONL(path string, records []pdfexport.JSONLRecord) error {73 content, err := EncodeJSONL(records)74 if err != nil {75 return err76 }7778 dir := filepath.Dir(path)79 if dir != "" && dir != "." {80 if err := os.MkdirAll(dir, 0o755); err != nil {81 return fmt.Errorf("create output directory: %w", err)82 }83 }8485 if err := os.WriteFile(path, content, 0o644); err != nil {86 return fmt.Errorf("write output jsonl: %w", err)87 }88 return nil89}9091type stagedOutput struct {92 targetPath string93 tempPath string94}9596func stageJSONL(path string, records []pdfexport.JSONLRecord) (stagedOutput, error) {97 content, err := EncodeJSONL(records)98 if err != nil {99 return stagedOutput{}, err100 }101102 staged, err := newStagedOutput(path)103 if err != nil {104 return stagedOutput{}, err105 }106 if err := os.WriteFile(staged.tempPath, content, 0o644); err != nil {107 staged.Cleanup()108 return stagedOutput{}, fmt.Errorf("write output jsonl: %w", err)109 }110 return staged, nil111}112113func stagePDF(path string, docs []pdfexport.PackDocument, puzzles []pdfexport.Puzzle, cfg pdfexport.RenderConfig) (stagedOutput, error) {114 staged, err := newStagedOutput(path)115 if err != nil {116 return stagedOutput{}, err117 }118 if err := writePDFFn(staged.tempPath, docs, puzzles, cfg); err != nil {119 staged.Cleanup()120 return stagedOutput{}, err121 }122 return staged, nil123}124125func newStagedOutput(targetPath string) (stagedOutput, error) {126 dir := filepath.Dir(targetPath)127 if dir != "" && dir != "." {128 if err := os.MkdirAll(dir, 0o755); err != nil {129 return stagedOutput{}, fmt.Errorf("create output directory: %w", err)130 }131 }132133 tempFile, err := os.CreateTemp(dir, ".puzzletea-export-*"+filepath.Ext(targetPath))134 if err != nil {135 return stagedOutput{}, fmt.Errorf("create temporary output file: %w", err)136 }137 tempPath := tempFile.Name()138 if err := tempFile.Close(); err != nil {139 return stagedOutput{}, fmt.Errorf("close temporary output file: %w", err)140 }141 if err := os.Remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {142 return stagedOutput{}, fmt.Errorf("prepare temporary output file: %w", err)143 }144145 return stagedOutput{146 targetPath: targetPath,147 tempPath: tempPath,148 }, nil149}150151func (s stagedOutput) Cleanup() {152 if s.tempPath == "" {153 return154 }155 _ = os.Remove(s.tempPath)156}157158func (s stagedOutput) Publish() error {159 if err := os.Rename(s.tempPath, s.targetPath); err == nil {160 return nil161 }162163 if err := os.Remove(s.targetPath); err != nil && !errors.Is(err, os.ErrNotExist) {164 return fmt.Errorf("replace output file: %w", err)165 }166 if err := os.Rename(s.tempPath, s.targetPath); err != nil {167 return fmt.Errorf("publish output file: %w", err)168 }169 return nil170}171172func EncodeJSONL(records []pdfexport.JSONLRecord) ([]byte, error) {173 var b strings.Builder174 for _, record := range records {175 line, err := json.Marshal(record)176 if err != nil {177 return nil, fmt.Errorf("encode jsonl record: %w", err)178 }179 b.Write(line)180 b.WriteByte('\n')181 }182 return []byte(b.String()), nil183}184185func flattenPuzzles(docs []pdfexport.PackDocument) []pdfexport.Puzzle {186 puzzles := []pdfexport.Puzzle{}187 for _, doc := range docs {188 puzzles = append(puzzles, doc.Puzzles...)189 }190 return puzzles191}192193func normalizeDifficultyToken(s string) string {194 return puzzle.NormalizeName(s)195}