clone

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

chore/regenerate-vhs-gifs
puzzletea / app / weekly.go
weekly.go 7.4K
1package app23import (4 "sort"5 "strconv"6 "time"78 "charm.land/bubbles/v2/table"9 tea "charm.land/bubbletea/v2"10 sessionflow "github.com/FelineStateMachine/puzzletea/session"11 "github.com/FelineStateMachine/puzzletea/store"12 "github.com/FelineStateMachine/puzzletea/ui"13 "github.com/FelineStateMachine/puzzletea/weekly"14)1516const weeklyEntryCount = 991718type weeklyRow struct {19 Index int20 Name string21 GameType string22 Mode string23 BonusXP int24 Status store.GameStatus25 Record *store.GameRecord26 Playable bool27 ReadOnly bool28}2930func (r weeklyRow) tableRow() table.Row {31 return table.Row{32 formatWeeklyIndex(r.Index),33 r.GameType,34 r.Mode,35 formatWeeklyBonus(r.BonusXP),36 ui.FormatStatus(r.Status),37 }38}3940func (m model) enterWeeklyView() (tea.Model, tea.Cmd) {41 current := weekly.Current(time.Now())42 m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)43 m = m.refreshWeeklyBrowser()44 m.state = weeklyView45 return m, nil46}4748func (m model) refreshWeeklyBrowser() model {49 year, weekNumber := m.selectedWeek()50 games, err := m.store.ListWeeklyGames(year, weekNumber)51 if err != nil {52 m = m.setErrorf("Could not load weekly puzzles: %v", err)53 games = nil54 } else {55 m = m.clearNotice()56 }5758 if m.isCurrentWeeklySelection() {59 m.weekly.rows = buildCurrentWeeklyRows(year, weekNumber, games)60 } else {61 m.weekly.rows = buildReviewWeeklyRows(games)62 }6364 rows := make([]table.Row, 0, len(m.weekly.rows))65 for _, row := range m.weekly.rows {66 rows = append(rows, row.tableRow())67 }68 m.weekly.table = ui.InitWeeklyTable(rows, m.height)69 return m70}7172func (m model) isCurrentWeeklySelection() bool {73 current := weekly.Current(time.Now())74 year, weekNumber := m.selectedWeek()75 return current.Year == year && current.Week == weekNumber76}7778func (m model) selectedWeek() (int, int) {79 cursor := m.weekly.cursor80 if cursor.IsZero() {81 current := weekly.Current(time.Now())82 cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)83 }84 return cursor.ISOWeek()85}8687func (m model) weeklyPanelTitle() string {88 year, weekNumber := m.selectedWeek()89 return "Week " + formatTwoDigits(weekNumber) + "-" + strconv.Itoa(year)90}9192func (m model) moveWeeklyWeek(delta int) model {93 if m.weekly.cursor.IsZero() {94 current := weekly.Current(time.Now())95 m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)96 }9798 nextCursor := weekly.AddWeeks(m.weekly.cursor, delta)99 current := weekly.Current(time.Now())100 currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)101 if nextCursor.After(currentCursor) {102 nextCursor = currentCursor103 }104105 m.weekly.cursor = nextCursor106 return m.refreshWeeklyBrowser()107}108109func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) {110 idx := m.weekly.table.Cursor()111 if idx < 0 || idx >= len(m.weekly.rows) {112 return m, nil113 }114115 return m.openWeeklyRow(m.weekly.rows[idx])116}117118func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {119 info, ok := weekly.ParseName(row.Name)120 if !ok {121 return m, nil122 }123124 options := gameOpenOptions{125 returnState: weeklyView,126 }127 if row.Playable && !row.ReadOnly {128 infoCopy := info129 options.weeklyInfo = &infoCopy130 }131132 if row.Record != nil {133 if row.ReadOnly {134 m, _ = m.importAndActivateRecordWithOptions(*row.Record, gameOpenOptions{135 readOnly: true,136 returnState: weeklyView,137 })138 return m, nil139 }140141 var resumed bool142 m, resumed = m.importAndActivateRecordWithOptions(*row.Record, options)143 if resumed {144 if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, row.Record); err != nil {145 m = m.setErrorf("%v", err)146 }147 }148 return m, nil149 }150151 if !row.Playable {152 return m, nil153 }154155 spawner, gameType, modeTitle := weekly.Mode(info.Year, info.Week, info.Index)156 if spawner == nil {157 return m.setErrorf("No weekly puzzle is configured for %s", row.Name), nil158 }159160 rng := weekly.RNG(info.Year, info.Week, info.Index)161 infoCopy := info162 cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{163 source: spawnSourceWeekly,164 name: row.Name,165 gameType: gameType,166 modeTitle: modeTitle,167 run: store.WeeklyRunMetadata(info.Year, info.Week, info.Index),168 returnState: weeklyView,169 exitState: weeklyView,170 weeklyInfo: &infoCopy,171 })172 return m, cmd173}174175func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {176 if m.state != gameView || m.session.game == nil || !m.session.game.IsSolved() || m.session.weeklyAdvance == nil {177 return m, nil, false178 }179180 info := *m.session.weeklyAdvance181 m = m.persistCompletionIfSolved()182 m.weekly.cursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)183 m = m.refreshWeeklyBrowser()184 m.state = weeklyView185 if len(m.weekly.rows) == 0 || !m.weekly.rows[0].Playable {186 return m, nil, true187 }188189 next, cmd := m.openWeeklyRow(m.weekly.rows[0])190 return next.(model), cmd, true191}192193func buildCurrentWeeklyRows(year, weekNumber int, games []store.GameRecord) []weeklyRow {194 byIndex := make(map[int]store.GameRecord, len(games))195 completed := make(map[int]store.GameRecord, len(games))196197 for _, rec := range games {198 info, ok := weekly.ParseName(rec.Name)199 if !ok || info.Year != year || info.Week != weekNumber {200 continue201 }202 byIndex[info.Index] = rec203 if rec.Status == store.StatusCompleted {204 completed[info.Index] = rec205 }206 }207208 prefix := 0209 for index := 1; index <= weeklyEntryCount; index++ {210 if _, ok := completed[index]; !ok {211 break212 }213 prefix = index214 }215216 rows := make([]weeklyRow, 0, prefix+1)217 if prefix < weeklyEntryCount {218 nextIndex := prefix + 1219 if rec, ok := byIndex[nextIndex]; ok {220 recCopy := rec221 rows = append(rows, weeklyRow{222 Index: nextIndex,223 Name: rec.Name,224 GameType: rec.GameType,225 Mode: rec.Mode,226 BonusXP: weekly.BonusXP(nextIndex),227 Status: rec.Status,228 Record: &recCopy,229 Playable: true,230 })231 } else {232 _, gameType, modeTitle := weekly.Mode(year, weekNumber, nextIndex)233 rows = append(rows, weeklyRow{234 Index: nextIndex,235 Name: weekly.Name(year, weekNumber, nextIndex),236 GameType: gameType,237 Mode: modeTitle,238 BonusXP: weekly.BonusXP(nextIndex),239 Status: store.StatusNew,240 Playable: true,241 })242 }243 }244245 for index := prefix; index >= 1; index-- {246 rec := completed[index]247 recCopy := rec248 rows = append(rows, weeklyRow{249 Index: index,250 Name: rec.Name,251 GameType: rec.GameType,252 Mode: rec.Mode,253 BonusXP: weekly.BonusXP(index),254 Status: rec.Status,255 Record: &recCopy,256 ReadOnly: true,257 })258 }259260 return rows261}262263func buildReviewWeeklyRows(games []store.GameRecord) []weeklyRow {264 rows := make([]weeklyRow, 0, len(games))265 for _, rec := range games {266 info, ok := weekly.ParseName(rec.Name)267 if !ok || rec.Status != store.StatusCompleted {268 continue269 }270271 recCopy := rec272 rows = append(rows, weeklyRow{273 Index: info.Index,274 Name: rec.Name,275 GameType: rec.GameType,276 Mode: rec.Mode,277 BonusXP: weekly.BonusXP(info.Index),278 Status: rec.Status,279 Record: &recCopy,280 ReadOnly: true,281 })282 }283284 sort.Slice(rows, func(i, j int) bool {285 return rows[i].Index > rows[j].Index286 })287 return rows288}289290func formatWeeklyIndex(index int) string {291 return "#" + formatTwoDigits(index)292}293294func formatWeeklyBonus(bonus int) string {295 return "+" + strconv.Itoa(bonus)296}297298func formatWeeklyMenuIndex(index int) string {299 if index < 10 {300 return " " + strconv.Itoa(index)301 }302 return strconv.Itoa(index)303}304305func formatTwoDigits(value int) string {306 if value < 10 {307 return "0" + strconv.Itoa(value)308 }309 return strconv.Itoa(value)310}