clone

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

Add weekly gauntlet play flow

Dami committed 0c32ee2
commit
0c32ee22b85aedcb67ceeff288ae7031c198dd26
parent
f5c235947f8b88072479f9bb70c6be53e9548077
author
Dami <dami.etoile@gmail.com>
21 files changed, +1491 -64
Mapp/game_session.go24++-
Mapp/model.go30++-
Mapp/seed_input.go31++-
Mapp/spawn.go17+-
Mapp/update.go83+++++++-
Mapp/update_test.go244+++++++++++++++++++++++-
Mapp/view.go37++++
Aapp/weekly.go311++++++++++++++++++++++++++++++
Mgame/border.go7+-
Msession/session.go6+-
Msession/session_test.go1+
Mstats/stats.go38++-
Mstats/stats_test.go40++++
Mstore/stats.go135+++++++++++++-
Mstore/stats_test.go92+++++++++
Mstore/store.go39++++-
Mstore/store_test.go65++++++
Mui/table.go41+++-
Mui/ui_test.go17++
Aweekly/weekly.go166++++++++++++++++
Aweekly/weekly_test.go131+++++++++++++
M app/game_session.go +22 -2
@@ -6,28 +6,48 @@ import (
66 "github.com/FelineStateMachine/puzzletea/game"77 sessionflow "github.com/FelineStateMachine/puzzletea/session"88 "github.com/FelineStateMachine/puzzletea/store"9+ "github.com/FelineStateMachine/puzzletea/weekly"910 1011 tea "charm.land/bubbletea/v2"1112 )1213 14+type gameOpenOptions struct {15+ readOnly bool16+ returnState viewState17+ weeklyInfo *weekly.Info18+}19+1320 // activateGame prepares the game with global UI state and moves to game view.14-func (m model) activateGame(g game.Gamer, activeGameID int64, completionSaved bool) model {21+func (m model) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) model {1522 g, _ = g.Update(game.HelpToggleMsg{Show: m.help.showFull})1623 g, _ = g.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})1724 1825 m.session.game = g1926 m.session.activeGameID = activeGameID2027 m.session.completionSaved = completionSaved28+ m.session.returnState = options.returnState29+ m.session.weeklyAdvance = options.weeklyInfo2130 m.state = gameView2231 return m2332 }2433 2534 func (m model) importAndActivateRecord(rec store.GameRecord) (model, bool) {35+ return m.importAndActivateRecordWithOptions(rec, gameOpenOptions{returnState: mainMenuView})36+}37+38+func (m model) importAndActivateRecordWithOptions(rec store.GameRecord, options gameOpenOptions) (model, bool) {2639 g, err := sessionflow.ImportRecord(&rec)2740 if err != nil {2841 log.Printf("failed to import game: %v", err)2942 return m, false3043 }3144 32- return m.activateGame(g, rec.ID, rec.Status == store.StatusCompleted), true45+ activeGameID := rec.ID46+ completionSaved := rec.Status == store.StatusCompleted47+ if options.readOnly {48+ activeGameID = 049+ completionSaved = true50+ }51+52+ return m.activateGame(g, activeGameID, completionSaved, options), true3353 }
M app/model.go +23 -7
@@ -4,6 +4,7 @@ package app
44 55 import (66 "context"7+ "strconv"78 "time"89 910 "github.com/FelineStateMachine/puzzletea/catalog"
@@ -13,6 +14,7 @@ import (
1314 "github.com/FelineStateMachine/puzzletea/store"1415 "github.com/FelineStateMachine/puzzletea/theme"1516 "github.com/FelineStateMachine/puzzletea/ui"17+ "github.com/FelineStateMachine/puzzletea/weekly"1618 1719 "charm.land/bubbles/v2/list"1820 "charm.land/bubbles/v2/spinner"
@@ -39,13 +41,6 @@ var mainMenuItems = []ui.MenuItem{
3941 {ItemTitle: "Quit", Desc: "exit puzzletea"},4042 }4143 42-var playMenuItems = []ui.MenuItem{43- {ItemTitle: "Daily Puzzle", Desc: time.Now().Format("Jan _2 06")},44- {ItemTitle: "Generate", Desc: "a new puzzle"},45- {ItemTitle: "Continue", Desc: "a previously played puzzle"},46- {ItemTitle: "Enter Seed", Desc: "for a set puzzle"},47-}48-4944 var optionsMenuItems = []ui.MenuItem{5045 {ItemTitle: "Theme", Desc: "change colors"},5146 {ItemTitle: "Guides", Desc: "learn the rules"},
@@ -61,6 +56,7 @@ const (
6156 generatingView6257 gameView6358 continueView59+ weeklyView6460 helpSelectView6561 helpDetailView6662 statsView
@@ -78,6 +74,9 @@ type navigationState struct {
7874 selectedModeTitle string7975 continueTable table.Model8076 continueGames []store.GameRecord77+ weeklyTable table.Model78+ weeklyRows []weeklyRow79+ weeklyCursor time.Time8180 seedInput textinput.Model8281 seedModeOptions []seedModeOption8382 seedModeIndex int
@@ -90,6 +89,8 @@ type sessionState struct {
9089 game game.Gamer9190 activeGameID int649291 completionSaved bool92+ returnState viewState93+ weeklyAdvance *weekly.Info9394 generating bool9495 spawnJobID int649596 spawnCancel context.CancelFunc
@@ -127,6 +128,7 @@ const (
127128 spawnSourceNormal spawnSource = "normal"128129 spawnSourceDaily spawnSource = "daily"129130 spawnSourceSeed spawnSource = "seed"131+ spawnSourceWeekly spawnSource = "weekly"130132 )131133 132134 type spawnRequest struct {
@@ -135,6 +137,8 @@ type spawnRequest struct {
135137 gameType string136138 modeTitle string137139 returnState viewState140+ exitState viewState141+ weeklyInfo *weekly.Info138142 }139143 140144 type seedInputFocus int
@@ -211,6 +215,7 @@ func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, acti
211215 game: g,212216 activeGameID: activeGameID,213217 completionSaved: completionSaved,218+ returnState: mainMenuView,214219 },215220 }216221 }
@@ -218,3 +223,14 @@ func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, acti
218223 func (m model) Init() tea.Cmd {219224 return nil220225 }226+227+func buildPlayMenuItems(now time.Time, currentWeeklyIndex int) []ui.MenuItem {228+ year, week := now.ISOWeek()229+ return []ui.MenuItem{230+ {ItemTitle: "Create", Desc: "a new puzzle"},231+ {ItemTitle: "Continue", Desc: "a previously played puzzle"},232+ {ItemTitle: "Daily", Desc: now.Format("Jan _2 06")},233+ {ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)},234+ {ItemTitle: "Seeded", Desc: "enter a specific seed"},235+ }236+}
M app/seed_input.go +22 -9
@@ -5,6 +5,7 @@ import (
55 66 "github.com/FelineStateMachine/puzzletea/catalog"77 "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/theme"89 "github.com/FelineStateMachine/puzzletea/ui"910 1011 "charm.land/bubbles/v2/textinput"
@@ -146,14 +147,7 @@ func (m model) handleSeedInputUpdate(msg tea.Msg) (model, tea.Cmd) {
146147 }147148 148149 func (m model) seedInputBody() string {149- selector := "< " + m.currentSeedMode().label + " >"150- if m.nav.seedFocus == seedFocusMode {151- selector = ui.SelectedItemStyle().Render(selector)152- } else {153- selector = ui.NormalItemStyle().Render(selector)154- }155-156- modeLabel := ui.DimItemStyle().Render("game ")150+ selector := renderSeedModeTitle(m.currentSeedMode().label, m.nav.seedModeIndex)157151 modePrefix := " "158152 if m.nav.seedFocus == seedFocusMode {159153 modePrefix = ui.CursorStyle().Render("> ")
@@ -168,6 +162,25 @@ func (m model) seedInputBody() string {
168162 lipgloss.Left,169163 seedInputView,170164 "",171- modePrefix+modeLabel+selector,165+ modePrefix+selector,172166 )173167 }168+169+func renderSeedModeTitle(label string, colorIndex int) string {170+ colors := theme.Current().ThemeColors()171+ if len(colors) == 0 {172+ return ui.PanelTitle().Render("game: " + label)173+ }174+175+ bg := colors[((colorIndex%len(colors))+len(colors))%len(colors)]176+ chip := lipgloss.NewStyle().177+ Bold(true).178+ Foreground(theme.TextOnBG(bg)).179+ Background(bg).180+ Render(label)181+182+ var b strings.Builder183+ b.WriteString(ui.PanelTitle().Render("game: "))184+ b.WriteString(chip)185+ return b.String()186+}
M app/spawn.go +15 -2
@@ -113,7 +113,10 @@ func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.
113113 log.Printf("missing spawn request metadata")114114 return m, nil115115 }116- m = m.activateGame(msg.Game.SetTitle(request.name), 0, false)116+ m = m.activateGame(msg.Game.SetTitle(request.name), 0, false, gameOpenOptions{117+ returnState: request.exitState,118+ weeklyInfo: request.weeklyInfo,119+ })117120 118121 // Capture initial state and create DB record.119122 rec, err := sessionflow.CreateRecord(
@@ -133,9 +136,12 @@ func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.
133136 134137 // saveCurrentGame saves the current game state to the DB if a game is active.135138 func saveCurrentGame(m model, status store.GameStatus) model {136- if m.session.game == nil || m.session.activeGameID == 0 {139+ if m.session.game == nil {137140 return m138141 }142+ if m.session.activeGameID == 0 {143+ return clearActiveGame(m)144+ }139145 saveData, err := m.session.game.GetSave()140146 if err != nil {141147 log.Printf("failed to get save data: %v", err)
@@ -150,7 +156,14 @@ func saveCurrentGame(m model, status store.GameStatus) model {
150156 log.Printf("failed to update game status: %v", err)151157 }152158 }159+ return clearActiveGame(m)160+}161+162+func clearActiveGame(m model) model {153163 m.session.activeGameID = 0154164 m.session.game = nil165+ m.session.completionSaved = false166+ m.session.returnState = mainMenuView167+ m.session.weeklyAdvance = nil155168 return m156169 }
M app/update.go +74 -9
@@ -231,6 +231,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
231231 m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg)232232 case continueView:233233 m.nav.continueTable, cmd = m.nav.continueTable.Update(msg)234+ case weeklyView:235+ m.nav.weeklyTable, cmd = m.nav.weeklyTable.Update(msg)234236 case helpSelectView:235237 m.nav.helpSelectList, cmd = m.nav.helpSelectList.Update(msg)236238 case helpDetailView:
@@ -269,6 +271,9 @@ func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {
269271 visibleRows := min(len(m.nav.continueGames), ui.MaxTableRows)270272 m.nav.continueTable.SetHeight(min(m.height, visibleRows))271273 }274+ if m.state == weeklyView {275+ m = m.refreshWeeklyBrowser()276+ }272277 if m.state == helpSelectView {273278 m.nav.helpSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.helpSelectList)))274279 }
@@ -316,6 +321,17 @@ func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) {
316321 }317322 318323 switch {324+ case m.state == weeklyView && (msg.String() == "left" || msg.String() == "h"):325+ m = m.moveWeeklyWeek(-1)326+ return m, nil, true327+ case m.state == weeklyView && (msg.String() == "right" || msg.String() == "l"):328+ m = m.moveWeeklyWeek(1)329+ return m, nil, true330+ case m.state == gameView && key.Matches(msg, rootKeys.Enter):331+ next, cmd, handled := m.advanceSolvedWeekly()332+ if handled {333+ return next, cmd, true334+ }319335 case m.state == gameSelectView && msg.String() == "pgup":320336 m.nav.categoryDetail.PageUp()321337 return m, nil, true
@@ -323,8 +339,12 @@ func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) {
323339 m.nav.categoryDetail.PageDown()324340 return m, nil, true325341 case m.state == gameView && key.Matches(msg, rootKeys.Escape):342+ returnState := m.session.returnState326343 m = saveCurrentGame(m, store.StatusInProgress)327- m.state = mainMenuView344+ m.state = returnState345+ if returnState == weeklyView {346+ m = m.refreshWeeklyBrowser()347+ }328348 m.debug.enabled = false329349 return m, nil, true330350 case key.Matches(msg, rootKeys.Enter):
@@ -364,7 +384,8 @@ func (m model) activeSpawnReturnState() viewState {
364384 }365385 366386 func (m model) persistCompletionIfSolved() model {367- if m.session.game == nil || m.session.completionSaved || !m.session.game.IsSolved() {387+ if m.session.game == nil || m.session.activeGameID == 0 ||388+ m.session.completionSaved || !m.session.game.IsSolved() {368389 return m369390 }370391
@@ -410,6 +431,8 @@ func (m model) handleEnter() (tea.Model, tea.Cmd) {
410431 return m.handleModeSelectEnter()411432 case continueView:412433 return m.handleContinueEnter()434+ case weeklyView:435+ return m.handleWeeklyEnter()413436 case helpSelectView:414437 return m.handleHelpSelectEnter()415438 case themeSelectView:
@@ -422,7 +445,7 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
422445 item := m.nav.mainMenu.Selected()423446 switch item.Title() {424447 case "Play":425- m.nav.playMenu = ui.NewMainMenu(playMenuItems)448+ m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))426449 m.state = playMenuView427450 case "Stats":428451 return m.handleStatsEnter()
@@ -435,18 +458,39 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
435458 return m, nil436459 }437460 461+func (m model) currentWeeklyMenuIndex() int {462+ if m.store == nil {463+ return 1464+ }465+466+ year, week := time.Now().ISOWeek()467+ highestCompleted, err := m.store.GetCurrentWeeklyHighestCompletedIndex(year, week)468+ if err != nil {469+ return 1470+ }471+ if highestCompleted >= weeklyEntryCount {472+ return weeklyEntryCount473+ }474+ if highestCompleted < 1 {475+ return 1476+ }477+ return highestCompleted + 1478+}479+438480 func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) {439481 item := m.nav.playMenu.Selected()440482 switch item.Title() {441- case "Daily Puzzle":442- return m.handleDailyPuzzle()443- case "Generate":483+ case "Create":444484 m.state = gameSelectView445485 m = m.updateCategoryDetailViewport()446486 case "Continue":447487 m.nav.continueTable, m.nav.continueGames = ui.InitContinueTable(m.store, m.height)448488 m.state = continueView449- case "Enter Seed":489+ case "Daily":490+ return m.handleDailyPuzzle()491+ case "Weekly":492+ return m.enterWeeklyView()493+ case "Seeded":450494 return m.enterSeedInputView()451495 }452496 return m, nil
@@ -518,6 +562,7 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
518562 gameType: gameType,519563 modeTitle: modeTitle,520564 returnState: playMenuView,565+ exitState: mainMenuView,521566 }522567 m.state = generatingView523568 return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))
@@ -557,6 +602,7 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
557602 gameType: gameType,558603 modeTitle: modeTitle,559604 returnState: playMenuView,605+ exitState: mainMenuView,560606 }561607 m.state = generatingView562608 return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))
@@ -592,6 +638,7 @@ func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {
592638 gameType: m.nav.selectedCategory.Name,593639 modeTitle: m.nav.selectedModeTitle,594640 returnState: modeSelectView,641+ exitState: mainMenuView,595642 }596643 m.state = generatingView597644 return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner, ctx, jobID))
@@ -622,7 +669,7 @@ func (m model) handleEscape() (tea.Model, tea.Cmd) {
622669 switch m.state {623670 case playMenuView, optionsMenuView, statsView:624671 m.state = mainMenuView625- case seedInputView, gameSelectView, continueView:672+ case seedInputView, gameSelectView, continueView, weeklyView:626673 m.state = playMenuView627674 case generatingView:628675 returnState := m.activeSpawnReturnState()
@@ -713,7 +760,18 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
713760 log.Printf("failed to get daily streak dates: %v", err)714761 return m, nil715762 }763+ weekliesCompleted, err := m.store.GetCompletedWeeklyGauntlets()764+ if err != nil {765+ log.Printf("failed to get weekly gauntlet completions: %v", err)766+ return m, nil767+ }716768 now := time.Now()769+ currentYear, currentWeek := now.ISOWeek()770+ thisWeekHighestIndex, err := m.store.GetCurrentWeeklyHighestCompletedIndex(currentYear, currentWeek)771+ if err != nil {772+ log.Printf("failed to get current weekly progress: %v", err)773+ return m, nil774+ }717775 currentDaily := false718776 rec, err := m.store.GetDailyGame(daily.Name(now))719777 if err != nil {
@@ -723,7 +781,14 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
723781 }724782 725783 m.stats.cards = stats.BuildCards(catStats, modeStats)726- m.stats.profile = stats.BuildProfileBanner(catStats, modeStats, streakDates, currentDaily)784+ m.stats.profile = stats.BuildProfileBanner(785+ catStats,786+ modeStats,787+ streakDates,788+ currentDaily,789+ weekliesCompleted,790+ thisWeekHighestIndex,791+ )727792 728793 statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)729794 m.stats.viewport = viewport.New(
M app/update_test.go +235 -9
@@ -16,6 +16,7 @@ import (
1616 sessionflow "github.com/FelineStateMachine/puzzletea/session"1717 "github.com/FelineStateMachine/puzzletea/store"1818 "github.com/FelineStateMachine/puzzletea/ui"19+ "github.com/FelineStateMachine/puzzletea/weekly"1920 )2021 2122 type escapeTrackingGame struct {
@@ -23,6 +24,10 @@ type escapeTrackingGame struct {
2324 sawEscape bool2425 }2526 27+type solvedWeeklyGame struct {28+ save []byte29+}30+2631 func (g *escapeTrackingGame) GetDebugInfo() string {2732 return ""2833 }
@@ -64,6 +69,42 @@ func (g *escapeTrackingGame) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6469 return g, nil6570 }6671 72+func (g *solvedWeeklyGame) GetDebugInfo() string {73+ return ""74+}75+76+func (g *solvedWeeklyGame) GetFullHelp() [][]key.Binding {77+ return nil78+}79+80+func (g *solvedWeeklyGame) GetSave() ([]byte, error) {81+ return g.save, nil82+}83+84+func (g *solvedWeeklyGame) IsSolved() bool {85+ return true86+}87+88+func (g *solvedWeeklyGame) Reset() game.Gamer {89+ return g90+}91+92+func (g *solvedWeeklyGame) SetTitle(string) game.Gamer {93+ return g94+}95+96+func (g *solvedWeeklyGame) Init() tea.Cmd {97+ return nil98+}99+100+func (g *solvedWeeklyGame) View() string {101+ return ""102+}103+104+func (g *solvedWeeklyGame) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {105+ return g, nil106+}107+67108 func TestGameViewEscapeReturnsToMainMenu(t *testing.T) {68109 tracker := &escapeTrackingGame{}69110 m := model{
@@ -197,19 +238,12 @@ func TestSeedInputSelectorCyclesAndPersistsDefault(t *testing.T) {
197238 t.Fatalf("lastSeedModeKey = %q, want %q", got.nav.lastSeedModeKey, options[1].key)198239 }199240 200- playMenu := ui.NewMainMenu(playMenuItems)201- for range 3 {202- playMenu.CursorDown()203- }204-205- reopened, _ := (model{241+ reopenedModel, _ := (model{206242 state: playMenuView,207243 nav: navigationState{208- playMenu: playMenu,209244 lastSeedModeKey: got.nav.lastSeedModeKey,210245 },211- }).handlePlayMenuEnter()212- reopenedModel := reopened.(model)246+ }).enterSeedInputView()213247 214248 if reopenedModel.state != seedInputView {215249 t.Fatalf("state = %d, want %d (seedInputView)", reopenedModel.state, seedInputView)
@@ -336,6 +370,198 @@ func TestGameSelectEscapeClearsAppliedFilterBeforeLeavingView(t *testing.T) {
336370 }337371 }338372 373+func TestBuildCurrentWeeklyRowsStartsAtOne(t *testing.T) {374+ rows := buildCurrentWeeklyRows(2026, 1, nil)375+ if len(rows) != 1 {376+ t.Fatalf("len(rows) = %d, want 1", len(rows))377+ }378+ if rows[0].Index != 1 || !rows[0].Playable || rows[0].Status != store.StatusNew {379+ t.Fatalf("row[0] = %+v, want index=1 playable status=new", rows[0])380+ }381+}382+383+func TestBuildCurrentWeeklyRowsUnlocksSequentially(t *testing.T) {384+ games := []store.GameRecord{385+ {Name: weekly.Name(2026, 1, 1), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},386+ {Name: weekly.Name(2026, 1, 2), GameType: "Sudoku", Mode: "Easy", Status: store.StatusInProgress},387+ {Name: weekly.Name(2026, 1, 4), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},388+ }389+390+ rows := buildCurrentWeeklyRows(2026, 1, games)391+ if len(rows) != 2 {392+ t.Fatalf("len(rows) = %d, want 2", len(rows))393+ }394+ if rows[0].Index != 2 || !rows[0].Playable {395+ t.Fatalf("row[0] = %+v, want playable #2", rows[0])396+ }397+ if rows[1].Index != 1 || !rows[1].ReadOnly {398+ t.Fatalf("row[1] = %+v, want read-only #1", rows[1])399+ }400+}401+402+func TestBuildReviewWeeklyRowsOnlyIncludesCompleted(t *testing.T) {403+ games := []store.GameRecord{404+ {Name: weekly.Name(2026, 1, 1), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},405+ {Name: weekly.Name(2026, 1, 2), GameType: "Sudoku", Mode: "Easy", Status: store.StatusInProgress},406+ {Name: weekly.Name(2026, 1, 3), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},407+ }408+409+ rows := buildReviewWeeklyRows(games)410+ if len(rows) != 2 {411+ t.Fatalf("len(rows) = %d, want 2", len(rows))412+ }413+ if rows[0].Index != 3 || rows[1].Index != 1 {414+ t.Fatalf("rows = %+v, want descending completed rows", rows)415+ }416+}417+418+func TestImportAndActivateWeeklyReviewRecordSkipsSaveBinding(t *testing.T) {419+ loadedGame, err := lightsout.New(3, 3)420+ if err != nil {421+ t.Fatal(err)422+ }423+ save, err := loadedGame.GetSave()424+ if err != nil {425+ t.Fatal(err)426+ }427+428+ rec := store.GameRecord{429+ ID: 42,430+ Name: weekly.Name(2026, 1, 1),431+ GameType: "Lights Out",432+ SaveState: string(save),433+ Status: store.StatusCompleted,434+ }435+436+ next, ok := (model{437+ state: weeklyView,438+ width: 80,439+ height: 24,440+ }).importAndActivateRecordWithOptions(rec, gameOpenOptions{441+ readOnly: true,442+ returnState: weeklyView,443+ })444+ if !ok {445+ t.Fatal("expected import to succeed")446+ }447+ if next.session.activeGameID != 0 {448+ t.Fatalf("activeGameID = %d, want 0", next.session.activeGameID)449+ }450+ if next.session.returnState != weeklyView {451+ t.Fatalf("returnState = %d, want %d", next.session.returnState, weeklyView)452+ }453+}454+455+func TestEnterOnSolvedLatestWeeklyCompletesAndQueuesNext(t *testing.T) {456+ s := openAppTestStore(t)457+ now := time.Now()458+ year, weekNumber := now.ISOWeek()459+ currentName := weekly.Name(year, weekNumber, 1)460+461+ rec := &store.GameRecord{462+ Name: currentName,463+ GameType: "Lights Out",464+ Mode: "Easy",465+ InitialState: "{}",466+ SaveState: "{}",467+ Status: store.StatusInProgress,468+ }469+ if err := s.CreateGame(rec); err != nil {470+ t.Fatal(err)471+ }472+473+ m := model{474+ state: gameView,475+ store: s,476+ spinner: newSpinner(),477+ session: sessionState{478+ game: &solvedWeeklyGame{save: []byte(`{"done":true}`)},479+ activeGameID: rec.ID,480+ returnState: weeklyView,481+ weeklyAdvance: &weekly.Info{Year: year, Week: weekNumber, Index: 1},482+ completionSaved: false,483+ },484+ nav: navigationState{485+ weeklyCursor: weekly.StartOfWeek(year, weekNumber, time.Local),486+ },487+ }488+489+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})490+ got := next.(model)491+492+ if got.state != generatingView {493+ t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)494+ }495+ if got.session.spawn == nil {496+ t.Fatal("expected spawn request")497+ }498+ if got.session.spawn.name != weekly.Name(year, weekNumber, 2) {499+ t.Fatalf("spawn name = %q, want %q", got.session.spawn.name, weekly.Name(year, weekNumber, 2))500+ }501+502+ saved, err := s.GetWeeklyGame(year, weekNumber, 1)503+ if err != nil {504+ t.Fatal(err)505+ }506+ if saved == nil {507+ t.Fatal("expected weekly record")508+ }509+ if saved.Status != store.StatusCompleted {510+ t.Fatalf("status = %q, want %q", saved.Status, store.StatusCompleted)511+ }512+}513+514+func TestMoveWeeklyWeekDoesNotAdvancePastCurrentWeek(t *testing.T) {515+ s := openAppTestStore(t)516+ current := weekly.Current(time.Now())517+ currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)518+519+ m := model{520+ store: s,521+ nav: navigationState{522+ weeklyCursor: currentCursor,523+ },524+ }525+526+ got := m.moveWeeklyWeek(1)527+ if !got.nav.weeklyCursor.Equal(currentCursor) {528+ t.Fatalf("weeklyCursor = %v, want %v", got.nav.weeklyCursor, currentCursor)529+ }530+}531+532+func TestCurrentWeeklyMenuIndexTracksNextPlayableSlot(t *testing.T) {533+ s := openAppTestStore(t)534+ year, weekNumber := time.Now().ISOWeek()535+536+ for index := 1; index <= 2; index++ {537+ rec := &store.GameRecord{538+ Name: weekly.Name(year, weekNumber, index),539+ GameType: "Nonogram",540+ Mode: "Easy",541+ InitialState: "{}",542+ SaveState: "{}",543+ Status: store.StatusCompleted,544+ }545+ if err := s.CreateGame(rec); err != nil {546+ t.Fatal(err)547+ }548+ }549+550+ m := model{store: s}551+ if got := m.currentWeeklyMenuIndex(); got != 3 {552+ t.Fatalf("currentWeeklyMenuIndex() = %d, want 3", got)553+ }554+}555+556+func TestBuildPlayMenuItemsShowsCurrentWeeklyChallenge(t *testing.T) {557+ now := time.Date(2026, time.March, 7, 12, 0, 0, 0, time.Local)558+559+ items := buildPlayMenuItems(now, 7)560+ if got := items[3].Desc; got != "Week 10-2026 # 7" {561+ t.Fatalf("weekly desc = %q, want %q", got, "Week 10-2026 # 7")562+ }563+}564+339565 func openAppTestStore(t *testing.T) *store.Store {340566 t.Helper()341567 s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
M app/view.go +37 -0
A app/weekly.go +311 -0
@@ -0,0 +1,311 @@
1+package app2+3+import (4+ "log"5+ "sort"6+ "strconv"7+ "time"8+9+ "charm.land/bubbles/v2/table"10+ tea "charm.land/bubbletea/v2"11+ sessionflow "github.com/FelineStateMachine/puzzletea/session"12+ "github.com/FelineStateMachine/puzzletea/store"13+ "github.com/FelineStateMachine/puzzletea/ui"14+ "github.com/FelineStateMachine/puzzletea/weekly"15+)16+17+const weeklyEntryCount = 9918+19+type weeklyRow struct {20+ Index int21+ Name string22+ GameType string23+ Mode string24+ BonusXP int25+ Status store.GameStatus26+ Record *store.GameRecord27+ Playable bool28+ ReadOnly bool29+}30+31+func (r weeklyRow) tableRow() table.Row {32+ return table.Row{33+ formatWeeklyIndex(r.Index),34+ r.GameType,35+ r.Mode,36+ formatWeeklyBonus(r.BonusXP),37+ ui.FormatStatus(r.Status),38+ }39+}40+41+func (m model) enterWeeklyView() (tea.Model, tea.Cmd) {42+ current := weekly.Current(time.Now())43+ m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)44+ m = m.refreshWeeklyBrowser()45+ m.state = weeklyView46+ return m, nil47+}48+49+func (m model) refreshWeeklyBrowser() model {50+ year, weekNumber := m.selectedWeek()51+ games, err := m.store.ListWeeklyGames(year, weekNumber)52+ if err != nil {53+ log.Printf("failed to list weekly games: %v", err)54+ games = nil55+ }56+57+ if m.isCurrentWeeklySelection() {58+ m.nav.weeklyRows = buildCurrentWeeklyRows(year, weekNumber, games)59+ } else {60+ m.nav.weeklyRows = buildReviewWeeklyRows(games)61+ }62+63+ rows := make([]table.Row, 0, len(m.nav.weeklyRows))64+ for _, row := range m.nav.weeklyRows {65+ rows = append(rows, row.tableRow())66+ }67+ m.nav.weeklyTable = ui.InitWeeklyTable(rows, m.height)68+ return m69+}70+71+func (m model) isCurrentWeeklySelection() bool {72+ current := weekly.Current(time.Now())73+ year, weekNumber := m.selectedWeek()74+ return current.Year == year && current.Week == weekNumber75+}76+77+func (m model) selectedWeek() (int, int) {78+ cursor := m.nav.weeklyCursor79+ if cursor.IsZero() {80+ current := weekly.Current(time.Now())81+ cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)82+ }83+ return cursor.ISOWeek()84+}85+86+func (m model) weeklyPanelTitle() string {87+ year, weekNumber := m.selectedWeek()88+ return "Week " + formatTwoDigits(weekNumber) + "-" + strconv.Itoa(year)89+}90+91+func (m model) moveWeeklyWeek(delta int) model {92+ if m.nav.weeklyCursor.IsZero() {93+ current := weekly.Current(time.Now())94+ m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)95+ }96+97+ nextCursor := weekly.AddWeeks(m.nav.weeklyCursor, delta)98+ current := weekly.Current(time.Now())99+ currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)100+ if nextCursor.After(currentCursor) {101+ nextCursor = currentCursor102+ }103+104+ m.nav.weeklyCursor = nextCursor105+ return m.refreshWeeklyBrowser()106+}107+108+func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) {109+ idx := m.nav.weeklyTable.Cursor()110+ if idx < 0 || idx >= len(m.nav.weeklyRows) {111+ return m, nil112+ }113+114+ return m.openWeeklyRow(m.nav.weeklyRows[idx])115+}116+117+func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {118+ info, ok := weekly.ParseName(row.Name)119+ if !ok {120+ return m, nil121+ }122+123+ options := gameOpenOptions{124+ returnState: weeklyView,125+ }126+ if row.Playable && !row.ReadOnly {127+ infoCopy := info128+ options.weeklyInfo = &infoCopy129+ }130+131+ if row.Record != nil {132+ if row.ReadOnly {133+ m, _ = m.importAndActivateRecordWithOptions(*row.Record, gameOpenOptions{134+ readOnly: true,135+ returnState: weeklyView,136+ })137+ return m, nil138+ }139+140+ var resumed bool141+ m, resumed = m.importAndActivateRecordWithOptions(*row.Record, options)142+ if resumed {143+ if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, row.Record); err != nil {144+ log.Printf("%v", err)145+ }146+ }147+ return m, nil148+ }149+150+ if !row.Playable {151+ return m, nil152+ }153+154+ spawner, gameType, modeTitle := weekly.Mode(info.Year, info.Week, info.Index)155+ if spawner == nil {156+ log.Printf("no weekly mode available for %s", row.Name)157+ return m, nil158+ }159+160+ rng := weekly.RNG(info.Year, info.Week, info.Index)161+ ctx, jobID := m.beginSpawnContext()162+ infoCopy := info163+ m.session.spawn = &spawnRequest{164+ source: spawnSourceWeekly,165+ name: row.Name,166+ gameType: gameType,167+ modeTitle: modeTitle,168+ returnState: weeklyView,169+ exitState: weeklyView,170+ weeklyInfo: &infoCopy,171+ }172+ m.state = generatingView173+ return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))174+}175+176+func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {177+ if m.state != gameView || m.session.game == nil || !m.session.game.IsSolved() || m.session.weeklyAdvance == nil {178+ return m, nil, false179+ }180+181+ info := *m.session.weeklyAdvance182+ m = m.persistCompletionIfSolved()183+ m.nav.weeklyCursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)184+ m = m.refreshWeeklyBrowser()185+ m.state = weeklyView186+ if len(m.nav.weeklyRows) == 0 || !m.nav.weeklyRows[0].Playable {187+ return m, nil, true188+ }189+190+ next, cmd := m.openWeeklyRow(m.nav.weeklyRows[0])191+ return next.(model), cmd, true192+}193+194+func buildCurrentWeeklyRows(year, weekNumber int, games []store.GameRecord) []weeklyRow {195+ byIndex := make(map[int]store.GameRecord, len(games))196+ completed := make(map[int]store.GameRecord, len(games))197+198+ for _, rec := range games {199+ info, ok := weekly.ParseName(rec.Name)200+ if !ok || info.Year != year || info.Week != weekNumber {201+ continue202+ }203+ byIndex[info.Index] = rec204+ if rec.Status == store.StatusCompleted {205+ completed[info.Index] = rec206+ }207+ }208+209+ prefix := 0210+ for index := 1; index <= weeklyEntryCount; index++ {211+ if _, ok := completed[index]; !ok {212+ break213+ }214+ prefix = index215+ }216+217+ rows := make([]weeklyRow, 0, prefix+1)218+ if prefix < weeklyEntryCount {219+ nextIndex := prefix + 1220+ if rec, ok := byIndex[nextIndex]; ok {221+ recCopy := rec222+ rows = append(rows, weeklyRow{223+ Index: nextIndex,224+ Name: rec.Name,225+ GameType: rec.GameType,226+ Mode: rec.Mode,227+ BonusXP: weekly.BonusXP(nextIndex),228+ Status: rec.Status,229+ Record: &recCopy,230+ Playable: true,231+ })232+ } else {233+ _, gameType, modeTitle := weekly.Mode(year, weekNumber, nextIndex)234+ rows = append(rows, weeklyRow{235+ Index: nextIndex,236+ Name: weekly.Name(year, weekNumber, nextIndex),237+ GameType: gameType,238+ Mode: modeTitle,239+ BonusXP: weekly.BonusXP(nextIndex),240+ Status: store.StatusNew,241+ Playable: true,242+ })243+ }244+ }245+246+ for index := prefix; index >= 1; index-- {247+ rec := completed[index]248+ recCopy := rec249+ rows = append(rows, weeklyRow{250+ Index: index,251+ Name: rec.Name,252+ GameType: rec.GameType,253+ Mode: rec.Mode,254+ BonusXP: weekly.BonusXP(index),255+ Status: rec.Status,256+ Record: &recCopy,257+ ReadOnly: true,258+ })259+ }260+261+ return rows262+}263+264+func buildReviewWeeklyRows(games []store.GameRecord) []weeklyRow {265+ rows := make([]weeklyRow, 0, len(games))266+ for _, rec := range games {267+ info, ok := weekly.ParseName(rec.Name)268+ if !ok || rec.Status != store.StatusCompleted {269+ continue270+ }271+272+ recCopy := rec273+ rows = append(rows, weeklyRow{274+ Index: info.Index,275+ Name: rec.Name,276+ GameType: rec.GameType,277+ Mode: rec.Mode,278+ BonusXP: weekly.BonusXP(info.Index),279+ Status: rec.Status,280+ Record: &recCopy,281+ ReadOnly: true,282+ })283+ }284+285+ sort.Slice(rows, func(i, j int) bool {286+ return rows[i].Index > rows[j].Index287+ })288+ return rows289+}290+291+func formatWeeklyIndex(index int) string {292+ return "#" + formatTwoDigits(index)293+}294+295+func formatWeeklyBonus(bonus int) string {296+ return "+" + strconv.Itoa(bonus)297+}298+299+func formatWeeklyMenuIndex(index int) string {300+ if index < 10 {301+ return " " + strconv.Itoa(index)302+ }303+ return strconv.Itoa(index)304+}305+306+func formatTwoDigits(value int) string {307+ if value < 10 {308+ return "0" + strconv.Itoa(value)309+ }310+ return strconv.Itoa(value)311+}
M game/border.go +4 -3
M session/session.go +4 -2
M session/session_test.go +1 -0
M stats/stats.go +24 -14
@@ -77,6 +77,7 @@ func ComputeCategoryXP(gameType string, modeStats []store.ModeStat) int {
7777 normalVictories := ms.Victories - ms.DailyVictories7878 total += normalVictories * baseXP7979 total += ms.DailyVictories * baseXP * 280+ total += ms.WeeklyBonusXP8081 }8182 return total8283 }
@@ -181,10 +182,12 @@ func BuildCards(catStats []store.CategoryStat, modeStats []store.ModeStat) []Car
181182 182183 // ProfileBanner holds the summary data shown above the card grid.183184 type ProfileBanner struct {184- ProfileLevel int185- DailyStreak int186- TotalDailies int187- CurrentDaily bool185+ ProfileLevel int186+ DailyStreak int187+ TotalDailies int188+ CurrentDaily bool189+ WeekliesCompleted int190+ ThisWeekHighestIndex int188191 }189192 190193 // BuildProfileBanner constructs the summary banner shown above the card grid.
@@ -193,6 +196,8 @@ func BuildProfileBanner(
193196 modeStats []store.ModeStat,194197 streakDates []time.Time,195198 currentDaily bool,199+ weekliesCompleted int,200+ thisWeekHighestIndex int,196201 ) ProfileBanner {197202 profileLevel := 0198203 totalDailies := 0
@@ -205,10 +210,12 @@ func BuildProfileBanner(
205210 streak := ComputeDailyStreak(streakDates, time.Now())206211 207212 return ProfileBanner{208- ProfileLevel: profileLevel,209- DailyStreak: streak,210- TotalDailies: totalDailies,211- CurrentDaily: currentDaily,213+ ProfileLevel: profileLevel,214+ DailyStreak: streak,215+ TotalDailies: totalDailies,216+ CurrentDaily: currentDaily,217+ WeekliesCompleted: weekliesCompleted,218+ ThisWeekHighestIndex: thisWeekHighestIndex,212219 }213220 }214221
@@ -396,20 +403,23 @@ func RenderBanner(b ProfileBanner, width int) string {
396403 "\u2500\u2500\u2500 Profile " + strings.Repeat("\u2500", max(width-12, 1)),397404 )398405 399- check := bannerValueStyle().Render("\u2714")400- if !b.CurrentDaily {401- check = lipgloss.NewStyle().Foreground(theme.Current().TextDim).Render("\u2014")402- }403-404406 streakStr := fmt.Sprintf("%d days", b.DailyStreak)405407 if b.DailyStreak == 1 {406408 streakStr = "1 day"407409 }410+ streakSuffix := "( )"411+ if b.CurrentDaily {412+ streakSuffix = "(\u2714)"413+ }414+ streakStr += " " + streakSuffix408415 409416 col1 := bannerLabelStyle().Render("Profile Level: ") + bannerValueStyle().Render(fmt.Sprintf("%d", b.ProfileLevel))410417 col2 := bannerLabelStyle().Render("Daily Streak: ") + bannerValueStyle().Render(streakStr)411418 col3 := bannerLabelStyle().Render("Total Dailies: ") + bannerValueStyle().Render(fmt.Sprintf("%d", b.TotalDailies))412- col4 := bannerLabelStyle().Render("Current daily: ") + check419+ col4 := bannerLabelStyle().Render("Weeklies Completed: ") +420+ bannerValueStyle().Render(421+ fmt.Sprintf("%d total (%2d/99 this week)", b.WeekliesCompleted, b.ThisWeekHighestIndex),422+ )413423 414424 // Two-column layout: left side + gap + right side.415425 gap := width - lipgloss.Width(col1) - lipgloss.Width(col2)
M stats/stats_test.go +40 -0
@@ -244,6 +244,14 @@ func TestComputeCategoryXP(t *testing.T) {
244244 },245245 want: 1,246246 },247+ {248+ name: "weekly bonus xp added on top",249+ gameType: "Sudoku",250+ modeStats: []store.ModeStat{251+ {GameType: "Sudoku", Mode: "Easy", Victories: 2, WeeklyBonusXP: 5},252+ },253+ want: 7,254+ },247255 {248256 name: "unknown mode defaults to 1 XP",249257 gameType: "Sudoku",
@@ -336,6 +344,38 @@ func TestRenderViewEmpty(t *testing.T) {
336344 }337345 }338346 347+func TestRenderBannerAppendsDailyStatusToStreak(t *testing.T) {348+ withCurrent := ansi.Strip(RenderBanner(ProfileBanner{349+ ProfileLevel: 3,350+ DailyStreak: 2,351+ TotalDailies: 5,352+ CurrentDaily: true,353+ WeekliesCompleted: 4,354+ ThisWeekHighestIndex: 17,355+ }, 80))356+ if !strings.Contains(withCurrent, "Daily Streak: 2 days (✔)") {357+ t.Fatalf("expected current daily marker in banner:\n%s", withCurrent)358+ }359+ if !strings.Contains(withCurrent, "Weeklies Completed: 4 total (17/99 this week)") {360+ t.Fatalf("expected weekly completion summary in banner:\n%s", withCurrent)361+ }362+363+ withoutCurrent := ansi.Strip(RenderBanner(ProfileBanner{364+ ProfileLevel: 3,365+ DailyStreak: 2,366+ TotalDailies: 5,367+ CurrentDaily: false,368+ WeekliesCompleted: 0,369+ ThisWeekHighestIndex: 0,370+ }, 80))371+ if !strings.Contains(withoutCurrent, "Daily Streak: 2 days ( )") {372+ t.Fatalf("expected empty daily marker in banner:\n%s", withoutCurrent)373+ }374+ if !strings.Contains(withoutCurrent, "Weeklies Completed: 0 total ( 0/99 this week)") {375+ t.Fatalf("expected weekly completion summary in banner:\n%s", withoutCurrent)376+ }377+}378+339379 // --- Card grid layout (P1) ---340380 341381 func TestCardColumnCount(t *testing.T) {
M store/stats.go +134 -1
@@ -4,6 +4,8 @@ import (
44 "database/sql"55 "fmt"66 "time"7+8+ "github.com/FelineStateMachine/puzzletea/weekly"79 )810 911 // CategoryStat holds aggregate statistics for a single game category.
@@ -23,6 +25,7 @@ type ModeStat struct {
2325 Mode string2426 Victories int2527 DailyVictories int28+ WeeklyBonusXP int2629 }2730 2831 // GetCategoryStats returns aggregate stats per game category from the
@@ -78,7 +81,15 @@ func (s *Store) GetModeStats() ([]ModeStat, error) {
7881 }7982 stats = append(stats, ms)8083 }81- return stats, rows.Err()84+ if err := rows.Err(); err != nil {85+ return nil, err86+ }87+88+ stats, err = s.applyWeeklyBonusXP(stats)89+ if err != nil {90+ return nil, err91+ }92+ return stats, nil8293 }8394 8495 // GetDailyStreakDates returns distinct completion dates for daily games,
@@ -111,3 +122,125 @@ func (s *Store) GetDailyStreakDates() ([]time.Time, error) {
111122 }112123 return dates, rows.Err()113124 }125+126+// GetCompletedWeeklyGauntlets returns the number of ISO weeks whose highest127+// completed weekly index is 99.128+func (s *Store) GetCompletedWeeklyGauntlets() (int, error) {129+ highestByWeek, err := s.highestCompletedWeeklyIndexByWeek()130+ if err != nil {131+ return 0, err132+ }133+134+ completed := 0135+ for _, highest := range highestByWeek {136+ if highest >= 99 {137+ completed++138+ }139+ }140+ return completed, nil141+}142+143+// GetCurrentWeeklyHighestCompletedIndex returns the highest completed weekly144+// index for the provided ISO week-year.145+func (s *Store) GetCurrentWeeklyHighestCompletedIndex(year, week int) (int, error) {146+ highestByWeek, err := s.highestCompletedWeeklyIndexByWeek()147+ if err != nil {148+ return 0, err149+ }150+ return highestByWeek[weeklyKey{year: year, week: week}], nil151+}152+153+type weeklyKey struct {154+ year int155+ week int156+}157+158+func (s *Store) highestCompletedWeeklyIndexByWeek() (map[weeklyKey]int, error) {159+ rows, err := s.db.Query(160+ `SELECT name161+ FROM games162+ WHERE status = 'completed'163+ AND name LIKE 'Week %'`,164+ )165+ if err != nil {166+ return nil, fmt.Errorf("querying completed weekly names: %w", err)167+ }168+ defer rows.Close()169+170+ highestByWeek := make(map[weeklyKey]int)171+ for rows.Next() {172+ var name string173+ if err := rows.Scan(&name); err != nil {174+ return nil, fmt.Errorf("scanning completed weekly name: %w", err)175+ }176+177+ info, ok := weekly.ParseName(name)178+ if !ok {179+ continue180+ }181+182+ key := weeklyKey{year: info.Year, week: info.Week}183+ if info.Index > highestByWeek[key] {184+ highestByWeek[key] = info.Index185+ }186+ }187+ if err := rows.Err(); err != nil {188+ return nil, fmt.Errorf("reading completed weekly names: %w", err)189+ }190+ return highestByWeek, nil191+}192+193+func (s *Store) applyWeeklyBonusXP(stats []ModeStat) ([]ModeStat, error) {194+ rows, err := s.db.Query(195+ `SELECT name, game_type, mode196+ FROM games197+ WHERE status = 'completed'198+ AND name LIKE 'Week %'`,199+ )200+ if err != nil {201+ return nil, fmt.Errorf("querying completed weekly games: %w", err)202+ }203+ defer rows.Close()204+205+ type key struct {206+ gameType string207+ mode string208+ }209+210+ indexByKey := make(map[key]int, len(stats))211+ for i := range stats {212+ indexByKey[key{gameType: stats[i].GameType, mode: stats[i].Mode}] = i213+ }214+215+ for rows.Next() {216+ var name string217+ var gameType string218+ var mode string219+ if err := rows.Scan(&name, &gameType, &mode); err != nil {220+ return nil, fmt.Errorf("scanning completed weekly game: %w", err)221+ }222+223+ info, ok := weekly.ParseName(name)224+ if !ok {225+ continue226+ }227+228+ k := key{gameType: gameType, mode: mode}229+ i, ok := indexByKey[k]230+ if !ok {231+ stats = append(stats, ModeStat{232+ GameType: gameType,233+ Mode: mode,234+ Victories: 1,235+ })236+ i = len(stats) - 1237+ indexByKey[k] = i238+ }239+ stats[i].WeeklyBonusXP += weekly.BonusXP(info.Index)240+ }241+242+ if err := rows.Err(); err != nil {243+ return nil, fmt.Errorf("reading completed weekly games: %w", err)244+ }245+ return stats, nil246+}
M store/stats_test.go +92 -0
@@ -3,6 +3,8 @@ package store
33 import (44 "testing"55 "time"6+7+ "github.com/FelineStateMachine/puzzletea/weekly"68 )79 810 // --- GetCategoryStats (P1) ---
@@ -170,6 +172,42 @@ func TestGetModeStats(t *testing.T) {
170172 t.Errorf("Medium DailyVictories = %d, want 0", medium.DailyVictories)171173 }172174 })175+176+ t.Run("weekly bonus xp is aggregated by parsed name", func(t *testing.T) {177+ s := openTestStore(t)178+ games := []*GameRecord{179+ {Name: weekly.Name(2026, 1, 10), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},180+ {Name: weekly.Name(2026, 1, 25), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},181+ }182+ for _, g := range games {183+ if err := s.CreateGame(g); err != nil {184+ t.Fatal(err)185+ }186+ if err := s.UpdateStatus(g.ID, StatusCompleted); err != nil {187+ t.Fatal(err)188+ }189+ }190+191+ stats, err := s.GetModeStats()192+ if err != nil {193+ t.Fatal(err)194+ }195+196+ found := false197+ for _, ms := range stats {198+ if ms.GameType != "Sudoku" || ms.Mode != "Easy" {199+ continue200+ }201+ found = true202+ want := weekly.BonusXP(10) + weekly.BonusXP(25)203+ if ms.WeeklyBonusXP != want {204+ t.Fatalf("WeeklyBonusXP = %d, want %d", ms.WeeklyBonusXP, want)205+ }206+ }207+ if !found {208+ t.Fatal("expected Sudoku/Easy mode stats")209+ }210+ })173211 }174212 175213 // --- GetDailyStreakDates (P1) ---
@@ -252,3 +290,57 @@ func TestGetDailyStreakDates(t *testing.T) {
252290 }253291 })254292 }293+294+func TestGetCompletedWeeklyGauntlets(t *testing.T) {295+ s := openTestStore(t)296+297+ games := []*GameRecord{298+ {Name: weekly.Name(2026, 1, 98), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},299+ {Name: weekly.Name(2026, 1, 99), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},300+ {Name: weekly.Name(2026, 2, 50), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},301+ {Name: weekly.Name(2026, 3, 99), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},302+ {Name: "Week 3-2026 - #99", GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},303+ }304+ for _, g := range games {305+ if err := s.CreateGame(g); err != nil {306+ t.Fatal(err)307+ }308+ if err := s.UpdateStatus(g.ID, StatusCompleted); err != nil {309+ t.Fatal(err)310+ }311+ }312+313+ got, err := s.GetCompletedWeeklyGauntlets()314+ if err != nil {315+ t.Fatal(err)316+ }317+ if got != 2 {318+ t.Fatalf("GetCompletedWeeklyGauntlets() = %d, want 2", got)319+ }320+}321+322+func TestGetCurrentWeeklyHighestCompletedIndex(t *testing.T) {323+ s := openTestStore(t)324+ games := []*GameRecord{325+ {Name: weekly.Name(2026, 10, 1), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},326+ {Name: weekly.Name(2026, 10, 17), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},327+ {Name: weekly.Name(2026, 10, 9), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},328+ {Name: weekly.Name(2026, 11, 30), GameType: "Sudoku", Mode: "Easy", InitialState: "{}", SaveState: "{}", Status: StatusNew},329+ }330+ for _, g := range games {331+ if err := s.CreateGame(g); err != nil {332+ t.Fatal(err)333+ }334+ if err := s.UpdateStatus(g.ID, StatusCompleted); err != nil {335+ t.Fatal(err)336+ }337+ }338+339+ got, err := s.GetCurrentWeeklyHighestCompletedIndex(2026, 10)340+ if err != nil {341+ t.Fatal(err)342+ }343+ if got != 17 {344+ t.Fatalf("GetCurrentWeeklyHighestCompletedIndex() = %d, want 17", got)345+ }346+}
M store/store.go +38 -1
@@ -9,6 +9,7 @@ import (
99 "path/filepath"1010 "time"1111 12+ "github.com/FelineStateMachine/puzzletea/weekly"1213 _ "modernc.org/sqlite"1314 )1415
@@ -218,6 +219,12 @@ func (s *Store) ListGames() ([]GameRecord, error) {
218219 // Daily puzzles should always be resumable regardless of status.219220 // Returns nil, nil if no matching game is found.220221 func (s *Store) GetDailyGame(name string) (*GameRecord, error) {222+ return s.GetDeterministicGame(name)223+}224+225+// GetDeterministicGame looks up an exact deterministic game name, including226+// abandoned rows.227+func (s *Store) GetDeterministicGame(name string) (*GameRecord, error) {221228 g, err := s.getGameByQuery(222229 fmt.Sprintf(`SELECT %s223230 FROM games
@@ -225,11 +232,41 @@ func (s *Store) GetDailyGame(name string) (*GameRecord, error) {
225232 name,226233 )227234 if err != nil {228- return nil, fmt.Errorf("querying daily game: %w", err)235+ return nil, fmt.Errorf("querying deterministic game: %w", err)229236 }230237 return g, nil231238 }232239 240+// GetWeeklyGame looks up a weekly game by canonical week and index.241+func (s *Store) GetWeeklyGame(year, weekNumber, index int) (*GameRecord, error) {242+ return s.GetDeterministicGame(weekly.Name(year, weekNumber, index))243+}244+245+// ListWeeklyGames returns all records matching the canonical weekly prefix for246+// the provided ISO week-year, including abandoned rows.247+func (s *Store) ListWeeklyGames(year, weekNumber int) ([]GameRecord, error) {248+ games, err := s.listGamesByQuery(249+ fmt.Sprintf(`SELECT %s250+ FROM games251+ WHERE name LIKE ?252+ ORDER BY name DESC`, gameSelectColumns),253+ weekly.Prefix(year, weekNumber)+"%",254+ )255+ if err != nil {256+ return nil, fmt.Errorf("listing weekly games: %w", err)257+ }258+259+ filtered := make([]GameRecord, 0, len(games))260+ for _, g := range games {261+ info, ok := weekly.ParseName(g.Name)262+ if !ok || info.Year != year || info.Week != weekNumber {263+ continue264+ }265+ filtered = append(filtered, g)266+ }267+ return filtered, nil268+}269+233270 // GetGameByName looks up a single non-abandoned game by its unique name.234271 // Returns nil, nil if no matching game is found.235272 func (s *Store) GetGameByName(name string) (*GameRecord, error) {
M store/store_test.go +65 -0
@@ -4,6 +4,8 @@ import (
44 "path/filepath"55 "testing"66 "time"7+8+ "github.com/FelineStateMachine/puzzletea/weekly"79 )810 911 func openTestStore(t *testing.T) *Store {
@@ -507,6 +509,69 @@ func TestGetGameByName(t *testing.T) {
507509 })508510 }509511 512+func TestGetDeterministicGame(t *testing.T) {513+ s := openTestStore(t)514+ rec := newTestRecord("Week 01-2026 - #01")515+ if err := s.CreateGame(rec); err != nil {516+ t.Fatal(err)517+ }518+ if err := s.UpdateStatus(rec.ID, StatusAbandoned); err != nil {519+ t.Fatal(err)520+ }521+522+ got, err := s.GetDeterministicGame(rec.Name)523+ if err != nil {524+ t.Fatal(err)525+ }526+ if got == nil {527+ t.Fatal("expected deterministic record")528+ }529+ if got.Status != StatusAbandoned {530+ t.Fatalf("status = %q, want %q", got.Status, StatusAbandoned)531+ }532+}533+534+func TestWeeklyQueries(t *testing.T) {535+ s := openTestStore(t)536+537+ records := []*GameRecord{538+ newTestRecord(weekly.Name(2026, 1, 1)),539+ newTestRecord(weekly.Name(2026, 1, 2)),540+ newTestRecord(weekly.Name(2026, 2, 1)),541+ newTestRecord("Week 1-2026 - #01"),542+ }543+ for _, rec := range records {544+ if err := s.CreateGame(rec); err != nil {545+ t.Fatal(err)546+ }547+ }548+549+ got, err := s.GetWeeklyGame(2026, 1, 2)550+ if err != nil {551+ t.Fatal(err)552+ }553+ if got == nil || got.Name != weekly.Name(2026, 1, 2) {554+ t.Fatalf("GetWeeklyGame() = %#v, want %q", got, weekly.Name(2026, 1, 2))555+ }556+557+ weeklyGames, err := s.ListWeeklyGames(2026, 1)558+ if err != nil {559+ t.Fatal(err)560+ }561+ if len(weeklyGames) != 2 {562+ t.Fatalf("ListWeeklyGames() len = %d, want 2", len(weeklyGames))563+ }564+ for _, rec := range weeklyGames {565+ info, ok := weekly.ParseName(rec.Name)566+ if !ok {567+ t.Fatalf("ListWeeklyGames() included non-canonical name %q", rec.Name)568+ }569+ if info.Year != 2026 || info.Week != 1 {570+ t.Fatalf("ListWeeklyGames() included wrong week %#v", info)571+ }572+ }573+}574+510575 func TestFullLifecycle(t *testing.T) {511576 s := openTestStore(t)512577
M ui/table.go +36 -5
@@ -62,8 +62,6 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec
6262 // Account for column gaps (2 chars between each column).6363 tableWidth += (len(columns) - 1) * 26464 65- p := theme.Current()66-6765 visibleRows := min(len(rows), MaxTableRows)6866 t := table.New(6967 table.WithColumns(columns),
@@ -72,6 +70,41 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec
7270 table.WithWidth(tableWidth),7371 table.WithHeight(min(max(height-2, 1), visibleRows)),7472 )73+ t.SetStyles(defaultTableStyles())74+75+ return t, games76+}77+78+// InitWeeklyTable builds the weekly browser table from pre-rendered rows.79+func InitWeeklyTable(rows []table.Row, height int) table.Model {80+ columns := []table.Column{81+ {Title: "Puzzle", Width: 8},82+ {Title: "Game", Width: 15},83+ {Title: "Mode", Width: 18},84+ {Title: "Bonus XP", Width: 10},85+ {Title: "Status", Width: 12},86+ }87+88+ tableWidth := 089+ for _, c := range columns {90+ tableWidth += c.Width91+ }92+ tableWidth += (len(columns) - 1) * 293+94+ tableHeight := min(max(height-2, 1), MaxTableRows)95+ t := table.New(96+ table.WithColumns(columns),97+ table.WithRows(rows),98+ table.WithFocused(true),99+ table.WithWidth(tableWidth),100+ table.WithHeight(tableHeight),101+ )102+ t.SetStyles(defaultTableStyles())103+ return t104+}105+106+func defaultTableStyles() table.Styles {107+ p := theme.Current()75108 76109 st := table.DefaultStyles()77110 st.Header = st.Header.
@@ -86,9 +119,7 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec
86119 Bold(true)87120 st.Cell = st.Cell.88121 Foreground(p.FG)89- t.SetStyles(st)90-91- return t, games122+ return st92123 }93124 94125 // TablePagination returns a pagination string like "1-20 of 45" for the
M ui/ui_test.go +17 -0
A weekly/weekly.go +166 -0
@@ -0,0 +1,166 @@
1+package weekly2+3+import (4+ "fmt"5+ "hash/fnv"6+ "math/rand/v2"7+ "regexp"8+ "strconv"9+ "time"10+11+ "github.com/FelineStateMachine/puzzletea/catalog"12+ "github.com/FelineStateMachine/puzzletea/game"13+)14+15+// Entry pairs a SeededSpawner with metadata for the eligible weekly pool.16+type Entry struct {17+ Spawner game.SeededSpawner18+ GameType string19+ Mode string20+}21+22+// Info identifies a single weekly gauntlet puzzle.23+type Info struct {24+ Year int25+ Week int26+ Index int27+}28+29+var (30+ eligibleModes = buildEligibleModes()31+ weeklyNamePattern = regexp.MustCompile(`^Week (\d{2})-(\d{4}) - #(\d{2})$`)32+ currentWeekEntries = 9933+)34+35+func buildEligibleModes() []Entry {36+ catalogEntries := catalog.DailyEntries()37+ entries := make([]Entry, 0, len(catalogEntries))38+ for _, entry := range catalogEntries {39+ entries = append(entries, Entry{40+ Spawner: entry.Spawner,41+ GameType: entry.GameType,42+ Mode: entry.Mode,43+ })44+ }45+ return entries46+}47+48+// Name returns the canonical persisted name for a weekly puzzle.49+func Name(year, week, index int) string {50+ return fmt.Sprintf("Week %02d-%04d - #%02d", week, year, index)51+}52+53+// Prefix returns the exact query prefix for all rows in a weekly gauntlet.54+func Prefix(year, week int) string {55+ return fmt.Sprintf("Week %02d-%04d - #", week, year)56+}57+58+// NameForDate returns the weekly puzzle name for the date's ISO week.59+func NameForDate(date time.Time, index int) string {60+ year, week := date.ISOWeek()61+ return Name(year, week, index)62+}63+64+// Current returns the current ISO week identity for the provided date.65+func Current(date time.Time) Info {66+ year, week := date.ISOWeek()67+ return Info{Year: year, Week: week}68+}69+70+// StartOfWeek returns the Monday for the given ISO week.71+func StartOfWeek(year, week int, loc *time.Location) time.Time {72+ if loc == nil {73+ loc = time.Local74+ }75+ jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, loc)76+ weekdayOffset := (int(jan4.Weekday()) + 6) % 777+ weekOneMonday := jan4.AddDate(0, 0, -weekdayOffset)78+ return weekOneMonday.AddDate(0, 0, (week-1)*7)79+}80+81+// AddWeeks moves to the next or previous ISO week.82+func AddWeeks(weekStart time.Time, delta int) time.Time {83+ return weekStart.AddDate(0, 0, delta*7)84+}85+86+// ParseName parses a canonical weekly name.87+func ParseName(name string) (Info, bool) {88+ matches := weeklyNamePattern.FindStringSubmatch(name)89+ if len(matches) != 4 {90+ return Info{}, false91+ }92+93+ week, err := strconv.Atoi(matches[1])94+ if err != nil {95+ return Info{}, false96+ }97+ year, err := strconv.Atoi(matches[2])98+ if err != nil {99+ return Info{}, false100+ }101+ index, err := strconv.Atoi(matches[3])102+ if err != nil {103+ return Info{}, false104+ }105+ if week < 1 || week > 53 || index < 1 || index > currentWeekEntries {106+ return Info{}, false107+ }108+109+ info := Info{Year: year, Week: week, Index: index}110+ if Name(info.Year, info.Week, info.Index) != name {111+ return Info{}, false112+ }113+ return info, true114+}115+116+// Seed returns a deterministic uint64 seed derived from the weekly identity.117+func Seed(year, week, index int) uint64 {118+ h := fnv.New64a()119+ h.Write([]byte(Name(year, week, index)))120+ return h.Sum64()121+}122+123+// RNG creates a deterministic RNG seeded from the weekly identity.124+func RNG(year, week, index int) *rand.Rand {125+ seed := Seed(year, week, index)126+ return rand.New(rand.NewPCG(seed, ^seed))127+}128+129+// Mode selects the weekly mode from the daily-eligible pool using rendezvous130+// hashing on the canonical weekly name.131+func Mode(year, week, index int) (game.SeededSpawner, string, string) {132+ if len(eligibleModes) == 0 {133+ return nil, "", ""134+ }135+136+ 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+ }154+ if !found {155+ return nil, "", ""156+ }157+ return best.Spawner, best.GameType, best.Mode158+}159+160+// BonusXP returns the slot-based bonus XP for a completed weekly puzzle.161+func BonusXP(index int) int {162+ if index < 10 {163+ return 0164+ }165+ return index / 10166+}
A weekly/weekly_test.go +131 -0
@@ -0,0 +1,131 @@
1+package weekly2+3+import (4+ "testing"5+ "time"6+)7+8+func TestNameAndParseRoundTrip(t *testing.T) {9+ name := Name(2026, 1, 1)10+ if name != "Week 01-2026 - #01" {11+ t.Fatalf("Name() = %q, want %q", name, "Week 01-2026 - #01")12+ }13+14+ info, ok := ParseName(name)15+ if !ok {16+ t.Fatal("ParseName() = false, want true")17+ }18+ if info.Year != 2026 || info.Week != 1 || info.Index != 1 {19+ t.Fatalf("ParseName() = %+v, want year=2026 week=1 index=1", info)20+ }21+}22+23+func TestParseNameRejectsInvalidValues(t *testing.T) {24+ tests := []string{25+ "Week 1-2026 - #01",26+ "Week 01-2026 - #1",27+ "Week 00-2026 - #01",28+ "Week 01-2026 - #00",29+ "Week 54-2026 - #01",30+ "Week 01-2026 - #100",31+ "week 01-2026 - #01",32+ }33+34+ for _, name := range tests {35+ t.Run(name, func(t *testing.T) {36+ if _, ok := ParseName(name); ok {37+ t.Fatalf("ParseName(%q) = true, want false", name)38+ }39+ })40+ }41+}42+43+func TestRNGDeterministic(t *testing.T) {44+ rngA := RNG(2026, 12, 7)45+ rngB := RNG(2026, 12, 7)46+47+ for i := range 10 {48+ if got, want := rngA.Int64(), rngB.Int64(); got != want {49+ t.Fatalf("draw %d = %d, want %d", i, got, want)50+ }51+ }52+}53+54+func TestModeDeterministic(t *testing.T) {55+ _, gameTypeA, modeA := Mode(2026, 20, 17)56+ _, gameTypeB, modeB := Mode(2026, 20, 17)57+58+ if gameTypeA != gameTypeB || modeA != modeB {59+ t.Fatalf("Mode() mismatch: (%q,%q) vs (%q,%q)", gameTypeA, modeA, gameTypeB, modeB)60+ }61+}62+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+98+func TestISOWeekBoundary(t *testing.T) {99+ date := time.Date(2025, time.December, 29, 12, 0, 0, 0, time.UTC)100+ info := Current(date)101+ if info.Year != 2026 || info.Week != 1 {102+ t.Fatalf("Current() = %+v, want year=2026 week=1", info)103+ }104+105+ start := StartOfWeek(2026, 1, time.UTC)106+ if !start.Equal(time.Date(2025, time.December, 29, 0, 0, 0, 0, time.UTC)) {107+ t.Fatalf("StartOfWeek() = %v, want 2025-12-29", start)108+ }109+}110+111+func TestBonusXP(t *testing.T) {112+ tests := []struct {113+ index int114+ want int115+ }{116+ {index: 1, want: 0},117+ {index: 9, want: 0},118+ {index: 10, want: 1},119+ {index: 19, want: 1},120+ {index: 20, want: 2},121+ {index: 99, want: 9},122+ }123+124+ for _, tt := range tests {125+ t.Run(Name(2026, 1, tt.index), func(t *testing.T) {126+ if got := BonusXP(tt.index); got != tt.want {127+ t.Fatalf("BonusXP(%d) = %d, want %d", tt.index, got, tt.want)128+ }129+ })130+ }131+}