clone

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

feat/elo-difficulty
puzzletea / export / pack / runtime.go
runtime.go 5.5K
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/difficulty"14 "github.com/FelineStateMachine/puzzletea/export/pdf"15 "github.com/FelineStateMachine/puzzletea/game"16 "github.com/FelineStateMachine/puzzletea/puzzle"17 "github.com/FelineStateMachine/puzzletea/resolve"18)1920var writePDFFn = pdfexport.WritePDF2122type randState struct {23 rng *rand.Rand24}2526type generatedSaveData struct {27 SaveData json.RawMessage28 Report difficulty.Report29}3031func newRandState(seed string) *randState {32 return &randState{rng: resolve.RNGFromString(seed)}33}3435func (r *randState) RNG() *rand.Rand {36 if r == nil {37 return nil38 }39 return r.rng40}4142func spawnSaveData(ctx context.Context, selection modeSelection, state *randState, eloSeed string) (generatedSaveData, error) {43 var (44 puzzleGame game.Gamer45 report difficulty.Report46 err error47 )4849 if selection.targetElo != nil {50 if selection.eloSpawner == nil {51 return generatedSaveData{}, fmt.Errorf("mode does not support Elo difficulty")52 }53 if cancellable, ok := selection.eloSpawner.(game.CancellableEloSpawner); ok {54 puzzleGame, report, err = cancellable.SpawnEloContext(ctx, eloSeed, *selection.targetElo)55 } else {56 puzzleGame, report, err = selection.eloSpawner.SpawnElo(eloSeed, *selection.targetElo)57 }58 } else if state != nil {59 if selection.seededSpawner == nil {60 return generatedSaveData{}, fmt.Errorf("mode does not support deterministic spawning")61 }62 rng := state.RNG()63 if cancellable, ok := selection.seededSpawner.(game.CancellableSeededSpawner); ok {64 puzzleGame, err = cancellable.SpawnSeededContext(ctx, rng)65 } else {66 puzzleGame, err = selection.seededSpawner.SpawnSeeded(rng)67 }68 } else if cancellable, ok := selection.spawner.(game.CancellableSpawner); ok {69 puzzleGame, err = cancellable.SpawnContext(ctx)70 } else {71 puzzleGame, err = selection.spawner.Spawn()72 }73 if err != nil {74 return generatedSaveData{}, err75 }7677 save, err := puzzleGame.GetSave()78 if err != nil {79 return generatedSaveData{}, fmt.Errorf("serialize puzzle: %w", err)80 }81 if !json.Valid(save) {82 return generatedSaveData{}, fmt.Errorf("save payload is not valid json")83 }8485 return generatedSaveData{86 SaveData: append(json.RawMessage(nil), save...),87 Report: report,88 }, nil89}9091func WriteJSONL(path string, records []pdfexport.JSONLRecord) error {92 content, err := EncodeJSONL(records)93 if err != nil {94 return err95 }9697 dir := filepath.Dir(path)98 if dir != "" && dir != "." {99 if err := os.MkdirAll(dir, 0o755); err != nil {100 return fmt.Errorf("create output directory: %w", err)101 }102 }103104 if err := os.WriteFile(path, content, 0o644); err != nil {105 return fmt.Errorf("write output jsonl: %w", err)106 }107 return nil108}109110type stagedOutput struct {111 targetPath string112 tempPath string113}114115func stageJSONL(path string, records []pdfexport.JSONLRecord) (stagedOutput, error) {116 content, err := EncodeJSONL(records)117 if err != nil {118 return stagedOutput{}, err119 }120121 staged, err := newStagedOutput(path)122 if err != nil {123 return stagedOutput{}, err124 }125 if err := os.WriteFile(staged.tempPath, content, 0o644); err != nil {126 staged.Cleanup()127 return stagedOutput{}, fmt.Errorf("write output jsonl: %w", err)128 }129 return staged, nil130}131132func stagePDF(path string, docs []pdfexport.PackDocument, puzzles []pdfexport.Puzzle, cfg pdfexport.RenderConfig) (stagedOutput, error) {133 staged, err := newStagedOutput(path)134 if err != nil {135 return stagedOutput{}, err136 }137 if err := writePDFFn(staged.tempPath, docs, puzzles, cfg); err != nil {138 staged.Cleanup()139 return stagedOutput{}, err140 }141 return staged, nil142}143144func newStagedOutput(targetPath string) (stagedOutput, error) {145 dir := filepath.Dir(targetPath)146 if dir != "" && dir != "." {147 if err := os.MkdirAll(dir, 0o755); err != nil {148 return stagedOutput{}, fmt.Errorf("create output directory: %w", err)149 }150 }151152 tempFile, err := os.CreateTemp(dir, ".puzzletea-export-*"+filepath.Ext(targetPath))153 if err != nil {154 return stagedOutput{}, fmt.Errorf("create temporary output file: %w", err)155 }156 tempPath := tempFile.Name()157 if err := tempFile.Close(); err != nil {158 return stagedOutput{}, fmt.Errorf("close temporary output file: %w", err)159 }160 if err := os.Remove(tempPath); err != nil && !errors.Is(err, os.ErrNotExist) {161 return stagedOutput{}, fmt.Errorf("prepare temporary output file: %w", err)162 }163164 return stagedOutput{165 targetPath: targetPath,166 tempPath: tempPath,167 }, nil168}169170func (s stagedOutput) Cleanup() {171 if s.tempPath == "" {172 return173 }174 _ = os.Remove(s.tempPath)175}176177func (s stagedOutput) Publish() error {178 if err := os.Rename(s.tempPath, s.targetPath); err == nil {179 return nil180 }181182 if err := os.Remove(s.targetPath); err != nil && !errors.Is(err, os.ErrNotExist) {183 return fmt.Errorf("replace output file: %w", err)184 }185 if err := os.Rename(s.tempPath, s.targetPath); err != nil {186 return fmt.Errorf("publish output file: %w", err)187 }188 return nil189}190191func EncodeJSONL(records []pdfexport.JSONLRecord) ([]byte, error) {192 var b strings.Builder193 for _, record := range records {194 line, err := json.Marshal(record)195 if err != nil {196 return nil, fmt.Errorf("encode jsonl record: %w", err)197 }198 b.Write(line)199 b.WriteByte('\n')200 }201 return []byte(b.String()), nil202}203204func flattenPuzzles(docs []pdfexport.PackDocument) []pdfexport.Puzzle {205 puzzles := []pdfexport.Puzzle{}206 for _, doc := range docs {207 puzzles = append(puzzles, doc.Puzzles...)208 }209 return puzzles210}211212func normalizeDifficultyToken(s string) string {213 return puzzle.NormalizeName(s)214}