1package app23import (4 "context"5 "math/rand/v2"67 "github.com/FelineStateMachine/puzzletea/difficulty"8 "github.com/FelineStateMachine/puzzletea/game"9 sessionflow "github.com/FelineStateMachine/puzzletea/session"10 "github.com/FelineStateMachine/puzzletea/store"1112 "charm.land/bubbles/v2/key"13 tea "charm.land/bubbletea/v2"14)1516type sessionController struct {17 model *model18}1920func newSessionController(m *model) sessionController {21 return sessionController{model: m}22}2324func (c sessionController) beginSpawnContext() (context.Context, int64) {25 c.cancelActiveSpawn()26 c.model.session.spawnJobID++27 jobID := c.model.session.spawnJobID28 ctx, cancel := context.WithTimeout(context.Background(), spawnTimeout)29 c.model.session.spawnCancel = cancel30 c.model.session.generating = true31 return ctx, jobID32}3334func (c sessionController) cancelActiveSpawn() {35 if c.model.session.spawnCancel != nil {36 c.model.session.spawnCancel()37 c.model.session.spawnCancel = nil38 }39 if c.model.session.generating {40 c.model.session.spawnJobID++41 }42 c.model.session.generating = false43 c.model.session.spawn = nil44}4546func (c sessionController) startSpawn(spawner game.Spawner, request spawnRequest) tea.Cmd {47 ctx, jobID := c.beginSpawnContext()48 c.model.session.spawn = &request49 c.model.state = generatingView50 *c.model = c.model.clearNotice()51 *c.model = c.model.initScreen(generatingView)52 return tea.Batch(c.model.spinner.Tick, spawnCmd(spawner, ctx, jobID))53}5455func (c sessionController) startEloSpawn(spawner game.EloSpawner, seed string, elo difficulty.Elo, request spawnRequest) tea.Cmd {56 ctx, jobID := c.beginSpawnContext()57 c.model.session.spawn = &request58 c.model.state = generatingView59 *c.model = c.model.clearNotice()60 *c.model = c.model.initScreen(generatingView)61 return tea.Batch(c.model.spinner.Tick, spawnEloCmd(spawner, seed, elo, ctx, jobID))62}6364func (c sessionController) startSeededSpawn(spawner game.SeededSpawner, rng *rand.Rand, request spawnRequest) tea.Cmd {65 ctx, jobID := c.beginSpawnContext()66 c.model.session.spawn = &request67 c.model.state = generatingView68 *c.model = c.model.clearNotice()69 *c.model = c.model.initScreen(generatingView)70 return tea.Batch(c.model.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))71}7273func (c sessionController) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) tea.Cmd {74 if jobID != c.model.session.spawnJobID {75 return nil76 }7778 c.model.session.generating = false79 if c.model.session.spawnCancel != nil {80 c.model.session.spawnCancel()81 c.model.session.spawnCancel = nil82 }83 request := c.model.session.spawn84 c.model.session.spawn = nil8586 if c.model.state != generatingView {87 return nil88 }8990 if msg.Err != nil {91 *c.model = c.model.setErrorf("Could not generate puzzle: %v", msg.Err)92 if request != nil {93 c.model.state = request.returnState94 }95 return nil96 }9798 if request == nil {99 *c.model = c.model.setErrorf("Internal error: missing spawn request metadata")100 return nil101 }102103 c.activateGame(msg.Game.SetTitle(request.name), 0, false, gameOpenOptions{104 returnState: request.exitState,105 weeklyInfo: request.weeklyInfo,106 })107 *c.model = c.model.clearNotice()108109 rec, err := sessionflow.CreateRecordWithDifficulty(110 c.model.store,111 c.model.session.game,112 request.name,113 request.gameType,114 request.modeTitle,115 request.run,116 difficultyMetadataFromSpawnReport(msg.Report),117 )118 if err != nil {119 *c.model = c.model.setErrorf("Started puzzle, but could not create a save record: %v", err)120 } else {121 c.model.session.activeGameID = rec.ID122 }123 return nil124}125126func difficultyMetadataFromSpawnReport(report difficulty.Report) sessionflow.DifficultyMetadata {127 if report.Confidence == "" {128 return sessionflow.DifficultyMetadata{}129 }130 target := report.TargetElo131 actual := report.ActualElo132 return sessionflow.DifficultyMetadata{133 TargetElo: &target,134 ActualElo: &actual,135 Confidence: report.Confidence,136 }137}138139func (c sessionController) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) {140 g, _ = g.Update(game.HelpToggleMsg{Show: c.model.help.showFull})141 g, _ = g.Update(tea.WindowSizeMsg{Width: c.model.width, Height: c.model.height})142143 c.model.session.game = g144 c.model.session.activeGameID = activeGameID145 c.model.session.completionSaved = completionSaved146 c.model.session.returnState = options.returnState147 c.model.session.weeklyAdvance = options.weeklyInfo148 c.model.state = gameView149}150151func (c sessionController) importRecord(rec store.GameRecord, options gameOpenOptions) bool {152 g, err := sessionflow.ImportRecord(&rec)153 if err != nil {154 *c.model = c.model.setErrorf("Could not load saved puzzle %q: %v", rec.Name, err)155 return false156 }157158 activeGameID := rec.ID159 completionSaved := rec.Status == store.StatusCompleted160 if options.readOnly {161 activeGameID = 0162 completionSaved = true163 }164165 c.activateGame(g, activeGameID, completionSaved, options)166 *c.model = c.model.clearNotice()167 return true168}169170func (c sessionController) updateActiveGame(msg tea.Msg) tea.Cmd {171 var cmd tea.Cmd172 c.model.session.game, cmd = c.model.session.game.Update(msg)173 if c.model.debug.enabled {174 c.model.debug.info = c.model.renderDebugInfo()175 }176 c.persistCompletionIfSolved()177 return cmd178}179180func (c sessionController) persistCompletionIfSolved() {181 if c.model.session.game == nil || c.model.session.activeGameID == 0 ||182 c.model.session.completionSaved || !c.model.session.game.IsSolved() {183 return184 }185186 c.model.session.completionSaved = true187 saveData, err := c.model.session.game.GetSave()188 if err == nil {189 if err := c.model.store.UpdateSaveState(c.model.session.activeGameID, string(saveData)); err != nil {190 *c.model = c.model.setErrorf("Puzzle completed, but saving the final state failed: %v", err)191 }192 } else {193 *c.model = c.model.setErrorf("Puzzle completed, but reading the final state failed: %v", err)194 }195 if err := c.model.store.UpdateStatus(c.model.session.activeGameID, store.StatusCompleted); err != nil {196 *c.model = c.model.setErrorf("Puzzle completed, but updating completion status failed: %v", err)197 }198}199200func (c sessionController) saveCurrentGame(status store.GameStatus) {201 if c.model.session.game == nil {202 return203 }204 if c.model.session.activeGameID == 0 {205 c.clearActiveGame()206 return207 }208209 saveData, err := c.model.session.game.GetSave()210 if err != nil {211 *c.model = c.model.setErrorf("Could not save puzzle progress: %v", err)212 return213 }214 if err := c.model.store.UpdateSaveState(c.model.session.activeGameID, string(saveData)); err != nil {215 *c.model = c.model.setErrorf("Could not save puzzle progress: %v", err)216 }217 if !(c.model.session.completionSaved && status != store.StatusCompleted) {218 if err := c.model.store.UpdateStatus(c.model.session.activeGameID, status); err != nil {219 *c.model = c.model.setErrorf("Could not update puzzle status: %v", err)220 }221 }222 c.clearActiveGame()223}224225func (c sessionController) clearActiveGame() {226 c.model.session.activeGameID = 0227 c.model.session.game = nil228 c.model.session.completionSaved = false229 c.model.session.returnState = mainMenuView230 c.model.session.weeklyAdvance = nil231}232233// activeSpawnReturnState returns the route to return to when a pending234// generation is canceled.235func (c sessionController) activeSpawnReturnState() viewState {236 if c.model.session.spawn == nil {237 return modeSelectView238 }239 return c.model.session.spawn.returnState240}241242// handleGeneratingKey handles global keys while a puzzle is being generated.243// All key messages are consumed (handled=true): escape cancels the spawn and244// returns to the previous route, quit exits the app, and all other keys are245// swallowed.246func (c sessionController) handleGeneratingKey(keyMsg tea.KeyPressMsg) (model, tea.Cmd, bool) {247 switch {248 case key.Matches(keyMsg, rootKeys.Escape):249 returnState := c.activeSpawnReturnState()250 c.cancelActiveSpawn()251 c.model.state = returnState252 *c.model = c.model.resizeActiveScreen()253 return *c.model, nil, true254 case key.Matches(keyMsg, rootKeys.Quit):255 return *c.model, tea.Quit, true256 }257 return *c.model, nil, true258}259260// handleGameKey handles global keys while a puzzle is active. Escape saves261// and returns, quit saves as abandoned and exits, plus debug/full-help/reset262// toggles and weekly advancement on enter.263func (c sessionController) handleGameKey(keyMsg tea.KeyPressMsg) (model, tea.Cmd, bool) {264 switch {265 case key.Matches(keyMsg, rootKeys.Enter):266 m, cmd, handled := c.model.advanceSolvedWeekly()267 if handled {268 return m, cmd, true269 }270 return *c.model, nil, false271 case key.Matches(keyMsg, rootKeys.Escape):272 returnState := c.model.session.returnState273 c.saveCurrentGame(store.StatusInProgress)274 c.model.state = returnState275 if returnState == weeklyView {276 *c.model = c.model.refreshWeeklyBrowser()277 *c.model = c.model.initScreen(weeklyView)278 }279 c.model.debug.enabled = false280 return *c.model, nil, true281 case key.Matches(keyMsg, rootKeys.Quit):282 c.saveCurrentGame(store.StatusAbandoned)283 return *c.model, tea.Quit, true284 case key.Matches(keyMsg, rootKeys.Debug):285 c.model.debug.enabled = !c.model.debug.enabled286 return *c.model, nil, true287 case key.Matches(keyMsg, rootKeys.FullHelp):288 c.model.help.showFull = !c.model.help.showFull289 if c.model.session.game != nil {290 c.model.session.game, _ = c.model.session.game.Update(game.HelpToggleMsg{Show: c.model.help.showFull})291 }292 return *c.model, nil, true293 case key.Matches(keyMsg, rootKeys.ResetGame):294 if c.model.session.game != nil {295 c.model.session.game = c.model.session.game.Reset()296 }297 return *c.model, nil, true298 }299 return *c.model, nil, false300}