- commit
- b6747ff1e16f74a802ae6543d2598e4a8d88c814
- parent
- 95c0b27c37a2ed11097e7db330163ee8963fcfd0
- author
- Dami <dami.etoile@gmail.com>
@@ -0,0 +1,107 @@
1+package app2+3+import (4+ "github.com/FelineStateMachine/puzzletea/game"5+ "github.com/FelineStateMachine/puzzletea/theme"6+ "github.com/FelineStateMachine/puzzletea/ui"7+8+ tea "charm.land/bubbletea/v2"9+)10+11+func (m model) updateActiveState(msg tea.Msg) (model, tea.Cmd) {12+ var cmd tea.Cmd13+14+ switch m.state {15+ case mainMenuView:16+ updateMainMenuCursor(msg, &m.nav.mainMenu)17+ case playMenuView:18+ updateMainMenuCursor(msg, &m.nav.playMenu)19+ case optionsMenuView:20+ updateMainMenuCursor(msg, &m.nav.optionsMenu)21+ case seedInputView:22+ m, cmd = m.handleSeedInputUpdate(msg)23+ case generatingView:24+ m.spinner, cmd = m.spinner.Update(msg)25+ case gameView:26+ m.session.game, cmd = m.session.game.Update(msg)27+ if m.debug.enabled {28+ m.debug.info = m.renderDebugInfo()29+ }30+ m = m.persistCompletionIfSolved()31+ case gameSelectView:32+ prev := selectedCategoryName(m.nav.gameSelectList.SelectedItem())33+ m.nav.gameSelectList, cmd = m.nav.gameSelectList.Update(msg)34+ if selectedCategoryName(m.nav.gameSelectList.SelectedItem()) != prev {35+ m = m.updateCategoryDetailViewport()36+ }37+ case modeSelectView:38+ m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg)39+ case continueView:40+ m.nav.continueTable, cmd = m.nav.continueTable.Update(msg)41+ case weeklyView:42+ m.nav.weeklyTable, cmd = m.nav.weeklyTable.Update(msg)43+ case helpSelectView:44+ m.nav.helpSelectList, cmd = m.nav.helpSelectList.Update(msg)45+ case helpDetailView:46+ m.help.viewport, cmd = m.help.viewport.Update(msg)47+ case statsView:48+ m.stats.viewport, cmd = m.stats.viewport.Update(msg)49+ case themeSelectView:50+ prev := m.theme.list.Index()51+ m.theme.list, cmd = m.theme.list.Update(msg)52+ if m.theme.list.Index() != prev {53+ if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok {54+ _ = theme.Apply(item.ItemTitle)55+ ui.UpdateThemeListStyles(&m.theme.list)56+ }57+ }58+ }59+60+ return m, cmd61+}62+63+func (m model) resizeActiveState(menuW int) model {64+ switch m.state {65+ case seedInputView:66+ m.nav.seedInput.SetWidth(min(m.width, 48))67+ case modeSelectView:68+ m.nav.modeSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.modeSelectList)))69+ 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))73+ case weeklyView:74+ m = m.refreshWeeklyBrowser()75+ case helpSelectView:76+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)77+ m.nav.helpSelectList.SetSize(listWidth, listHeight)78+ case helpDetailView:79+ m = m.updateHelpDetailViewport()80+ case themeSelectView:81+ const maxVisibleItems = 882+ listW := min(menuW, theme.MaxNameLen+4)83+ m.theme.list.SetSize(listW, min(m.height, maxVisibleItems*3))84+ case statsView:85+ m = m.updateStatsViewport()86+ }87+88+ return m89+}90+91+func (m model) handleFrameworkMsg(msg tea.Msg) (model, tea.Cmd, bool) {92+ switch msg := msg.(type) {93+ case spawnCompleteMsg:94+ next, cmd := m.handleSpawnComplete(msg.jobID, msg.result)95+ return next.(model), cmd, true96+ case game.SpawnCompleteMsg:97+ next, cmd := m.handleSpawnComplete(m.session.spawnJobID, msg)98+ return next.(model), cmd, true99+ case tea.WindowSizeMsg:100+ return m.handleWindowSize(msg), nil, false101+ case tea.KeyPressMsg:102+ next, cmd, done := m.handleGlobalKey(msg)103+ return next, cmd, done104+ default:105+ return m, nil, false106+ }107+}@@ -0,0 +1,116 @@
1+package app2+3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/puzzletea/ui"7+8+ "charm.land/lipgloss/v2"9+)10+11+func (m model) viewContent() string {12+ switch m.state {13+ case mainMenuView:14+ return ui.CenterView(m.width, m.height, m.nav.mainMenu.View())15+ case playMenuView:16+ return ui.CenterView(m.width, m.height, m.nav.playMenu.ViewAsPanel("Play"))17+ case optionsMenuView:18+ items := m.nav.optionsMenu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")19+ panel := ui.Panel("Options", items, "↑/↓ navigate • enter select • esc back")20+ return ui.CenterView(m.width, m.height, panel)21+ case seedInputView:22+ panel := ui.Panel(23+ "Enter Seed",24+ m.seedInputBody(),25+ "↑/↓ change field • ←/→ game • enter confirm • esc back",26+ )27+ return ui.CenterView(m.width, m.height, panel)28+ case gameSelectView:29+ return m.gameSelectViewContent()30+ case modeSelectView:31+ panel := ui.Panel(32+ m.nav.selectedCategory.Definition.Name+" — Select Mode",33+ m.nav.modeSelectList.View(),34+ "↑/↓ navigate • enter select • esc back",35+ )36+ return ui.CenterView(m.width, m.height, panel)37+ case generatingView:38+ s := m.spinner.View() + " Generating puzzle..."39+ box := ui.GeneratingFrame().Render(s)40+ return ui.CenterView(m.width, m.height, box)41+ case continueView:42+ return m.renderContinueView()43+ case weeklyView:44+ return ui.CenterView(m.width, m.height, m.weeklyViewContent())45+ case gameView:46+ return m.renderGameView()47+ case helpSelectView:48+ panel := ui.Panel(49+ "How to Play",50+ m.nav.helpSelectList.View(),51+ "↑/↓ navigate • enter select • esc back",52+ )53+ return ui.CenterView(m.width, m.height, panel)54+ case helpDetailView:55+ panel := ui.Panel(56+ m.help.category.Definition.Name+" — Guide",57+ m.help.viewport.View(),58+ "↑/↓ scroll • esc back",59+ )60+ return ui.CenterView(m.width, m.height, panel)61+ case themeSelectView:62+ return m.themeSelectViewContent()63+ case statsView:64+ return m.renderStatsView()65+ default:66+ return fmt.Sprintf("unknown state: %d", m.state)67+ }68+}69+70+func (m model) renderContinueView() string {71+ if len(m.nav.continueGames) == 0 {72+ panel := ui.Panel("Saved Games", "No saved games yet.", "esc back")73+ return ui.CenterView(m.width, m.height, panel)74+ }75+76+ footer := "↑/↓ navigate • enter resume • esc back"77+ if pg := ui.TablePagination(m.nav.continueTable); pg != "" {78+ footer = pg + " " + footer79+ }80+ panel := ui.Panel("Saved Games", m.nav.continueTable.View(), footer)81+ return ui.CenterView(m.width, m.height, panel)82+}83+84+func (m model) renderGameView() string {85+ if m.session.game == nil {86+ return ""87+ }88+89+ gameView := lipgloss.NewStyle().MaxWidth(m.width).Render(m.session.game.View())90+ centered := gameView91+ if m.debug.enabled {92+ debugInfo := lipgloss.NewStyle().MaxWidth(m.width).Render(93+ ui.DebugStyle().Render(m.debug.info),94+ )95+ centered = lipgloss.JoinVertical(lipgloss.Center, gameView, debugInfo)96+ }97+ return ui.CenterView(m.width, m.height, centered)98+}99+100+func (m model) renderStatsView() string {101+ statsWidth, _ := statsViewportSize(m.width, m.height, m.stats.cards)102+ var statsBody string103+ if len(m.stats.cards) == 0 {104+ statsBody = m.stats.viewport.View()105+ } else {106+ banner := ui.RenderStatsBanner(m.stats.profile, statsWidth)107+ statsBody = lipgloss.JoinVertical(lipgloss.Left,108+ banner,109+ "",110+ m.stats.viewport.View(),111+ )112+ }113+ statsBody = lipgloss.NewStyle().Width(statsWidth).Render(statsBody)114+ panel := ui.Panel("Stats", statsBody, "↑/↓ scroll • esc back")115+ return ui.CenterView(m.width, m.height, panel)116+}@@ -192,71 +192,13 @@ func (m model) updateStatsViewport() model {
192192 }193193 194194 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {195- var cmd tea.Cmd196-197- switch msg := msg.(type) {198- case spawnCompleteMsg:199- return m.handleSpawnComplete(msg.jobID, msg.result)200- case game.SpawnCompleteMsg:201- // Backward compatibility for callers still using the old message type.202- return m.handleSpawnComplete(m.session.spawnJobID, msg)203- case tea.WindowSizeMsg:204- m = m.handleWindowSize(msg)205- case tea.KeyPressMsg:206- next, keyCmd, done := m.handleGlobalKey(msg)207- if done {208- return next, keyCmd209- }210- m = next195+ next, cmd, handled := m.handleFrameworkMsg(msg)196+ if handled {197+ return next, cmd211198 }212199 213- switch m.state {214- case mainMenuView:215- updateMainMenuCursor(msg, &m.nav.mainMenu)216- case playMenuView:217- updateMainMenuCursor(msg, &m.nav.playMenu)218- case optionsMenuView:219- updateMainMenuCursor(msg, &m.nav.optionsMenu)220- case seedInputView:221- m, cmd = m.handleSeedInputUpdate(msg)222- case generatingView:223- m.spinner, cmd = m.spinner.Update(msg)224- case gameView:225- m.session.game, cmd = m.session.game.Update(msg)226- if m.debug.enabled {227- m.debug.info = m.renderDebugInfo()228- }229- m = m.persistCompletionIfSolved()230- case gameSelectView:231- prev := selectedCategoryName(m.nav.gameSelectList.SelectedItem())232- m.nav.gameSelectList, cmd = m.nav.gameSelectList.Update(msg)233- if selectedCategoryName(m.nav.gameSelectList.SelectedItem()) != prev {234- m = m.updateCategoryDetailViewport()235- }236- case modeSelectView:237- m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg)238- case continueView:239- m.nav.continueTable, cmd = m.nav.continueTable.Update(msg)240- case weeklyView:241- m.nav.weeklyTable, cmd = m.nav.weeklyTable.Update(msg)242- case helpSelectView:243- m.nav.helpSelectList, cmd = m.nav.helpSelectList.Update(msg)244- case helpDetailView:245- m.help.viewport, cmd = m.help.viewport.Update(msg)246- case statsView:247- m.stats.viewport, cmd = m.stats.viewport.Update(msg)248- case themeSelectView:249- prev := m.theme.list.Index()250- m.theme.list, cmd = m.theme.list.Update(msg)251- if m.theme.list.Index() != prev {252- if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok {253- _ = theme.Apply(item.ItemTitle)254- ui.UpdateThemeListStyles(&m.theme.list)255- }256- }257- }258-259- return m, cmd200+ m = next201+ return m.updateActiveState(msg)260202 }261203 262204 func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {@@ -266,36 +208,7 @@ func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {
266208 metrics := categoryPickerSize(m.width, m.height)267209 m.nav.gameSelectList.SetSize(metrics.listWidth, metrics.listHeight)268210 m = m.updateCategoryDetailViewport()269- if m.state == seedInputView {270- m.nav.seedInput.SetWidth(min(m.width, 48))271- }272- if m.state == modeSelectView {273- m.nav.modeSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.modeSelectList)))274- }275- if m.state == continueView {276- m.nav.continueTable.SetWidth(m.width)277- visibleRows := min(len(m.nav.continueGames), ui.MaxTableRows)278- m.nav.continueTable.SetHeight(min(m.height, visibleRows))279- }280- if m.state == weeklyView {281- m = m.refreshWeeklyBrowser()282- }283- if m.state == helpSelectView {284- listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)285- m.nav.helpSelectList.SetSize(listWidth, listHeight)286- }287- if m.state == helpDetailView {288- m = m.updateHelpDetailViewport()289- }290- if m.state == themeSelectView {291- const maxVisibleItems = 8292- listW := min(menuW, theme.MaxNameLen+4)293- m.theme.list.SetSize(listW, min(m.height, maxVisibleItems*3))294- }295- if m.state == statsView {296- m = m.updateStatsViewport()297- }298- return m211+ return m.resizeActiveState(menuW)299212 }300213 301214 func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) {@@ -415,6 +328,3 @@ func (m model) handleEnter() (tea.Model, tea.Cmd) {
415328 }416329 return m, nil417330 }418-419-420-@@ -13,114 +13,7 @@ import (
1313 )1414 1515 func (m model) View() tea.View {16- var content string17-18- switch m.state {19- case mainMenuView:20- content = ui.CenterView(m.width, m.height, m.nav.mainMenu.View())21- case playMenuView:22- content = ui.CenterView(m.width, m.height, m.nav.playMenu.ViewAsPanel("Play"))23- case optionsMenuView:24- items := m.nav.optionsMenu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")25- panel := ui.Panel("Options", items, "↑/↓ navigate • enter select • esc back")26- content = ui.CenterView(m.width, m.height, panel)27- case seedInputView:28- panel := ui.Panel(29- "Enter Seed",30- m.seedInputBody(),31- "↑/↓ change field • ←/→ game • enter confirm • esc back",32- )33- content = ui.CenterView(m.width, m.height, panel)34- case gameSelectView:35- content = m.gameSelectViewContent()36- case modeSelectView:37- panel := ui.Panel(38- m.nav.selectedCategory.Definition.Name+" — Select Mode",39- m.nav.modeSelectList.View(),40- "↑/↓ navigate • enter select • esc back",41- )42- content = ui.CenterView(m.width, m.height, panel)43- case generatingView:44- s := m.spinner.View() + " Generating puzzle..."45- box := ui.GeneratingFrame().Render(s)46- content = ui.CenterView(m.width, m.height, box)47- case continueView:48- var s string49- if len(m.nav.continueGames) == 0 {50- s = ui.Panel(51- "Saved Games",52- "No saved games yet.",53- "esc back",54- )55- } else {56- footer := "↑/↓ navigate • enter resume • esc back"57- if pg := ui.TablePagination(m.nav.continueTable); pg != "" {58- footer = pg + " " + footer59- }60- s = ui.Panel(61- "Saved Games",62- m.nav.continueTable.View(),63- footer,64- )65- }66- content = ui.CenterView(m.width, m.height, s)67- case weeklyView:68- content = ui.CenterView(m.width, m.height, m.weeklyViewContent())69- case gameView:70- if m.session.game == nil {71- content = ""72- } else {73- gameView := lipgloss.NewStyle().MaxWidth(m.width).Render(m.session.game.View())74- centered := gameView75- if m.debug.enabled {76- debugInfo := lipgloss.NewStyle().MaxWidth(m.width).Render(77- ui.DebugStyle().Render(m.debug.info),78- )79- centered = lipgloss.JoinVertical(lipgloss.Center, gameView, debugInfo)80- }81- content = ui.CenterView(m.width, m.height, centered)82- }83- case helpSelectView:84- panel := ui.Panel(85- "How to Play",86- m.nav.helpSelectList.View(),87- "↑/↓ navigate • enter select • esc back",88- )89- content = ui.CenterView(m.width, m.height, panel)90- case helpDetailView:91- panel := ui.Panel(92- m.help.category.Definition.Name+" — Guide",93- m.help.viewport.View(),94- "↑/↓ scroll • esc back",95- )96- content = ui.CenterView(m.width, m.height, panel)97- case themeSelectView:98- content = m.themeSelectViewContent()99- case statsView:100- statsWidth, _ := statsViewportSize(m.width, m.height, m.stats.cards)101- var statsBody string102- if len(m.stats.cards) == 0 {103- statsBody = m.stats.viewport.View()104- } else {105- banner := ui.RenderStatsBanner(m.stats.profile, statsWidth)106- statsBody = lipgloss.JoinVertical(lipgloss.Left,107- banner,108- "",109- m.stats.viewport.View(),110- )111- }112- statsBody = lipgloss.NewStyle().Width(statsWidth).Render(statsBody)113- panel := ui.Panel(114- "Stats",115- statsBody,116- "↑/↓ scroll • esc back",117- )118- content = ui.CenterView(m.width, m.height, panel)119- default:120- content = fmt.Sprintf("unknown state: %d", m.state)121- }122-123- v := tea.NewView(content)16+ v := tea.NewView(m.viewContent())12417 v.AltScreen = true12518 if m.state == gameView {12619 v.MouseMode = tea.MouseModeCellMotion@@ -0,0 +1,45 @@
1+package builtinprint2+3+import (4+ "sync"5+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"11+ "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"20+)21+22+var registerOnce sync.Once23+24+func Register() {25+ 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)43+ }44+ })45+}@@ -0,0 +1,33 @@
1+package builtinprint2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/pdfexport"7+)8+9+func TestRegisterBuiltins(t *testing.T) {10+ Register()11+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)31+ }32+ }33+}@@ -7,6 +7,7 @@ import (
77 "strings"88 "time"99 10+ "github.com/FelineStateMachine/puzzletea/builtinprint"1011 "github.com/FelineStateMachine/puzzletea/pdfexport"1112 "github.com/FelineStateMachine/puzzletea/puzzle"1213 "github.com/FelineStateMachine/puzzletea/registry"@@ -43,6 +44,8 @@ func init() {
4344 }4445 4546 func runExportPDF(cmd *cobra.Command, args []string) error {47+ builtinprint.Register()48+4649 docs, err := pdfexport.ParseJSONLFiles(args)4750 if err != nil {4851 return err@@ -201,10 +204,7 @@ func annotateDifficulty(puzzles []pdfexport.Puzzle, lookup map[string]map[string
201204 }202205 203206 func normalizeDifficultyToken(s string) string {204- s = strings.ToLower(strings.TrimSpace(s))205- s = strings.ReplaceAll(s, "-", " ")206- s = strings.ReplaceAll(s, "_", " ")207- return strings.Join(strings.Fields(s), " ")207+ return puzzle.NormalizeName(s)208208 }209209 210210 // parseCoverColor parses a cover color string in hex ("#RRGGBB") or@@ -10,6 +10,7 @@ import (
1010 "strings"1111 "time"1212 13+ "github.com/FelineStateMachine/puzzletea/builtinprint"1314 "github.com/FelineStateMachine/puzzletea/game"1415 "github.com/FelineStateMachine/puzzletea/namegen"1516 "github.com/FelineStateMachine/puzzletea/pdfexport"@@ -27,6 +28,8 @@ type exportModeEntry struct {
2728 }2829 2930 func runNewExport(cmd *cobra.Command, args []string) error {31+ builtinprint.Register()32+3033 if err := validateNewExportFlags(cmd, args); err != nil {3134 return err3235 }@@ -8,29 +8,16 @@ import (
88 "github.com/FelineStateMachine/puzzletea/game"99 "github.com/FelineStateMachine/puzzletea/namegen"1010 "github.com/FelineStateMachine/puzzletea/registry"11+ "github.com/FelineStateMachine/puzzletea/schedule"1112 )1213 13-// Entry pairs a SeededSpawner with metadata for the eligible daily pool.14-type Entry struct {15- Spawner game.SeededSpawner16- GameType string17- Mode string18-}14+type Entry = schedule.Entry1915 2016 // eligibleModes is the flattened pool built from each package's DailyModes.2117 var eligibleModes = buildEligibleModes()2218 2319 func buildEligibleModes() []Entry {24- registryEntries := registry.DailyEntries()25- entries := make([]Entry, 0, len(registryEntries))26- for _, entry := range registryEntries {27- entries = append(entries, Entry{28- Spawner: entry.Spawner,29- GameType: entry.GameType,30- Mode: entry.Mode,31- })32- }33- return entries20+ return schedule.BuildEligibleModes(registry.DailyEntries())3421 }3522 3623 // Seed returns a deterministic uint64 seed derived from the date.@@ -73,24 +60,7 @@ func Mode(date time.Time) (game.SeededSpawner, string, string) {
7360 }7461 7562 dateStr := date.Format("2006-01-02")76-77- var best Entry78- var bestHash uint6479- found := false80- for _, entry := range eligibleModes {81- h := fnv.New64a()82- h.Write([]byte(dateStr))83- h.Write([]byte{0})84- h.Write([]byte(entry.GameType))85- h.Write([]byte{0})86- h.Write([]byte(entry.Mode))87- score := h.Sum64()88- if !found || score > bestHash {89- bestHash = score90- best = entry91- found = true92- }93- }63+ best, found := schedule.SelectBySeed(dateStr, eligibleModes)9464 if !found {9565 return nil, "", ""9666 }@@ -132,55 +132,6 @@ func TestModeDeterministic(t *testing.T) {
132132 }133133 }134134 135-func TestModeStableOnPoolChange(t *testing.T) {136- // Verify the core property of rendezvous hashing: for a given date,137- // the selected entry depends only on the (date, gameType, mode) triple,138- // not on the total number of entries in the pool.139- //140- // We test this by recording the mode for a set of dates, then adding141- // a synthetic entry to eligibleModes and confirming that dates which142- // did NOT select the new entry still return the same mode as before.143- dates := make([]time.Time, 30)144- for i := range dates {145- dates[i] = time.Date(2026, 1, 1+i, 0, 0, 0, 0, time.UTC)146- }147-148- // Record original selections.149- type selection struct {150- gameType string151- mode string152- }153- original := make([]selection, len(dates))154- for i, d := range dates {155- _, gt, m := Mode(d)156- original[i] = selection{gt, m}157- }158-159- // Temporarily add a synthetic entry.160- synth := Entry{161- Spawner: eligibleModes[0].Spawner,162- GameType: "SyntheticGame",163- Mode: "SyntheticMode",164- }165- eligibleModes = append(eligibleModes, synth)166- defer func() {167- eligibleModes = eligibleModes[:len(eligibleModes)-1]168- }()169-170- for i, d := range dates {171- _, gt, m := Mode(d)172- if gt == "SyntheticGame" && m == "SyntheticMode" {173- // This date was "stolen" by the new entry — expected for some dates.174- continue175- }176- if gt != original[i].gameType || m != original[i].mode {177- t.Errorf("date %s: selection changed from (%q,%q) to (%q,%q) after adding unrelated entry",178- d.Format("2006-01-02"),179- original[i].gameType, original[i].mode, gt, m)180- }181- }182-}183-184135 // --- EligibleModes pool (P1) ---185136 186137 func TestEligibleModesNotEmpty(t *testing.T) {@@ -9,6 +9,8 @@ import (
99 1010 type printAdapter struct{}1111 12+var PDFPrintAdapter = printAdapter{}13+1214 func (printAdapter) CanonicalGameType() string { return "Fillomino" }1315 func (printAdapter) Aliases() []string { return []string{"fillomino", "polyomino", "regions"} }1416 @@ -83,7 +85,3 @@ func drawFillominoGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) {
8385 pdf.SetXY(x, y+(cellSize-lineH)/2)8486 pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")8587 }86-87-func init() {88- pdfexport.RegisterPrintAdapter(printAdapter{})89-}@@ -4,11 +4,11 @@ package game
44 import (55 "context"66 "math/rand/v2"7- "strings"87 98 "charm.land/bubbles/v2/key"109 "charm.land/bubbles/v2/list"1110 tea "charm.land/bubbletea/v2"11+ "github.com/FelineStateMachine/puzzletea/puzzle"1212 )1313 1414 // Gamer is the interface that an active game instance must implement.@@ -115,10 +115,7 @@ func (d Definition) Category() Category {
115115 // NormalizeName lowercases and replaces hyphens/underscores with spaces for116116 // fuzzy matching of CLI arguments to game names and aliases.117117 func NormalizeName(s string) string {118- s = strings.ToLower(s)119- s = strings.ReplaceAll(s, "-", " ")120- s = strings.ReplaceAll(s, "_", " ")121- return strings.TrimSpace(s)118+ return puzzle.NormalizeName(s)122119 }123120 124121 // SpawnCompleteMsg is sent when an async Spawn() call finishes.@@ -11,6 +11,8 @@ import (
1111 1212 type printAdapter struct{}1313 14+var PDFPrintAdapter = printAdapter{}15+1416 func (printAdapter) CanonicalGameType() string { return "Hashiwokakero" }1517 func (printAdapter) Aliases() []string {1618 return []string{"hashi", "hashiwokakero", "hashi wokakero"}@@ -133,7 +135,3 @@ func drawHashiIslandNumber(pdf *fpdf.Fpdf, cx, cy, radius float64, required int)
133135 pdf.SetXY(cx-radius, cy-lineH/2)134136 pdf.CellFormat(radius*2, lineH, text, "", 0, "C", false, 0, "")135137 }136-137-func init() {138- pdfexport.RegisterPrintAdapter(printAdapter{})139-}@@ -10,6 +10,8 @@ import (
1010 1111 type printAdapter struct{}1212 13+var PDFPrintAdapter = printAdapter{}14+1315 func (printAdapter) CanonicalGameType() string { return "Hitori" }1416 func (printAdapter) Aliases() []string { return []string{"hitori"} }1517 @@ -99,7 +101,3 @@ func drawHitoriCellNumber(pdf *fpdf.Fpdf, x, y, cellSize float64, text string) {
99101 pdf.SetXY(x, y+(cellSize-lineH)/2)100102 pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")101103 }102-103-func init() {104- pdfexport.RegisterPrintAdapter(printAdapter{})105-}@@ -10,6 +10,8 @@ import (
1010 1111 type printAdapter struct{}1212 13+var PDFPrintAdapter = printAdapter{}14+1315 func (printAdapter) CanonicalGameType() string { return "Nonogram" }1416 func (printAdapter) Aliases() []string { return []string{"nonogram"} }1517 @@ -277,7 +279,3 @@ func drawNonogramMajorLines(
277279 pdf.Line(puzzleStartX, y, puzzleStartX+float64(width)*cellSize, y)278280 }279281 }280-281-func init() {282- pdfexport.RegisterPrintAdapter(printAdapter{})283-}@@ -10,6 +10,8 @@ import (
1010 1111 type printAdapter struct{}1212 13+var PDFPrintAdapter = printAdapter{}14+1315 func (printAdapter) CanonicalGameType() string { return "Nurikabe" }1416 func (printAdapter) Aliases() []string { return []string{"nurikabe"} }1517 @@ -95,7 +97,3 @@ func drawNurikabeClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) {
9597 pdf.SetXY(x, y+(cellSize-lineH)/2)9698 pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")9799 }98-99-func init() {100- pdfexport.RegisterPrintAdapter(printAdapter{})101-}@@ -6,6 +6,8 @@ import (
66 "sort"77 "strings"88 "time"9+10+ "github.com/FelineStateMachine/puzzletea/puzzle"911 )1012 1113 func OrderPuzzlesForPrint(puzzles []Puzzle, seed string) []Puzzle {@@ -18,10 +20,10 @@ func OrderPuzzlesForPrint(puzzles []Puzzle, seed string) []Puzzle {
1820 if ordered[i].DifficultyScore != ordered[j].DifficultyScore {1921 return ordered[i].DifficultyScore < ordered[j].DifficultyScore2022 }21- if c := strings.Compare(normalizeToken(ordered[i].Category), normalizeToken(ordered[j].Category)); c != 0 {23+ if c := strings.Compare(puzzle.NormalizeName(ordered[i].Category), puzzle.NormalizeName(ordered[j].Category)); c != 0 {2224 return c < 02325 }24- if c := strings.Compare(normalizeToken(ordered[i].ModeSelection), normalizeToken(ordered[j].ModeSelection)); c != 0 {26+ if c := strings.Compare(puzzle.NormalizeName(ordered[i].ModeSelection), puzzle.NormalizeName(ordered[j].ModeSelection)); c != 0 {2527 return c < 02628 }2729 if c := strings.Compare(ordered[i].SourceFileName, ordered[j].SourceFileName); c != 0 {@@ -93,12 +95,5 @@ func seededRand(seed string) *rand.Rand {
9395 }9496 9597 func sameCategory(a, b Puzzle) bool {96- return normalizeToken(a.Category) == normalizeToken(b.Category)97-}98-99-func normalizeToken(s string) string {100- s = strings.ToLower(strings.TrimSpace(s))101- s = strings.ReplaceAll(s, "-", " ")102- s = strings.ReplaceAll(s, "_", " ")103- return strings.Join(strings.Fields(s), " ")98+ return puzzle.NormalizeName(a.Category) == puzzle.NormalizeName(b.Category)10499 }@@ -2,9 +2,9 @@ package pdfexport
22 33 import (44 "reflect"5- "strings"65 76 "codeberg.org/go-pdf/fpdf"7+ "github.com/FelineStateMachine/puzzletea/puzzle"88 )99 1010 type PrintAdapter interface {@@ -57,8 +57,5 @@ func IsNilPrintPayload(payload any) bool {
5757 }5858 5959 func normalizeGameTypeToken(s string) string {60- s = strings.ToLower(strings.TrimSpace(s))61- s = strings.ReplaceAll(s, "-", " ")62- s = strings.ReplaceAll(s, "_", " ")63- return strings.Join(strings.Fields(s), " ")60+ return puzzle.NormalizeName(s)6461 }@@ -0,0 +1,31 @@
1+package puzzle2+3+import "testing"4+5+func TestNormalizeName(t *testing.T) {6+ tests := []struct {7+ input string8+ want string9+ }{10+ {input: "Word Search", want: "word search"},11+ {input: "lights-out", want: "lights out"},12+ {input: "word_search", want: "word search"},13+ {input: " Sudoku ", want: "sudoku"},14+ {input: "Sudoku RGB", want: "sudoku rgb"},15+ }16+17+ for _, tt := range tests {18+ if got := NormalizeName(tt.input); got != tt.want {19+ t.Fatalf("NormalizeName(%q) = %q, want %q", tt.input, got, tt.want)20+ }21+ }22+}23+24+func TestCanonicalIDsUseNormalizedNames(t *testing.T) {25+ if got, want := CanonicalGameID(" Sudoku RGB "), GameID("sudoku rgb"); got != want {26+ t.Fatalf("CanonicalGameID() = %q, want %q", got, want)27+ }28+ if got, want := CanonicalModeID("Mini-Hard"), ModeID("mini hard"); got != want {29+ t.Fatalf("CanonicalModeID() = %q, want %q", got, want)30+ }31+}@@ -0,0 +1,35 @@
1+package registry2+3+import "testing"4+5+func TestResolveNormalizesSpacingAndAliases(t *testing.T) {6+ tests := []struct {7+ name string8+ want string9+ }{10+ {name: "word search", want: "Word Search"},11+ {name: "hashi", want: "Hashiwokakero"},12+ {name: "polyomino", want: "Fillomino"},13+ }14+15+ for _, tt := range tests {16+ entry, ok := Resolve(tt.name)17+ if !ok {18+ t.Fatalf("Resolve(%q) = false, want true", tt.name)19+ }20+ if got := entry.Definition.Name; got != tt.want {21+ t.Fatalf("Resolve(%q) = %q, want %q", tt.name, got, tt.want)22+ }23+ }24+}25+26+func TestModeSeededFlagMatchesSpawnerAvailability(t *testing.T) {27+ for _, entry := range Entries() {28+ for _, mode := range entry.Modes {29+ if got, want := mode.Definition.Seeded, mode.Seeded != nil; got != want {30+ t.Fatalf("%s/%s seeded flag = %v, want %v",31+ entry.Definition.Name, mode.Definition.Title, got, want)32+ }33+ }34+ }35+}@@ -9,6 +9,8 @@ import (
99 1010 type printAdapter struct{}1111 12+var PDFPrintAdapter = printAdapter{}13+1214 func (printAdapter) CanonicalGameType() string { return "Ripple Effect" }1315 func (printAdapter) Aliases() []string { return []string{"ripple effect", "rippleeffect", "ripple"} }1416 @@ -119,7 +121,3 @@ func drawRippleEffectGiven(pdf *fpdf.Fpdf, x, y, cellSize float64, text string)
119121 pdf.SetXY(x, y+(cellSize-lineH)/2)120122 pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")121123 }122-123-func init() {124- pdfexport.RegisterPrintAdapter(printAdapter{})125-}@@ -0,0 +1,47 @@
1+package schedule2+3+import (4+ "hash/fnv"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/FelineStateMachine/puzzletea/registry"8+)9+10+type Entry struct {11+ Spawner game.SeededSpawner12+ GameType string13+ Mode string14+}15+16+func BuildEligibleModes(entries []registry.DailyEntry) []Entry {17+ result := make([]Entry, 0, len(entries))18+ for _, entry := range entries {19+ result = append(result, Entry{20+ Spawner: entry.Spawner,21+ GameType: entry.GameType,22+ Mode: entry.Mode,23+ })24+ }25+ return result26+}27+28+func SelectBySeed(seed string, entries []Entry) (Entry, bool) {29+ var best Entry30+ var bestHash uint6431+ found := false32+ for _, entry := range entries {33+ h := fnv.New64a()34+ h.Write([]byte(seed))35+ h.Write([]byte{0})36+ h.Write([]byte(entry.GameType))37+ h.Write([]byte{0})38+ h.Write([]byte(entry.Mode))39+ score := h.Sum64()40+ if !found || score > bestHash {41+ bestHash = score42+ best = entry43+ found = true44+ }45+ }46+ return best, found47+}@@ -0,0 +1,69 @@
1+package schedule2+3+import (4+ "math/rand/v2"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+type testSeededSpawner struct{}11+12+func (testSeededSpawner) Spawn() (game.Gamer, error) { return nil, nil }13+func (testSeededSpawner) SpawnSeeded(*rand.Rand) (game.Gamer, error) { return nil, nil }14+15+func TestSelectBySeedDeterministic(t *testing.T) {16+ entries := []Entry{17+ {Spawner: testSeededSpawner{}, GameType: "Sudoku", Mode: "Easy"},18+ {Spawner: testSeededSpawner{}, GameType: "Nonogram", Mode: "Mini"},19+ {Spawner: testSeededSpawner{}, GameType: "Takuzu", Mode: "Beginner"},20+ }21+22+ first, ok := SelectBySeed("seed-1", entries)23+ if !ok {24+ t.Fatal("SelectBySeed() = false, want true")25+ }26+ second, ok := SelectBySeed("seed-1", entries)27+ if !ok {28+ t.Fatal("SelectBySeed() = false, want true")29+ }30+ if first.GameType != second.GameType || first.Mode != second.Mode {31+ t.Fatalf("selection mismatch: (%q,%q) vs (%q,%q)",32+ first.GameType, first.Mode, second.GameType, second.Mode)33+ }34+}35+36+func TestSelectBySeedStableForUnchangedWinners(t *testing.T) {37+ entries := []Entry{38+ {Spawner: testSeededSpawner{}, GameType: "Sudoku", Mode: "Easy"},39+ {Spawner: testSeededSpawner{}, GameType: "Nonogram", Mode: "Mini"},40+ {Spawner: testSeededSpawner{}, GameType: "Takuzu", Mode: "Beginner"},41+ }42+43+ seeds := []string{"seed-a", "seed-b", "seed-c", "seed-d", "seed-e"}44+ original := make(map[string]Entry, len(seeds))45+ for _, seed := range seeds {46+ selected, ok := SelectBySeed(seed, entries)47+ if !ok {48+ t.Fatalf("SelectBySeed(%q) = false, want true", seed)49+ }50+ original[seed] = selected51+ }52+53+ synthetic := Entry{Spawner: testSeededSpawner{}, GameType: "Synthetic", Mode: "Synthetic"}54+ extended := append(append([]Entry(nil), entries...), synthetic)55+ for _, seed := range seeds {56+ selected, ok := SelectBySeed(seed, extended)57+ if !ok {58+ t.Fatalf("SelectBySeed(%q) = false, want true", seed)59+ }60+ if selected.GameType == synthetic.GameType && selected.Mode == synthetic.Mode {61+ continue62+ }63+ want := original[seed]64+ if selected.GameType != want.GameType || selected.Mode != want.Mode {65+ t.Fatalf("seed %q changed from (%q,%q) to (%q,%q)",66+ seed, want.GameType, want.Mode, selected.GameType, selected.Mode)67+ }68+ }69+}@@ -10,6 +10,8 @@ import (
1010 1111 type printAdapter struct{}1212 13+var PDFPrintAdapter = printAdapter{}14+1315 func (printAdapter) CanonicalGameType() string { return "Shikaku" }1416 func (printAdapter) Aliases() []string { return []string{"shikaku"} }1517 @@ -95,7 +97,3 @@ func drawShikakuClue(pdf *fpdf.Fpdf, x, y, cellSize float64, value int) {
9597 pdf.SetXY(x, y+(cellSize-lineH)/2)9698 pdf.CellFormat(cellSize, lineH, text, "", 0, "C", false, 0, "")9799 }98-99-func init() {100- pdfexport.RegisterPrintAdapter(printAdapter{})101-}@@ -11,6 +11,8 @@ import (
1111 1212 type printAdapter struct{}1313 14+var PDFPrintAdapter = printAdapter{}15+1416 type printPayload struct {1517 Board board1618 Placements []WordPlacement@@ -226,7 +228,3 @@ func drawSpellPuzzleBank(pdf *fpdf.Fpdf, bank []rune, layout printLayout) {
226228 pdf.CellFormat(layout.tileSize, lineH, strings.ToUpper(string(letter)), "", 0, "C", false, 0, "")227229 }228230 }229-230-func init() {231- pdfexport.RegisterPrintAdapter(printAdapter{})232-}@@ -95,6 +95,8 @@ func TestPrintAdapterDeduplicatesDuplicatePlacements(t *testing.T) {
9595 }9696 9797 func TestSpellPuzzlePrintAdapterRegistration(t *testing.T) {98+ pdfexport.RegisterPrintAdapter(PDFPrintAdapter)99+98100 for _, gameType := range []string{"Spell Puzzle", "spell", "spellpuzzle"} {99101 if !game.HasPrintAdapter(gameType) {100102 t.Fatalf("expected print adapter for %q", gameType)@@ -103,6 +105,8 @@ func TestSpellPuzzlePrintAdapterRegistration(t *testing.T) {
103105 }104106 105107 func TestSpellPuzzleJSONLHydratesPrintPayload(t *testing.T) {108+ pdfexport.RegisterPrintAdapter(PDFPrintAdapter)109+106110 record := pdfexport.JSONLRecord{107111 Schema: pdfexport.ExportSchemaV1,108112 Pack: pdfexport.JSONLPackMeta{@@ -151,6 +155,8 @@ func TestSpellPuzzleJSONLHydratesPrintPayload(t *testing.T) {
151155 }152156 153157 func TestSpellPuzzlePDFRenderSmokeForAllModes(t *testing.T) {158+ pdfexport.RegisterPrintAdapter(PDFPrintAdapter)159+154160 puzzles := make([]pdfexport.Puzzle, 0, len(Modes))155161 for i, item := range Modes {156162 mode := item.(SpellPuzzleMode)@@ -9,6 +9,8 @@ import (
99 1010 type printAdapter struct{}1111 12+var PDFPrintAdapter = printAdapter{}13+1214 func (printAdapter) CanonicalGameType() string { return "Sudoku" }1315 func (printAdapter) Aliases() []string { return []string{"sudoku"} }1416 @@ -105,7 +107,3 @@ func drawSudokuGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [
105107 }106108 }107109 }108-109-func init() {110- pdfexport.RegisterPrintAdapter(printAdapter{})111-}@@ -9,6 +9,8 @@ import (
99 1010 type printAdapter struct{}1111 12+var PDFPrintAdapter = printAdapter{}13+1214 func (printAdapter) CanonicalGameType() string { return "Sudoku RGB" }1315 func (printAdapter) Aliases() []string {1416 return []string{"sudoku rgb", "rgb sudoku", "ripeto", "sudoku ripeto"}@@ -107,7 +109,3 @@ func drawSudokuRGBGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, given
107109 }108110 }109111 }110-111-func init() {112- pdfexport.RegisterPrintAdapter(printAdapter{})113-}@@ -9,6 +9,8 @@ import (
99 1010 type printAdapter struct{}1111 12+var PDFPrintAdapter = printAdapter{}13+1214 var defaultTakuzuRules = []string{1315 "No three equal adjacent in any row or column.",1416 "Each row/column has equal 0 and 1 counts, and rows/columns are unique.",@@ -196,7 +198,3 @@ func takuzuRelationFontSize(cellSize float64, size int) float64 {
196198 func takuzuRelationBackdropSize(cellSize, fontSize float64) float64 {197199 return fontSize + cellSize*0.12198200 }199-200-func init() {201- pdfexport.RegisterPrintAdapter(printAdapter{})202-}@@ -8,6 +8,8 @@ import (
88 99 type printAdapter struct{}1010 11+var PDFPrintAdapter = printAdapter{}12+1113 var takuzuPlusRules = []string{1214 "No three equal adjacent in any row or column.",1315 "Each row/column has equal 0 and 1 counts, and rows/columns are unique.",@@ -28,7 +30,3 @@ func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {
2830 }2931 return nil3032 }31-32-func init() {33- pdfexport.RegisterPrintAdapter(printAdapter{})34-}@@ -10,14 +10,10 @@ import (
1010 1111 "github.com/FelineStateMachine/puzzletea/game"1212 "github.com/FelineStateMachine/puzzletea/registry"13+ "github.com/FelineStateMachine/puzzletea/schedule"1314 )1415 15-// Entry pairs a SeededSpawner with metadata for the eligible weekly pool.16-type Entry struct {17- Spawner game.SeededSpawner18- GameType string19- Mode string20-}16+type Entry = schedule.Entry2117 2218 // Info identifies a single weekly gauntlet puzzle.2319 type Info struct {@@ -33,16 +29,7 @@ var (
3329 )3430 3531 func buildEligibleModes() []Entry {36- registryEntries := registry.DailyEntries()37- entries := make([]Entry, 0, len(registryEntries))38- for _, entry := range registryEntries {39- entries = append(entries, Entry{40- Spawner: entry.Spawner,41- GameType: entry.GameType,42- Mode: entry.Mode,43- })44- }45- return entries32+ return schedule.BuildEligibleModes(registry.DailyEntries())4633 }4734 4835 // Name returns the canonical persisted name for a weekly puzzle.@@ -134,23 +121,7 @@ func Mode(year, week, index int) (game.SeededSpawner, string, string) {
134121 }135122 136123 seedName := Name(year, week, index)137- var best Entry138- var bestHash uint64139- found := false140- for _, entry := range eligibleModes {141- h := fnv.New64a()142- h.Write([]byte(seedName))143- h.Write([]byte{0})144- h.Write([]byte(entry.GameType))145- h.Write([]byte{0})146- h.Write([]byte(entry.Mode))147- score := h.Sum64()148- if !found || score > bestHash {149- bestHash = score150- best = entry151- found = true152- }153- }124+ best, found := schedule.SelectBySeed(seedName, eligibleModes)154125 if !found {155126 return nil, "", ""156127 }@@ -60,41 +60,6 @@ func TestModeDeterministic(t *testing.T) {
6060 }6161 }6262 63-func TestModeStableOnPoolChange(t *testing.T) {64- type selection struct {65- gameType string66- mode string67- }68-69- selections := make([]selection, 0, 20)70- for index := 1; index <= 20; index++ {71- _, gameType, mode := Mode(2026, 1, index)72- selections = append(selections, selection{gameType: gameType, mode: mode})73- }74-75- synth := Entry{76- Spawner: eligibleModes[0].Spawner,77- GameType: "SyntheticGame",78- Mode: "SyntheticMode",79- }80- eligibleModes = append(eligibleModes, synth)81- defer func() {82- eligibleModes = eligibleModes[:len(eligibleModes)-1]83- }()84-85- for index := 1; index <= 20; index++ {86- _, gameType, mode := Mode(2026, 1, index)87- if gameType == synth.GameType && mode == synth.Mode {88- continue89- }90- want := selections[index-1]91- if gameType != want.gameType || mode != want.mode {92- t.Fatalf("index %d selection changed from (%q,%q) to (%q,%q)",93- index, want.gameType, want.mode, gameType, mode)94- }95- }96-}97-9863 func TestISOWeekBoundary(t *testing.T) {9964 date := time.Date(2025, time.December, 29, 12, 0, 0, 0, time.UTC)10065 info := Current(date)@@ -9,6 +9,8 @@ import (
99 1010 type printAdapter struct{}1111 12+var PDFPrintAdapter = printAdapter{}13+1214 func (printAdapter) CanonicalGameType() string { return "Word Search" }1315 func (printAdapter) Aliases() []string {1416 return []string{"word search", "wordsearch"}@@ -254,7 +256,3 @@ func minLineCountColumn(lineCounts []int) int {
254256 }255257 return idx256258 }257-258-func init() {259- pdfexport.RegisterPrintAdapter(printAdapter{})260-}