clone

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

tech debt 2

Dami committed b6747ff
commit
b6747ff1e16f74a802ae6543d2598e4a8d88c814
parent
95c0b27c37a2ed11097e7db330163ee8963fcfd0
author
Dami <dami.etoile@gmail.com>
34 files changed, +553 -431
Aapp/state_update.go107++++++++++++++++++++++++++++
Aapp/state_view.go116++++++++++++++++++++++++++++++
Mapp/update.go102++-------------------------
Mapp/view.go109+----------------------------
Abuiltinprint/register.go45++++++++++++
Abuiltinprint/register_test.go33+++++++++
Mcmd/export_pdf.go8+-
Mcmd/new_export.go3+
Mdaily/daily.go38+---------
Mdaily/daily_test.go49-------------
Mfillomino/print_adapter.go6+-
Mgame/gamer.go7+-
Mhashiwokakero/print_adapter.go6+-
Mhitori/print_adapter.go6+-
Mnonogram/print_adapter.go6+-
Mnurikabe/print_adapter.go6+-
Apdfexport/normalize.go7++
Mpdfexport/order.go15+---
Mpdfexport/print_adapter.go7+-
Apuzzle/puzzle_test.go31++++++++
Aregistry/registry_test.go35+++++++++
Mrippleeffect/print_adapter.go6+-
Aschedule/select.go47++++++++++++
Aschedule/select_test.go69++++++++++++++++++
Mshikaku/print_adapter.go6+-
Mspellpuzzle/print_adapter.go6+-
Mspellpuzzle/print_adapter_test.go6++
Msudoku/print_adapter.go6+-
Msudokurgb/print_adapter.go6+-
Mtakuzu/print_adapter.go6+-
Mtakuzuplus/print_adapter.go6+-
Mweekly/weekly.go37+---------
Mweekly/weekly_test.go35---------
Mwordsearch/print_adapter.go6+-
A app/state_update.go +107 -0
@@ -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+}
A app/state_view.go +116 -0
@@ -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+}
M app/update.go +6 -96
@@ -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-
M app/view.go +1 -108
@@ -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
A builtinprint/register.go +45 -0
A builtinprint/register_test.go +33 -0
M cmd/export_pdf.go +4 -4
M cmd/new_export.go +3 -0
M daily/daily.go +4 -34
@@ -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 }
M daily/daily_test.go +0 -49
@@ -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) {
M fillomino/print_adapter.go +2 -4
M game/gamer.go +2 -5
M hashiwokakero/print_adapter.go +2 -4
M hitori/print_adapter.go +2 -4
M nonogram/print_adapter.go +2 -4
M nurikabe/print_adapter.go +2 -4
A pdfexport/normalize.go +7 -0
M pdfexport/order.go +5 -10
@@ -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 }
M pdfexport/print_adapter.go +2 -5
A puzzle/puzzle_test.go +31 -0
A registry/registry_test.go +35 -0
M rippleeffect/print_adapter.go +2 -4
A schedule/select.go +47 -0
A schedule/select_test.go +69 -0
@@ -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+}
M shikaku/print_adapter.go +2 -4
M spellpuzzle/print_adapter.go +2 -4
M spellpuzzle/print_adapter_test.go +6 -0
M sudoku/print_adapter.go +2 -4
M sudokurgb/print_adapter.go +2 -4
M takuzu/print_adapter.go +2 -4
M takuzuplus/print_adapter.go +2 -4
M weekly/weekly.go +4 -33
@@ -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 }
M weekly/weekly_test.go +0 -35
M wordsearch/print_adapter.go +2 -4