clone

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

feat/elo-difficulty
puzzletea / app / weekly.go
weekly.go 7.7K
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() (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 m = m.initScreen(weeklyView)46 return m, nil47}4849func (m model) refreshWeeklyBrowser() model {50 year, weekNumber := m.selectedWeek()51 games, err := m.store.ListWeeklyGames(year, weekNumber)52 if err != nil {53 m = m.setErrorf("Could not load weekly puzzles: %v", err)54 games = nil55 } else {56 m = m.clearNotice()57 }5859 if m.isCurrentWeeklySelection() {60 m.weekly.rows = buildCurrentWeeklyRows(year, weekNumber, games)61 } else {62 m.weekly.rows = buildReviewWeeklyRows(games)63 }6465 rows := make([]table.Row, 0, len(m.weekly.rows))66 for _, row := range m.weekly.rows {67 rows = append(rows, row.tableRow())68 }69 m.weekly.table = ui.InitWeeklyTable(rows, m.height)70 return m71}7273func (m model) isCurrentWeeklySelection() bool {74 return isCurrentWeeklySelection(m.weekly.cursor)75}7677func isCurrentWeeklySelection(cursor time.Time) bool {78 current := weekly.Current(time.Now())79 year, weekNumber := resolvedWeekCursor(cursor).ISOWeek()80 return current.Year == year && current.Week == weekNumber81}8283func weeklyPanelTitle(cursor time.Time) string {84 year, weekNumber := resolvedWeekCursor(cursor).ISOWeek()85 return "Week " + formatTwoDigits(weekNumber) + "-" + strconv.Itoa(year)86}8788func resolvedWeekCursor(cursor time.Time) time.Time {89 if cursor.IsZero() {90 current := weekly.Current(time.Now())91 return weekly.StartOfWeek(current.Year, current.Week, time.Local)92 }93 return cursor94}9596func (m model) selectedWeek() (int, int) {97 return resolvedWeekCursor(m.weekly.cursor).ISOWeek()98}99100func (m model) moveWeeklyWeek(delta int) model {101 if m.weekly.cursor.IsZero() {102 current := weekly.Current(time.Now())103 m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)104 }105106 nextCursor := weekly.AddWeeks(m.weekly.cursor, delta)107 current := weekly.Current(time.Now())108 currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)109 if nextCursor.After(currentCursor) {110 nextCursor = currentCursor111 }112113 m.weekly.cursor = nextCursor114 return m.refreshWeeklyBrowser()115}116117func (m model) handleWeeklyEnter() (model, tea.Cmd) {118 idx := m.weekly.table.Cursor()119 if idx < 0 || idx >= len(m.weekly.rows) {120 return m, nil121 }122123 return m.openWeeklyRow(m.weekly.rows[idx])124}125126func (m model) openWeeklyRow(row weeklyRow) (model, tea.Cmd) {127 info, ok := weekly.ParseName(row.Name)128 if !ok {129 return m, nil130 }131132 options := gameOpenOptions{133 returnState: weeklyView,134 }135 if row.Playable && !row.ReadOnly {136 infoCopy := info137 options.weeklyInfo = &infoCopy138 }139140 if row.Record != nil {141 if row.ReadOnly {142 m, _ = m.importAndActivateRecordWithOptions(*row.Record, gameOpenOptions{143 readOnly: true,144 returnState: weeklyView,145 })146 return m, nil147 }148149 var resumed bool150 m, resumed = m.importAndActivateRecordWithOptions(*row.Record, options)151 if resumed {152 if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, row.Record); err != nil {153 m = m.setErrorf("%v", err)154 }155 }156 return m, nil157 }158159 if !row.Playable {160 return m, nil161 }162163 spawner, gameType, modeTitle := weekly.Mode(info.Year, info.Week, info.Index)164 if spawner == nil {165 return m.setErrorf("No weekly puzzle is configured for %s", row.Name), nil166 }167168 rng := weekly.RNG(info.Year, info.Week, info.Index)169 infoCopy := info170 cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{171 source: spawnSourceWeekly,172 name: row.Name,173 gameType: gameType,174 modeTitle: modeTitle,175 run: store.WeeklyRunMetadata(info.Year, info.Week, info.Index),176 returnState: weeklyView,177 exitState: weeklyView,178 weeklyInfo: &infoCopy,179 })180 return m, cmd181}182183func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {184 if m.state != gameView || m.session.game == nil || !m.session.game.IsSolved() || m.session.weeklyAdvance == nil {185 return m, nil, false186 }187188 info := *m.session.weeklyAdvance189 m = m.persistCompletionIfSolved()190 m.weekly.cursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)191 m = m.refreshWeeklyBrowser()192 m.state = weeklyView193 m = m.initScreen(weeklyView)194 if len(m.weekly.rows) == 0 || !m.weekly.rows[0].Playable {195 return m, nil, true196 }197198 next, cmd := m.openWeeklyRow(m.weekly.rows[0])199 return next, cmd, true200}201202func buildCurrentWeeklyRows(year, weekNumber int, games []store.GameRecord) []weeklyRow {203 byIndex := make(map[int]store.GameRecord, len(games))204 completed := make(map[int]store.GameRecord, len(games))205206 for _, rec := range games {207 info, ok := weekly.ParseName(rec.Name)208 if !ok || info.Year != year || info.Week != weekNumber {209 continue210 }211 byIndex[info.Index] = rec212 if rec.Status == store.StatusCompleted {213 completed[info.Index] = rec214 }215 }216217 prefix := 0218 for index := 1; index <= weeklyEntryCount; index++ {219 if _, ok := completed[index]; !ok {220 break221 }222 prefix = index223 }224225 rows := make([]weeklyRow, 0, prefix+1)226 if prefix < weeklyEntryCount {227 nextIndex := prefix + 1228 if rec, ok := byIndex[nextIndex]; ok {229 recCopy := rec230 rows = append(rows, weeklyRow{231 Index: nextIndex,232 Name: rec.Name,233 GameType: rec.GameType,234 Mode: rec.Mode,235 BonusXP: weekly.BonusXP(nextIndex),236 Status: rec.Status,237 Record: &recCopy,238 Playable: true,239 })240 } else {241 _, gameType, modeTitle := weekly.Mode(year, weekNumber, nextIndex)242 rows = append(rows, weeklyRow{243 Index: nextIndex,244 Name: weekly.Name(year, weekNumber, nextIndex),245 GameType: gameType,246 Mode: modeTitle,247 BonusXP: weekly.BonusXP(nextIndex),248 Status: store.StatusNew,249 Playable: true,250 })251 }252 }253254 for index := prefix; index >= 1; index-- {255 rec := completed[index]256 recCopy := rec257 rows = append(rows, weeklyRow{258 Index: index,259 Name: rec.Name,260 GameType: rec.GameType,261 Mode: rec.Mode,262 BonusXP: weekly.BonusXP(index),263 Status: rec.Status,264 Record: &recCopy,265 ReadOnly: true,266 })267 }268269 return rows270}271272func buildReviewWeeklyRows(games []store.GameRecord) []weeklyRow {273 rows := make([]weeklyRow, 0, len(games))274 for _, rec := range games {275 info, ok := weekly.ParseName(rec.Name)276 if !ok || rec.Status != store.StatusCompleted {277 continue278 }279280 recCopy := rec281 rows = append(rows, weeklyRow{282 Index: info.Index,283 Name: rec.Name,284 GameType: rec.GameType,285 Mode: rec.Mode,286 BonusXP: weekly.BonusXP(info.Index),287 Status: rec.Status,288 Record: &recCopy,289 ReadOnly: true,290 })291 }292293 sort.Slice(rows, func(i, j int) bool {294 return rows[i].Index > rows[j].Index295 })296 return rows297}298299func formatWeeklyIndex(index int) string {300 return "#" + formatTwoDigits(index)301}302303func formatWeeklyBonus(bonus int) string {304 return "+" + strconv.Itoa(bonus)305}306307func formatWeeklyMenuIndex(index int) string {308 if index < 10 {309 return " " + strconv.Itoa(index)310 }311 return strconv.Itoa(index)312}313314func formatTwoDigits(value int) string {315 if value < 10 {316 return "0" + strconv.Itoa(value)317 }318 return strconv.Itoa(value)319}