clone

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

swap to jsonl export.

Dami committed 39167a3
commit
39167a3fd7abd9973142f5d191914de69e53dfaa
parent
0c5cbc33eac807a0baf0a01376882acad1026633
author
Dami <dami.etoile@gmail.com>
7 files changed, +381 -54
MREADME.md18++-
Mcmd/export_pdf.go10+-
Mcmd/new.go2+-
Mcmd/new_export.go109++++++++++++++-----
Mcmd/new_export_test.go44+++++---
Apdfexport/jsonl.go172++++++++++++++++++++++++++++++
Apdfexport/jsonl_test.go80++++++++++++++
M README.md +12 -6
M cmd/export_pdf.go +5 -5
M cmd/new.go +1 -1
M cmd/new_export.go +82 -27
@@ -1,6 +1,7 @@
11 package cmd22 33 import (4+ "encoding/json"45 "errors"56 "fmt"67 "io"
@@ -13,6 +14,8 @@ import (
1314 "github.com/FelineStateMachine/puzzletea/app"1415 "github.com/FelineStateMachine/puzzletea/game"1516 "github.com/FelineStateMachine/puzzletea/markdownexport"17+ "github.com/FelineStateMachine/puzzletea/namegen"18+ "github.com/FelineStateMachine/puzzletea/pdfexport"1619 "github.com/FelineStateMachine/puzzletea/resolve"1720 1821 "github.com/spf13/cobra"
@@ -35,7 +38,7 @@ func runNewExport(cmd *cobra.Command, args []string) error {
3538 return err3639 }3740 if !markdownexport.SupportsGameType(cat.Name) {38- return fmt.Errorf("game %q does not support markdown export", cat.Name)41+ return fmt.Errorf("game %q does not support export", cat.Name)3942 }4043 4144 modeArg := ""
@@ -48,21 +51,13 @@ func runNewExport(cmd *cobra.Command, args []string) error {
4851 return err4952 }5053 51- sections, err := buildExportSections(cat.Name, entries, flagExport, flagWithSeed)54+ generatedAt := exportNow()55+ records, err := buildExportRecords(cat.Name, modeSelection, entries, flagExport, flagWithSeed, generatedAt)5256 if err != nil {5357 return err5458 }5559 56- doc := markdownexport.BuildDocument(markdownexport.DocumentConfig{57- Version: Version,58- Category: cat.Name,59- ModeSelection: modeSelection,60- Count: flagExport,61- Seed: flagWithSeed,62- GeneratedAt: exportNow(),63- }, sections)64-65- if err := writeExportMarkdown(cmd, flagOutput, doc); err != nil {60+ if err := writeExportJSONL(cmd, flagOutput, records); err != nil {6661 return err6762 }6863
@@ -73,11 +68,11 @@ func validateNewExportFlags(cmd *cobra.Command, args []string) error {
7368 if flagExport < 1 {7469 return fmt.Errorf("--export must be at least 1")7570 }76- if strings.TrimSpace(flagOutput) != "" && !strings.EqualFold(filepath.Ext(flagOutput), ".md") {77- return fmt.Errorf("--output must use a .md extension")71+ if strings.TrimSpace(flagOutput) != "" && !strings.EqualFold(filepath.Ext(flagOutput), ".jsonl") {72+ return fmt.Errorf("--output must use a .jsonl extension")7873 }7974 if flagSetSeed != "" {80- return fmt.Errorf("--set-seed cannot be combined with markdown export (--export/--output)")75+ return fmt.Errorf("--set-seed cannot be combined with export (--export/--output)")8176 }8277 if len(args) == 0 {8378 return fmt.Errorf("requires at least 1 arg(s), only received 0")
@@ -119,13 +114,25 @@ func collectExportModes(cat game.Category, modeArg string) ([]exportModeEntry, s
119114 return entries, "mixed modes", nil120115 }121116 122-func buildExportSections(gameType string, entries []exportModeEntry, count int, seed string) ([]markdownexport.PuzzleSection, error) {117+func buildExportRecords(118+ gameType, modeSelection string,119+ entries []exportModeEntry,120+ count int,121+ seed string,122+ generatedAt time.Time,123+) ([]pdfexport.JSONLRecord, error) {123124 var rng *rand.Rand124125 if seed != "" {125126 rng = resolve.RNGFromString(seed)126127 }127128 128- sections := make([]markdownexport.PuzzleSection, 0, count)129+ nameSeed := seed130+ if strings.TrimSpace(nameSeed) == "" {131+ nameSeed = generatedAt.Format(time.RFC3339Nano)132+ }133+ nameRNG := resolve.RNGFromString("export-names:" + nameSeed)134+135+ records := make([]pdfexport.JSONLRecord, 0, count)129136 for i := 0; i < count; i++ {130137 entry := entries[0]131138 if len(entries) > 1 {
@@ -147,24 +154,61 @@ func buildExportSections(gameType string, entries []exportModeEntry, count int,
147154 if err != nil {148155 return nil, fmt.Errorf("serialize puzzle %d: %w", i+1, err)149156 }157+ if !json.Valid(save) {158+ return nil, fmt.Errorf("serialize puzzle %d: save payload is not valid JSON", i+1)159+ }150160 151161 snippet, err := markdownexport.RenderPuzzleSnippet(gameType, entry.mode, save)152162 if err != nil {153163 if errors.Is(err, markdownexport.ErrUnsupportedGame) {154- return nil, fmt.Errorf("game %q does not support markdown export", gameType)164+ return nil, fmt.Errorf("game %q does not support export", gameType)155165 }156166 return nil, fmt.Errorf("render puzzle %d: %w", i+1, err)157167 }158168 159- sections = append(sections, markdownexport.PuzzleSection{160- Index: i + 1,161- GameType: gameType,162- Mode: entry.mode,163- Body: snippet,169+ nonogram, table, err := pdfexport.ParsePrintableFromSnippet(gameType, snippet)170+ if err != nil {171+ return nil, fmt.Errorf("build print payload for puzzle %d: %w", i+1, err)172+ }173+174+ printKind := "text"175+ if nonogram != nil {176+ printKind = "nonogram"177+ } else if table != nil {178+ printKind = "grid-table"179+ }180+181+ records = append(records, pdfexport.JSONLRecord{182+ Schema: pdfexport.ExportSchemaV1,183+ Pack: pdfexport.JSONLPackMeta{184+ Generated: generatedAt.Format(time.RFC3339),185+ Version: Version,186+ Category: gameType,187+ ModeSelection: modeSelection,188+ Count: count,189+ Seed: seed,190+ },191+ Puzzle: pdfexport.JSONLPuzzle{192+ Index: i + 1,193+ Name: namegen.GenerateSeeded(nameRNG),194+ Game: gameType,195+ Mode: entry.mode,196+ Save: json.RawMessage(save),197+ Snippet: snippet,198+ },199+ Print: pdfexport.JSONLPrintData{200+ Kind: printKind,201+ Paper: "A5",202+ MarginMM: 10,203+ EmptyGlyph: " ",204+ HintTone: "dim",205+ Nonogram: nonogram,206+ Table: table,207+ },164208 })165209 }166210 167- return sections, nil211+ return records, nil168212 }169213 170214 func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error) {
@@ -179,10 +223,21 @@ func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error)
179223 return seeded.SpawnSeeded(rng)180224 }181225 182-func writeExportMarkdown(cmd *cobra.Command, path, content string) error {226+func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error {227+ var b strings.Builder228+ for _, record := range records {229+ data, err := json.Marshal(record)230+ if err != nil {231+ return fmt.Errorf("encode jsonl record: %w", err)232+ }233+ b.Write(data)234+ b.WriteByte('\n')235+ }236+237+ content := b.String()183238 if strings.TrimSpace(path) == "" {184239 if _, err := io.WriteString(cmd.OutOrStdout(), content); err != nil {185- return fmt.Errorf("write export markdown to stdout: %w", err)240+ return fmt.Errorf("write export jsonl to stdout: %w", err)186241 }187242 return nil188243 }
@@ -195,7 +250,7 @@ func writeExportMarkdown(cmd *cobra.Command, path, content string) error {
195250 }196251 197252 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {198- return fmt.Errorf("write output markdown: %w", err)253+ return fmt.Errorf("write output jsonl: %w", err)199254 }200255 return nil201256 }
M cmd/new_export_test.go +29 -15
@@ -2,6 +2,7 @@ package cmd
22 33 import (44 "bytes"5+ "encoding/json"56 "os"67 "path/filepath"78 "strconv"
@@ -9,25 +10,27 @@ import (
910 "testing"1011 "time"1112 13+ "github.com/FelineStateMachine/puzzletea/pdfexport"14+1215 "github.com/spf13/cobra"1316 )1417 1518 func TestRunNewExportRejectsUnsupportedGame(t *testing.T) {1619 withExportFlagReset(t)17- flagOutput = filepath.Join(t.TempDir(), "lights.md")20+ flagOutput = filepath.Join(t.TempDir(), "lights.jsonl")1821 1922 cmd, _ := newExportTestCmd(t, false)2023 err := runNewExport(cmd, []string{"lights-out"})2124 if err == nil {2225 t.Fatal("expected unsupported game error")2326 }24- if !strings.Contains(err.Error(), "does not support markdown export") {27+ if !strings.Contains(err.Error(), "does not support export") {2528 t.Fatalf("unexpected error: %v", err)2629 }2730 }2831 2932 func TestRunNewExportValidation(t *testing.T) {30- t.Run("writes to stdout when output omitted", func(t *testing.T) {33+ t.Run("writes jsonl to stdout when output omitted", func(t *testing.T) {3134 withExportFlagReset(t)3235 flagExport = 23336
@@ -36,12 +39,23 @@ func TestRunNewExportValidation(t *testing.T) {
3639 if err != nil {3740 t.Fatalf("expected stdout export success, got error: %v", err)3841 }39- if !strings.Contains(out.String(), "# PuzzleTea Export") {40- t.Fatalf("expected markdown output on stdout, got:\n%s", out.String())42+43+ lines := strings.Split(strings.TrimSpace(out.String()), "\n")44+ if got, want := len(lines), 2; got != want {45+ t.Fatalf("jsonl lines = %d, want %d", got, want)46+ }47+ for i, line := range lines {48+ var record pdfexport.JSONLRecord49+ if err := json.Unmarshal([]byte(line), &record); err != nil {50+ t.Fatalf("line %d is not valid jsonl: %v", i+1, err)51+ }52+ if record.Schema != pdfexport.ExportSchemaV1 {53+ t.Fatalf("line %d schema = %q, want %q", i+1, record.Schema, pdfexport.ExportSchemaV1)54+ }4155 }4256 })4357 44- t.Run("output extension must be markdown", func(t *testing.T) {58+ t.Run("output extension must be jsonl", func(t *testing.T) {4559 withExportFlagReset(t)4660 flagOutput = filepath.Join(t.TempDir(), "out.txt")4761
@@ -50,7 +64,7 @@ func TestRunNewExportValidation(t *testing.T) {
5064 if err == nil {5165 t.Fatal("expected extension validation error")5266 }53- if !strings.Contains(err.Error(), ".md extension") {67+ if !strings.Contains(err.Error(), ".jsonl extension") {5468 t.Fatalf("unexpected error: %v", err)5569 }5670 })
@@ -58,14 +72,14 @@ func TestRunNewExportValidation(t *testing.T) {
5872 t.Run("set-seed cannot be combined with output", func(t *testing.T) {5973 withExportFlagReset(t)6074 flagSetSeed = "abc"61- flagOutput = filepath.Join(t.TempDir(), "out.md")75+ flagOutput = filepath.Join(t.TempDir(), "out.jsonl")6276 6377 cmd, _ := newExportTestCmd(t, false)6478 err := runNewExport(cmd, []string{"nonogram", "mini"})6579 if err == nil {6680 t.Fatal("expected set-seed validation error")6781 }68- if !strings.Contains(err.Error(), "--set-seed cannot be combined with markdown export (--export/--output)") {82+ if !strings.Contains(err.Error(), "--set-seed cannot be combined with export (--export/--output)") {6983 t.Fatalf("unexpected error: %v", err)7084 }7185 })
@@ -79,8 +93,8 @@ func TestRunNewExportReproducibleWithSeed(t *testing.T) {
7993 exportNow = func() time.Time { return fixedNow }8094 t.Cleanup(func() { exportNow = previousNow })8195 82- fileA := filepath.Join(t.TempDir(), "a.md")83- fileB := filepath.Join(t.TempDir(), "b.md")96+ fileA := filepath.Join(t.TempDir(), "a.jsonl")97+ fileB := filepath.Join(t.TempDir(), "b.jsonl")8498 8599 flagExport = 386100 flagWithSeed = "zine-seed-01"
@@ -105,14 +119,14 @@ func TestRunNewExportReproducibleWithSeed(t *testing.T) {
105119 t.Fatal(err)106120 }107121 if string(a) != string(b) {108- t.Fatal("expected deterministic markdown output for identical seed and args")122+ t.Fatal("expected deterministic jsonl output for identical seed and args")109123 }110124 }111125 112126 func TestRunNewExportOverwritesOutputFile(t *testing.T) {113127 withExportFlagReset(t)114128 115- file := filepath.Join(t.TempDir(), "out.md")129+ file := filepath.Join(t.TempDir(), "out.jsonl")116130 if err := os.WriteFile(file, []byte("old"), 0o644); err != nil {117131 t.Fatal(err)118132 }
@@ -133,8 +147,8 @@ func TestRunNewExportOverwritesOutputFile(t *testing.T) {
133147 if string(data) == "old" {134148 t.Fatal("expected output file to be overwritten")135149 }136- if !strings.Contains(string(data), "# PuzzleTea Export") {137- t.Fatal("expected markdown export header")150+ if !strings.Contains(string(data), pdfexport.ExportSchemaV1) {151+ t.Fatal("expected jsonl export schema marker")138152 }139153 }140154
A pdfexport/jsonl.go +172 -0
@@ -0,0 +1,172 @@
1+package pdfexport2+3+import (4+ "bufio"5+ "encoding/json"6+ "fmt"7+ "os"8+ "path/filepath"9+ "strings"10+)11+12+const ExportSchemaV1 = "puzzletea.export.v1"13+14+type JSONLRecord struct {15+ Schema string `json:"schema"`16+ Pack JSONLPackMeta `json:"pack"`17+ Puzzle JSONLPuzzle `json:"puzzle"`18+ Print JSONLPrintData `json:"print"`19+}20+21+type JSONLPackMeta struct {22+ Generated string `json:"generated"`23+ Version string `json:"version"`24+ Category string `json:"category"`25+ ModeSelection string `json:"mode_selection"`26+ Count int `json:"count"`27+ Seed string `json:"seed"`28+}29+30+type JSONLPuzzle struct {31+ Index int `json:"index"`32+ Name string `json:"name"`33+ Game string `json:"game"`34+ Mode string `json:"mode"`35+ Save json.RawMessage `json:"save"`36+ Snippet string `json:"snippet,omitempty"`37+}38+39+type JSONLPrintData struct {40+ Kind string `json:"kind"`41+ Paper string `json:"paper"`42+ MarginMM float64 `json:"margin_mm"`43+ EmptyGlyph string `json:"empty_glyph"`44+ HintTone string `json:"hint_tone"`45+ Nonogram *NonogramData `json:"nonogram,omitempty"`46+ Table *GridTable `json:"table,omitempty"`47+}48+49+func ParseJSONLFiles(paths []string) ([]PackDocument, error) {50+ docs := make([]PackDocument, 0, len(paths))51+ for _, path := range paths {52+ doc, err := ParseJSONLFile(path)53+ if err != nil {54+ return nil, err55+ }56+ docs = append(docs, doc)57+ }58+ return docs, nil59+}60+61+func ParseJSONLFile(path string) (PackDocument, error) {62+ if !strings.EqualFold(filepath.Ext(path), ".jsonl") {63+ return PackDocument{}, fmt.Errorf("%s: expected .jsonl input", path)64+ }65+66+ f, err := os.Open(path)67+ if err != nil {68+ return PackDocument{}, fmt.Errorf("open input jsonl: %w", err)69+ }70+ defer f.Close()71+72+ scanner := bufio.NewScanner(f)73+ buf := make([]byte, 0, 64*1024)74+ scanner.Buffer(buf, 16*1024*1024)75+76+ doc := PackDocument{SourcePath: path}77+ doc.Metadata.SourceFileName = filepath.Base(path)78+ puzzles := []Puzzle{}79+ lineNo := 080+ seenAny := false81+82+ for scanner.Scan() {83+ lineNo++84+ line := strings.TrimSpace(scanner.Text())85+ if line == "" {86+ continue87+ }88+ seenAny = true89+90+ var record JSONLRecord91+ if err := json.Unmarshal([]byte(line), &record); err != nil {92+ return PackDocument{}, fmt.Errorf("%s:%d: decode jsonl record: %w", path, lineNo, err)93+ }94+ if record.Schema != ExportSchemaV1 {95+ return PackDocument{}, fmt.Errorf("%s:%d: unsupported schema %q", path, lineNo, record.Schema)96+ }97+98+ if doc.Metadata.GeneratedRaw == "" {99+ doc.Metadata.GeneratedRaw = record.Pack.Generated100+ doc.Metadata.Version = record.Pack.Version101+ doc.Metadata.Category = record.Pack.Category102+ doc.Metadata.ModeSelection = record.Pack.ModeSelection103+ doc.Metadata.Count = record.Pack.Count104+ doc.Metadata.Seed = record.Pack.Seed105+ }106+107+ category := strings.TrimSpace(record.Puzzle.Game)108+ if category == "" {109+ category = strings.TrimSpace(record.Pack.Category)110+ }111+ mode := strings.TrimSpace(record.Puzzle.Mode)112+ if mode == "" {113+ mode = strings.TrimSpace(record.Pack.ModeSelection)114+ }115+116+ p := Puzzle{117+ SourcePath: path,118+ SourceFileName: filepath.Base(path),119+ Category: category,120+ ModeSelection: mode,121+ Name: record.Puzzle.Name,122+ Index: record.Puzzle.Index,123+ Body: record.Puzzle.Snippet,124+ Nonogram: record.Print.Nonogram,125+ Table: record.Print.Table,126+ }127+128+ if p.Nonogram == nil && p.Table == nil && strings.TrimSpace(record.Puzzle.Snippet) != "" {129+ nonogram, table, err := ParsePrintableFromSnippet(category, record.Puzzle.Snippet)130+ if err != nil {131+ return PackDocument{}, fmt.Errorf("%s:%d: parse printable snippet: %w", path, lineNo, err)132+ }133+ p.Nonogram = nonogram134+ p.Table = table135+ }136+137+ puzzles = append(puzzles, p)138+ }139+140+ if err := scanner.Err(); err != nil {141+ return PackDocument{}, fmt.Errorf("read input jsonl: %w", err)142+ }143+ if !seenAny {144+ return PackDocument{}, fmt.Errorf("%s: input jsonl is empty", path)145+ }146+ if len(puzzles) == 0 {147+ return PackDocument{}, fmt.Errorf("%s: no puzzle records found", path)148+ }149+150+ if doc.Metadata.Count == 0 {151+ doc.Metadata.Count = len(puzzles)152+ }153+ doc.Puzzles = puzzles154+ return doc, nil155+}156+157+func ParsePrintableFromSnippet(category, snippet string) (*NonogramData, *GridTable, error) {158+ lines := strings.Split(strings.ReplaceAll(strings.ReplaceAll(snippet, "\r\n", "\n"), "\r", "\n"), "\n")159+ if strings.EqualFold(strings.TrimSpace(category), "nonogram") {160+ nonogram, err := parseNonogramBody(lines, "snippet", 1)161+ if err != nil {162+ return nil, nil, err163+ }164+ return nonogram, nil, nil165+ }166+167+ table, err := parseGridTableBody(lines, "snippet", 1)168+ if err != nil {169+ return nil, nil, err170+ }171+ return nil, table, nil172+}
A pdfexport/jsonl_test.go +80 -0
@@ -0,0 +1,80 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "os"6+ "path/filepath"7+ "strings"8+ "testing"9+)10+11+func TestParseJSONLFile(t *testing.T) {12+ temp := t.TempDir()13+ path := filepath.Join(temp, "pack.jsonl")14+15+ record := JSONLRecord{16+ Schema: ExportSchemaV1,17+ Pack: JSONLPackMeta{18+ Generated: "2026-02-22T10:00:00Z",19+ Version: "v-test",20+ Category: "Nonogram",21+ ModeSelection: "Mini",22+ Count: 1,23+ Seed: "seed-1",24+ },25+ Puzzle: JSONLPuzzle{26+ Index: 1,27+ Name: "ember-newt",28+ Game: "Nonogram",29+ Mode: "Mini",30+ Save: json.RawMessage(`{"width":2}`),31+ Snippet: "### Puzzle Grid with Integrated Hints\n\n" +32+ "| R1 | C1 | C2 |\n" +33+ "| --- | --- | --- |\n" +34+ "| . | 1 | 2 |\n" +35+ "| 1 | . | . |\n",36+ },37+ Print: JSONLPrintData{38+ Kind: "nonogram",39+ Paper: "A5",40+ MarginMM: 10,41+ EmptyGlyph: " ",42+ HintTone: "dim",43+ },44+ }45+46+ data, err := json.Marshal(record)47+ if err != nil {48+ t.Fatal(err)49+ }50+ if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {51+ t.Fatal(err)52+ }53+54+ doc, err := ParseJSONLFile(path)55+ if err != nil {56+ t.Fatal(err)57+ }58+ if got, want := len(doc.Puzzles), 1; got != want {59+ t.Fatalf("puzzles = %d, want %d", got, want)60+ }61+ if doc.Puzzles[0].Nonogram == nil {62+ t.Fatal("expected nonogram print payload from snippet fallback")63+ }64+}65+66+func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) {67+ temp := t.TempDir()68+ path := filepath.Join(temp, "pack.md")69+ if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {70+ t.Fatal(err)71+ }72+73+ _, err := ParseJSONLFile(path)74+ if err == nil {75+ t.Fatal("expected extension validation error")76+ }77+ if !strings.Contains(err.Error(), "expected .jsonl input") {78+ t.Fatalf("unexpected error: %v", err)79+ }80+}