- commit
- 85d46b9064fd5ff121126b0384b0cbf8ad1adab0
- parent
- 4242efd6cc85182bcfbe9f8cf55579309e61f575
- author
- Dami <dami.etoile@gmail.com>
@@ -5,6 +5,7 @@ import (
55 66 "github.com/FelineStateMachine/puzzletea/daily"77 sessionflow "github.com/FelineStateMachine/puzzletea/session"8+ "github.com/FelineStateMachine/puzzletea/store"89 910 tea "charm.land/bubbletea/v2"1011 )@@ -40,6 +41,7 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
4041 name: name,4142 gameType: gameType,4243 modeTitle: modeTitle,44+ run: store.DailyRunMetadata(today),4345 returnState: playMenuView,4446 exitState: mainMenuView,4547 }@@ -34,6 +34,7 @@ func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {
3434 name: sessionflow.GenerateUniqueName(m.store),3535 gameType: m.nav.selectedCategory.Definition.Name,3636 modeTitle: m.nav.selectedModeTitle,37+ run: store.NormalRunMetadata(),3738 returnState: modeSelectView,3839 exitState: mainMenuView,3940 }@@ -42,11 +43,11 @@ func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {
4243 }4344 4445 func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {45- idx := m.nav.continueTable.Cursor()46- if idx < 0 || idx >= len(m.nav.continueGames) {46+ idx := m.cont.table.Cursor()47+ if idx < 0 || idx >= len(m.cont.games) {4748 return m, nil4849 }49- rec := m.nav.continueGames[idx]50+ rec := m.cont.games[idx]5051 m, _ = m.importAndActivateRecord(rec)5152 return m, nil5253 }@@ -55,7 +55,7 @@ func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) {
5555 m = m.updateCategoryDetailViewport()5656 m = m.clearNotice()5757 case "Continue":58- m.nav.continueTable, m.nav.continueGames = ui.InitContinueTable(m.store, m.height)58+ m.cont.table, m.cont.games = ui.InitContinueTable(m.store, m.height)5959 m.state = continueView6060 m = m.clearNotice()6161 case "Daily":@@ -74,9 +74,9 @@ func (m model) handleOptionsMenuEnter() (tea.Model, tea.Cmd) {
7474 case "Theme":7575 return m.handleThemeEnter()7676 case "Guides":77- m.nav.helpSelectList = ui.InitList(gameCategoryItems, "How to Play")78- listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)79- m.nav.helpSelectList.SetSize(listWidth, listHeight)77+ m.help.selectList = ui.InitList(gameCategoryItems, "How to Play")78+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.help.selectList)79+ m.help.selectList.SetSize(listWidth, listHeight)8080 m.state = helpSelectView8181 m = m.clearNotice()8282 }@@ -5,12 +5,13 @@ import (
55 "github.com/FelineStateMachine/puzzletea/registry"66 "github.com/FelineStateMachine/puzzletea/resolve"77 sessionflow "github.com/FelineStateMachine/puzzletea/session"8+ "github.com/FelineStateMachine/puzzletea/store"89 910 tea "charm.land/bubbletea/v2"1011 )1112 1213 func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {13- seed := sessionflow.NormalizeSeed(m.nav.seedInput.Value())14+ seed := sessionflow.NormalizeSeed(m.seed.input.Value())1415 if seed == "" {1516 return m, nil1617 }@@ -58,6 +59,7 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
5859 name: name,5960 gameType: gameType,6061 modeTitle: modeTitle,62+ run: store.SeededRunMetadata(seed),6163 returnState: playMenuView,6264 exitState: mainMenuView,6365 }@@ -71,17 +71,25 @@ type navigationState struct {
7171 modeSelectList list.Model7272 selectedCategory registry.Entry7373 selectedModeTitle string74- continueTable table.Model75- continueGames []store.GameRecord76- weeklyTable table.Model77- weeklyRows []weeklyRow78- weeklyCursor time.Time79- seedInput textinput.Model80- seedModeOptions []seedModeOption81- seedModeIndex int82- seedFocus seedInputFocus83- lastSeedModeKey string84- helpSelectList list.Model74+}75+76+type continueState struct {77+ table table.Model78+ games []store.GameRecord79+}80+81+type weeklyState struct {82+ table table.Model83+ rows []weeklyRow84+ cursor time.Time85+}86+87+type seedState struct {88+ input textinput.Model89+ modeOptions []seedModeOption90+ modeIndex int91+ focus seedInputFocus92+ lastModeKey string8593 }8694 8795 type sessionState struct {@@ -97,6 +105,7 @@ type sessionState struct {
97105 }98106 99107 type helpState struct {108+ selectList list.Model100109 category registry.Entry101110 viewport viewport.Model102111 renderer *glamour.TermRenderer@@ -136,6 +145,7 @@ type spawnRequest struct {
136145 name string137146 gameType string138147 modeTitle string148+ run store.RunMetadata139149 returnState viewState140150 exitState viewState141151 weeklyInfo *weekly.Info@@ -158,7 +168,10 @@ type model struct {
158168 state viewState159169 160170 nav navigationState171+ cont continueState161172 session sessionState173+ seed seedState174+ weekly weeklyState162175 help helpState163176 stats statsState164177 theme themeState@@ -75,46 +75,46 @@ func (m model) enterSeedInputView() (model, tea.Cmd) {
7575 ti.SetWidth(min(m.width, 48))7676 7777 options := buildSeedModeOptions(registry.Definitions())78- index := findSeedModeIndex(options, m.nav.lastSeedModeKey)78+ index := findSeedModeIndex(options, m.seed.lastModeKey)7979 80- m.nav.seedInput = ti81- m.nav.seedModeOptions = options82- m.nav.seedModeIndex = index83- m.nav.seedFocus = seedFocusText80+ m.seed.input = ti81+ m.seed.modeOptions = options82+ m.seed.modeIndex = index83+ m.seed.focus = seedFocusText8484 m.state = seedInputView85- return m, m.nav.seedInput.Focus()85+ return m, m.seed.input.Focus()8686 }8787 8888 func (m model) currentSeedMode() seedModeOption {89- if len(m.nav.seedModeOptions) == 0 {89+ if len(m.seed.modeOptions) == 0 {9090 return seedModeOption{label: randomSeedModeLabel}9191 }92- if m.nav.seedModeIndex < 0 || m.nav.seedModeIndex >= len(m.nav.seedModeOptions) {93- return m.nav.seedModeOptions[0]92+ if m.seed.modeIndex < 0 || m.seed.modeIndex >= len(m.seed.modeOptions) {93+ return m.seed.modeOptions[0]9494 }95- return m.nav.seedModeOptions[m.nav.seedModeIndex]95+ return m.seed.modeOptions[m.seed.modeIndex]9696 }9797 9898 func (m model) moveSeedMode(step int) model {99- if len(m.nav.seedModeOptions) == 0 {99+ if len(m.seed.modeOptions) == 0 {100100 return m101101 }102102 103- index := m.nav.seedModeIndex + step103+ index := m.seed.modeIndex + step104104 for index < 0 {105- index += len(m.nav.seedModeOptions)105+ index += len(m.seed.modeOptions)106106 }107- m.nav.seedModeIndex = index % len(m.nav.seedModeOptions)108- m.nav.lastSeedModeKey = m.currentSeedMode().key107+ m.seed.modeIndex = index % len(m.seed.modeOptions)108+ m.seed.lastModeKey = m.currentSeedMode().key109109 return m110110 }111111 112112 func (m model) setSeedFocus(focus seedInputFocus) (model, tea.Cmd) {113- m.nav.seedFocus = focus113+ m.seed.focus = focus114114 if focus == seedFocusText {115- return m, m.nav.seedInput.Focus()115+ return m, m.seed.input.Focus()116116 }117- m.nav.seedInput.Blur()117+ m.seed.input.Blur()118118 return m, nil119119 }120120 @@ -127,34 +127,34 @@ func (m model) handleSeedInputUpdate(msg tea.Msg) (model, tea.Cmd) {
127127 case "down", "j":128128 return m.setSeedFocus(seedFocusMode)129129 case "left", "h":130- if m.nav.seedFocus == seedFocusMode {130+ if m.seed.focus == seedFocusMode {131131 return m.moveSeedMode(-1), nil132132 }133133 case "right", "l":134- if m.nav.seedFocus == seedFocusMode {134+ if m.seed.focus == seedFocusMode {135135 return m.moveSeedMode(1), nil136136 }137137 }138138 }139139 140- if m.nav.seedFocus != seedFocusText {140+ if m.seed.focus != seedFocusText {141141 return m, nil142142 }143143 144144 var cmd tea.Cmd145- m.nav.seedInput, cmd = m.nav.seedInput.Update(msg)145+ m.seed.input, cmd = m.seed.input.Update(msg)146146 return m, cmd147147 }148148 149149 func (m model) seedInputBody() string {150- selector := renderSeedModeTitle(m.currentSeedMode().label, m.nav.seedModeIndex)150+ selector := renderSeedModeTitle(m.currentSeedMode().label, m.seed.modeIndex)151151 modePrefix := " "152- if m.nav.seedFocus == seedFocusMode {152+ if m.seed.focus == seedFocusMode {153153 modePrefix = ui.CursorStyle().Render("> ")154154 }155155 156- seedInputView := m.nav.seedInput.View()157- if m.nav.seedFocus != seedFocusText {156+ seedInputView := m.seed.input.View()157+ if m.seed.focus != seedFocusText {158158 seedInputView = strings.Replace(seedInputView, ">", " ", 1)159159 }160160 @@ -123,6 +123,7 @@ func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.
123123 request.name,124124 request.gameType,125125 request.modeTitle,126+ request.run,126127 )127128 if err != nil {128129 m = m.setErrorf("Started puzzle, but could not create a save record: %v", err)@@ -37,11 +37,11 @@ func (m model) updateActiveState(msg tea.Msg) (model, tea.Cmd) {
3737 case modeSelectView:3838 m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg)3939 case continueView:40- m.nav.continueTable, cmd = m.nav.continueTable.Update(msg)40+ m.cont.table, cmd = m.cont.table.Update(msg)4141 case weeklyView:42- m.nav.weeklyTable, cmd = m.nav.weeklyTable.Update(msg)42+ m.weekly.table, cmd = m.weekly.table.Update(msg)4343 case helpSelectView:44- m.nav.helpSelectList, cmd = m.nav.helpSelectList.Update(msg)44+ m.help.selectList, cmd = m.help.selectList.Update(msg)4545 case helpDetailView:4646 m.help.viewport, cmd = m.help.viewport.Update(msg)4747 case statsView:@@ -63,18 +63,18 @@ func (m model) updateActiveState(msg tea.Msg) (model, tea.Cmd) {
6363 func (m model) resizeActiveState(menuW int) model {6464 switch m.state {6565 case seedInputView:66- m.nav.seedInput.SetWidth(min(m.width, 48))66+ m.seed.input.SetWidth(min(m.width, 48))6767 case modeSelectView:6868 m.nav.modeSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.modeSelectList)))6969 case continueView:70- m.nav.continueTable.SetWidth(m.width)71- visibleRows := min(len(m.nav.continueGames), ui.MaxTableRows)72- m.nav.continueTable.SetHeight(min(m.height, visibleRows))70+ m.cont.table.SetWidth(m.width)71+ visibleRows := min(len(m.cont.games), ui.MaxTableRows)72+ m.cont.table.SetHeight(min(m.height, visibleRows))7373 case weeklyView:7474 m = m.refreshWeeklyBrowser()7575 case helpSelectView:76- listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)77- m.nav.helpSelectList.SetSize(listWidth, listHeight)76+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.help.selectList)77+ m.help.selectList.SetSize(listWidth, listHeight)7878 case helpDetailView:7979 m = m.updateHelpDetailViewport()8080 case themeSelectView:@@ -44,7 +44,7 @@ func (m model) viewContent() string {
4444 case helpSelectView:4545 return m.renderPanel(4646 "How to Play",47- m.nav.helpSelectList.View(),47+ m.help.selectList.View(),4848 "↑/↓ navigate • enter select • esc back",4949 )5050 case helpDetailView:@@ -63,15 +63,15 @@ func (m model) viewContent() string {
6363 }6464 6565 func (m model) renderContinueView() string {66- if len(m.nav.continueGames) == 0 {66+ if len(m.cont.games) == 0 {6767 return m.renderPanel("Saved Games", "No saved games yet.", "esc back")6868 }6969 7070 footer := "↑/↓ navigate • enter resume • esc back"71- if pg := ui.TablePagination(m.nav.continueTable); pg != "" {71+ if pg := ui.TablePagination(m.cont.table); pg != "" {7272 footer = pg + " " + footer7373 }74- return m.renderPanel("Saved Games", m.nav.continueTable.View(), footer)74+ return m.renderPanel("Saved Games", m.cont.table.View(), footer)7575 }7676 7777 func (m model) renderGameView() string {@@ -203,7 +203,7 @@ func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {
203203 m := model{204204 state: seedInputView,205205 store: s,206- nav: navigationState{seedInput: ti},206+ seed: seedState{input: ti},207207 }208208 209209 next, _ := m.handleSeedConfirm()@@ -232,27 +232,25 @@ func TestSeedInputSelectorCyclesAndPersistsDefault(t *testing.T) {
232232 233233 m := model{234234 state: seedInputView,235- nav: navigationState{236- seedModeOptions: options,237- seedFocus: seedFocusMode,235+ seed: seedState{236+ modeOptions: options,237+ focus: seedFocusMode,238238 },239239 }240240 241241 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})242242 got := next.(model)243243 244- if got.nav.seedModeIndex != 1 {245- t.Fatalf("seedModeIndex = %d, want 1", got.nav.seedModeIndex)244+ if got.seed.modeIndex != 1 {245+ t.Fatalf("seedModeIndex = %d, want 1", got.seed.modeIndex)246246 }247- if got.nav.lastSeedModeKey != options[1].key {248- t.Fatalf("lastSeedModeKey = %q, want %q", got.nav.lastSeedModeKey, options[1].key)247+ if got.seed.lastModeKey != options[1].key {248+ t.Fatalf("lastSeedModeKey = %q, want %q", got.seed.lastModeKey, options[1].key)249249 }250250 251251 reopenedModel, _ := (model{252252 state: playMenuView,253- nav: navigationState{254- lastSeedModeKey: got.nav.lastSeedModeKey,255- },253+ seed: seedState{lastModeKey: got.seed.lastModeKey},256254 }).enterSeedInputView()257255 258256 if reopenedModel.state != seedInputView {@@ -276,10 +274,10 @@ func TestHandleSeedConfirmUsesSelectedSpecificMode(t *testing.T) {
276274 m := model{277275 state: seedInputView,278276 store: s,279- nav: navigationState{280- seedInput: ti,281- seedModeOptions: options,282- seedModeIndex: 1,277+ seed: seedState{278+ input: ti,279+ modeOptions: options,280+ modeIndex: 1,283281 },284282 }285283 @@ -491,8 +489,8 @@ func TestEnterOnSolvedLatestWeeklyCompletesAndQueuesNext(t *testing.T) {
491489 weeklyAdvance: &weekly.Info{Year: year, Week: weekNumber, Index: 1},492490 completionSaved: false,493491 },494- nav: navigationState{495- weeklyCursor: weekly.StartOfWeek(year, weekNumber, time.Local),492+ weekly: weeklyState{493+ cursor: weekly.StartOfWeek(year, weekNumber, time.Local),496494 },497495 }498496 @@ -528,14 +526,14 @@ func TestMoveWeeklyWeekDoesNotAdvancePastCurrentWeek(t *testing.T) {
528526 529527 m := model{530528 store: s,531- nav: navigationState{532- weeklyCursor: currentCursor,529+ weekly: weeklyState{530+ cursor: currentCursor,533531 },534532 }535533 536534 got := m.moveWeeklyWeek(1)537- if !got.nav.weeklyCursor.Equal(currentCursor) {538- t.Fatalf("weeklyCursor = %v, want %v", got.nav.weeklyCursor, currentCursor)535+ if !got.weekly.cursor.Equal(currentCursor) {536+ t.Fatalf("weeklyCursor = %v, want %v", got.weekly.cursor, currentCursor)539537 }540538 }541539 @@ -57,7 +57,7 @@ func (m model) gameSelectViewContent() string {
5757 5858 func (m model) weeklyViewContent() string {5959 title := "Weekly Gauntlet — " + m.weeklyPanelTitle()60- if len(m.nav.weeklyRows) == 0 {60+ if len(m.weekly.rows) == 0 {6161 body := "No completed puzzles for this week yet."6262 if m.isCurrentWeeklySelection() {6363 body = "No weekly puzzles are available."@@ -73,11 +73,11 @@ func (m model) weeklyViewContent() string {
7373 if !m.isCurrentWeeklySelection() {7474 footer = "←/→ week • enter review • esc back"7575 }76- if pg := ui.TablePagination(m.nav.weeklyTable); pg != "" {76+ if pg := ui.TablePagination(m.weekly.table); pg != "" {7777 footer = pg + " " + footer7878 }7979 80- description := m.nav.weeklyTable.View()80+ description := m.weekly.table.View()8181 if !m.isCurrentWeeklySelection() {8282 description = lipgloss.JoinVertical(8383 lipgloss.Left,@@ -39,7 +39,7 @@ func (r weeklyRow) tableRow() table.Row {
3939 4040 func (m model) enterWeeklyView() (tea.Model, tea.Cmd) {4141 current := weekly.Current(time.Now())42- m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)42+ m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)4343 m = m.refreshWeeklyBrowser()4444 m.state = weeklyView4545 return m, nil@@ -56,16 +56,16 @@ func (m model) refreshWeeklyBrowser() model {
5656 }5757 5858 if m.isCurrentWeeklySelection() {59- m.nav.weeklyRows = buildCurrentWeeklyRows(year, weekNumber, games)59+ m.weekly.rows = buildCurrentWeeklyRows(year, weekNumber, games)6060 } else {61- m.nav.weeklyRows = buildReviewWeeklyRows(games)61+ m.weekly.rows = buildReviewWeeklyRows(games)6262 }6363 64- rows := make([]table.Row, 0, len(m.nav.weeklyRows))65- for _, row := range m.nav.weeklyRows {64+ rows := make([]table.Row, 0, len(m.weekly.rows))65+ for _, row := range m.weekly.rows {6666 rows = append(rows, row.tableRow())6767 }68- m.nav.weeklyTable = ui.InitWeeklyTable(rows, m.height)68+ m.weekly.table = ui.InitWeeklyTable(rows, m.height)6969 return m7070 }7171 @@ -76,7 +76,7 @@ func (m model) isCurrentWeeklySelection() bool {
7676 }7777 7878 func (m model) selectedWeek() (int, int) {79- cursor := m.nav.weeklyCursor79+ cursor := m.weekly.cursor8080 if cursor.IsZero() {8181 current := weekly.Current(time.Now())8282 cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)@@ -90,29 +90,29 @@ func (m model) weeklyPanelTitle() string {
9090 }9191 9292 func (m model) moveWeeklyWeek(delta int) model {93- if m.nav.weeklyCursor.IsZero() {93+ if m.weekly.cursor.IsZero() {9494 current := weekly.Current(time.Now())95- m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)95+ m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)9696 }9797 98- nextCursor := weekly.AddWeeks(m.nav.weeklyCursor, delta)98+ nextCursor := weekly.AddWeeks(m.weekly.cursor, delta)9999 current := weekly.Current(time.Now())100100 currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)101101 if nextCursor.After(currentCursor) {102102 nextCursor = currentCursor103103 }104104 105- m.nav.weeklyCursor = nextCursor105+ m.weekly.cursor = nextCursor106106 return m.refreshWeeklyBrowser()107107 }108108 109109 func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) {110- idx := m.nav.weeklyTable.Cursor()111- if idx < 0 || idx >= len(m.nav.weeklyRows) {110+ idx := m.weekly.table.Cursor()111+ if idx < 0 || idx >= len(m.weekly.rows) {112112 return m, nil113113 }114114 115- return m.openWeeklyRow(m.nav.weeklyRows[idx])115+ return m.openWeeklyRow(m.weekly.rows[idx])116116 }117117 118118 func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {@@ -165,6 +165,7 @@ func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {
165165 name: row.Name,166166 gameType: gameType,167167 modeTitle: modeTitle,168+ run: store.WeeklyRunMetadata(info.Year, info.Week, info.Index),168169 returnState: weeklyView,169170 exitState: weeklyView,170171 weeklyInfo: &infoCopy,@@ -181,14 +182,14 @@ func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {
181182 182183 info := *m.session.weeklyAdvance183184 m = m.persistCompletionIfSolved()184- m.nav.weeklyCursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)185+ m.weekly.cursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)185186 m = m.refreshWeeklyBrowser()186187 m.state = weeklyView187- if len(m.nav.weeklyRows) == 0 || !m.nav.weeklyRows[0].Playable {188+ if len(m.weekly.rows) == 0 || !m.weekly.rows[0].Playable {188189 return m, nil, true189190 }190191 191- next, cmd := m.openWeeklyRow(m.nav.weeklyRows[0])192+ next, cmd := m.openWeeklyRow(m.weekly.rows[0])192193 return next.(model), cmd, true193194 }194195 @@ -3,6 +3,7 @@ package main_test
33 import (44 "go/parser"55 "go/token"6+ "os"67 "path/filepath"78 "strings"89 "testing"@@ -54,6 +55,43 @@ func TestCatalogPackageDoesNotImportConcreteGames(t *testing.T) {
5455 })5556 }5657 58+func TestBuiltinPrintDoesNotImportConcreteGames(t *testing.T) {59+ assertPackageDoesNotImport(t, "builtinprint", []string{60+ "github.com/FelineStateMachine/puzzletea/fillomino",61+ "github.com/FelineStateMachine/puzzletea/hashiwokakero",62+ "github.com/FelineStateMachine/puzzletea/hitori",63+ "github.com/FelineStateMachine/puzzletea/lightsout",64+ "github.com/FelineStateMachine/puzzletea/nonogram",65+ "github.com/FelineStateMachine/puzzletea/nurikabe",66+ "github.com/FelineStateMachine/puzzletea/rippleeffect",67+ "github.com/FelineStateMachine/puzzletea/shikaku",68+ "github.com/FelineStateMachine/puzzletea/spellpuzzle",69+ "github.com/FelineStateMachine/puzzletea/sudoku",70+ "github.com/FelineStateMachine/puzzletea/sudokurgb",71+ "github.com/FelineStateMachine/puzzletea/takuzu",72+ "github.com/FelineStateMachine/puzzletea/takuzuplus",73+ "github.com/FelineStateMachine/puzzletea/wordsearch",74+ })75+}76+77+func TestSessionPackageDoesNotUseNameDerivedRunMetadata(t *testing.T) {78+ assertFilesDoNotContain(t, "session", []string{79+ "RunKindForName(",80+ "RunDateForName(",81+ "SeedTextForName(",82+ "WeeklyIdentityForName(",83+ })84+}85+86+func TestPDFExportPackageDoesNotContainMarkdownParseEntrypoints(t *testing.T) {87+ assertFilesDoNotContain(t, "pdfexport", []string{88+ "func ParseMarkdown(",89+ "func ParseFile(",90+ "func ParseFiles(",91+ "func lookupMarkdownBodyParser(",92+ })93+}94+5795 func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) {5896 t.Helper()5997 @@ -81,3 +119,29 @@ func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) {
81119 }82120 }83121 }122+123+func assertFilesDoNotContain(t *testing.T, dir string, forbidden []string) {124+ t.Helper()125+126+ files, err := filepath.Glob(filepath.Join(dir, "*.go"))127+ if err != nil {128+ t.Fatalf("glob %s: %v", dir, err)129+ }130+131+ for _, path := range files {132+ if strings.HasSuffix(path, "_test.go") {133+ continue134+ }135+136+ data, err := os.ReadFile(path)137+ if err != nil {138+ t.Fatalf("read %s: %v", path, err)139+ }140+ content := string(data)141+ for _, pattern := range forbidden {142+ if strings.Contains(content, pattern) {143+ t.Fatalf("%s contains forbidden pattern %q", path, pattern)144+ }145+ }146+ }147+}@@ -3,43 +3,19 @@ package builtinprint
33 import (44 "sync"55 6- "github.com/FelineStateMachine/puzzletea/fillomino"7- "github.com/FelineStateMachine/puzzletea/hashiwokakero"8- "github.com/FelineStateMachine/puzzletea/hitori"9- "github.com/FelineStateMachine/puzzletea/nonogram"10- "github.com/FelineStateMachine/puzzletea/nurikabe"116 "github.com/FelineStateMachine/puzzletea/pdfexport"12- "github.com/FelineStateMachine/puzzletea/rippleeffect"13- "github.com/FelineStateMachine/puzzletea/shikaku"14- "github.com/FelineStateMachine/puzzletea/spellpuzzle"15- "github.com/FelineStateMachine/puzzletea/sudoku"16- "github.com/FelineStateMachine/puzzletea/sudokurgb"17- "github.com/FelineStateMachine/puzzletea/takuzu"18- "github.com/FelineStateMachine/puzzletea/takuzuplus"19- "github.com/FelineStateMachine/puzzletea/wordsearch"7+ "github.com/FelineStateMachine/puzzletea/registry"208 )219 2210 var registerOnce sync.Once2311 2412 func Register() {2513 registerOnce.Do(func() {26- adapters := []pdfexport.PrintAdapter{27- fillomino.PDFPrintAdapter,28- hashiwokakero.PDFPrintAdapter,29- hitori.PDFPrintAdapter,30- nonogram.PDFPrintAdapter,31- nurikabe.PDFPrintAdapter,32- rippleeffect.PDFPrintAdapter,33- shikaku.PDFPrintAdapter,34- spellpuzzle.PDFPrintAdapter,35- sudoku.PDFPrintAdapter,36- sudokurgb.PDFPrintAdapter,37- takuzu.PDFPrintAdapter,38- takuzuplus.PDFPrintAdapter,39- wordsearch.PDFPrintAdapter,40- }41- for _, adapter := range adapters {42- pdfexport.RegisterPrintAdapter(adapter)14+ for _, entry := range registry.Entries() {15+ if entry.Print == nil {16+ continue17+ }18+ pdfexport.RegisterPrintAdapter(entry.Print)4319 }4420 })4521 }@@ -4,30 +4,19 @@ import (
44 "testing"55 66 "github.com/FelineStateMachine/puzzletea/pdfexport"7+ "github.com/FelineStateMachine/puzzletea/registry"78 )89 910 func TestRegisterBuiltins(t *testing.T) {1011 Register()1112 12- tests := []string{13- "Fillomino",14- "hashi",15- "Hitori",16- "Nonogram",17- "Nurikabe",18- "Ripple Effect",19- "Shikaku",20- "spellpuzzle",21- "Sudoku",22- "Sudoku RGB",23- "Takuzu",24- "binario+",25- "Word Search",26- }27-28- for _, name := range tests {29- if !pdfexport.HasPrintAdapter(name) {30- t.Fatalf("expected print adapter for %q", name)13+ for _, entry := range registry.Entries() {14+ hasAdapter := pdfexport.HasPrintAdapter(entry.Definition.Name)15+ if entry.Print == nil && hasAdapter {16+ t.Fatalf("unexpected print adapter for %q", entry.Definition.Name)17+ }18+ if entry.Print != nil && !hasAdapter {19+ t.Fatalf("expected print adapter for %q", entry.Definition.Name)3120 }3221 }3322 }@@ -137,7 +137,7 @@ func TestBuildRenderConfigForPDFCoverColorControlsCoverPages(t *testing.T) {
137137 }138138 }139139 140-func TestRunExportPDFSilentlyNoOpsWhenAllPuzzlesUnsupported(t *testing.T) {140+func TestRunExportPDFRejectsInputsWhenAllPuzzlesUnsupported(t *testing.T) {141141 reset := snapshotExportPDFFlags()142142 defer reset()143143 @@ -177,8 +177,12 @@ func TestRunExportPDFSilentlyNoOpsWhenAllPuzzlesUnsupported(t *testing.T) {
177177 cmd := &cobra.Command{}178178 cmd.SetOut(&out)179179 180- if err := runExportPDF(cmd, []string{input}); err != nil {181- t.Fatalf("expected no-op success, got %v", err)180+ err = runExportPDF(cmd, []string{input})181+ if err == nil {182+ t.Fatal("expected unsupported export error")183+ }184+ if !strings.Contains(err.Error(), "no printable puzzles found") {185+ t.Fatalf("unexpected error: %v", err)182186 }183187 if out.String() != "" {184188 t.Fatalf("expected no stdout output, got %q", out.String())@@ -87,7 +87,7 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
8787 name := sessionflow.GenerateUniqueName(s)8888 g = g.SetTitle(name)8989 90- rec, err := sessionflow.CreateRecord(s, g, name, entry.Definition.Name, modeTitle)90+ rec, err := sessionflow.CreateRecord(s, g, name, entry.Definition.Name, modeTitle, store.NormalRunMetadata())9191 if err != nil {9292 return err9393 }@@ -155,7 +155,7 @@ func launchSeededGame(seed string, cfg *config.Config) error {
155155 }156156 g = g.SetTitle(name)157157 158- rec, err := sessionflow.CreateRecord(s, g, name, gameType, modeTitle)158+ rec, err := sessionflow.CreateRecord(s, g, name, gameType, modeTitle, store.SeededRunMetadata(seed))159159 if err != nil {160160 return err161161 }@@ -39,7 +39,7 @@ func runNewExport(cmd *cobra.Command, args []string) error {
3939 return fmt.Errorf("unknown game %q", args[0])4040 }4141 if !pdfexport.HasPrintAdapter(entry.Definition.Name) {42- return nil42+ return fmt.Errorf("game %q does not support export", entry.Definition.Name)4343 }4444 4545 modeArg := ""@@ -22,8 +22,11 @@ func TestRunNewExportRejectsUnsupportedGame(t *testing.T) {
2222 2323 cmd, _ := newExportTestCmd(t, false)2424 err := runNewExport(cmd, []string{"lights-out"})25- if err != nil {26- t.Fatalf("expected silent no-op for unsupported game, got: %v", err)25+ if err == nil {26+ t.Fatal("expected unsupported export error")27+ }28+ if !strings.Contains(err.Error(), "does not support export") {29+ t.Fatalf("unexpected error: %v", err)2730 }2831 if _, statErr := os.Stat(output); !os.IsNotExist(statErr) {2932 t.Fatalf("expected no output file, stat err = %v", statErr)@@ -73,4 +73,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
7373 Help: HelpContent,7474 Import: game.AdaptImport(ImportModel),7575 Modes: Modes,76+ Print: PDFPrintAdapter,7677 })@@ -18,6 +18,7 @@ type Entry struct {
1818 Help string1919 Import func([]byte) (game.Gamer, error)2020 Modes []ModeEntry21+ Print game.PrintAdapter2122 }2223 2324 type EntrySpec struct {@@ -25,6 +26,7 @@ type EntrySpec struct {
2526 Help string2627 Import func([]byte) (game.Gamer, error)2728 Modes []game.Mode29+ Print game.PrintAdapter2830 }2931 3032 func BuildModeDefs(modes []game.Mode) []puzzle.ModeDef {@@ -95,5 +97,6 @@ func NewEntry(spec EntrySpec) Entry {
9597 Help: spec.Help,9698 Import: spec.Import,9799 Modes: entries,100+ Print: spec.Print,98101 }99102 }@@ -69,4 +69,7 @@ func TestNewEntryKeepsMetadataAndRuntimeModesAligned(t *testing.T) {
6969 if entry.Modes[0].Seeded == nil {7070 t.Fatal("Modes[0].Seeded = nil, want seeded spawner")7171 }72+ if entry.Print != nil {73+ t.Fatal("Print = non-nil, want nil")74+ }7275 }@@ -82,4 +82,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
8282 Help: HelpContent,8383 Import: game.AdaptImport(ImportModel),8484 Modes: Modes,85+ Print: PDFPrintAdapter,8586 })@@ -71,4 +71,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
7171 Help: HelpContent,7272 Import: game.AdaptImport(ImportModel),7373 Modes: Modes,74+ Print: PDFPrintAdapter,7475 })@@ -74,4 +74,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
7474 Help: HelpContent,7575 Import: game.AdaptImport(ImportModel),7676 Modes: Modes,77+ Print: PDFPrintAdapter,7778 })@@ -90,4 +90,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
9090 Help: HelpContent,9191 Import: game.AdaptImport(ImportModel),9292 Modes: Modes,93+ Print: PDFPrintAdapter,9394 })@@ -6,6 +6,7 @@ import (
66 "fmt"77 "os"88 "path/filepath"9+ "slices"910 "strings"1011 )1112 @@ -66,6 +67,7 @@ func ParseJSONLFile(path string) (PackDocument, error) {
6667 puzzles := []Puzzle{}6768 lineNo := 06869 seenAny := false70+ unsupportedCategories := []string{}6971 7072 for scanner.Scan() {7173 lineNo++@@ -104,6 +106,9 @@ func ParseJSONLFile(path string) (PackDocument, error) {
104106 105107 adapter, ok := LookupPrintAdapter(category)106108 if !ok {109+ if category != "" && !slices.Contains(unsupportedCategories, category) {110+ unsupportedCategories = append(unsupportedCategories, category)111+ }107112 continue108113 }109114 payload, err := adapter.BuildPDFPayload(p.SaveData)@@ -142,6 +147,16 @@ func ParseJSONLFile(path string) (PackDocument, error) {
142147 if !seenAny {143148 return PackDocument{}, fmt.Errorf("%s: input jsonl is empty", path)144149 }150+ if len(puzzles) == 0 {151+ if len(unsupportedCategories) > 0 {152+ return PackDocument{}, fmt.Errorf(153+ "%s: no printable puzzles found; unsupported categories: %s",154+ path,155+ strings.Join(unsupportedCategories, ", "),156+ )157+ }158+ return PackDocument{}, fmt.Errorf("%s: no printable puzzles found", path)159+ }145160 if doc.Metadata.Count == 0 {146161 doc.Metadata.Count = len(puzzles)147162 }@@ -95,6 +95,39 @@ func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) {
9595 }9696 }9797 98+func TestParseJSONLFileRejectsFilesWithoutPrintablePuzzles(t *testing.T) {99+ path := filepath.Join(t.TempDir(), "lights.jsonl")100+ record := JSONLRecord{101+ Schema: ExportSchemaV1,102+ Pack: JSONLPackMeta{103+ Generated: "2026-02-22T10:00:00Z",104+ Version: "v-test",105+ Category: "Lights Out",106+ ModeSelection: "Standard",107+ Count: 1,108+ },109+ Puzzle: JSONLPuzzle{110+ Index: 1,111+ Name: "glow-shore",112+ Game: "Lights Out",113+ Mode: "Standard",114+ Save: json.RawMessage(`{"size":5}`),115+ },116+ }117+ writeSingleJSONLRecord(t, path, record)118+119+ _, err := ParseJSONLFile(path)120+ if err == nil {121+ t.Fatal("expected no-printable-puzzles error")122+ }123+ if !strings.Contains(err.Error(), "no printable puzzles found") {124+ t.Fatalf("unexpected error: %v", err)125+ }126+ if !strings.Contains(err.Error(), "Lights Out") {127+ t.Fatalf("expected unsupported category in error, got: %v", err)128+ }129+}130+98131 func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) {99132 path := filepath.Join(t.TempDir(), "takuzu-pack.jsonl")100133 record := JSONLRecord{@@ -401,36 +434,6 @@ func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) {
401434 }402435 }403436 404-func TestParseJSONLFileSilentlySkipsUnsupportedGame(t *testing.T) {405- path := filepath.Join(t.TempDir(), "lights.jsonl")406- record := JSONLRecord{407- Schema: ExportSchemaV1,408- Pack: JSONLPackMeta{409- Generated: "2026-02-22T10:00:00Z",410- Version: "v-test",411- Category: "Lights Out",412- ModeSelection: "Standard",413- Count: 1,414- },415- Puzzle: JSONLPuzzle{416- Index: 1,417- Name: "glow-shore",418- Game: "Lights Out",419- Mode: "Standard",420- Save: json.RawMessage(`{"size":5}`),421- },422- }423- writeSingleJSONLRecord(t, path, record)424-425- doc, err := ParseJSONLFile(path)426- if err != nil {427- t.Fatalf("expected silent no-op for unsupported game, got: %v", err)428- }429- if got := len(doc.Puzzles); got != 0 {430- t.Fatalf("puzzles = %d, want 0", got)431- }432-}433-434437 func TestParseJSONLFileMetadataFromFirstPrintableRecord(t *testing.T) {435438 path := filepath.Join(t.TempDir(), "mixed.jsonl")436439 @@ -1,488 +0,0 @@
1-package pdfexport2-3-import (4- "fmt"5- "os"6- "path/filepath"7- "regexp"8- "strconv"9- "strings"10- "time"11-)12-13-var (14- puzzleHeadingPattern = regexp.MustCompile(`^##\s+(.+?)\s+-\s+(\d+)\s*$`)15- nonogramRowHeaderRegex = regexp.MustCompile(`^R\d+$`)16- nonogramColHeaderRegex = regexp.MustCompile(`^C\d+$`)17- tableSepCellRegex = regexp.MustCompile(`^:?-{3,}:?$`)18-)19-20-func ParseFiles(paths []string) ([]PackDocument, error) {21- docs := make([]PackDocument, 0, len(paths))22- for _, path := range paths {23- doc, err := ParseFile(path)24- if err != nil {25- return nil, err26- }27- docs = append(docs, doc)28- }29- return docs, nil30-}31-32-func ParseFile(path string) (PackDocument, error) {33- data, err := os.ReadFile(path)34- if err != nil {35- return PackDocument{}, fmt.Errorf("read input markdown: %w", err)36- }37- return ParseMarkdown(path, string(data))38-}39-40-func ParseMarkdown(path, content string) (PackDocument, error) {41- content = strings.ReplaceAll(content, "\r\n", "\n")42- content = strings.ReplaceAll(content, "\r", "\n")43- lines := strings.Split(content, "\n")44-45- firstContentLine := firstNonEmptyLine(lines)46- if firstContentLine < 0 {47- return PackDocument{}, parseError(path, 1, "input markdown is empty")48- }49- if strings.TrimSpace(lines[firstContentLine]) != "# PuzzleTea Export" {50- return PackDocument{}, parseError(path, firstContentLine+1, "expected markdown title '# PuzzleTea Export'")51- }52-53- headings := findHeadingLines(lines)54- if len(headings) == 0 {55- return PackDocument{}, parseError(path, firstContentLine+1, "expected at least one puzzle section heading")56- }57-58- meta, err := parseMetadata(lines[firstContentLine+1:headings[0]], path, firstContentLine+2)59- if err != nil {60- return PackDocument{}, err61- }62- meta.SourceFileName = filepath.Base(path)63-64- puzzles := make([]Puzzle, 0, len(headings))65- for i, start := range headings {66- end := len(lines)67- if i+1 < len(headings) {68- end = headings[i+1]69- }70-71- puzzle, err := parsePuzzleSection(lines[start:end], path, start+1, meta)72- if err != nil {73- return PackDocument{}, err74- }75- puzzles = append(puzzles, puzzle)76- }77-78- if len(puzzles) == 0 {79- return PackDocument{}, parseError(path, firstContentLine+1, "no puzzle sections were parsed")80- }81-82- return PackDocument{83- SourcePath: path,84- Metadata: meta,85- Puzzles: puzzles,86- }, nil87-}88-89-func parseMetadata(lines []string, path string, startLine int) (PackMetadata, error) {90- meta := PackMetadata{}91-92- for i, line := range lines {93- trimmed := strings.TrimSpace(line)94- if trimmed == "" {95- continue96- }97- if !strings.HasPrefix(trimmed, "- ") {98- continue99- }100-101- entry := strings.TrimSpace(strings.TrimPrefix(trimmed, "- "))102- key, value, ok := strings.Cut(entry, ":")103- if !ok {104- continue105- }106-107- lineNo := startLine + i108- key = strings.ToLower(strings.TrimSpace(key))109- value = strings.TrimSpace(value)110-111- switch key {112- case "generated":113- meta.GeneratedRaw = value114- if ts, err := time.Parse(time.RFC3339, value); err == nil {115- meta.GeneratedAt = ts116- }117- case "version":118- meta.Version = value119- case "category":120- meta.Category = value121- case "mode selection":122- meta.ModeSelection = value123- case "count":124- count, err := strconv.Atoi(value)125- if err != nil {126- return PackMetadata{}, parseError(path, lineNo, "invalid Count value %q", value)127- }128- meta.Count = count129- case "seed":130- meta.Seed = value131- case "export format":132- meta.Format = value133- }134- }135-136- if strings.TrimSpace(meta.Category) == "" {137- return PackMetadata{}, parseError(path, startLine, "missing required metadata field: Category")138- }139- if strings.TrimSpace(meta.ModeSelection) == "" {140- return PackMetadata{}, parseError(path, startLine, "missing required metadata field: Mode Selection")141- }142-143- return meta, nil144-}145-146-func parsePuzzleSection(section []string, path string, startLine int, meta PackMetadata) (Puzzle, error) {147- if len(section) == 0 {148- return Puzzle{}, parseError(path, startLine, "empty puzzle section")149- }150-151- heading := strings.TrimSpace(section[0])152- matches := puzzleHeadingPattern.FindStringSubmatch(heading)153- if len(matches) != 3 {154- return Puzzle{}, parseError(path, startLine, "invalid puzzle heading %q", heading)155- }156-157- index, err := strconv.Atoi(matches[2])158- if err != nil {159- return Puzzle{}, parseError(path, startLine, "invalid puzzle index %q", matches[2])160- }161-162- bodyLines := append([]string(nil), section[1:]...)163- trimSectionBody(&bodyLines)164- body := strings.Join(bodyLines, "\n")165-166- p := Puzzle{167- SourcePath: path,168- SourceFileName: meta.SourceFileName,169- Category: meta.Category,170- ModeSelection: meta.ModeSelection,171- Name: matches[1],172- Index: index,173- Body: body,174- }175-176- parser := lookupMarkdownBodyParser(meta.Category)177- payload, err := parser.ParseMarkdownBody(bodyLines, path, startLine+1)178- if err != nil {179- return Puzzle{}, err180- }181- p.PrintPayload = payload182-183- return p, nil184-}185-186-func parseNonogramBody(bodyLines []string, path string, bodyStartLine int) (*NonogramData, error) {187- tableLines, tableLineNumbers := findFirstMarkdownTable(bodyLines, bodyStartLine)188- if len(tableLines) < 3 {189- lineNo := bodyStartLine190- if len(tableLineNumbers) > 0 {191- lineNo = tableLineNumbers[0]192- }193- return nil, parseError(path, lineNo, "expected nonogram markdown table with header, separator, and data rows")194- }195-196- header := parseTableRow(tableLines[0])197- if len(header) == 0 {198- return nil, parseError(path, tableLineNumbers[0], "nonogram header row is empty")199- }200-201- rowHintCols := 0202- colCount := 0203- for _, cell := range header {204- switch {205- case nonogramRowHeaderRegex.MatchString(cell):206- rowHintCols++207- case nonogramColHeaderRegex.MatchString(cell):208- colCount++209- }210- }211- if rowHintCols < 1 || colCount < 1 {212- return nil, parseError(path, tableLineNumbers[0], "expected nonogram header cells like R1.. and C1..")213- }214-215- expectedCols := rowHintCols + colCount216- dataRows := make([][]string, 0, len(tableLines)-2)217- for i := 2; i < len(tableLines); i++ {218- cells := parseTableRow(tableLines[i])219- if len(cells) < expectedCols {220- return nil, parseError(path, tableLineNumbers[i], "expected %d columns, found %d", expectedCols, len(cells))221- }222- if len(cells) > expectedCols {223- cells = cells[:expectedCols]224- }225- dataRows = append(dataRows, cells)226- }227-228- if len(dataRows) == 0 {229- return nil, parseError(path, tableLineNumbers[0], "nonogram table has no data rows")230- }231-232- colHintRows := 0233- for _, row := range dataRows {234- if rowHintPlaceholderRow(row[:rowHintCols]) {235- colHintRows++236- continue237- }238- break239- }240-241- height := len(dataRows) - colHintRows242- if height <= 0 {243- return nil, parseError(path, tableLineNumbers[len(tableLineNumbers)-1], "nonogram table does not contain puzzle rows")244- }245-246- rowHints := make([][]int, height)247- grid := make([][]string, height)248- for y := range height {249- row := dataRows[colHintRows+y]250- hints := parseHintCells(row[:rowHintCols])251- if len(hints) == 0 {252- hints = []int{0}253- }254- rowHints[y] = hints255-256- gridRow := make([]string, colCount)257- for x := 0; x < colCount; x++ {258- cell := strings.TrimSpace(row[rowHintCols+x])259- switch cell {260- case "", ".":261- gridRow[x] = " "262- default:263- gridRow[x] = cell264- }265- }266- grid[y] = gridRow267- }268-269- colHints := make([][]int, colCount)270- for x := 0; x < colCount; x++ {271- hints := make([]int, 0, colHintRows)272- for r := 0; r < colHintRows; r++ {273- if v, ok := parseHintValue(dataRows[r][rowHintCols+x]); ok {274- hints = append(hints, v)275- }276- }277- if len(hints) == 0 {278- hints = []int{0}279- }280- colHints[x] = hints281- }282-283- return &NonogramData{284- Width: colCount,285- Height: height,286- RowHints: rowHints,287- ColHints: colHints,288- Grid: grid,289- }, nil290-}291-292-func parseGridTableBody(bodyLines []string, path string, bodyStartLine int) (*GridTable, error) {293- tableLines, tableLineNumbers := findFirstMarkdownTable(bodyLines, bodyStartLine)294- if len(tableLines) == 0 {295- return nil, nil296- }297- if len(tableLines) < 2 {298- return nil, parseError(path, tableLineNumbers[0], "expected markdown table to include data rows")299- }300-301- rows := make([][]string, 0, len(tableLines))302- for i, line := range tableLines {303- cells := parseTableRow(line)304- if len(cells) == 0 {305- return nil, parseError(path, tableLineNumbers[i], "empty table row")306- }307- rows = append(rows, cells)308- }309-310- hasHeaderRow := false311- if len(rows) > 1 && isMarkdownSeparatorRow(rows[1]) {312- hasHeaderRow = true313- rows = append(rows[:1], rows[2:]...)314- }315- if len(rows) < 2 {316- return nil, parseError(path, tableLineNumbers[0], "table must include header and data rows")317- }318-319- width := 0320- for _, row := range rows {321- if len(row) > width {322- width = len(row)323- }324- }325- for i := range rows {326- if len(rows[i]) >= width {327- continue328- }329- padded := make([]string, width)330- copy(padded, rows[i])331- rows[i] = padded332- }333-334- return &GridTable{335- Rows: rows,336- HasHeaderRow: hasHeaderRow,337- HasHeaderCol: detectHeaderColumn(rows, hasHeaderRow),338- }, nil339-}340-341-func findFirstMarkdownTable(lines []string, startLine int) ([]string, []int) {342- table := []string{}343- lineNumbers := []int{}344- started := false345-346- for i, line := range lines {347- trimmed := strings.TrimSpace(line)348- if strings.HasPrefix(trimmed, "|") {349- started = true350- table = append(table, line)351- lineNumbers = append(lineNumbers, startLine+i)352- continue353- }354- if started {355- break356- }357- }358-359- return table, lineNumbers360-}361-362-func parseTableRow(line string) []string {363- trimmed := strings.TrimSpace(line)364- trimmed = strings.TrimPrefix(trimmed, "|")365- trimmed = strings.TrimSuffix(trimmed, "|")366- if trimmed == "" {367- return []string{}368- }369-370- parts := strings.Split(trimmed, "|")371- cells := make([]string, 0, len(parts))372- for _, part := range parts {373- cells = append(cells, strings.TrimSpace(part))374- }375- return cells376-}377-378-func isMarkdownSeparatorRow(cells []string) bool {379- if len(cells) == 0 {380- return false381- }382- for _, cell := range cells {383- if !tableSepCellRegex.MatchString(strings.TrimSpace(cell)) {384- return false385- }386- }387- return true388-}389-390-func rowHintPlaceholderRow(cells []string) bool {391- for _, cell := range cells {392- trimmed := strings.TrimSpace(cell)393- if trimmed != "" && trimmed != "." {394- return false395- }396- }397- return true398-}399-400-func parseHintCells(cells []string) []int {401- hints := make([]int, 0, len(cells))402- for _, cell := range cells {403- if v, ok := parseHintValue(cell); ok {404- hints = append(hints, v)405- }406- }407- return hints408-}409-410-func detectHeaderColumn(rows [][]string, hasHeaderRow bool) bool {411- if len(rows) < 2 {412- return false413- }414-415- start := 1416- if !hasHeaderRow {417- start = 0418- }419-420- total := 0421- numeric := 0422- for i := start; i < len(rows); i++ {423- if len(rows[i]) == 0 {424- continue425- }426- total++427- if _, err := strconv.Atoi(strings.TrimSpace(rows[i][0])); err == nil {428- numeric++429- }430- }431-432- return total > 0 && numeric*100/total >= 70433-}434-435-func parseHintValue(cell string) (int, bool) {436- trimmed := strings.TrimSpace(cell)437- if trimmed == "" || trimmed == "." {438- return 0, false439- }440- v, err := strconv.Atoi(trimmed)441- if err != nil {442- return 0, false443- }444- return v, true445-}446-447-func trimSectionBody(lines *[]string) {448- for len(*lines) > 0 {449- trimmed := strings.TrimSpace((*lines)[0])450- if trimmed != "" && trimmed != "---" {451- break452- }453- *lines = (*lines)[1:]454- }455- for len(*lines) > 0 {456- trimmed := strings.TrimSpace((*lines)[len(*lines)-1])457- if trimmed != "" && trimmed != "---" {458- break459- }460- *lines = (*lines)[:len(*lines)-1]461- }462-}463-464-func firstNonEmptyLine(lines []string) int {465- for i, line := range lines {466- if strings.TrimSpace(line) != "" {467- return i468- }469- }470- return -1471-}472-473-func findHeadingLines(lines []string) []int {474- indexes := []int{}475- for i, line := range lines {476- if strings.HasPrefix(strings.TrimSpace(line), "## ") {477- indexes = append(indexes, i)478- }479- }480- return indexes481-}482-483-func parseError(path string, line int, format string, args ...any) error {484- if line < 1 {485- line = 1486- }487- return fmt.Errorf("%s:%d: %s", path, line, fmt.Sprintf(format, args...))488-}@@ -1,31 +0,0 @@
1-package pdfexport2-3-import "strings"4-5-type markdownBodyParser interface {6- ParseMarkdownBody(bodyLines []string, path string, bodyStartLine int) (any, error)7-}8-9-type markdownBodyParserFunc func(bodyLines []string, path string, bodyStartLine int) (any, error)10-11-func (f markdownBodyParserFunc) ParseMarkdownBody(bodyLines []string, path string, bodyStartLine int) (any, error) {12- return f(bodyLines, path, bodyStartLine)13-}14-15-var markdownBodyParsers = map[string]markdownBodyParser{16- normalizeGameTypeToken("nonogram"): markdownBodyParserFunc(func(bodyLines []string, path string, bodyStartLine int) (any, error) {17- return parseNonogramBody(bodyLines, path, bodyStartLine)18- }),19-}20-21-var defaultMarkdownBodyParser markdownBodyParser = markdownBodyParserFunc(func(bodyLines []string, path string, bodyStartLine int) (any, error) {22- return parseGridTableBody(bodyLines, path, bodyStartLine)23-})24-25-func lookupMarkdownBodyParser(category string) markdownBodyParser {26- normalized := normalizeGameTypeToken(strings.TrimSpace(category))27- if parser, ok := markdownBodyParsers[normalized]; ok {28- return parser29- }30- return defaultMarkdownBodyParser31-}@@ -1,134 +0,0 @@
1-package pdfexport2-3-import (4- "os"5- "path/filepath"6- "strconv"7- "testing"8-)9-10-func TestParseMarkdownNonogram(t *testing.T) {11- doc, err := ParseMarkdown("sample.md", sampleNonogramDoc("Standard", 1))12- if err != nil {13- t.Fatal(err)14- }15-16- if got, want := doc.Metadata.Category, "Nonogram"; got != want {17- t.Fatalf("category = %q, want %q", got, want)18- }19- if got, want := len(doc.Puzzles), 1; got != want {20- t.Fatalf("puzzles = %d, want %d", got, want)21- }22-23- p := doc.Puzzles[0]24- nonogram, ok := p.PrintPayload.(*NonogramData)25- if !ok || nonogram == nil {26- t.Fatal("expected parsed nonogram data")27- }28- if nonogram.Width != 2 || nonogram.Height != 2 {29- t.Fatalf("nonogram size = %dx%d, want 2x2", nonogram.Width, nonogram.Height)30- }31-32- if got, want := nonogram.RowHints[0][0], 1; got != want {33- t.Fatalf("first row first hint = %d, want %d", got, want)34- }35- if got, want := nonogram.ColHints[0][0], 1; got != want {36- t.Fatalf("first col first hint = %d, want %d", got, want)37- }38-39- if got, want := nonogram.Grid[0][0], " "; got != want {40- t.Fatalf("grid dot replacement = %q, want %q", got, want)41- }42-}43-44-func TestParseFilesMultipleInputs(t *testing.T) {45- temp := t.TempDir()46-47- fileA := filepath.Join(temp, "pack-a.md")48- if err := os.WriteFile(fileA, []byte(sampleNonogramDoc("Standard", 1)), 0o644); err != nil {49- t.Fatal(err)50- }51-52- fileB := filepath.Join(temp, "pack-b.md")53- if err := os.WriteFile(fileB, []byte(sampleNonogramDoc("Classic", 2)), 0o644); err != nil {54- t.Fatal(err)55- }56-57- docs, err := ParseFiles([]string{fileA, fileB})58- if err != nil {59- t.Fatal(err)60- }61- if got, want := len(docs), 2; got != want {62- t.Fatalf("docs = %d, want %d", got, want)63- }64-65- if got, want := docs[0].Metadata.SourceFileName, "pack-a.md"; got != want {66- t.Fatalf("first source file = %q, want %q", got, want)67- }68- if got, want := docs[1].Puzzles[0].ModeSelection, "Classic"; got != want {69- t.Fatalf("second mode selection = %q, want %q", got, want)70- }71-}72-73-func TestParseMarkdownTakuzuTable(t *testing.T) {74- doc, err := ParseMarkdown("takuzu.md", sampleTakuzuDoc())75- if err != nil {76- t.Fatal(err)77- }78-79- if got, want := doc.Metadata.Category, "Takuzu"; got != want {80- t.Fatalf("category = %q, want %q", got, want)81- }82-83- p := doc.Puzzles[0]84- table, ok := p.PrintPayload.(*GridTable)85- if !ok || table == nil {86- t.Fatal("expected parsed grid table for takuzu")87- }88- if !table.HasHeaderRow {89- t.Fatal("expected takuzu table to detect a header row")90- }91- if !table.HasHeaderCol {92- t.Fatal("expected takuzu table to detect a header column")93- }94-95- if got, want := table.Rows[1][1], "."; got != want {96- t.Fatalf("table cell = %q, want %q", got, want)97- }98-}99-100-func sampleNonogramDoc(mode string, idx int) string {101- return "# PuzzleTea Export\n\n" +102- "- Generated: 2026-02-21T20:42:05-07:00\n" +103- "- Version: v1.6.0\n" +104- "- Category: Nonogram\n" +105- "- Mode Selection: " + mode + "\n" +106- "- Count: 1\n" +107- "- Seed: zine\n\n" +108- "## ember-newt - " + strconv.Itoa(idx) + "\n\n" +109- "### Puzzle Grid with Integrated Hints\n\n" +110- "| R1 | R2 | C1 | C2 |\n" +111- "| --- | --- | --- | --- |\n" +112- "| . | . | 1 | 2 |\n" +113- "| . | . | 3 | 4 |\n" +114- "| 1 | 1 | . | . |\n" +115- "| . | 2 | . | . |\n\n" +116- "Row hints are right-aligned beside each row.\n"117-}118-119-func sampleTakuzuDoc() string {120- return "# PuzzleTea Export\n\n" +121- "- Generated: 2026-02-21T20:42:05-07:00\n" +122- "- Version: v1.6.0\n" +123- "- Category: Takuzu\n" +124- "- Mode Selection: Beginner\n" +125- "- Count: 1\n" +126- "- Seed: zine\n\n" +127- "## scarlet-lichen - 1\n\n" +128- "### Given Grid\n\n" +129- "| | 1 | 2 | 3 |\n" +130- "| --- | --- | --- | --- |\n" +131- "| 1 | . | 0 | . |\n" +132- "| 2 | 1 | . | 0 |\n\n" +133- "Goal: fill with 0/1.\n"134-}@@ -54,3 +54,17 @@ func TestEntriesStayAlignedWithDefinitions(t *testing.T) {
5454 }5555 }5656 }57+58+func TestPrintAdaptersStayAlignedWithRegistryEntries(t *testing.T) {59+ for _, entry := range Entries() {60+ if entry.Definition.Name == "Lights Out" {61+ if entry.Print != nil {62+ t.Fatal("Lights Out should not expose a print adapter")63+ }64+ continue65+ }66+ if entry.Print == nil {67+ t.Fatalf("%s missing print adapter", entry.Definition.Name)68+ }69+ }70+}@@ -139,6 +139,7 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
139139 Help: HelpContent,140140 Import: game.AdaptImport(ImportModel),141141 Modes: Modes,142+ Print: PDFPrintAdapter,142143 })143144 144145 func (m Mode) generatePuzzle() (Puzzle, error) {@@ -66,6 +66,7 @@ func CreateRecord(
6666 name string,6767 gameType string,6868 modeTitle string,69+ run store.RunMetadata,6970 ) (*store.GameRecord, error) {7071 initialState, err := g.GetSave()7172 if err != nil {@@ -81,14 +82,12 @@ func CreateRecord(
8182 InitialState: string(initialState),8283 SaveState: string(initialState),8384 Status: store.StatusNew,84- RunKind: store.RunKindForName(name),85- RunDate: store.RunDateForName(name),86- SeedText: store.SeedTextForName(name),87- }88- if year, week, index, ok := store.WeeklyIdentityForName(name); ok {89- rec.WeekYear = year90- rec.WeekNumber = week91- rec.WeekIndex = index85+ RunKind: run.Kind,86+ RunDate: run.Date,87+ WeekYear: run.WeekYear,88+ WeekNumber: run.WeekNumber,89+ WeekIndex: run.WeekIndex,90+ SeedText: run.SeedText,9291 }9392 if err := s.CreateGame(rec); err != nil {9493 return nil, fmt.Errorf("failed to create game record: %w", err)@@ -3,6 +3,7 @@ package session
33 import (44 "path/filepath"55 "testing"6+ "time"67 78 "github.com/FelineStateMachine/puzzletea/lightsout"89 "github.com/FelineStateMachine/puzzletea/store"@@ -77,7 +78,7 @@ func TestCreateRecordAndResumeAbandonedDeterministicRecord(t *testing.T) {
7778 if err != nil {7879 t.Fatal(err)7980 }80- rec, err := CreateRecord(s, g, "demo", "Lights Out", "Easy")81+ rec, err := CreateRecord(s, g, "demo", "Lights Out", "Easy", store.NormalRunMetadata())8182 if err != nil {8283 t.Fatalf("CreateRecord returned error: %v", err)8384 }@@ -105,6 +106,30 @@ func TestCreateRecordAndResumeAbandonedDeterministicRecord(t *testing.T) {
105106 }106107 }107108 109+func TestCreateRecordUsesExplicitRunMetadata(t *testing.T) {110+ s := openSessionTestStore(t)111+112+ g, err := lightsout.New(3, 3)113+ if err != nil {114+ t.Fatal(err)115+ }116+117+ day := time.Date(2026, 2, 14, 15, 30, 0, 0, time.Local)118+ rec, err := CreateRecord(s, g, "plain-name", "Lights Out", "Easy", store.DailyRunMetadata(day))119+ if err != nil {120+ t.Fatalf("CreateRecord returned error: %v", err)121+ }122+ if rec.RunKind != store.RunKindDaily {123+ t.Fatalf("RunKind = %q, want %q", rec.RunKind, store.RunKindDaily)124+ }125+ if rec.RunDate == nil {126+ t.Fatal("RunDate = nil, want daily date")127+ }128+ if got, want := rec.RunDate.Format("2006-01-02"), "2026-02-14"; got != want {129+ t.Fatalf("RunDate = %q, want %q", got, want)130+ }131+}132+108133 func openSessionTestStore(t *testing.T) *store.Store {109134 t.Helper()110135 s, err := store.Open(filepath.Join(t.TempDir(), "session.db"))@@ -73,4 +73,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
7373 Help: HelpContent,7474 Import: game.AdaptImport(ImportModel),7575 Modes: Modes,76+ Print: PDFPrintAdapter,7677 })@@ -72,4 +72,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
7272 Help: HelpContent,7373 Import: game.AdaptImport(ImportModel),7474 Modes: Modes,75+ Print: PDFPrintAdapter,7576 })@@ -11,6 +11,34 @@ import (
1111 1212 var weeklyNamePattern = regexp.MustCompile(`^Week (\d{2})-(\d{4}) - #(\d{2})$`)1313 14+func NormalRunMetadata() RunMetadata {15+ return RunMetadata{Kind: RunKindNormal}16+}17+18+func DailyRunMetadata(date time.Time) RunMetadata {19+ day := dayOnly(date)20+ return RunMetadata{21+ Kind: RunKindDaily,22+ Date: &day,23+ }24+}25+26+func WeeklyRunMetadata(year, week, index int) RunMetadata {27+ return RunMetadata{28+ Kind: RunKindWeekly,29+ WeekYear: year,30+ WeekNumber: week,31+ WeekIndex: index,32+ }33+}34+35+func SeededRunMetadata(seed string) RunMetadata {36+ return RunMetadata{37+ Kind: RunKindSeeded,38+ SeedText: strings.TrimSpace(seed),39+ }40+}41+1442 func RunKindForName(name string) RunKind {1543 switch {1644 case RunDateForName(name) != nil:@@ -36,7 +64,7 @@ func RunDateForName(name string) *time.Time {
3664 if err != nil {3765 return nil3866 }39- day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)67+ day := dayOnly(t)4068 return &day4169 }4270 @@ -91,3 +119,7 @@ func isWeeklyName(name string) bool {
91119 _, _, _, ok := WeeklyIdentityForName(name)92120 return ok93121 }122+123+func dayOnly(t time.Time) time.Time {124+ return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())125+}@@ -61,4 +61,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
6161 Help: HelpContent,6262 Import: game.AdaptImport(ImportModel),6363 Modes: Modes,64+ Print: PDFPrintAdapter,6465 })@@ -62,4 +62,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
6262 Help: HelpContent,6363 Import: game.AdaptImport(ImportModel),6464 Modes: Modes,65+ Print: PDFPrintAdapter,6566 })@@ -69,4 +69,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
6969 Help: HelpContent,7070 Import: game.AdaptImport(ImportModel),7171 Modes: Modes,72+ Print: PDFPrintAdapter,7273 })@@ -73,4 +73,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
7373 Help: HelpContent,7474 Import: game.AdaptImport(ImportModel),7575 Modes: Modes,76+ Print: PDFPrintAdapter,7677 })@@ -71,4 +71,5 @@ var Entry = gamereg.NewEntry(gamereg.EntrySpec{
7171 Help: HelpContent,7272 Import: game.AdaptImport(ImportModel),7373 Modes: Modes,74+ Print: PDFPrintAdapter,7475 })