diff --git a/.gitignore b/.gitignore index 82a7767..850cb2c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /bin/ /dist/ /build/ +/stuff/ # Go test and profiling output *.test diff --git a/AGENTS.MD b/AGENTS.MD index 54d6c22..e77a482 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -405,6 +405,8 @@ Update documentation when changing: * supported platforms; * security or privacy behavior; * package contents. +* ONLY update the README.md when the issue states it OR when an already there documented thing changes. Keep the README.md small and focused on the user experience. Use AGENTS.md for technical details. +* The full documentation belongs in the manpage, which is generated from the Markdown source in `docs/sidequest.1.md`. The manpage must be kept in sync with the Markdown source. Do not edit the manpage directly. Examples in documentation must use the `--` separator. diff --git a/README.md b/README.md index b086cac..0bf1540 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ You also get noticed when the command finishes. By default, command-pane output - [Installation](#installation) - [Quick Start](#quick-start) - [Gameplay](#gameplay) +- [FAQ / Known Issues](#faq--known-issues) ## Installation @@ -43,6 +44,8 @@ packages for manual installation. sidequest -- ssh deploy@example.com sidequest -- sh -c 'sudo du -xh /var /usr /home 2>/dev/null | sort -h' sidequest --no-history -- ssh production.example.com +sidequest --no-color -- make test +sidequest --aug -- make test sidequest --mode quest -- make test sidequest -- codex sidequest -- claude "Run the test suite, fix any failures, and summarize the changes." @@ -51,7 +54,7 @@ sidequest -- claude "Run the test suite, fix any failures, and summarize the cha Try it with a harmless demo workload: ```bash -sidequest -- bash -c 'for i in {1..60}; do printf "working step %02d/60\n" "$i"; sleep 1; done' +sidequest --aug -- bash -c 'for i in {1..60}; do printf "working step %02d/60\n" "$i"; sleep 1; done' ``` ## Gameplay @@ -61,19 +64,29 @@ sidequest -- bash -c 'for i in {1..60}; do printf "working step %02d/60\n" "$i"; - `F12` switches between Snake and the command pane. - Snake focus-pauses while the command pane is active and resumes when the game pane is active again, unless you paused manually. +- In the command pane, `Page Up` and `Page Down` scroll pages; arrow up and + arrow down scroll line by line. Scrolling back to the bottom resumes live + command output. - `F10` detaches back to your shell. If the command is still running, Sidequest prints the `sidequest attach ` command. - `R` restarts Snake after a round over while the command keeps running. -- After the command finishes, `C` continues the current round and `Q` finalizes - and quits. +- After the command finishes, `C` continues the current round. Use `F10` to + return to your shell. Classic mode keeps Snake simple and adds Command Heat: the longer you actively play, the faster Snake gets and the more food is worth. Time spent in the -command pane or on pause does not raise Heat. After the command has finished, -Heat stays frozen at the reached level while the round can continue. +command pane, on pause, or in the command-finished choice does not raise Heat. +If you press `C` after the command finishes, the same round continues and Heat +resumes increasing only during active gameplay. -Quest mode adds combo scoring, one mission per command, Golden Bytes, random -arena pickups and other stuff. +Quest mode adds combo scoring, one mission per command, Golden Bytes that grow +Snake by two cells, random arena pickups and other stuff. + +Use `--aug` to show the latest command output line centered in the game pane +while the original command pane stays unchanged. + +Use `--no-color` or a non-empty `NO_COLOR` environment variable for monochrome +Sidequest game/UI rendering. Wrapped command output is left untouched. For complete controls and behavior details, use: @@ -81,6 +94,24 @@ For complete controls and behavior details, use: man sidequest ``` +## FAQ / Known Issues + +### Snake graphics look wrong in MobaXterm or older SSH terminals + +Some terminals render block characters differently. Sidequest keeps rich block +graphics by default, but can use a safer ASCII fallback: + +```bash +SIDEQUEST_GRAPHICS=ascii sidequest -- +``` + +If Sidequest detects a fallback but your terminal renders rich graphics +correctly, force the default style: + +```bash +SIDEQUEST_GRAPHICS=rich sidequest -- +``` + ## Sessions and History Runtime sessions: diff --git a/docs/man/sidequest.1 b/docs/man/sidequest.1 index 1610f84..35fa136 100644 --- a/docs/man/sidequest.1 +++ b/docs/man/sidequest.1 @@ -28,15 +28,15 @@ sidequest \- play a tiny terminal game while waiting for a command runs a command in one tmux pane and opens a small terminal game in another. The game pane is focused first and starts on the first move. When the command finishes during a playable round, Sidequest pauses Snake and asks whether to -continue the current round or quit. Quitting finalizes the score and shows the -command state, exit code or signal, runtime, final score, and the local TOP 5. +continue the current round. Finished-command screens show the command state, +exit code or signal, runtime, current score, and the local TOP 5. .PP Snake uses Command Heat. Heat rises with accumulated active Snake playtime, speeds up Snake movement, increases the food score multiplier, and is capped at the maximum Heat level. Time spent before the first move, paused, detached, -after round over, after command completion, or focused in the command pane does -not raise Heat. If the player continues after command completion, the reached -Heat level still controls Snake speed and food value. +after round over, in the command-finished choice, or focused in the command pane +does not raise Heat. If the player continues after command completion, the same +round resumes and Heat increases again only during active gameplay. .PP .B F9 hides or restores the Sidequest UI by zooming the command pane inside the @@ -85,6 +85,17 @@ bonuses and local game statistics. Do not capture or persist command-pane output when the Sidequest session is closed. No stored run metadata or output file is created for that run. The live tmux pane scrollback remains visible until the session is closed. +.TP +.B \-\-no-color +Disable colors in Sidequest's own game and UI rendering. The wrapped command's +output is not modified. A non-empty +.B NO_COLOR +environment variable enables the same behavior. +.TP +.B \-\-aug +Enable augmented game HUD context. Sidequest shows the latest non-empty command +output line centered in the game pane while leaving the original command pane +unchanged. .SH COMMANDS .TP .B list @@ -117,6 +128,11 @@ Switch between the game pane and the command pane. Snake focus-pauses while the command pane is active and resumes when the game pane is active again, unless it was manually paused. .TP +.B Page Up, Page Down, arrow up, arrow down +Scroll the command pane when the command pane is active. In the game pane, arrow +keys continue to control Snake. Scrolling back to the bottom resumes live +command output. +.TP .B F10 Detach from the Sidequest tmux session and return to the shell. The session stays listed for later attach. If the command is still running, Sidequest prints the @@ -130,23 +146,11 @@ Toggle manual pause. Manual pause is separate from command-pane focus pause. .TP .B C Continue the same Snake round when the wrapped command has finished and the -round is still playable. The command is not run again, and Command Heat remains -frozen at the level reached when the command ended. +round is still playable. The command is not run again. Heat resumes increasing +only during active gameplay after the player continues. .TP .B R Restart Snake after a round over while the command is still running. -.TP -.B Q -Finalize and quit after the wrapped command has reached a terminal state. During -a playable round, the command-finished modal offers -.B C -to continue or -.B Q -to finalize. During a running command, use -.B F9 -to hide Sidequest or -.B F10 -to detach. .SH HIGHSCORES Sidequest keeps at most five scores per mode. Scores are sorted descending and equal scores keep deterministic insertion order. The result panel marks the diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e697ab2..58a2436 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -7,6 +7,7 @@ import ( "io" "math/rand" "os" + "os/exec" "strings" "time" @@ -35,6 +36,8 @@ Options: -v, --version Show the sidequest version. --mode Select game mode: classic or quest. --no-history Do not persist command-pane output after the run. + --no-color Disable Sidequest game/UI colors. + --aug Show augmented live command context in the game pane. The -- separator marks the end of Sidequest options and the start of the command to run. Command arguments are preserved exactly and are not passed @@ -51,6 +54,8 @@ type Config struct { Arguments []string Mode string NoHistory bool + NoColor bool + Augmented bool } type Result struct { @@ -60,29 +65,35 @@ type Result struct { } type App struct { - Out io.Writer - Err io.Writer - Version string - Preflight func() error - CreateSession func() (session.Session, error) - ListSessions func() ([]session.Record, error) - AttachSession func(string) error - TmuxHasSession func(tmux.Info) bool - CloseTmux func(tmux.Info) error - DetachClients func(tmux.Info) error - CapturePane func(tmux.Info) (string, bool, error) - CleanupSession func(session.Session) error - StoreRun func(session.Record, string, bool) (runhistory.Run, error) - ListRuns func() ([]runhistory.Run, error) - FindRun func(string) (runhistory.Run, error) - OutputRun func(string) error - PurgeRun func(string) error - RunLayout func(session.Session, session.Command) error - ReceiveCommand func(context.Context, string) (session.Command, error) - ExecCommand func(session.Session, session.Command) error - RunGameShell func(string) error - RunShell func(game.Shell) error - Now func() time.Time + Out io.Writer + Err io.Writer + Version string + Preflight func() error + CreateSession func() (session.Session, error) + ListSessions func() ([]session.Record, error) + AttachSession func(string) error + TmuxHasSession func(tmux.Info) bool + CloseTmux func(tmux.Info) error + DetachClients func(tmux.Info) error + CapturePane func(tmux.Info) (string, bool, error) + CleanupSession func(session.Session) error + StoreRun func(session.Record, string, bool) (runhistory.Run, error) + ListRuns func() ([]runhistory.Run, error) + FindRun func(string) (runhistory.Run, error) + OutputRun func(string) error + PurgeRun func(string) error + RunLayout func(session.Session, session.Command) error + StartLayout func(session.Session, []string, []string) (tmux.Info, error) + AttachLayout func(tmux.Info) error + ServeCommand func(context.Context, *session.CommandListener, session.Command) (session.CommandStartup, error) + UpdateState func(session.Session, time.Time, func(*session.State)) error + ReadState func(session.Session) (session.State, error) + ReceiveCommand func(context.Context, string) (session.Command, error) + ReceiveExchange func(context.Context, string) (session.Command, *session.CommandExchange, error) + ExecCommand func(session.Session, session.Command) error + RunGameShell func(string) error + RunShell func(game.Shell) error + Now func() time.Time } func (a App) Run(args []string) int { @@ -96,7 +107,7 @@ func (a App) Run(args []string) int { if len(args) == 2 && args[0] == commandRunnerMode { if err := a.runCommandRunner(args[1]); err != nil { fmt.Fprintf(a.errorWriter(), "sidequest command runner: %v\n", err) - return commandexec.ExitCodeForError(err) + return exitCodeForError(err) } return 0 } @@ -179,33 +190,42 @@ func (a App) Run(args []string) int { fmt.Fprintf(a.outputWriter(), "sidequest %s\n", a.version()) return 0 default: - if err := a.runPreflight(); err != nil { - fmt.Fprintf(a.errorWriter(), "sidequest: %v\n", err) - return 2 - } - command := session.Command{ Executable: result.Config.Executable, Arguments: result.Config.Arguments, } + if err := validateCommandExecutable(command.Executable); err != nil { + fmt.Fprintf(a.errorWriter(), "sidequest: %v\n", err) + return exitCodeForError(err) + } + if err := a.runPreflight(); err != nil { + fmt.Fprintf(a.errorWriter(), "sidequest: %v\n", err) + return 2 + } runtimeSession, err := a.createSession() if err != nil { fmt.Fprintf(a.errorWriter(), "sidequest: %v\n", err) return 2 } if runtimeSession.StatePath != "" { - if err := session.UpdateState(runtimeSession, a.now(), func(state *session.State) { + if err := a.updateState(runtimeSession, a.now(), func(state *session.State) { state.GameMode = result.Config.Mode state.NoHistory = result.Config.NoHistory + state.NoColor = result.Config.NoColor + state.Augmented = result.Config.Augmented }); err != nil { + err = a.cleanupStartupFailure(err, runtimeSession, tmux.Info{}, false) fmt.Fprintf(a.errorWriter(), "sidequest: %v\n", err) return 2 } } if err := a.runLayout(runtimeSession, command); err != nil { + if a.RunLayout != nil { + err = a.cleanupStartupFailure(err, runtimeSession, tmux.Info{}, false) + } fmt.Fprintf(a.errorWriter(), "sidequest: %v\n", err) - return 2 + return exitCodeForError(err) } return 0 @@ -219,6 +239,8 @@ func Parse(args []string) (Result, error) { mode := game.GameModeClassic noHistory := false + noColor := os.Getenv("NO_COLOR") != "" + augmented := false for index := 0; index < len(args); index++ { arg := args[index] switch arg { @@ -238,8 +260,12 @@ func Parse(args []string) (Result, error) { index++ case "--no-history": noHistory = true + case "--no-color": + noColor = true + case "--aug": + augmented = true case "--": - return parseCommand(args[index+1:], mode, noHistory) + return parseCommand(args[index+1:], mode, noHistory, noColor, augmented) default: if strings.HasPrefix(arg, "--mode=") { selectedMode, err := parseMode(strings.TrimPrefix(arg, "--mode=")) @@ -252,12 +278,24 @@ func Parse(args []string) (Result, error) { if strings.HasPrefix(arg, "-") { return Result{}, fmt.Errorf("unknown option %q", arg) } + if containsSeparator(args[index+1:]) { + return Result{}, fmt.Errorf("unexpected argument %q before --", arg) + } } } return Result{}, ErrMissingSeparator } +func containsSeparator(args []string) bool { + for _, arg := range args { + if arg == "--" { + return true + } + } + return false +} + func parseMode(mode string) (string, error) { switch mode { case game.GameModeClassic, game.GameModeQuest: @@ -271,7 +309,7 @@ func Usage() string { return usage } -func parseCommand(args []string, mode string, noHistory bool) (Result, error) { +func parseCommand(args []string, mode string, noHistory bool, noColor bool, augmented bool) (Result, error) { if len(args) == 0 || args[0] == "" { return Result{}, ErrMissingCommand } @@ -282,6 +320,8 @@ func parseCommand(args []string, mode string, noHistory bool) (Result, error) { Arguments: append([]string(nil), args[1:]...), Mode: mode, NoHistory: noHistory, + NoColor: noColor, + Augmented: augmented, }, }, nil } @@ -462,50 +502,92 @@ func (a App) tmuxHasSession(info tmux.Info) bool { return tmux.Layout{}.HasSession(info) } -func (a App) runLayout(runtimeSession session.Session, command session.Command) error { +func (a App) startLayout(runtimeSession session.Session, commandRunner []string, gameRunner []string) (tmux.Info, error) { + if a.StartLayout != nil { + return a.StartLayout(runtimeSession, commandRunner, gameRunner) + } + return tmux.Layout{}.Start(runtimeSession, commandRunner, gameRunner) +} + +func (a App) attachLayout(info tmux.Info) error { + if a.AttachLayout != nil { + return a.AttachLayout(info) + } + return tmux.Layout{}.Attach(info) +} + +func (a App) serveCommand(ctx context.Context, listener *session.CommandListener, command session.Command) (session.CommandStartup, error) { + if a.ServeCommand != nil { + return a.ServeCommand(ctx, listener, command) + } + return listener.Serve(ctx, command) +} + +func (a App) updateState(runtimeSession session.Session, now time.Time, update func(*session.State)) error { + if a.UpdateState != nil { + return a.UpdateState(runtimeSession, now, update) + } + return session.UpdateState(runtimeSession, now, update) +} + +func (a App) readState(runtimeSession session.Session) (session.State, error) { + if a.ReadState != nil { + return a.ReadState(runtimeSession) + } + return session.ReadState(runtimeSession) +} + +func (a App) runLayout(runtimeSession session.Session, command session.Command) (err error) { if a.RunLayout != nil { return a.RunLayout(runtimeSession, command) } listener, err := session.ListenCommand(runtimeSession) if err != nil { - return err + return a.cleanupStartupFailure(err, runtimeSession, tmux.Info{}, false) } - defer listener.Close() + defer func() { + if closeErr := listener.Close(); closeErr != nil { + err = appendCleanupError(err, fmt.Errorf("close command listener: %w", closeErr)) + } + }() executable, err := os.Executable() if err != nil { - return fmt.Errorf("resolve sidequest executable: %w", err) + return a.cleanupStartupFailure(fmt.Errorf("resolve sidequest executable: %w", err), runtimeSession, tmux.Info{}, false) } - layout := tmux.Layout{} - info, err := layout.Start( + info, err := a.startLayout( runtimeSession, []string{executable, commandRunnerMode, runtimeSession.SocketPath}, []string{executable, gameRunnerMode, runtimeSession.StatePath}, ) if err != nil { - return err + return a.cleanupStartupFailure(err, runtimeSession, info, ownsTmuxInfo(runtimeSession, info)) } - if err := session.UpdateState(runtimeSession, a.now(), func(state *session.State) { + if err := a.updateState(runtimeSession, a.now(), func(state *session.State) { state.TmuxSocket = info.SocketName }); err != nil { - return err + return a.cleanupStartupFailure(err, runtimeSession, info, ownsTmuxInfo(runtimeSession, info)) } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := listener.Serve(ctx, command); err != nil { - return err + startup, err := a.serveCommand(ctx, listener, command) + if err != nil { + return a.cleanupStartupFailure(err, runtimeSession, info, ownsTmuxInfo(runtimeSession, info)) + } + if startup.Status != session.CommandStartupStarted { + return a.handleStartupTerminal(runtimeSession, info, startup) } - if err := layout.Attach(info); err != nil { - return err + if err := a.attachLayout(info); err != nil { + return a.cleanupStartupFailure(err, runtimeSession, info, ownsTmuxInfo(runtimeSession, info)) } - state, err := session.ReadState(runtimeSession) + state, err := a.readState(runtimeSession) if err != nil { - return err + return a.cleanupStartupFailure(err, runtimeSession, info, ownsTmuxInfo(runtimeSession, info)) } a.printReconnectHint(runtimeSession, state) return a.cleanupClosedSession(session.Record{Session: runtimeSession, State: state}) @@ -515,21 +597,58 @@ func (a App) runCommandRunner(socketPath string) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - receive := a.ReceiveCommand + if a.ReceiveCommand != nil { + command, err := a.ReceiveCommand(ctx, socketPath) + if err != nil { + return err + } + runtimeSession := session.FromSocketPath(socketPath) + execute := a.ExecCommand + if execute == nil { + execute = commandexec.DefaultExecutor().Run + } + err = execute(runtimeSession, command) + a.publishCommandPaneState(runtimeSession) + return err + } + + receive := a.ReceiveExchange if receive == nil { - receive = session.ReceiveCommand + receive = session.ReceiveCommandExchange } - command, err := receive(ctx, socketPath) + command, exchange, err := receive(ctx, socketPath) if err != nil { return err } + defer exchange.Close() + runtimeSession := session.FromSocketPath(socketPath) execute := a.ExecCommand - if execute == nil { - execute = commandexec.DefaultExecutor().Run + if execute != nil { + if err := exchange.ReportStartup(session.CommandStartup{Status: session.CommandStartupStarted}); err != nil { + return err + } + err := execute(runtimeSession, command) + a.publishCommandPaneState(runtimeSession) + return err } - return execute(runtimeSession, command) + err = commandexec.DefaultExecutor().RunWithStartupReporter(runtimeSession, command, exchange) + a.publishCommandPaneState(runtimeSession) + return err +} + +func (a App) publishCommandPaneState(runtimeSession session.Session) { + state, err := session.ReadState(runtimeSession) + if err != nil { + return + } + record := session.Record{Session: runtimeSession, State: state} + info, owned := ownedInfoFromRecord(record) + if !owned { + return + } + _ = tmux.Layout{}.SetCommandState(info, state) } func (a App) runGameShell(statePath string) error { @@ -562,7 +681,19 @@ func (a App) runGameShell(statePath string) error { } return tmux.Layout{}.GamePaneActive(info) }, - OnQuitTerminal: func() error { + ReadCommandPreview: func() (string, error) { + state, err := session.ReadState(runtimeSession) + if err != nil { + return "", err + } + record := session.Record{Session: runtimeSession, State: state} + info, owned := ownedInfoFromRecord(record) + if !owned { + return "", fmt.Errorf("invalid Sidequest tmux metadata") + } + return tmux.Layout{}.CaptureCommandPreview(info) + }, + UpdatePanePause: func(paused bool) error { state, err := session.ReadState(runtimeSession) if err != nil { return err @@ -572,7 +703,7 @@ func (a App) runGameShell(statePath string) error { if !owned { return nil } - return a.detachClients(info) + return tmux.Layout{}.SetGamePaused(info, paused) }, } if a.RunShell != nil { @@ -682,6 +813,150 @@ func (a App) cleanupSession(runtimeSession session.Session) error { return session.Cleanup(runtimeSession) } +func (a App) cleanupStartupFailure(primary error, runtimeSession session.Session, info tmux.Info, tmuxStarted bool) error { + err := primary + if tmuxStarted { + err = appendCleanupError(err, a.closeTmux(info)) + } + return appendCleanupError(err, a.cleanupSession(runtimeSession)) +} + +func (a App) handleStartupTerminal(runtimeSession session.Session, info tmux.Info, startup session.CommandStartup) error { + if ownsTmuxInfo(runtimeSession, info) && a.tmuxHasSession(info) { + if output, _, err := a.captureCommandPane(info); err == nil && output != "" { + fmt.Fprint(a.outputWriter(), output) + } + } + + primary := startupError(startup) + return a.cleanupStartupFailure(primary, runtimeSession, info, ownsTmuxInfo(runtimeSession, info)) +} + +func ownsTmuxInfo(runtimeSession session.Session, info tmux.Info) bool { + want := "sidequest-" + runtimeSession.ID + return info.SocketName == want && info.SessionName == want +} + +func appendCleanupError(primary error, cleanupErr error) error { + if cleanupErr == nil { + return primary + } + if primary == nil { + return cleanupErr + } + return fmt.Errorf("%w; cleanup failed: %v", primary, cleanupErr) +} + +type exitCoder interface { + ExitCode() int +} + +type commandExitError struct { + message string + code int +} + +func (e commandExitError) Error() string { + return e.message +} + +func (e commandExitError) ExitCode() int { + return e.code +} + +func exitCodeForError(err error) int { + if err == nil { + return 0 + } + var coded exitCoder + if errors.As(err, &coded) { + return coded.ExitCode() + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return commandexec.ExitCodeForError(exitErr) + } + return 2 +} + +func validateCommandExecutable(executable string) error { + if executable == "" { + return session.ErrEmptyExecutable + } + if strings.ContainsRune(executable, os.PathSeparator) { + return validateExplicitExecutable(executable) + } + if _, err := exec.LookPath(executable); err != nil { + return commandExitError{message: fmt.Sprintf("command not found: %s", executable), code: 127} + } + return nil +} + +func validateExplicitExecutable(path string) error { + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return commandExitError{message: fmt.Sprintf("command not found: %s", path), code: 127} + } + if errors.Is(err, os.ErrPermission) { + return commandExitError{message: fmt.Sprintf("cannot start %s: permission denied", path), code: 126} + } + return err + } + if info.IsDir() { + return commandExitError{message: fmt.Sprintf("cannot start %s: is a directory", path), code: 126} + } + if info.Mode().Perm()&0o111 == 0 { + return commandExitError{message: fmt.Sprintf("cannot start %s: permission denied", path), code: 126} + } + return nil +} + +func startupError(startup session.CommandStartup) error { + switch startup.Status { + case session.CommandStartupStartFailed: + return commandExitError{message: startupErrorMessage(startup, "command failed to start"), code: startupFailureExitCode(startup)} + case session.CommandStartupCompleted: + return nil + case session.CommandStartupFailed: + return commandExitError{message: startupErrorMessage(startup, "command exited during startup"), code: startupExitCode(startup, 1)} + case session.CommandStartupInterrupted: + return commandExitError{message: startupErrorMessage(startup, "command interrupted during startup"), code: startupExitCode(startup, 130)} + default: + return fmt.Errorf("unexpected command startup status %q", startup.Status) + } +} + +func startupErrorMessage(startup session.CommandStartup, fallback string) string { + if startup.Error != "" { + return startup.Error + } + if startup.ExitCode != nil { + return fmt.Sprintf("%s with status %d", fallback, *startup.ExitCode) + } + if startup.ExitSignal != "" { + return fmt.Sprintf("%s by signal %s", fallback, startup.ExitSignal) + } + return fallback +} + +func startupFailureExitCode(startup session.CommandStartup) int { + if startup.Error != "" && strings.Contains(strings.ToLower(startup.Error), "permission denied") { + return 126 + } + return 127 +} + +func startupExitCode(startup session.CommandStartup, fallback int) int { + if startup.ExitCode != nil { + return *startup.ExitCode + } + if startup.ExitSignal != "" { + return fallback + } + return fallback +} + func (a App) listRuns() ([]runhistory.Run, error) { if a.ListRuns != nil { return a.ListRuns() diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 4a6c056..843fd7e 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" "strings" "testing" @@ -60,6 +61,44 @@ func TestParseNoHistoryBeforeSeparator(t *testing.T) { } } +func TestParseNoColorBeforeSeparator(t *testing.T) { + t.Setenv("NO_COLOR", "") + result, err := Parse([]string{"--no-color", "--", "sleep", "1"}) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if !result.Config.NoColor { + t.Fatal("NoColor = false, want true") + } + if result.Config.Executable != "sleep" { + t.Fatalf("Executable = %q, want sleep", result.Config.Executable) + } +} + +func TestParseNoColorFromEnvironment(t *testing.T) { + t.Setenv("NO_COLOR", "1") + result, err := Parse([]string{"--", "sleep", "1"}) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if !result.Config.NoColor { + t.Fatal("NoColor = false, want true from NO_COLOR") + } +} + +func TestParseAugmentedBeforeSeparator(t *testing.T) { + result, err := Parse([]string{"--aug", "--", "sleep", "1"}) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + if !result.Config.Augmented { + t.Fatal("Augmented = false, want true") + } + if result.Config.Executable != "sleep" { + t.Fatalf("Executable = %q, want sleep", result.Config.Executable) + } +} + func TestParseRejectsUnknownMode(t *testing.T) { _, err := Parse([]string{"--mode", "arena", "--", "true"}) if err == nil || !strings.Contains(err.Error(), "unknown mode") { @@ -108,6 +147,20 @@ func TestParseRejectsUnknownSidequestOption(t *testing.T) { } } +func TestParseRejectsUnexpectedArgumentBeforeSeparator(t *testing.T) { + _, err := Parse([]string{"qest", "--", "make", "test"}) + if err == nil || !strings.Contains(err.Error(), `unexpected argument "qest" before --`) { + t.Fatalf("Parse error = %v, want unexpected argument before separator", err) + } +} + +func TestParseRejectsMultipleUnexpectedArgumentsBeforeSeparator(t *testing.T) { + _, err := Parse([]string{"foo", "bar", "--", "true"}) + if err == nil || !strings.Contains(err.Error(), `unexpected argument "foo" before --`) { + t.Fatalf("Parse error = %v, want first unexpected argument before separator", err) + } +} + func TestParseRejectsMissingSeparator(t *testing.T) { _, err := Parse([]string{"sleep", "1"}) if !errors.Is(err, ErrMissingSeparator) { @@ -135,7 +188,7 @@ func TestRunHelpDocumentsSeparator(t *testing.T) { t.Fatalf("Run exit code = %d, want 0", code) } - for _, want := range []string{"sidequest [options] -- [arguments...]", "--no-history"} { + for _, want := range []string{"sidequest [options] -- [arguments...]", "--no-history", "--no-color", "--aug"} { if !strings.Contains(out.String(), want) { t.Fatalf("help output missing %q:\n%s", want, out.String()) } @@ -173,6 +226,68 @@ func TestRunCommandStoresNoHistoryChoice(t *testing.T) { } } +func TestRunCommandStoresNoColorChoice(t *testing.T) { + var out bytes.Buffer + base := filepath.Join(t.TempDir(), "sidequest") + manager := session.Manager{BaseDir: base, IDGenerator: fixedID("no-color")} + app := App{ + Out: &out, + Preflight: func() error { return nil }, + CreateSession: func() (session.Session, error) { + return manager.Create() + }, + RunLayout: func(gotSession session.Session, gotCommand session.Command) error { + state, err := session.ReadState(gotSession) + if err != nil { + t.Fatalf("ReadState returned error: %v", err) + } + if !state.NoColor { + t.Fatal("NoColor = false, want true") + } + return nil + }, + } + + code := app.Run([]string{"--no-color", "--", "true"}) + if code != 0 { + t.Fatalf("Run exit code = %d, want 0", code) + } + if out.String() != "" { + t.Fatalf("stdout = %q, want empty", out.String()) + } +} + +func TestRunCommandStoresAugmentedChoice(t *testing.T) { + var out bytes.Buffer + base := filepath.Join(t.TempDir(), "sidequest") + manager := session.Manager{BaseDir: base, IDGenerator: fixedID("augmented")} + app := App{ + Out: &out, + Preflight: func() error { return nil }, + CreateSession: func() (session.Session, error) { + return manager.Create() + }, + RunLayout: func(gotSession session.Session, gotCommand session.Command) error { + state, err := session.ReadState(gotSession) + if err != nil { + t.Fatalf("ReadState returned error: %v", err) + } + if !state.Augmented { + t.Fatal("Augmented = false, want true") + } + return nil + }, + } + + code := app.Run([]string{"--aug", "--", "true"}) + if code != 0 { + t.Fatalf("Run exit code = %d, want 0", code) + } + if out.String() != "" { + t.Fatalf("stdout = %q, want empty", out.String()) + } +} + func TestRunVersion(t *testing.T) { var out bytes.Buffer app := App{Out: &out, Version: "1.2.3"} @@ -301,6 +416,456 @@ func TestRunCommandStoresSelectedGameMode(t *testing.T) { } } +func TestRunCommandRejectsMissingPATHExecutableBeforeCreatingSession(t *testing.T) { + var stderr bytes.Buffer + createSessionCalled := false + app := App{ + Err: &stderr, + Preflight: func() error { + t.Fatal("Preflight should not run for missing command") + return nil + }, + CreateSession: func() (session.Session, error) { + createSessionCalled = true + return session.Session{}, nil + }, + } + + code := app.Run([]string{"--", "sidequest-command-that-does-not-exist"}) + if code != 127 { + t.Fatalf("Run exit code = %d, want 127", code) + } + if createSessionCalled { + t.Fatal("CreateSession was called after missing command validation") + } + if !strings.Contains(stderr.String(), "command not found: sidequest-command-that-does-not-exist") { + t.Fatalf("stderr = %q, want command not found", stderr.String()) + } +} + +func TestRunCommandRejectsMissingExplicitPathBeforeCreatingSession(t *testing.T) { + var stderr bytes.Buffer + missing := filepath.Join(t.TempDir(), "missing-script") + app := App{ + Err: &stderr, + Preflight: func() error { return nil }, + CreateSession: func() (session.Session, error) { + t.Fatal("CreateSession should not run for missing explicit path") + return session.Session{}, nil + }, + } + + code := app.Run([]string{"--", missing}) + if code != 127 { + t.Fatalf("Run exit code = %d, want 127", code) + } + if !strings.Contains(stderr.String(), "command not found: "+missing) { + t.Fatalf("stderr = %q, want explicit missing path", stderr.String()) + } +} + +func TestRunCommandRejectsDirectoryAndNonExecutableBeforeCreatingSession(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "script.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\nexit 0\n"), 0o600); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + for _, test := range []struct { + name string + path string + want string + }{ + {name: "directory", path: dir, want: "is a directory"}, + {name: "non-executable", path: script, want: "permission denied"}, + } { + t.Run(test.name, func(t *testing.T) { + var stderr bytes.Buffer + app := App{ + Err: &stderr, + Preflight: func() error { return nil }, + CreateSession: func() (session.Session, error) { + t.Fatal("CreateSession should not run for invalid executable") + return session.Session{}, nil + }, + } + + code := app.Run([]string{"--", test.path}) + if code != 126 { + t.Fatalf("Run exit code = %d, want 126", code) + } + if !strings.Contains(stderr.String(), test.want) { + t.Fatalf("stderr = %q, want %q", stderr.String(), test.want) + } + }) + } +} + +func TestRunCommandCleansRuntimeSessionWhenInitialStateUpdateFails(t *testing.T) { + var stderr bytes.Buffer + base := filepath.Join(t.TempDir(), "sidequest") + manager := session.Manager{BaseDir: base, IDGenerator: fixedID("initial-state-fail")} + var runtimeSession session.Session + + app := App{ + Err: &stderr, + Preflight: func() error { return nil }, + CreateSession: func() (session.Session, error) { + var err error + runtimeSession, err = manager.Create() + return runtimeSession, err + }, + UpdateState: func(session.Session, time.Time, func(*session.State)) error { + return fmt.Errorf("write options failed") + }, + RunLayout: func(session.Session, session.Command) error { + t.Fatal("RunLayout should not start after initial state failure") + return nil + }, + } + + code := app.Run([]string{"--mode=quest", "--", "true"}) + if code != 2 { + t.Fatalf("Run exit code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "write options failed") { + t.Fatalf("stderr = %q, want original state update error", stderr.String()) + } + assertSessionDirRemoved(t, runtimeSession) +} + +func TestRunCommandCustomLayoutFailureCleansRuntimeSession(t *testing.T) { + var stderr bytes.Buffer + base := filepath.Join(t.TempDir(), "sidequest") + manager := session.Manager{BaseDir: base, IDGenerator: fixedID("custom-layout-fail")} + var runtimeSession session.Session + + app := App{ + Err: &stderr, + Preflight: func() error { return nil }, + CreateSession: func() (session.Session, error) { + var err error + runtimeSession, err = manager.Create() + return runtimeSession, err + }, + RunLayout: func(session.Session, session.Command) error { + return fmt.Errorf("layout failed") + }, + } + + code := app.Run([]string{"--", "true"}) + if code != 2 { + t.Fatalf("Run exit code = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "layout failed") { + t.Fatalf("stderr = %q, want original layout error", stderr.String()) + } + assertSessionDirRemoved(t, runtimeSession) +} + +func TestRunLayoutStartFailureCleansRuntimeSessionAndCommandSocket(t *testing.T) { + runtimeSession := createCLITestSession(t, "layout-start-fail") + app := App{ + StartLayout: func(session.Session, []string, []string) (tmux.Info, error) { + return tmux.Info{}, fmt.Errorf("start layout failed") + }, + CloseTmux: func(tmux.Info) error { + t.Fatal("CloseTmux should not run before layout ownership is established") + return nil + }, + } + + err := app.runLayout(runtimeSession, session.Command{Executable: "true"}) + if err == nil || !strings.Contains(err.Error(), "start layout failed") { + t.Fatalf("runLayout error = %v, want original start error", err) + } + assertSessionDirRemoved(t, runtimeSession) + assertSocketRemoved(t, runtimeSession) +} + +func TestRunLayoutStateMetadataFailureClosesOwnedTmuxAndCleansRuntimeSession(t *testing.T) { + runtimeSession := createCLITestSession(t, "state-metadata-fail") + ownedInfo := tmux.Info{SocketName: "sidequest-state-metadata-fail", SessionName: "sidequest-state-metadata-fail"} + var closed []tmux.Info + + app := App{ + StartLayout: func(session.Session, []string, []string) (tmux.Info, error) { + return ownedInfo, nil + }, + UpdateState: func(session.Session, time.Time, func(*session.State)) error { + return fmt.Errorf("store tmux metadata failed") + }, + CloseTmux: func(info tmux.Info) error { + closed = append(closed, info) + return nil + }, + } + + err := app.runLayout(runtimeSession, session.Command{Executable: "true"}) + if err == nil || !strings.Contains(err.Error(), "store tmux metadata failed") { + t.Fatalf("runLayout error = %v, want original metadata error", err) + } + if got, want := closed, []tmux.Info{ownedInfo}; !equalTmuxInfos(got, want) { + t.Fatalf("closed tmux = %#v, want %#v", got, want) + } + assertSessionDirRemoved(t, runtimeSession) +} + +func TestRunLayoutStartupFailureDoesNotCloseUnownedTmuxInfo(t *testing.T) { + runtimeSession := createCLITestSession(t, "unowned-tmux") + app := App{ + StartLayout: func(session.Session, []string, []string) (tmux.Info, error) { + return tmux.Info{SocketName: "external", SessionName: "external"}, nil + }, + ServeCommand: func(context.Context, *session.CommandListener, session.Command) (session.CommandStartup, error) { + return session.CommandStartup{}, fmt.Errorf("command handoff failed") + }, + CloseTmux: func(tmux.Info) error { + t.Fatal("CloseTmux should not run for unowned tmux metadata") + return nil + }, + } + + err := app.runLayout(runtimeSession, session.Command{Executable: "true"}) + if err == nil || !strings.Contains(err.Error(), "command handoff failed") { + t.Fatalf("runLayout error = %v, want original handoff error", err) + } + assertSessionDirRemoved(t, runtimeSession) +} + +func TestRunLayoutCommandHandoffFailureClosesOwnedTmuxCleansRuntimeSessionAndSocket(t *testing.T) { + runtimeSession := createCLITestSession(t, "handoff-fail") + ownedInfo := tmux.Info{SocketName: "sidequest-handoff-fail", SessionName: "sidequest-handoff-fail"} + var closed []tmux.Info + + app := App{ + StartLayout: func(session.Session, []string, []string) (tmux.Info, error) { + return ownedInfo, nil + }, + ServeCommand: func(context.Context, *session.CommandListener, session.Command) (session.CommandStartup, error) { + return session.CommandStartup{}, fmt.Errorf("command handoff failed") + }, + CloseTmux: func(info tmux.Info) error { + closed = append(closed, info) + return nil + }, + } + + err := app.runLayout(runtimeSession, session.Command{Executable: "true"}) + if err == nil || !strings.Contains(err.Error(), "command handoff failed") { + t.Fatalf("runLayout error = %v, want original handoff error", err) + } + if got, want := closed, []tmux.Info{ownedInfo}; !equalTmuxInfos(got, want) { + t.Fatalf("closed tmux = %#v, want %#v", got, want) + } + assertSessionDirRemoved(t, runtimeSession) + assertSocketRemoved(t, runtimeSession) +} + +func TestRunLayoutStartFailedStatusClosesOwnedTmuxAndReturnsCommandExitCode(t *testing.T) { + runtimeSession := createCLITestSession(t, "start-status-fail") + ownedInfo := tmux.Info{SocketName: "sidequest-start-status-fail", SessionName: "sidequest-start-status-fail"} + var closed []tmux.Info + app := App{ + StartLayout: func(session.Session, []string, []string) (tmux.Info, error) { + return ownedInfo, nil + }, + ServeCommand: func(context.Context, *session.CommandListener, session.Command) (session.CommandStartup, error) { + return session.CommandStartup{Status: session.CommandStartupStartFailed, Error: "fork/exec ./bad: no such file or directory"}, nil + }, + TmuxHasSession: func(tmux.Info) bool { return true }, + CapturePane: func(tmux.Info) (string, bool, error) { + return "", false, nil + }, + CloseTmux: func(info tmux.Info) error { + closed = append(closed, info) + return nil + }, + } + + err := app.runLayout(runtimeSession, session.Command{Executable: "true"}) + if err == nil || !strings.Contains(err.Error(), "fork/exec ./bad") { + t.Fatalf("runLayout error = %v, want start failure", err) + } + if got := exitCodeForError(err); got != 127 { + t.Fatalf("exit code = %d, want 127", got) + } + if got, want := closed, []tmux.Info{ownedInfo}; !equalTmuxInfos(got, want) { + t.Fatalf("closed tmux = %#v, want %#v", got, want) + } + assertSessionDirRemoved(t, runtimeSession) +} + +func TestRunLayoutImmediateZeroExitCleansOwnedResourcesWithoutAttach(t *testing.T) { + runtimeSession := createCLITestSession(t, "startup-zero") + ownedInfo := tmux.Info{SocketName: "sidequest-startup-zero", SessionName: "sidequest-startup-zero"} + var closed []tmux.Info + app := App{ + StartLayout: func(session.Session, []string, []string) (tmux.Info, error) { + return ownedInfo, nil + }, + ServeCommand: func(context.Context, *session.CommandListener, session.Command) (session.CommandStartup, error) { + exitCode := 0 + return session.CommandStartup{Status: session.CommandStartupCompleted, ExitCode: &exitCode}, nil + }, + AttachLayout: func(tmux.Info) error { + t.Fatal("AttachLayout should not run after immediate command exit") + return nil + }, + TmuxHasSession: func(tmux.Info) bool { return true }, + CapturePane: func(tmux.Info) (string, bool, error) { + return "", false, nil + }, + CloseTmux: func(info tmux.Info) error { + closed = append(closed, info) + return nil + }, + } + + if err := app.runLayout(runtimeSession, session.Command{Executable: "true"}); err != nil { + t.Fatalf("runLayout returned error: %v", err) + } + if got, want := closed, []tmux.Info{ownedInfo}; !equalTmuxInfos(got, want) { + t.Fatalf("closed tmux = %#v, want %#v", got, want) + } + assertSessionDirRemoved(t, runtimeSession) +} + +func TestRunLayoutImmediateNonZeroExitCleansOwnedResourcesAndReturnsExitCode(t *testing.T) { + runtimeSession := createCLITestSession(t, "startup-exit7") + ownedInfo := tmux.Info{SocketName: "sidequest-startup-exit7", SessionName: "sidequest-startup-exit7"} + var closed []tmux.Info + app := App{ + StartLayout: func(session.Session, []string, []string) (tmux.Info, error) { + return ownedInfo, nil + }, + ServeCommand: func(context.Context, *session.CommandListener, session.Command) (session.CommandStartup, error) { + exitCode := 7 + return session.CommandStartup{Status: session.CommandStartupFailed, ExitCode: &exitCode}, nil + }, + AttachLayout: func(tmux.Info) error { + t.Fatal("AttachLayout should not run after immediate command exit") + return nil + }, + TmuxHasSession: func(tmux.Info) bool { return true }, + CapturePane: func(tmux.Info) (string, bool, error) { + return "", false, nil + }, + CloseTmux: func(info tmux.Info) error { + closed = append(closed, info) + return nil + }, + } + + err := app.runLayout(runtimeSession, session.Command{Executable: "true"}) + if err == nil || !strings.Contains(err.Error(), "status 7") { + t.Fatalf("runLayout error = %v, want startup exit status", err) + } + if got := exitCodeForError(err); got != 7 { + t.Fatalf("exit code = %d, want 7", got) + } + if got, want := closed, []tmux.Info{ownedInfo}; !equalTmuxInfos(got, want) { + t.Fatalf("closed tmux = %#v, want %#v", got, want) + } + assertSessionDirRemoved(t, runtimeSession) +} + +func TestRunLayoutAttachFailureClosesOwnedTmuxAndCleansRuntimeSession(t *testing.T) { + runtimeSession := createCLITestSession(t, "attach-fail") + ownedInfo := tmux.Info{SocketName: "sidequest-attach-fail", SessionName: "sidequest-attach-fail"} + var closed []tmux.Info + + app := App{ + StartLayout: func(session.Session, []string, []string) (tmux.Info, error) { + return ownedInfo, nil + }, + ServeCommand: func(context.Context, *session.CommandListener, session.Command) (session.CommandStartup, error) { + return session.CommandStartup{Status: session.CommandStartupStarted}, nil + }, + AttachLayout: func(tmux.Info) error { + return fmt.Errorf("attach failed") + }, + CloseTmux: func(info tmux.Info) error { + closed = append(closed, info) + return nil + }, + } + + err := app.runLayout(runtimeSession, session.Command{Executable: "true"}) + if err == nil || !strings.Contains(err.Error(), "attach failed") { + t.Fatalf("runLayout error = %v, want original attach error", err) + } + if got, want := closed, []tmux.Info{ownedInfo}; !equalTmuxInfos(got, want) { + t.Fatalf("closed tmux = %#v, want %#v", got, want) + } + assertSessionDirRemoved(t, runtimeSession) +} + +func TestRunLayoutFinalStateReadFailureClosesOwnedTmuxAndCleansRuntimeSession(t *testing.T) { + runtimeSession := createCLITestSession(t, "final-read-fail") + ownedInfo := tmux.Info{SocketName: "sidequest-final-read-fail", SessionName: "sidequest-final-read-fail"} + var closed []tmux.Info + + app := App{ + StartLayout: func(session.Session, []string, []string) (tmux.Info, error) { + return ownedInfo, nil + }, + ServeCommand: func(context.Context, *session.CommandListener, session.Command) (session.CommandStartup, error) { + return session.CommandStartup{Status: session.CommandStartupStarted}, nil + }, + AttachLayout: func(tmux.Info) error { + return nil + }, + ReadState: func(session.Session) (session.State, error) { + return session.State{}, fmt.Errorf("read final state failed") + }, + CloseTmux: func(info tmux.Info) error { + closed = append(closed, info) + return nil + }, + } + + err := app.runLayout(runtimeSession, session.Command{Executable: "true"}) + if err == nil || !strings.Contains(err.Error(), "read final state failed") { + t.Fatalf("runLayout error = %v, want original read error", err) + } + if got, want := closed, []tmux.Info{ownedInfo}; !equalTmuxInfos(got, want) { + t.Fatalf("closed tmux = %#v, want %#v", got, want) + } + assertSessionDirRemoved(t, runtimeSession) +} + +func TestRunLayoutPreservesRunningSessionAfterSuccessfulAttach(t *testing.T) { + runtimeSession := createCLITestSession(t, "still-running") + ownedInfo := tmux.Info{SocketName: "sidequest-still-running", SessionName: "sidequest-still-running"} + app := App{ + StartLayout: func(session.Session, []string, []string) (tmux.Info, error) { + return ownedInfo, nil + }, + ServeCommand: func(context.Context, *session.CommandListener, session.Command) (session.CommandStartup, error) { + return session.CommandStartup{Status: session.CommandStartupStarted}, nil + }, + AttachLayout: func(tmux.Info) error { + return nil + }, + CloseTmux: func(tmux.Info) error { + t.Fatal("CloseTmux should not run for a detached running session") + return nil + }, + CleanupSession: func(session.Session) error { + t.Fatal("CleanupSession should not run for a detached running session") + return nil + }, + } + + if err := app.runLayout(runtimeSession, session.Command{Executable: "true"}); err != nil { + t.Fatalf("runLayout returned error: %v", err) + } + if _, err := session.ReadState(runtimeSession); err != nil { + t.Fatalf("running session was removed: %v", err) + } +} + func TestRunCommandRunnerReceivesAndExecsCommand(t *testing.T) { var executed session.Command app := App{ @@ -373,6 +938,9 @@ func TestRunGameShellConfiguresProductionRandomSource(t *testing.T) { if captured.ReadFocus == nil { t.Fatal("game shell ReadFocus = nil") } + if captured.ReadCommandPreview == nil { + t.Fatal("game shell ReadCommandPreview = nil") + } } func TestRunListShowsMetadataWithoutCommandArguments(t *testing.T) { @@ -926,3 +1494,49 @@ func fixedID(id string) func() (string, error) { return id, nil } } + +func createCLITestSession(t *testing.T, id string) session.Session { + t.Helper() + base, err := os.MkdirTemp("", "sq-cli-") + if err != nil { + t.Fatalf("MkdirTemp returned error: %v", err) + } + t.Cleanup(func() { + _ = os.RemoveAll(base) + }) + manager := session.Manager{BaseDir: base, IDGenerator: fixedID(id)} + runtimeSession, err := manager.Create() + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + return runtimeSession +} + +func assertSessionDirRemoved(t *testing.T, runtimeSession session.Session) { + t.Helper() + if runtimeSession.Dir == "" { + t.Fatal("runtimeSession.Dir is empty") + } + if _, err := os.Stat(runtimeSession.Dir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime dir stat error = %v, want not exist", err) + } +} + +func assertSocketRemoved(t *testing.T, runtimeSession session.Session) { + t.Helper() + if _, err := os.Stat(runtimeSession.SocketPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("socket stat error = %v, want not exist", err) + } +} + +func equalTmuxInfos(a, b []tmux.Info) bool { + if len(a) != len(b) { + return false + } + for index := range a { + if a[index] != b[index] { + return false + } + } + return true +} diff --git a/internal/commandexec/commandexec.go b/internal/commandexec/commandexec.go index 8ff01b6..dc0083d 100644 --- a/internal/commandexec/commandexec.go +++ b/internal/commandexec/commandexec.go @@ -13,12 +13,15 @@ import ( ) type Executor struct { - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Now func() time.Time + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Now func() time.Time + StartupGrace time.Duration } +const defaultStartupGrace = 150 * time.Millisecond + func DefaultExecutor() Executor { return Executor{ Stdin: os.Stdin, @@ -29,29 +32,21 @@ func DefaultExecutor() Executor { } func (e Executor) Run(runtimeSession session.Session, command session.Command) error { + return e.RunWithStartupReporter(runtimeSession, command, nil) +} + +func (e Executor) RunWithStartupReporter(runtimeSession session.Session, command session.Command, reporter StartupReporter) error { if command.Executable == "" { return session.ErrEmptyExecutable } - startedAt := e.now().UTC() - if err := session.UpdateState(runtimeSession, startedAt, func(state *session.State) { - state.Status = session.StatusRunning - state.StartedAt = &startedAt - state.FinishedAt = nil - state.DurationMillis = nil - state.ExitCode = nil - state.ExitSignal = "" - state.StartError = "" - }); err != nil { - return err - } - process := exec.Command(command.Executable, command.Arguments...) process.Stdin = e.Stdin process.Stdout = e.Stdout process.Stderr = e.Stderr if err := process.Start(); err != nil { + startedAt := e.now().UTC() finishedAt := e.now().UTC() durationMillis := finishedAt.Sub(startedAt).Milliseconds() if durationMillis < 0 { @@ -63,6 +58,24 @@ func (e Executor) Run(runtimeSession session.Session, command session.Command) e state.DurationMillis = &durationMillis state.StartError = err.Error() }) + _ = reportStartup(reporter, session.CommandStartup{ + Status: session.CommandStartupStartFailed, + Error: err.Error(), + }) + return err + } + + startedAt := e.now().UTC() + if err := session.UpdateState(runtimeSession, startedAt, func(state *session.State) { + state.Status = session.StatusRunning + state.StartedAt = &startedAt + state.FinishedAt = nil + state.DurationMillis = nil + state.ExitCode = nil + state.ExitSignal = "" + state.StartError = "" + }); err != nil { + _ = process.Process.Kill() return err } @@ -70,20 +83,48 @@ func (e Executor) Run(runtimeSession session.Session, command session.Command) e signal.Notify(signals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) defer signal.Stop(signals) - done := make(chan struct{}) + signalDone := make(chan struct{}) go func() { for { select { case <-signals: - case <-done: + case <-signalDone: return } } }() + defer close(signalDone) + + waitErrc := make(chan error, 1) + go func() { + waitErrc <- process.Wait() + }() + + if grace := e.startupGrace(); grace <= 0 { + if err := reportStartup(reporter, session.CommandStartup{Status: session.CommandStartupStarted}); err != nil { + _ = process.Process.Kill() + <-waitErrc + return err + } + } else { + timer := time.NewTimer(grace) + select { + case waitErr := <-waitErrc: + return e.recordFinished(runtimeSession, startedAt, waitErr, reporter) + case <-timer.C: + if err := reportStartup(reporter, session.CommandStartup{Status: session.CommandStartupStarted}); err != nil { + _ = process.Process.Kill() + <-waitErrc + return err + } + } + } - waitErr := process.Wait() - close(done) + waitErr := <-waitErrc + return e.recordFinished(runtimeSession, startedAt, waitErr, nil) +} +func (e Executor) recordFinished(runtimeSession session.Session, startedAt time.Time, waitErr error, reporter StartupReporter) error { finishedAt := e.now().UTC() durationMillis := finishedAt.Sub(startedAt).Milliseconds() if durationMillis < 0 { @@ -101,9 +142,26 @@ func (e Executor) Run(runtimeSession session.Session, command session.Command) e return err } + if reporter != nil { + if err := reportStartup(reporter, startupFromResult(result, waitErr)); err != nil { + return err + } + } + return waitErr } +type StartupReporter interface { + ReportStartup(session.CommandStartup) error +} + +func reportStartup(reporter StartupReporter, startup session.CommandStartup) error { + if reporter == nil { + return nil + } + return reporter.ReportStartup(startup) +} + func ExitCodeForError(err error) int { if err == nil { return 0 @@ -133,6 +191,13 @@ func (e Executor) now() time.Time { return time.Now() } +func (e Executor) startupGrace() time.Duration { + if e.StartupGrace != 0 { + return e.StartupGrace + } + return defaultStartupGrace +} + type result struct { status string exitCode *int @@ -163,3 +228,22 @@ func resultFromWaitError(err error) result { return result{status: session.StatusFailed, exitSignal: fmt.Sprintf("unknown: %v", err)} } + +func startupFromResult(result result, err error) session.CommandStartup { + startup := session.CommandStartup{ + ExitCode: result.exitCode, + ExitSignal: result.exitSignal, + } + switch result.status { + case session.StatusCompleted: + startup.Status = session.CommandStartupCompleted + case session.StatusInterrupted: + startup.Status = session.CommandStartupInterrupted + default: + startup.Status = session.CommandStartupFailed + } + if err != nil { + startup.Error = err.Error() + } + return startup +} diff --git a/internal/commandexec/commandexec_test.go b/internal/commandexec/commandexec_test.go index 3b75785..5451db3 100644 --- a/internal/commandexec/commandexec_test.go +++ b/internal/commandexec/commandexec_test.go @@ -69,6 +69,87 @@ func TestRunRecordsStartFailure(t *testing.T) { if state.StartError == "" { t.Fatal("StartError is empty") } + if state.Status == session.StatusRunning { + t.Fatal("Status was set to running before process start succeeded") + } +} + +func TestRunReportsStartedAfterStartupGrace(t *testing.T) { + runtimeSession := newTestSession(t, "started") + reporter := &startupRecorder{} + executor := Executor{StartupGrace: 10 * time.Millisecond, Now: fixedClock( + time.Now(), + time.Now().Add(250*time.Millisecond), + )} + + err := executor.RunWithStartupReporter(runtimeSession, session.Command{ + Executable: "bash", + Arguments: []string{"-c", "sleep 0.05"}, + }, reporter) + if err != nil { + t.Fatalf("RunWithStartupReporter returned error: %v", err) + } + if reporter.startup.Status != session.CommandStartupStarted { + t.Fatalf("startup status = %#v, want started", reporter.startup) + } +} + +func TestRunReportsImmediateZeroExitDuringStartupGrace(t *testing.T) { + runtimeSession := newTestSession(t, "immediate-zero") + reporter := &startupRecorder{} + executor := Executor{StartupGrace: 100 * time.Millisecond, Now: fixedClock(time.Now(), time.Now().Add(time.Millisecond))} + + err := executor.RunWithStartupReporter(runtimeSession, session.Command{Executable: "true"}, reporter) + if err != nil { + t.Fatalf("RunWithStartupReporter returned error: %v", err) + } + if reporter.startup.Status != session.CommandStartupCompleted { + t.Fatalf("startup status = %#v, want completed", reporter.startup) + } + if reporter.startup.ExitCode == nil || *reporter.startup.ExitCode != 0 { + t.Fatalf("startup exit code = %v, want 0", reporter.startup.ExitCode) + } +} + +func TestRunReportsImmediateNonZeroExitDuringStartupGrace(t *testing.T) { + runtimeSession := newTestSession(t, "immediate-fail") + reporter := &startupRecorder{} + executor := Executor{StartupGrace: 100 * time.Millisecond, Now: fixedClock(time.Now(), time.Now().Add(time.Millisecond))} + + err := executor.RunWithStartupReporter(runtimeSession, session.Command{ + Executable: "bash", + Arguments: []string{"-c", "exit 7"}, + }, reporter) + if err == nil { + t.Fatal("RunWithStartupReporter succeeded, want exit error") + } + if reporter.startup.Status != session.CommandStartupFailed { + t.Fatalf("startup status = %#v, want failed", reporter.startup) + } + if reporter.startup.ExitCode == nil || *reporter.startup.ExitCode != 7 { + t.Fatalf("startup exit code = %v, want 7", reporter.startup.ExitCode) + } +} + +func TestRunReportsBadShebangStartFailure(t *testing.T) { + runtimeSession := newTestSession(t, "bad-shebang") + script := filepath.Join(t.TempDir(), "bad-shebang") + if err := os.WriteFile(script, []byte("#!/definitely/missing/sidequest-interpreter\n"), 0o700); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + reporter := &startupRecorder{} + executor := Executor{StartupGrace: 100 * time.Millisecond, Now: fixedClock(time.Now(), time.Now().Add(time.Millisecond))} + + err := executor.RunWithStartupReporter(runtimeSession, session.Command{Executable: script}, reporter) + if err == nil { + t.Fatal("RunWithStartupReporter succeeded, want start failure") + } + if reporter.startup.Status != session.CommandStartupStartFailed { + t.Fatalf("startup status = %#v, want start_failed", reporter.startup) + } + if reporter.startup.Error == "" { + t.Fatal("startup error is empty") + } } func TestRunPreservesArgumentsWithSpaces(t *testing.T) { @@ -167,3 +248,12 @@ func fixedClock(times ...time.Time) func() time.Time { return value } } + +type startupRecorder struct { + startup session.CommandStartup +} + +func (r *startupRecorder) ReportStartup(startup session.CommandStartup) error { + r.startup = startup + return nil +} diff --git a/internal/game/heat.go b/internal/game/heat.go index 965f746..028b08f 100644 --- a/internal/game/heat.go +++ b/internal/game/heat.go @@ -6,9 +6,8 @@ import ( ) const ( - baseFoodScore = 10 - heatWarningWindow = 5 * time.Second - restartCatchUpEvery = 20 * time.Second + baseFoodScore = 10 + heatWarningWindow = 5 * time.Second ) type HeatLevel struct { @@ -65,34 +64,6 @@ func UpcomingHeat(elapsed time.Duration) (HeatLevel, time.Duration, bool) { return defaultHeatCurve[len(defaultHeatCurve)-1], 0, false } -func RestartStartHeat(commandHeatLevel int) int { - if commandHeatLevel <= 2 { - return commandHeatLevel - } - start := commandHeatLevel - 2 - if start < 1 { - return 1 - } - return start -} - -func RestartRampHeat(commandHeatLevel int, restartStartLevel int, roundElapsed time.Duration) int { - if commandHeatLevel < 1 { - commandHeatLevel = 1 - } - if restartStartLevel < 1 { - restartStartLevel = 1 - } - if roundElapsed < 0 { - roundElapsed = 0 - } - level := restartStartLevel + int(roundElapsed/restartCatchUpEvery) - if level > commandHeatLevel { - return commandHeatLevel - } - return level -} - func (h HeatLevel) ScoreAward(base int) int { if base < 0 { base = 0 diff --git a/internal/game/heat_test.go b/internal/game/heat_test.go index 263b17b..f5a8974 100644 --- a/internal/game/heat_test.go +++ b/internal/game/heat_test.go @@ -65,36 +65,6 @@ func TestHeatScoreAwardUsesFixedPointMultiplier(t *testing.T) { } } -func TestRestartHeatRampCatchesUpWithoutExceedingCommandHeat(t *testing.T) { - if got := RestartStartHeat(1); got != 1 { - t.Fatalf("RestartStartHeat(1) = %d, want 1", got) - } - if got := RestartStartHeat(2); got != 2 { - t.Fatalf("RestartStartHeat(2) = %d, want 2", got) - } - if got := RestartStartHeat(6); got != 4 { - t.Fatalf("RestartStartHeat(6) = %d, want 4", got) - } - - start := RestartStartHeat(6) - tests := []struct { - elapsed time.Duration - want int - }{ - {0, 4}, - {19*time.Second + 999*time.Millisecond, 4}, - {20 * time.Second, 5}, - {39*time.Second + 999*time.Millisecond, 5}, - {40 * time.Second, 6}, - {10 * time.Minute, 6}, - } - for _, test := range tests { - if got := RestartRampHeat(6, start, test.elapsed); got != test.want { - t.Fatalf("RestartRampHeat elapsed %s = %d, want %d", test.elapsed, got, test.want) - } - } -} - func TestUpcomingHeatWarnsBeforeNextThreshold(t *testing.T) { next, remaining, ok := UpcomingHeat(25 * time.Second) if !ok || next.Level != 2 || remaining != 5*time.Second { diff --git a/internal/game/quest.go b/internal/game/quest.go index 9901fa0..eaa6313 100644 --- a/internal/game/quest.go +++ b/internal/game/quest.go @@ -9,10 +9,19 @@ const ( GameModeClassic = "classic" GameModeQuest = "quest" - comboWindow = 8 * time.Second - goldenByteTTL = 12 * time.Second - goldenByteBase = 100 - pickupTTL = 12 * time.Second + comboWindow = 8 * time.Second + minComboWindow = 5 * time.Second + maxComboWindow = 12 * time.Second + goldenByteTTL = 20 * time.Second + goldenByteBase = 100 + pickupTTL = 20 * time.Second + pickupSpawnMin = 4 + pickupSpawnMax = 8 + goldenSpawnMin = 6 + goldenSpawnMax = 9 + // A Golden Byte is an explicit high-value risk/reward pickup: the move + // itself grows once, then tail growth attempts to reach this total. + goldenByteGrowth = 2 shieldDuration = 30 * time.Second phaseDuration = 20 * time.Second slowClockDuration = 15 * time.Second @@ -25,7 +34,8 @@ const ( maxComboBonusUnit = 100 maxHeatBonusUnit = 100 normalMinimumSpeed = 180 * time.Millisecond - turboMinimumSpeed = 70 * time.Millisecond + turboMinimumSpeed = 55 * time.Millisecond + turboSpeedPermille = 650 turboScorePermille = 1250 doubleScoreCharges = 3 doubleScoreCap = 6 @@ -143,6 +153,7 @@ type QuestState struct { NormalFood int GoldenCollected int Golden GoldenByte + NextGoldenFood int Mission Mission MissionProgress int @@ -151,6 +162,7 @@ type QuestState struct { MaxHeat int Pickup UpgradePickup + NextPickupFood int Shield TimedCharge Phase TimedCharge Warp TimedCharge @@ -187,10 +199,16 @@ func (q *QuestState) OnNormalFood(game *SnakeGame, heat HeatLevel, now time.Time if !q.Enabled() || q.Completed { return } + if q.NextPickupFood == 0 { + q.scheduleNextPickup(q.NormalFood) + } + if q.NextGoldenFood == 0 { + q.scheduleNextGolden(q.NormalFood) + } q.expireEffects(now) q.refreshCombo(now) q.Combo++ - q.ComboExpiresAt = now.Add(comboWindow) + q.ComboExpiresAt = now.Add(comboDuration(q.Combo)) if q.Combo > q.MaxCombo { q.MaxCombo = q.Combo } @@ -206,8 +224,10 @@ func (q *QuestState) OnNormalFood(game *SnakeGame, heat HeatLevel, now time.Time q.BaseScore += score game.Score = q.BaseScore q.updateMission(heat, now, false) - if q.NormalFood%5 == 0 && !q.Pickup.Active { - q.spawnPickup(game, now) + if q.NormalFood >= q.NextPickupFood && !q.Pickup.Active { + if q.spawnPickup(game, now) { + q.scheduleNextPickup(q.NormalFood) + } } q.maybeSpawnGolden(game, now) } @@ -219,7 +239,7 @@ func (q *QuestState) OnGoldenByte(game *SnakeGame, heat HeatLevel, now time.Time q.expireEffects(now) q.refreshCombo(now) q.Combo++ - q.ComboExpiresAt = now.Add(comboWindow) + q.ComboExpiresAt = now.Add(comboDuration(q.Combo)) if q.Combo > q.MaxCombo { q.MaxCombo = q.Combo } @@ -248,10 +268,7 @@ func (q *QuestState) OnPickupCollected(game *SnakeGame, heat HeatLevel, now time q.SlowUntil = now.Add(slowClockDuration) q.notice("PICKUP: SLOW CLOCK", now) case UpgradeDoubleScore: - q.DoubleCharges += doubleScoreCharges - if q.DoubleCharges > doubleScoreCap { - q.DoubleCharges = doubleScoreCap - } + q.DoubleCharges = doubleScoreCharges q.DoubleUntil = now.Add(doubleScoreDuration) q.notice("PICKUP: DOUBLE SCORE", now) case UpgradePatch: @@ -305,18 +322,20 @@ func (q *QuestState) TryCollisionEffects(game *SnakeGame, result StepResult, now q.expireEffects(now) if q.Phase.Charges > 0 && now.Before(q.Phase.ExpiresAt) && game.PhaseForward() { q.Phase = TimedCharge{} + q.ResizeObjects(game) q.notice("PHASE USED", now) return StepMoved } if q.Shield.Charges > 0 && now.Before(q.Shield.ExpiresAt) { q.Shield = TimedCharge{} game.Recover() - q.EnsureFood(game) + q.ResizeObjects(game) q.notice("SHIELD USED", now) return StepMoved } if q.Warp.Charges > 0 && now.Before(q.Warp.ExpiresAt) && game.WarpToFreePoint(q.Rand, append([]Point{game.Food}, q.ActiveObjectPoints()...)) { q.Warp = TimedCharge{} + q.ResizeObjects(game) q.notice("WARP USED", now) return StepMoved } @@ -329,7 +348,7 @@ func (q *QuestState) TryShieldRecovery(game *SnakeGame) bool { } q.Shield = TimedCharge{} game.Recover() - q.EnsureFood(game) + q.ResizeObjects(game) return true } @@ -345,7 +364,7 @@ func (q *QuestState) EffectiveInterval(base time.Duration, now time.Time) time.D } } if !q.TurboUntil.IsZero() && now.Before(q.TurboUntil) { - interval = interval * 85 / 100 + interval = interval * turboSpeedPermille / 1000 if interval < turboMinimumSpeed { interval = turboMinimumSpeed } @@ -416,7 +435,7 @@ func (q *QuestState) refreshCombo(now time.Time) { } q.ComboKeeperUntil = time.Time{} if !now.Before(q.ComboExpiresAt) { - q.ComboExpiresAt = now.Add(comboWindow) + q.ComboExpiresAt = now.Add(comboDuration(q.Combo)) return } } @@ -426,8 +445,22 @@ func (q *QuestState) refreshCombo(now time.Time) { } } +func comboDuration(combo int) time.Duration { + if combo <= 0 { + return minComboWindow + } + duration := time.Duration(4+combo) * time.Second + if duration < minComboWindow { + return minComboWindow + } + if duration > maxComboWindow { + return maxComboWindow + } + return duration +} + func (q *QuestState) maybeSpawnGolden(game *SnakeGame, now time.Time) { - if q.Golden.Active || q.NormalFood == 0 || q.NormalFood%7 != 0 { + if q.Golden.Active || q.NormalFood < q.NextGoldenFood { return } extraOccupied := []Point{game.Food} @@ -439,6 +472,15 @@ func (q *QuestState) maybeSpawnGolden(game *SnakeGame, now time.Time) { return } q.Golden = GoldenByte{Position: point, ExpiresAt: now.Add(goldenByteTTL), Active: true} + q.scheduleNextGolden(q.NormalFood) +} + +func (q *QuestState) scheduleNextPickup(normalFood int) { + q.NextPickupFood = normalFood + randomRange(q.Rand, pickupSpawnMin, pickupSpawnMax) +} + +func (q *QuestState) scheduleNextGolden(normalFood int) { + q.NextGoldenFood = normalFood + randomRange(q.Rand, goldenSpawnMin, goldenSpawnMax) } func (q *QuestState) spawnPickup(game *SnakeGame, now time.Time) bool { @@ -718,7 +760,7 @@ func secondsLeft(deadline time.Time, now time.Time) int { if remaining <= 0 { return 0 } - return int(remaining.Round(time.Second).Seconds()) + return int((remaining + time.Second - time.Nanosecond) / time.Second) } func freePoint(game *SnakeGame, random RandomSource, extraOccupied []Point) (Point, bool) { @@ -762,3 +804,10 @@ func randomIndex(random RandomSource, max int) int { } return value } + +func randomRange(random RandomSource, min int, max int) int { + if max < min { + return min + } + return min + randomIndex(random, max-min+1) +} diff --git a/internal/game/quest_test.go b/internal/game/quest_test.go index e90ec2f..17de6cf 100644 --- a/internal/game/quest_test.go +++ b/internal/game/quest_test.go @@ -1,6 +1,7 @@ package game import ( + "fmt" "reflect" "testing" "time" @@ -25,15 +26,30 @@ func TestQuestComboScoresWithHeatAndExpires(t *testing.T) { } } +func TestQuestComboDurationScalesWithCombo(t *testing.T) { + if got := comboDuration(1); got >= comboWindow { + t.Fatalf("comboDuration(1) = %s, want faster than %s", got, comboWindow) + } + if got := comboDuration(4); got != comboWindow { + t.Fatalf("comboDuration(4) = %s, want baseline %s", got, comboWindow) + } + if got := comboDuration(8); got <= comboWindow { + t.Fatalf("comboDuration(8) = %s, want slower than %s", got, comboWindow) + } + if got := comboDuration(20); got != maxComboWindow { + t.Fatalf("comboDuration(20) = %s, want cap %s", got, maxComboWindow) + } +} + func TestQuestGoldenByteSpawnCollectAndFullBoard(t *testing.T) { now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) game := NewSnakeGame(4, 4, func(int) int { return 0 }) quest := NewQuestState(GameModeQuest, now, fixedRandom(0), 4, 4) - for i := 0; i < 7; i++ { + for i := 0; i < goldenSpawnMin; i++ { quest.OnNormalFood(game, HeatByLevel(1), now.Add(time.Duration(i)*time.Second)) } if !quest.Golden.Active { - t.Fatal("golden byte did not spawn after seventh food") + t.Fatal("golden byte did not spawn after minimum food target") } if quest.Golden.Position == game.Food { t.Fatalf("golden byte spawned on normal food at %#v", quest.Golden.Position) @@ -47,7 +63,7 @@ func TestQuestGoldenByteSpawnCollectAndFullBoard(t *testing.T) { if quest.Golden.Active { t.Fatal("golden byte remained active after timeout") } - for i := 0; i < 7; i++ { + for i := 0; i < goldenSpawnMin; i++ { quest.OnNormalFood(game, HeatByLevel(1), now.Add(time.Duration(20+i)*time.Second)) } quest.OnGoldenByte(game, HeatByLevel(1), now.Add(8*time.Second)) @@ -59,7 +75,7 @@ func TestQuestGoldenByteSpawnCollectAndFullBoard(t *testing.T) { full.Snake = []Point{{X: 0, Y: 0}} full.Food = Point{X: -1, Y: -1} quest = NewQuestState(GameModeQuest, now, fixedRandom(0), 1, 1) - for i := 0; i < 7; i++ { + for i := 0; i < goldenSpawnMin; i++ { quest.OnNormalFood(full, HeatByLevel(1), now) } if quest.Golden.Active { @@ -72,8 +88,10 @@ func TestQuestGoldenByteUsesInjectedRandomSequence(t *testing.T) { game := NewSnakeGame(4, 4, func(int) int { return 0 }) game.Snake = []Point{{X: 1, Y: 1}, {X: 1, Y: 2}} game.Food = Point{X: 0, Y: 0} - random := &sequenceRandom{values: []int{0, 0, 1}} + random := &sequenceRandom{values: []int{0, 0, 0, 2}} quest := NewQuestState(GameModeQuest, now, random, 4, 4) + quest.NextPickupFood = 100 + quest.NextGoldenFood = 7 quest.NormalFood = 6 quest.OnNormalFood(game, HeatByLevel(1), now) @@ -83,6 +101,7 @@ func TestQuestGoldenByteUsesInjectedRandomSequence(t *testing.T) { first := quest.Golden.Position quest.Golden.Active = false + quest.NextGoldenFood = 14 quest.NormalFood = 13 quest.OnNormalFood(game, HeatByLevel(1), now.Add(time.Second)) if !quest.Golden.Active { @@ -123,25 +142,41 @@ func TestQuestMissionSelectionHonorsInjectedIndex(t *testing.T) { } } -func TestQuestPickupSpawnsOnFifthNormalFood(t *testing.T) { +func TestQuestPickupSpawnsOnRandomFoodTarget(t *testing.T) { now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) game := NewSnakeGame(8, 8, func(int) int { return 0 }) game.Snake = []Point{{X: 4, Y: 4}, {X: 3, Y: 4}} game.Food = Point{X: 0, Y: 0} quest := NewQuestState(GameModeQuest, now, &sequenceRandom{values: []int{0, 0, 0}}, 8, 8) - for index := 0; index < 4; index++ { + for index := 0; index < 3; index++ { quest.OnNormalFood(game, HeatByLevel(1), now.Add(time.Duration(index)*time.Second)) if quest.Pickup.Active { - t.Fatalf("pickup spawned after %d foods, want no pickup before fifth", index+1) + t.Fatalf("pickup spawned after %d foods, want no pickup before fourth", index+1) } } - quest.OnNormalFood(game, HeatByLevel(1), now.Add(5*time.Second)) + quest.OnNormalFood(game, HeatByLevel(1), now.Add(4*time.Second)) if !quest.Pickup.Active || quest.Pickup.Upgrade != UpgradeShield { t.Fatalf("pickup = %#v, want active shield", quest.Pickup) } } +func TestQuestPickupAndGoldenSpawnTargetsUseRandomRanges(t *testing.T) { + now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) + game := NewSnakeGame(10, 10, func(int) int { return 0 }) + game.Food = Point{X: 0, Y: 0} + quest := NewQuestState(GameModeQuest, now, &sequenceRandom{values: []int{0, pickupSpawnMax - pickupSpawnMin, goldenSpawnMax - goldenSpawnMin}}, 10, 10) + + quest.OnNormalFood(game, HeatByLevel(1), now) + + if quest.NextPickupFood != pickupSpawnMax { + t.Fatalf("NextPickupFood = %d, want max target %d", quest.NextPickupFood, pickupSpawnMax) + } + if quest.NextGoldenFood != goldenSpawnMax { + t.Fatalf("NextGoldenFood = %d, want max target %d", quest.NextGoldenFood, goldenSpawnMax) + } +} + func TestQuestPickupSelectionCanSelectEachType(t *testing.T) { game := NewSnakeGame(8, 8, func(int) int { return 0 }) game.Snake = []Point{{X: 4, Y: 4}, {X: 3, Y: 4}} @@ -245,24 +280,24 @@ func TestQuestPickupCollectionAppliesEffects(t *testing.T) { } } -func TestQuestDoubleScoreCapTimeoutAndNormalFoodOnly(t *testing.T) { +func TestQuestDoubleScoreRefreshesTimeoutAndNormalFoodOnly(t *testing.T) { now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) game := NewSnakeGame(8, 8, func(int) int { return 0 }) quest := NewQuestState(GameModeQuest, now, fixedRandom(0), 8, 8) quest.DoubleCharges = doubleScoreCap - 1 quest.Pickup = UpgradePickup{Active: true, Upgrade: UpgradeDoubleScore, Position: Point{X: 1, Y: 1}, ExpiresAt: now.Add(pickupTTL)} quest.OnPickupCollected(game, HeatByLevel(1), now) - if quest.DoubleCharges != doubleScoreCap { - t.Fatalf("DoubleCharges = %d, want cap %d", quest.DoubleCharges, doubleScoreCap) + if quest.DoubleCharges != doubleScoreCharges { + t.Fatalf("DoubleCharges = %d, want refreshed charges %d", quest.DoubleCharges, doubleScoreCharges) } quest.OnNormalFood(game, HeatByLevel(1), now.Add(time.Second)) - if quest.DoubleCharges != doubleScoreCap-1 || game.Score != 20 { + if quest.DoubleCharges != doubleScoreCharges-1 || game.Score != 20 { t.Fatalf("after normal food charges=%d score=%d, want one doubled food", quest.DoubleCharges, game.Score) } quest.Golden = GoldenByte{Active: true} quest.OnGoldenByte(game, HeatByLevel(1), now.Add(2*time.Second)) - if quest.DoubleCharges != doubleScoreCap-1 { + if quest.DoubleCharges != doubleScoreCharges-1 { t.Fatalf("Golden Byte consumed Double Score charge, charges=%d", quest.DoubleCharges) } quest.Tick(game, HeatByLevel(1), now.Add(doubleScoreDuration)) @@ -291,8 +326,11 @@ func TestQuestTimedEffectsRefreshAndExpire(t *testing.T) { turboQuest := NewQuestState(GameModeQuest, now, fixedRandom(0), 8, 8) turboQuest.Pickup = UpgradePickup{Active: true, Upgrade: UpgradeTurbo, ExpiresAt: now.Add(pickupTTL)} turboQuest.OnPickupCollected(game, HeatByLevel(1), now) - if got := turboQuest.EffectiveInterval(100*time.Millisecond, now.Add(time.Second)); got < turboMinimumSpeed || got >= 100*time.Millisecond { - t.Fatalf("turbo interval = %s, want faster but safe", got) + if got := turboQuest.EffectiveInterval(180*time.Millisecond, now.Add(time.Second)); got != 117*time.Millisecond { + t.Fatalf("turbo interval = %s, want noticeable speed boost", got) + } + if got := turboQuest.EffectiveInterval(80*time.Millisecond, now.Add(time.Second)); got != turboMinimumSpeed { + t.Fatalf("turbo capped interval = %s, want %s", got, turboMinimumSpeed) } turboQuest.Tick(game, HeatByLevel(1), now.Add(turboDuration)) if !turboQuest.TurboUntil.IsZero() { @@ -339,6 +377,66 @@ func TestQuestEffectHUDPartsKeepsDurationOnlyEffectsReadable(t *testing.T) { } } +func TestQuestEffectHUDPartsCeilActiveCountdowns(t *testing.T) { + now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) + tests := []struct { + name string + remaining time.Duration + want string + }{ + {name: "one and a half seconds", remaining: 1500 * time.Millisecond, want: "2s"}, + {name: "one second", remaining: time.Second, want: "1s"}, + {name: "half second", remaining: 500 * time.Millisecond, want: "1s"}, + {name: "one millisecond", remaining: time.Millisecond, want: "1s"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + quest := NewQuestState(GameModeQuest, now, fixedRandom(0), 8, 8) + deadline := now.Add(test.remaining) + quest.Shield = TimedCharge{Charges: 1, ExpiresAt: deadline} + quest.Phase = TimedCharge{Charges: 1, ExpiresAt: deadline} + quest.Warp = TimedCharge{Charges: 1, ExpiresAt: deadline} + quest.DoubleCharges = 2 + quest.DoubleUntil = deadline + quest.SlowUntil = deadline + quest.ComboKeeperUntil = deadline + quest.TurboUntil = deadline + + got := quest.effectHUDParts(now) + want := []string{ + fmt.Sprintf("SHIELD x1 %s", test.want), + fmt.Sprintf("PHASE x1 %s", test.want), + fmt.Sprintf("WARP x1 %s", test.want), + fmt.Sprintf("DOUBLE x2 %s", test.want), + fmt.Sprintf("SLOW %s", test.want), + fmt.Sprintf("COMBO LOCK %s", test.want), + fmt.Sprintf("TURBO %s", test.want), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("effectHUDParts = %#v, want %#v", got, want) + } + }) + } +} + +func TestQuestEffectHUDPartsHidesExpiredCountdowns(t *testing.T) { + now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) + quest := NewQuestState(GameModeQuest, now, fixedRandom(0), 8, 8) + quest.Shield = TimedCharge{Charges: 1, ExpiresAt: now} + quest.Phase = TimedCharge{Charges: 1, ExpiresAt: now} + quest.Warp = TimedCharge{Charges: 1, ExpiresAt: now} + quest.DoubleCharges = 2 + quest.DoubleUntil = now + quest.SlowUntil = now + quest.ComboKeeperUntil = now + quest.TurboUntil = now + + if got := quest.effectHUDParts(now); len(got) != 0 { + t.Fatalf("effectHUDParts = %#v, want no active countdowns", got) + } +} + func TestQuestComboKeeperFreezesComboExpiry(t *testing.T) { now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) game := NewSnakeGame(8, 8, func(int) int { return 0 }) @@ -381,6 +479,99 @@ func TestQuestCollisionEffectsUsePriority(t *testing.T) { } } +func TestQuestShieldRecoveryPreservesSnakeLength(t *testing.T) { + now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) + game := NewSnakeGame(6, 6, func(int) int { return 0 }) + game.Snake = []Point{{X: 5, Y: 2}, {X: 4, Y: 2}, {X: 3, Y: 2}, {X: 2, Y: 2}} + game.Dir = DirectionRight + quest := NewQuestState(GameModeQuest, now, fixedRandom(0), 6, 6) + quest.Shield = TimedCharge{Charges: 1, ExpiresAt: now.Add(shieldDuration)} + + result := game.Step() + result = quest.TryCollisionEffects(game, result, now) + + if result != StepMoved || game.Over || quest.Shield.Charges != 0 { + t.Fatalf("shield result=%v over=%t charges=%d", result, game.Over, quest.Shield.Charges) + } + if len(game.Snake) != 4 { + t.Fatalf("snake length after shield = %d, want preserved length 4", len(game.Snake)) + } + assertSnakeValid(t, game.Snake, game.Width, game.Height) +} + +func TestQuestShieldRecoveryReconcilesBoardObjects(t *testing.T) { + now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) + recoveredHead := Point{X: 3, Y: 3} + + tests := []struct { + name string + setup func(*SnakeGame, *QuestState) time.Time + check func(*testing.T, *SnakeGame, *QuestState, time.Time) + }{ + { + name: "normal food", + setup: func(game *SnakeGame, quest *QuestState) time.Time { + game.Food = recoveredHead + return time.Time{} + }, + check: func(t *testing.T, game *SnakeGame, quest *QuestState, _ time.Time) { + t.Helper() + if !game.FoodValid(quest.ActiveObjectPoints()) { + t.Fatalf("food invalid after shield recovery: food=%#v snake=%#v", game.Food, game.Snake) + } + }, + }, + { + name: "golden byte", + setup: func(game *SnakeGame, quest *QuestState) time.Time { + expiresAt := now.Add(goldenByteTTL) + quest.Golden = GoldenByte{Active: true, Position: recoveredHead, ExpiresAt: expiresAt} + return expiresAt + }, + check: func(t *testing.T, game *SnakeGame, quest *QuestState, expiresAt time.Time) { + t.Helper() + if !quest.Golden.Active || quest.Golden.ExpiresAt != expiresAt || game.Occupies(quest.Golden.Position) { + t.Fatalf("golden not safely reconciled: golden=%#v snake=%#v", quest.Golden, game.Snake) + } + }, + }, + { + name: "pickup", + setup: func(game *SnakeGame, quest *QuestState) time.Time { + expiresAt := now.Add(pickupTTL) + quest.Pickup = UpgradePickup{Active: true, Upgrade: UpgradeShield, Position: recoveredHead, ExpiresAt: expiresAt} + return expiresAt + }, + check: func(t *testing.T, game *SnakeGame, quest *QuestState, expiresAt time.Time) { + t.Helper() + if !quest.Pickup.Active || quest.Pickup.ExpiresAt != expiresAt || game.Occupies(quest.Pickup.Position) { + t.Fatalf("pickup not safely reconciled: pickup=%#v snake=%#v", quest.Pickup, game.Snake) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + game := NewSnakeGame(6, 6, func(int) int { return 0 }) + game.Snake = []Point{{X: 5, Y: 2}, {X: 4, Y: 2}, {X: 3, Y: 2}, {X: 2, Y: 2}} + game.Dir = DirectionRight + quest := NewQuestState(GameModeQuest, now, fixedRandom(0), 6, 6) + quest.Shield = TimedCharge{Charges: 1, ExpiresAt: now.Add(shieldDuration)} + expiresAt := test.setup(game, quest) + + result := game.Step() + result = quest.TryCollisionEffects(game, result, now) + + if result != StepMoved || game.Over || quest.Shield.Charges != 0 { + t.Fatalf("shield result=%v over=%t charges=%d", result, game.Over, quest.Shield.Charges) + } + assertQuestObjectsValid(t, game, quest) + test.check(t, game, quest, expiresAt) + }) + } +} + func TestQuestPhaseUnsafeFallbackKeepsCollision(t *testing.T) { now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) game := NewSnakeGame(1, 1, func(int) int { return 0 }) @@ -397,6 +588,99 @@ func TestQuestPhaseUnsafeFallbackKeepsCollision(t *testing.T) { } } +func TestQuestPhaseRecoveryReconcilesBoardObjects(t *testing.T) { + now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) + phaseTarget := Point{X: 0, Y: 2} + + tests := []struct { + name string + setup func(*SnakeGame, *QuestState) time.Time + check func(*testing.T, *SnakeGame, *QuestState, time.Time) + }{ + { + name: "normal food", + setup: func(game *SnakeGame, quest *QuestState) time.Time { + game.Food = phaseTarget + return time.Time{} + }, + check: func(t *testing.T, game *SnakeGame, quest *QuestState, _ time.Time) { + t.Helper() + if game.Food == phaseTarget || !game.FoodValid(quest.ActiveObjectPoints()) { + t.Fatalf("food not safely reconciled: food=%#v snake=%#v", game.Food, game.Snake) + } + }, + }, + { + name: "golden byte", + setup: func(game *SnakeGame, quest *QuestState) time.Time { + expiresAt := now.Add(goldenByteTTL) + quest.Golden = GoldenByte{Active: true, Position: phaseTarget, ExpiresAt: expiresAt} + return expiresAt + }, + check: func(t *testing.T, game *SnakeGame, quest *QuestState, expiresAt time.Time) { + t.Helper() + if !quest.Golden.Active || quest.Golden.ExpiresAt != expiresAt || quest.Golden.Position == phaseTarget { + t.Fatalf("golden not safely reconciled: %#v", quest.Golden) + } + }, + }, + { + name: "pickup", + setup: func(game *SnakeGame, quest *QuestState) time.Time { + expiresAt := now.Add(pickupTTL) + quest.Pickup = UpgradePickup{Active: true, Upgrade: UpgradeShield, Position: phaseTarget, ExpiresAt: expiresAt} + return expiresAt + }, + check: func(t *testing.T, game *SnakeGame, quest *QuestState, expiresAt time.Time) { + t.Helper() + if !quest.Pickup.Active || quest.Pickup.ExpiresAt != expiresAt || quest.Pickup.Position == phaseTarget { + t.Fatalf("pickup not safely reconciled: %#v", quest.Pickup) + } + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + game := NewSnakeGame(5, 5, func(int) int { return 0 }) + game.Snake = []Point{{X: 4, Y: 2}, {X: 3, Y: 2}} + game.Dir = DirectionRight + game.Food = Point{X: 1, Y: 1} + quest := NewQuestState(GameModeQuest, now, fixedRandom(0), 5, 5) + quest.Phase = TimedCharge{Charges: 1, ExpiresAt: now.Add(phaseDuration)} + expiresAt := test.setup(game, quest) + + result := game.Step() + result = quest.TryCollisionEffects(game, result, now) + + if result != StepMoved || game.Over || quest.Phase.Charges != 0 || game.Snake[0] != phaseTarget { + t.Fatalf("phase result=%v over=%t charges=%d head=%#v", result, game.Over, quest.Phase.Charges, game.Snake[0]) + } + assertQuestObjectsValid(t, game, quest) + test.check(t, game, quest, expiresAt) + }) + } +} + +func TestQuestObjectReconciliationClearsObjectsWithoutFreeCells(t *testing.T) { + now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) + game := NewSnakeGame(2, 1, func(int) int { return 0 }) + game.Snake = []Point{{X: 0, Y: 0}, {X: 1, Y: 0}} + game.Food = Point{X: 0, Y: 0} + quest := NewQuestState(GameModeQuest, now, fixedRandom(0), 2, 1) + quest.Golden = GoldenByte{Active: true, Position: Point{X: 0, Y: 0}, ExpiresAt: now.Add(goldenByteTTL)} + quest.Pickup = UpgradePickup{Active: true, Upgrade: UpgradeShield, Position: Point{X: 1, Y: 0}, ExpiresAt: now.Add(pickupTTL)} + + quest.ResizeObjects(game) + + if quest.Golden.Active || quest.Pickup.Active { + t.Fatalf("quest objects remained without free cells: golden=%#v pickup=%#v", quest.Golden, quest.Pickup) + } + if game.Food.X >= 0 || game.Food.Y >= 0 { + t.Fatalf("food remained without free cells: %#v", game.Food) + } +} + func TestQuestWarpUsesSafeDestinationAndFallback(t *testing.T) { now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) game := NewSnakeGame(4, 4, func(int) int { return 0 }) @@ -410,6 +694,10 @@ func TestQuestWarpUsesSafeDestinationAndFallback(t *testing.T) { if result != StepMoved || game.Over || quest.Warp.Charges != 0 { t.Fatalf("warp failed: result=%v over=%t charges=%d", result, game.Over, quest.Warp.Charges) } + if len(game.Snake) != 2 { + t.Fatalf("snake length after warp = %d, want preserved length 2", len(game.Snake)) + } + assertSnakeValid(t, game.Snake, game.Width, game.Height) if game.Snake[0].X < 0 || game.Snake[0].X >= game.Width || game.Snake[0].Y < 0 || game.Snake[0].Y >= game.Height { t.Fatalf("warp head out of bounds: %#v", game.Snake[0]) } @@ -639,14 +927,15 @@ func TestStepGameCollectsGoldenBytePreservesNormalFood(t *testing.T) { quest.Pickup = UpgradePickup{Active: true, Upgrade: UpgradeShield, Position: Point{X: 6, Y: 6}, ExpiresAt: now.Add(pickupTTL)} quest.DoubleCharges = 3 quest.DoubleUntil = now.Add(doubleScoreDuration) + beforeLength := len(game.Snake) result := stepGame(game, quest, HeatByLevel(1), now) if result != StepAteFood { t.Fatalf("stepGame result = %v, want golden-byte growth", result) } - if game.Snake[0] != (Point{X: 4, Y: 3}) || len(game.Snake) != 3 { - t.Fatalf("snake = %#v, want grown onto golden byte", game.Snake) + if game.Snake[0] != (Point{X: 4, Y: 3}) || len(game.Snake) != beforeLength+goldenByteGrowth { + t.Fatalf("snake = %#v, want grown by %d onto golden byte", game.Snake, goldenByteGrowth) } if game.Food != (Point{X: 1, Y: 1}) { t.Fatalf("food = %#v, want preserved normal food", game.Food) @@ -673,18 +962,30 @@ func TestStepGameCollectsGoldenByteOnNearlyFullBoard(t *testing.T) { game := NewSnakeGame(3, 2, func(int) int { return 0 }) game.Snake = []Point{{X: 1, Y: 0}, {X: 0, Y: 0}} game.Dir = DirectionRight - game.Food = Point{X: 1, Y: 1} + game.Food = Point{X: 0, Y: 1} quest := NewQuestState(GameModeQuest, now, fixedRandom(0), 3, 2) quest.Mission = Mission{ID: MissionGolden2, Label: "Collect 2 Golden Bytes", Target: 2} quest.Golden = GoldenByte{Active: true, Position: Point{X: 2, Y: 0}, ExpiresAt: now.Add(goldenByteTTL)} - quest.Pickup = UpgradePickup{Active: true, Upgrade: UpgradeShield, Position: Point{X: 2, Y: 1}, ExpiresAt: now.Add(pickupTTL)} + quest.Pickup = UpgradePickup{Active: true, Upgrade: UpgradeShield, Position: Point{X: 1, Y: 1}, ExpiresAt: now.Add(pickupTTL)} + beforeLength := len(game.Snake) result := stepGame(game, quest, HeatByLevel(1), now) if result != StepAteFood { t.Fatalf("stepGame result = %v, want golden-byte growth", result) } - if game.Food != (Point{X: 1, Y: 1}) { + if len(game.Snake) != beforeLength+1 { + t.Fatalf("snake length = %d, want only safe movement growth to %d", len(game.Snake), beforeLength+1) + } + for _, point := range game.Snake { + if point.X < 0 || point.X >= game.Width || point.Y < 0 || point.Y >= game.Height { + t.Fatalf("snake segment outside board: %#v in %#v", point, game.Snake) + } + if point == quest.Pickup.Position || point == game.Food { + t.Fatalf("snake segment overlaps occupied quest object or food: point=%#v pickup=%#v food=%#v snake=%#v", point, quest.Pickup.Position, game.Food, game.Snake) + } + } + if game.Food != (Point{X: 0, Y: 1}) { t.Fatalf("food = %#v, want preserved valid food on nearly full board", game.Food) } if game.Food == quest.Pickup.Position || game.Occupies(game.Food) { @@ -718,6 +1019,31 @@ func (r fixedRandom) Intn(max int) int { return int(r) } +func assertQuestObjectsValid(t *testing.T, game *SnakeGame, quest *QuestState) { + t.Helper() + if !game.FoodValid(quest.ActiveObjectPoints()) { + t.Fatalf("food invalid: food=%#v snake=%#v golden=%#v pickup=%#v", game.Food, game.Snake, quest.Golden, quest.Pickup) + } + if quest.Golden.Active { + extraOccupied := []Point{game.Food} + if quest.Pickup.Active { + extraOccupied = append(extraOccupied, quest.Pickup.Position) + } + if !objectPointValid(game, quest.Golden.Position, extraOccupied) { + t.Fatalf("golden invalid: food=%#v snake=%#v golden=%#v pickup=%#v", game.Food, game.Snake, quest.Golden, quest.Pickup) + } + } + if quest.Pickup.Active { + extraOccupied := []Point{game.Food} + if quest.Golden.Active { + extraOccupied = append(extraOccupied, quest.Golden.Position) + } + if !objectPointValid(game, quest.Pickup.Position, extraOccupied) { + t.Fatalf("pickup invalid: food=%#v snake=%#v golden=%#v pickup=%#v", game.Food, game.Snake, quest.Golden, quest.Pickup) + } + } +} + type sequenceRandom struct { values []int index int diff --git a/internal/game/shell.go b/internal/game/shell.go index 0aaf5f7..c698a27 100644 --- a/internal/game/shell.go +++ b/internal/game/shell.go @@ -3,6 +3,7 @@ package game import ( "context" "fmt" + "os" "strings" "time" @@ -15,9 +16,12 @@ const DefaultPollInterval = 250 * time.Millisecond const DefaultGameInterval = 0 const movementPulseInterval = 10 * time.Millisecond const focusPollInterval = 100 * time.Millisecond +const defaultInitialRenderDelay = 80 * time.Millisecond type StateReader func() (session.State, error) type FocusReader func() (bool, error) +type CommandPreviewReader func() (string, error) +type PanePauseWriter func(paused bool) error type CommandCompletionChoice int @@ -25,7 +29,6 @@ const ( CompletionNone CommandCompletionChoice = iota CompletionUndecided CompletionContinue - CompletionQuit ) type PauseState struct { @@ -78,15 +81,17 @@ func (c PlayClock) Elapsed(now time.Time) time.Duration { } type Shell struct { - NewScreen func() (tcell.Screen, error) - ReadState StateReader - ReadFocus FocusReader - OnQuitTerminal func() error - Random RandomSource - StatsManager StatsManager - PollInterval time.Duration - GameInterval time.Duration - Now func() time.Time + NewScreen func() (tcell.Screen, error) + ReadState StateReader + ReadFocus FocusReader + ReadCommandPreview CommandPreviewReader + UpdatePanePause PanePauseWriter + Random RandomSource + StatsManager StatsManager + PollInterval time.Duration + GameInterval time.Duration + InitialRenderDelay time.Duration + Now func() time.Time } type viewState struct { @@ -96,6 +101,7 @@ type viewState struct { Frozen bool Message string Started bool + NoColor bool Game *SnakeGame CommandHeat HeatLevel FrozenHeat HeatLevel @@ -104,9 +110,8 @@ type viewState struct { MaxHeat int HeatNotice string NoticeUntil time.Time + GraphicsNotice string RoundStarted time.Time - RoundHeat int - RoundCatchUp bool Clock PlayClock GameEpoch time.Time GameTime time.Time @@ -121,6 +126,9 @@ type viewState struct { PendingScore *PendingHighscore StatsMessage string Completion CommandCompletionChoice + CommandPreview string + PanePauseKnown bool + PanePaused bool } type PendingHighscore struct { @@ -150,12 +158,6 @@ func (s Shell) Run(ctx context.Context) error { } defer screen.Fini() - pollInterval := s.PollInterval - if pollInterval <= 0 { - pollInterval = DefaultPollInterval - } - gameIntervalOverride := s.GameInterval - events := make(chan tcell.Event, 8) done := make(chan struct{}) go pollEvents(screen, events, done) @@ -163,6 +165,13 @@ func (s Shell) Run(ctx context.Context) error { close(done) screen.PostEvent(tcell.NewEventInterrupt(nil)) }() + s.syncInitialScreen(screen, events) + + pollInterval := s.PollInterval + if pollInterval <= 0 { + pollInterval = DefaultPollInterval + } + gameIntervalOverride := s.GameInterval ticker := time.NewTicker(pollInterval) defer ticker.Stop() @@ -179,17 +188,20 @@ func (s Shell) Run(ctx context.Context) error { mode := gameMode(state.GameMode) boardWidth, boardHeight := boardSize(screen) view := viewState{ - State: state, - SessionState: state.Status, - Frozen: terminalState(state.Status), - Game: game, - RoundStarted: gameEpoch, - RoundHeat: 1, - GameEpoch: gameEpoch, - GameTime: gameEpoch, - Quest: NewQuestState(mode, gameEpoch, s.Random, boardWidth, boardHeight), + State: state, + SessionState: state.Status, + Frozen: terminalState(state.Status), + NoColor: state.NoColor, + Game: game, + RoundStarted: gameEpoch, + GameEpoch: gameEpoch, + GameTime: gameEpoch, + GraphicsNotice: initialGraphicsNotice(), + Quest: NewQuestState(mode, gameEpoch, s.Random, boardWidth, boardHeight), } s.syncFocus(&view, now, true) + s.syncPanePause(&view) + s.syncCommandPreview(&view) s.syncPlayClock(&view, now) updateViewGameTime(&view, now) updateViewHeat(&view, now) @@ -204,11 +216,8 @@ func (s Shell) Run(ctx context.Context) error { switch typed := event.(type) { case *tcell.EventKey: if view.PendingScore != nil { - quit, handled := s.handlePendingHighscoreKey(&view, typed) + handled := s.handlePendingHighscoreKey(&view, typed) render(screen, view) - if quit { - return s.quit(view) - } if handled { continue } @@ -219,32 +228,24 @@ func (s Shell) Run(ctx context.Context) error { case typed.Key() == tcell.KeyRune && (typed.Rune() == 'c' || typed.Rune() == 'C'): view.Completion = CompletionContinue view.Pause.Completion = false + view.HeatFrozen = false + view.FrozenHeat = HeatLevel{} game.ClearPendingDirections() updateViewGameTime(&view, now) updateViewHeat(&view, now) + s.syncPanePause(&view) if view.Started && !view.Pause.Active() && !game.Over { view.Clock.Start(now) } nextMove = now.Add(activeMoveInterval(view, gameIntervalOverride, view.GameTime)) render(screen, view) continue - case typed.Key() == tcell.KeyRune && (typed.Rune() == 'q' || typed.Rune() == 'Q'): - view.Completion = CompletionQuit - view.Pause.Completion = false - finalizeCompletionQuit(&view, s.statsManager()) - render(screen, view) - if view.PendingScore == nil { - return s.quit(view) - } - continue default: render(screen, view) continue } } switch { - case typed.Key() == tcell.KeyRune && (typed.Rune() == 'q' || typed.Rune() == 'Q') && session.IsTerminalStatus(view.SessionState): - return s.quit(view) case typed.Key() == tcell.KeyRune && (typed.Rune() == 'p' || typed.Rune() == 'P'): now := s.now() wasPaused := view.Pause.Active() @@ -259,10 +260,10 @@ func (s Shell) Run(ctx context.Context) error { } updateViewGameTime(&view, now) updateViewHeat(&view, now) + s.syncPanePause(&view) render(screen, view) case typed.Key() == tcell.KeyRune && (typed.Rune() == 'r' || typed.Rune() == 'R') && !view.Frozen && game.Over: now := s.now() - updateViewGameTime(&view, now) if view.RoundFinalized && !view.QuestStatsSaved { updateQuestStats(&view, s.statsManager()) } @@ -274,10 +275,18 @@ func (s Shell) Run(ctx context.Context) error { view.Game = game view.Started = false view.Pause.Manual = false - view.Clock.Stop(now) - view.RoundStarted = view.GameTime - view.RoundHeat = RestartStartHeat(view.CommandHeat.Level) - view.RoundCatchUp = view.RoundHeat < view.CommandHeat.Level + s.syncPanePause(&view) + view.Clock = PlayClock{} + view.GameEpoch = now + view.GameTime = now + view.RoundStarted = now + view.CommandHeat = HeatByLevel(1) + view.FrozenHeat = HeatLevel{} + view.HeatFrozen = false + view.Heat = HeatByLevel(1) + view.MaxHeat = 0 + view.HeatNotice = "" + view.NoticeUntil = time.Time{} view.RoundFinalized = false view.QuestStatsSaved = false view.ResultScore = 0 @@ -287,7 +296,7 @@ func (s Shell) Run(ctx context.Context) error { view.StatsMessage = "" view.Message = statsMessage boardWidth, boardHeight := boardSize(screen) - view.Quest = NewQuestState(mode, view.GameTime, s.Random, boardWidth, boardHeight) + view.Quest = NewQuestState(mode, now, s.Random, boardWidth, boardHeight) updateViewHeat(&view, now) nextMove = now.Add(activeMoveInterval(view, gameIntervalOverride, view.GameTime)) render(screen, view) @@ -315,6 +324,7 @@ func (s Shell) Run(ctx context.Context) error { view.Clock.Stop(now) updateViewGameTime(&view, now) updateViewHeat(&view, now) + s.syncPanePause(&view) } else { view.Pause.Resize = false view.Quest.ResizeObjects(game) @@ -324,6 +334,7 @@ func (s Shell) Run(ctx context.Context) error { view.Clock.Start(now) } nextMove = now.Add(activeMoveInterval(view, gameIntervalOverride, view.GameTime)) + s.syncPanePause(&view) } render(screen, view) } @@ -332,6 +343,9 @@ func (s Shell) Run(ctx context.Context) error { focusChanged, focusResumed := false, false if !now.Before(view.NextFocusCheck) { focusChanged, focusResumed = s.syncFocus(&view, now, true) + if focusChanged { + s.syncPanePause(&view) + } } clockStarted, _ := s.syncPlayClock(&view, now) updateViewGameTime(&view, now) @@ -350,6 +364,9 @@ func (s Shell) Run(ctx context.Context) error { continue } stepFocusChanged, stepFocusResumed := s.syncFocus(&view, now, true) + if stepFocusChanged { + s.syncPanePause(&view) + } if stepFocusResumed { nextMove = now.Add(activeMoveInterval(view, gameIntervalOverride, view.GameTime)) } @@ -388,6 +405,7 @@ func (s Shell) Run(ctx context.Context) error { state = mergeTerminalState(view.State, state) view.State = state view.SessionState = state.Status + view.NoColor = state.NoColor if session.IsTerminalStatus(state.Status) { now := s.now() if view.Completion != CompletionContinue { @@ -395,10 +413,13 @@ func (s Shell) Run(ctx context.Context) error { } updateViewGameTime(&view, now) updateViewHeat(&view, now) - freezeCommandHeat(&view) + if view.Completion != CompletionContinue { + freezeCommandHeat(&view) + } if shouldOfferCompletionDecision(&view) { view.Completion = CompletionUndecided view.Pause.Completion = true + s.syncPanePause(&view) if view.Game != nil { view.Game.ClearPendingDirections() } @@ -410,11 +431,54 @@ func (s Shell) Run(ctx context.Context) error { } } updateViewHeat(&view, s.now()) + s.syncCommandPreview(&view) render(screen, view) } } } +func (s Shell) syncInitialScreen(screen tcell.Screen, events <-chan tcell.Event) { + screen.Sync() + delay := s.initialRenderDelay(screen) + if delay <= 0 { + return + } + timer := time.NewTimer(delay) + defer timer.Stop() + pending := []tcell.Event{} + defer func() { + for _, event := range pending { + _ = screen.PostEvent(event) + } + }() + for { + select { + case <-timer.C: + screen.Sync() + return + case event := <-events: + if _, ok := event.(*tcell.EventResize); ok { + screen.Sync() + continue + } + pending = append(pending, event) + } + } +} + +func (s Shell) initialRenderDelay(screen tcell.Screen) time.Duration { + if s.InitialRenderDelay != 0 { + if s.InitialRenderDelay < 0 { + return 0 + } + return s.InitialRenderDelay + } + if _, ok := screen.(tcell.SimulationScreen); ok { + return 0 + } + return defaultInitialRenderDelay +} + func stepGame(game *SnakeGame, quest *QuestState, heat HeatLevel, now time.Time) StepResult { if quest.Enabled() && quest.Pickup.Active && game.NextPoint() == quest.Pickup.Position { overlappedFood := game.Food == quest.Pickup.Position @@ -433,8 +497,15 @@ func stepGame(game *SnakeGame, quest *QuestState, heat HeatLevel, now time.Time) return result } if quest.Enabled() && quest.Golden.Active && game.NextPoint() == quest.Golden.Position { + extraOccupied := []Point{game.Food} + if quest.Pickup.Active { + extraOccupied = append(extraOccupied, quest.Pickup.Position) + } result := game.StepGrow() if result == StepAteFood { + // StepGrow preserves the tail for the Golden Byte move, so the + // explicit tail growth completes the documented total reward. + game.GrowTail(goldenByteGrowth-1, extraOccupied) quest.OnGoldenByte(game, heat, now) quest.EnsureFood(game) } @@ -513,26 +584,6 @@ func shouldOfferCompletionDecision(view *viewState) bool { !view.Game.Over } -func finalizeCompletionQuit(view *viewState, manager StatsManager) { - view.Frozen = true - if shouldFinalizeCompletionQuit(view) { - finalizeRound(view, manager) - } else if !view.RoundFinalized { - refreshLeaderboard(view, manager) - } - updateQuestStats(view, manager) -} - -func shouldFinalizeCompletionQuit(view *viewState) bool { - if view == nil || !view.Started || view.Game == nil || view.Game.Over { - return false - } - if view.SessionState == session.StatusCompleted { - return true - } - return currentRoundScore(view) > 0 -} - func updateQuestStats(view *viewState, manager StatsManager) { if view.Quest.Enabled() && view.RoundFinalized && !view.QuestStatsSaved { if _, err := manager.UpdateQuest(view.FinalScore, view.Quest); err != nil { @@ -565,13 +616,6 @@ func statsManagerOrDefault(manager StatsManager) StatsManager { return manager } -func (s Shell) quit(view viewState) error { - if session.IsTerminalStatus(view.SessionState) && s.OnQuitTerminal != nil { - return s.OnQuitTerminal() - } - return nil -} - func finalizeRound(view *viewState, manager StatsManager) { if view.RoundFinalized || view.Game == nil || !view.Started { refreshLeaderboard(view, manager) @@ -623,63 +667,52 @@ func refreshLeaderboard(view *viewState, manager StatsManager) { view.Leaderboard = manager.Leaderboard(gameModeLabel(*view)) } -func (s Shell) handlePendingHighscoreKey(view *viewState, event *tcell.EventKey) (bool, bool) { +func (s Shell) handlePendingHighscoreKey(view *viewState, event *tcell.EventKey) bool { pending := view.PendingScore if pending == nil { - return false, false + return false } switch event.Key() { case tcell.KeyEnter: confirmPendingHighscore(view, s.statsManager()) - return false, true + return true case tcell.KeyEscape: - if session.IsTerminalStatus(view.SessionState) { - confirmPendingHighscore(view, s.statsManager()) - return true, true - } - return false, true + return true case tcell.KeyBackspace, tcell.KeyBackspace2: if pending.ReplaceOnType { pending.Input = "" pending.ReplaceOnType = false - return false, true + return true } runes := []rune(pending.Input) if len(runes) > 0 { pending.Input = string(runes[:len(runes)-1]) } - return false, true + return true case tcell.KeyDelete: pending.Input = "" pending.ReplaceOnType = false - return false, true + return true case tcell.KeyCtrlU: pending.Input = "" pending.ReplaceOnType = false - return false, true + return true case tcell.KeyRune: r := event.Rune() - if r == 'q' || r == 'Q' { - if session.IsTerminalStatus(view.SessionState) { - confirmPendingHighscore(view, s.statsManager()) - return true, true - } - return false, true - } if r == 'r' || r == 'R' || r == 'p' || r == 'P' { - return false, true + return true } if r < ' ' || r == '\u007f' { - return false, true + return true } if pending.ReplaceOnType { pending.Input = "" pending.ReplaceOnType = false } pending.Input = NormalizePlayerName(pending.Input + string(r)) - return false, true + return true default: - return false, true + return true } } @@ -732,6 +765,45 @@ func (s Shell) syncFocus(view *viewState, now time.Time, force bool) (changed bo return true, !nextFocusPause } +func (s Shell) syncPanePause(view *viewState) { + if s.UpdatePanePause == nil { + return + } + paused := view.Pause.Active() + if view.PanePauseKnown && view.PanePaused == paused { + return + } + if err := s.UpdatePanePause(paused); err != nil { + view.Message = err.Error() + return + } + view.PanePauseKnown = true + view.PanePaused = paused +} + +func (s Shell) syncCommandPreview(view *viewState) bool { + if !view.State.Augmented { + if view.CommandPreview == "" { + return false + } + view.CommandPreview = "" + return true + } + if s.ReadCommandPreview == nil { + return false + } + preview, err := s.ReadCommandPreview() + if err != nil { + preview = "" + } + preview = formatCommandPreview(preview) + if preview == view.CommandPreview { + return false + } + view.CommandPreview = preview + return true +} + func (s Shell) syncPlayClock(view *viewState, now time.Time) (started bool, stopped bool) { if canRunPlayClock(*view) { return view.Clock.Start(now), false @@ -789,18 +861,22 @@ func render(screen tcell.Screen, view viewState) { return } - style := tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) - titleStyle := style.Bold(true).Foreground(tcell.ColorAqua) - statusStyle := style.Foreground(statusColor(view.SessionState)) - secondaryStyle := style.Foreground(tcell.ColorGray) - scoreStyle := style.Foreground(tcell.ColorGreen) + style := baseRenderStyle(view.NoColor) + titleStyle := colorStyle(style.Bold(true), view.NoColor, tcell.ColorAqua, tcell.ColorDefault) + statusStyle := colorStyle(style, view.NoColor, statusColor(view.SessionState), tcell.ColorDefault) + secondaryStyle := colorStyle(style, view.NoColor, tcell.ColorGray, tcell.ColorDefault) + scoreStyle := colorStyle(style, view.NoColor, tcell.ColorGreen, tcell.ColorDefault) drawBox(screen, 0, 0, width, height, style) - drawPlayfield(screen, style) + drawPlayfield(screen, style, view.NoColor) + commandLine := "Command state: " + displayState(view.SessionState) + if view.CommandPreview != "" { + commandLine = view.CommandPreview + } lines := []renderLine{ {y: 0, text: "Sidequest Snake [" + gameModeLabel(view) + "]", style: titleStyle, centered: true}, - {y: 1, text: "Command state: " + displayState(view.SessionState), style: statusStyle, centered: true}, + {y: 1, text: commandLine, style: statusStyle, centered: true}, {y: 2, text: heatScoreLine(view), style: scoreStyle, centered: true}, {y: 3, text: statusLine(view), style: secondaryStyle, centered: true}, } @@ -815,9 +891,10 @@ func render(screen tcell.Screen, view viewState) { lines = append(lines, renderLine{y: y, text: view.Message, style: secondaryStyle}) } - drawSnake(screen, view.Game, style) - drawGoldenByte(screen, view.Quest, style) - drawPickup(screen, view.Quest, style) + drawSnake(screen, view.Game, style, view.NoColor) + drawGoldenByte(screen, view.Quest, style, view.NoColor) + drawPickup(screen, view.Quest, style, view.NoColor) + drawGraphicsNotice(screen, view, style) drawResultPanel(screen, view, style) drawCompletionDecisionPanel(screen, view, style) drawResizePausePanel(screen, view, style) @@ -833,6 +910,14 @@ func render(screen tcell.Screen, view viewState) { screen.Show() } +func formatCommandPreview(preview string) string { + preview = strings.TrimSpace(preview) + if preview == "" { + return "" + } + return "CMD " + preview +} + type renderLine struct { y int text string @@ -840,6 +925,26 @@ type renderLine struct { centered bool } +func baseRenderStyle(noColor bool) tcell.Style { + if noColor { + return tcell.StyleDefault + } + return tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) +} + +func colorStyle(style tcell.Style, noColor bool, foreground tcell.Color, background tcell.Color) tcell.Style { + if noColor { + return style + } + if foreground != tcell.ColorDefault { + style = style.Foreground(foreground) + } + if background != tcell.ColorDefault { + style = style.Background(background) + } + return style +} + func newSnakeGameForScreen(screen tcell.Screen) *SnakeGame { width, height := boardSize(screen) return NewSnakeGame(width, height, nil) @@ -860,7 +965,7 @@ func arenaForScreen(screen tcell.Screen) Arena { return ArenaForScreen(width, height) } -func drawPlayfield(screen tcell.Screen, style tcell.Style) { +func drawPlayfield(screen tcell.Screen, style tcell.Style, noColor bool) { width, height := screen.Size() arena := arenaForScreen(screen) topWallY := arena.Y - 1 @@ -871,8 +976,16 @@ func drawPlayfield(screen tcell.Screen, style tcell.Style) { return } - boardStyle := style.Background(tcell.ColorDarkSlateGray) - wallStyle := style.Foreground(tcell.ColorTeal).Background(tcell.ColorTeal).Bold(true) + boardStyle := colorStyle(style, noColor, tcell.ColorDefault, tcell.ColorDarkSlateGray) + asciiGraphics := asciiGraphicsEnabled() + wallStyle := colorStyle(style.Bold(true), noColor, tcell.ColorTeal, tcell.ColorTeal) + topWall := tcell.RuneBlock + sideWall := tcell.RuneBlock + if asciiGraphics { + wallStyle = colorStyle(style.Bold(true), noColor, tcell.ColorTeal, tcell.ColorDefault) + topWall = '=' + sideWall = '|' + } for y := arena.Y; y < arena.Y+arena.Height; y++ { for x := arena.X; x < arena.X+arena.RenderWidth(); x++ { @@ -880,26 +993,27 @@ func drawPlayfield(screen tcell.Screen, style tcell.Style) { } } for x := leftWallX; x <= rightWallX; x++ { - screen.SetContent(x, topWallY, tcell.RuneBlock, nil, wallStyle) - screen.SetContent(x, bottomWallY, tcell.RuneBlock, nil, wallStyle) + screen.SetContent(x, topWallY, topWall, nil, wallStyle) + screen.SetContent(x, bottomWallY, topWall, nil, wallStyle) } for y := arena.Y; y < bottomWallY; y++ { - screen.SetContent(leftWallX, y, tcell.RuneBlock, nil, wallStyle) - screen.SetContent(rightWallX, y, tcell.RuneBlock, nil, wallStyle) + screen.SetContent(leftWallX, y, sideWall, nil, wallStyle) + screen.SetContent(rightWallX, y, sideWall, nil, wallStyle) } } -func drawSnake(screen tcell.Screen, game *SnakeGame, baseStyle tcell.Style) { +func drawSnake(screen tcell.Screen, game *SnakeGame, baseStyle tcell.Style, noColor bool) { if game == nil { return } arena := arenaForScreen(screen) boardBackground := tcell.ColorDarkSlateGray - foodStyle := baseStyle.Foreground(tcell.ColorYellow).Background(boardBackground).Bold(true) - bodyStyle := baseStyle.Foreground(tcell.ColorLimeGreen).Background(boardBackground).Bold(true) - tailStyle := baseStyle.Foreground(tcell.ColorGreen).Background(boardBackground) + foodStyle := colorStyle(baseStyle.Bold(true), noColor, tcell.ColorYellow, boardBackground) + bodyStyle := colorStyle(baseStyle.Bold(true), noColor, tcell.ColorLimeGreen, boardBackground) + tailStyle := colorStyle(baseStyle, noColor, tcell.ColorGreen, boardBackground) headStyle := bodyStyle.Bold(true) + asciiGraphics := asciiGraphicsEnabled() if game.Food.X >= 0 && game.Food.X < arena.Width && game.Food.Y >= 0 && game.Food.Y < arena.Height { drawCell(screen, arena, game.Food, "()", foodStyle) @@ -918,11 +1032,47 @@ func drawSnake(screen tcell.Screen, game *SnakeGame, baseStyle tcell.Style) { cell = "▒▒" style = tailStyle } + if asciiGraphics { + cell = "##" + if index == 0 { + cell = "@@" + } else if index == len(game.Snake)-1 { + cell = "--" + } + } drawCell(screen, arena, point, cell, style) } } -func drawGoldenByte(screen tcell.Screen, quest *QuestState, baseStyle tcell.Style) { +func asciiGraphicsEnabled() bool { + value := strings.ToLower(strings.TrimSpace(os.Getenv("SIDEQUEST_GRAPHICS"))) + if value == "ascii" || value == "plain" { + return true + } + if value == "rich" || value == "unicode" { + return false + } + if truthyEnv(os.Getenv("SIDEQUEST_ASCII")) { + return true + } + for _, variable := range []string{"MOBAXTERM", "TERM_PROGRAM", "XTERM_VERSION", "LC_TERMINAL", "TERM"} { + if strings.Contains(strings.ToLower(os.Getenv(variable)), "moba") { + return true + } + } + return false +} + +func truthyEnv(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func drawGoldenByte(screen tcell.Screen, quest *QuestState, baseStyle tcell.Style, noColor bool) { if !quest.Enabled() || !quest.Golden.Active { return } @@ -931,11 +1081,11 @@ func drawGoldenByte(screen tcell.Screen, quest *QuestState, baseStyle tcell.Styl if point.X < 0 || point.X >= arena.Width || point.Y < 0 || point.Y >= arena.Height { return } - style := baseStyle.Foreground(tcell.ColorOrange).Background(tcell.ColorDarkSlateGray).Bold(true) + style := colorStyle(baseStyle.Bold(true), noColor, tcell.ColorOrange, tcell.ColorDarkSlateGray) drawCell(screen, arena, point, "<>", style) } -func drawPickup(screen tcell.Screen, quest *QuestState, baseStyle tcell.Style) { +func drawPickup(screen tcell.Screen, quest *QuestState, baseStyle tcell.Style, noColor bool) { if !quest.Enabled() || !quest.Pickup.Active { return } @@ -944,11 +1094,34 @@ func drawPickup(screen tcell.Screen, quest *QuestState, baseStyle tcell.Style) { if point.X < 0 || point.X >= arena.Width || point.Y < 0 || point.Y >= arena.Height { return } - style := pickupStyle(baseStyle, quest.Pickup.Upgrade) + style := pickupStyle(baseStyle, quest.Pickup.Upgrade, noColor) drawCell(screen, arena, point, PickupSymbol(quest.Pickup.Upgrade), style) } -func pickupStyle(baseStyle tcell.Style, upgrade Upgrade) tcell.Style { +func drawGraphicsNotice(screen tcell.Screen, view viewState, baseStyle tcell.Style) { + if view.GraphicsNotice == "" || view.Started || view.Game == nil || view.Game.Over || view.Frozen { + return + } + arena := arenaForScreen(screen) + if arena.RenderWidth() < 20 || arena.Height < 5 { + return + } + lines := []string{ + view.GraphicsNotice, + "Set SIDEQUEST_GRAPHICS=rich for block graphics", + "Press Arrow Keys/WASD to play", + } + style := colorStyle(baseStyle.Bold(true), view.NoColor, tcell.ColorYellow, tcell.ColorDarkSlateGray) + startY := arena.Y + arena.Height/2 - len(lines)/2 + for index, line := range lines { + drawCenteredText(screen, arena.X, startY+index, arena.RenderWidth(), line, style) + } +} + +func pickupStyle(baseStyle tcell.Style, upgrade Upgrade, noColor bool) tcell.Style { + if noColor { + return baseStyle.Bold(true) + } background := tcell.ColorDarkSlateGray switch upgrade { case UpgradeShield: @@ -1025,8 +1198,8 @@ func drawResultPanel(screen tcell.Screen, view viewState, baseStyle tcell.Style) panelX := arena.X + (arena.RenderWidth()-panelWidth)/2 panelY := arena.Y + (arena.Height-panelHeight)/2 - panelStyle := baseStyle.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) - borderStyle := baseStyle.Foreground(accent).Background(tcell.ColorBlack).Bold(true) + panelStyle := colorStyle(baseStyle, view.NoColor, tcell.ColorWhite, tcell.ColorBlack) + borderStyle := colorStyle(baseStyle.Bold(true), view.NoColor, accent, tcell.ColorBlack) fillRect(screen, panelX, panelY, panelWidth, panelHeight, ' ', panelStyle) drawBox(screen, panelX, panelY, panelWidth, panelHeight, borderStyle) @@ -1064,8 +1237,8 @@ func drawResizePausePanel(screen tcell.Screen, view viewState, baseStyle tcell.S panelX := arena.X + (arena.RenderWidth()-panelWidth)/2 panelY := arena.Y + (arena.Height-panelHeight)/2 - panelStyle := baseStyle.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) - borderStyle := baseStyle.Foreground(tcell.ColorYellow).Background(tcell.ColorBlack).Bold(true) + panelStyle := colorStyle(baseStyle, view.NoColor, tcell.ColorWhite, tcell.ColorBlack) + borderStyle := colorStyle(baseStyle.Bold(true), view.NoColor, tcell.ColorYellow, tcell.ColorBlack) fillRect(screen, panelX, panelY, panelWidth, panelHeight, ' ', panelStyle) drawBox(screen, panelX, panelY, panelWidth, panelHeight, borderStyle) for index, line := range lines { @@ -1089,12 +1262,12 @@ func drawCompletionDecisionPanel(screen tcell.Screen, view viewState, baseStyle resultSummary(view.State), fmt.Sprintf("Current Score %d", score), "", - "C Continue Q Quit", + "C Continue F10 Shell", } if arena.RenderWidth() < 34 { lines = []string{ fmt.Sprintf("COMMAND FINISHED - Score %d", score), - "C Continue Q Quit", + "C Continue F10", } } @@ -1112,9 +1285,9 @@ func drawCompletionDecisionPanel(screen tcell.Screen, view viewState, baseStyle panelX := arena.X + (arena.RenderWidth()-panelWidth)/2 panelY := arena.Y + (arena.Height-panelHeight)/2 - panelStyle := baseStyle.Foreground(tcell.ColorWhite).Background(tcell.ColorBlack) - borderStyle := baseStyle.Foreground(tcell.ColorAqua).Background(tcell.ColorBlack).Bold(true) - actionStyle := baseStyle.Foreground(tcell.ColorYellow).Background(tcell.ColorBlack).Bold(true) + panelStyle := colorStyle(baseStyle, view.NoColor, tcell.ColorWhite, tcell.ColorBlack) + borderStyle := colorStyle(baseStyle.Bold(true), view.NoColor, tcell.ColorAqua, tcell.ColorBlack) + actionStyle := colorStyle(baseStyle.Bold(true), view.NoColor, tcell.ColorYellow, tcell.ColorBlack) fillRect(screen, panelX, panelY, panelWidth, panelHeight, ' ', panelStyle) drawBox(screen, panelX, panelY, panelWidth, panelHeight, borderStyle) for index, line := range lines { @@ -1165,7 +1338,7 @@ func resultPanelLines(view viewState, maxWidth int) []string { action := "R Restart F9 Hide" if view.Frozen { title = "COMMAND FINISHED" - action = "Q Quit" + action = "F10 Shell" } if maxWidth < 34 { lines := []string{ @@ -1295,12 +1468,7 @@ func updateViewHeat(view *viewState, now time.Time) bool { if view.MaxHeat < commandHeat.Level { view.MaxHeat = commandHeat.Level } - - activeLevel := commandHeat.Level - if view.RoundCatchUp { - activeLevel = RestartRampHeat(commandHeat.Level, view.RoundHeat, view.GameTime.Sub(view.RoundStarted)) - } - view.Heat = HeatByLevel(activeLevel) + view.Heat = commandHeat view.HeatNotice = "" if !view.Frozen && !view.HeatFrozen { @@ -1357,18 +1525,18 @@ func pauseLine(pause PauseState) string { func statusLine(view viewState) string { if view.PendingScore != nil { if session.IsTerminalStatus(view.SessionState) { - return "New high score. Type name Enter confirm Q save+exit" + return "New high score. Type name Enter confirm F10 shell" } return "New high score. Type name Enter confirm F9 hide" } if view.Completion == CompletionUndecided { - return "Command finished C continue Q quit F9 hide F10 detach" + return "Command finished C continue F9 hide F10 shell" } if view.Frozen { - return "Command finished Q quit F9 hide F10 detach" + return "Command finished F9 hide F10 shell" } if view.Game != nil && view.Game.Over { - return "Round over R restart F9 hide F10 detach" + return "Round over R restart F9 hide F10 shell" } if view.Pause.Active() { return pauseLine(view.Pause) @@ -1380,15 +1548,22 @@ func statusLine(view viewState) string { return questLine(view) } if view.Started { - return "Arrows/WASD move P pause F9 hide F10 detach F12 command" + return "Arrows/WASD move P pause F9 hide F10 shell F12 command" } - return "Arrows/WASD start F9 hide F12 command F10 detach" + return "Arrows/WASD start F9 hide F12 command F10 shell" } func heatTransitionText(heat HeatLevel) string { return fmt.Sprintf("COMMAND HEAT RISING... SPEED %d SCORE %s", heat.Level, heat.MultiplierText()) } +func initialGraphicsNotice() string { + if !asciiGraphicsEnabled() { + return "" + } + return "ASCII graphics mode" +} + func heatScoreLine(view viewState) string { heat := view.Heat if heat.Level == 0 { diff --git a/internal/game/shell_lifecycle_test.go b/internal/game/shell_lifecycle_test.go new file mode 100644 index 0000000..5cc1d19 --- /dev/null +++ b/internal/game/shell_lifecycle_test.go @@ -0,0 +1,107 @@ +package game + +import ( + "testing" + "time" + + "github.com/WBT112/sidequest/internal/session" +) + +func TestTerminalState(t *testing.T) { + for _, state := range []string{session.StatusCompleted, session.StatusFailed, session.StatusInterrupted, session.StatusStartFailed} { + if !terminalState(state) { + t.Fatalf("terminalState(%q) = false, want true", state) + } + } + if terminalState(session.StatusRunning) { + t.Fatal("terminalState(running) = true, want false") + } +} + +func TestPauseStateActive(t *testing.T) { + tests := []struct { + pause PauseState + want bool + }{ + {pause: PauseState{}, want: false}, + {pause: PauseState{Manual: true}, want: true}, + {pause: PauseState{Focus: true}, want: true}, + {pause: PauseState{Resize: true}, want: true}, + {pause: PauseState{Manual: true, Focus: true}, want: true}, + } + for _, test := range tests { + if got := test.pause.Active(); got != test.want { + t.Fatalf("PauseState(%#v).Active() = %t, want %t", test.pause, got, test.want) + } + } +} + +func TestPlayClockAccumulatesOnlyWhileRunning(t *testing.T) { + base := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + var clock PlayClock + + clock.Start(base) + if got := clock.Elapsed(base.Add(10 * time.Second)); got != 10*time.Second { + t.Fatalf("elapsed while running = %s, want 10s", got) + } + clock.Stop(base.Add(10 * time.Second)) + if got := clock.Elapsed(base.Add(time.Hour)); got != 10*time.Second { + t.Fatalf("elapsed while stopped = %s, want 10s", got) + } + clock.Start(base.Add(time.Hour)) + if got := clock.Elapsed(base.Add(time.Hour + 5*time.Second)); got != 15*time.Second { + t.Fatalf("elapsed after restart = %s, want 15s", got) + } +} + +func TestUpdateViewHeatUsesActivePlayClock(t *testing.T) { + now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + view := viewState{ + Clock: PlayClock{Accumulated: 60 * time.Second}, + GameEpoch: now, + GameTime: now.Add(60 * time.Second), + RoundStarted: now, + } + + updateViewHeat(&view, now) + + if view.Heat.Level != 3 { + t.Fatalf("Heat level = %d, want active-play level 3", view.Heat.Level) + } +} + +func TestResizePauseStopsPlayClock(t *testing.T) { + now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + view := viewState{ + Started: true, + Pause: PauseState{Resize: true}, + Game: NewSnakeGame(5, 5, nil), + SessionState: session.StatusRunning, + Clock: PlayClock{Accumulated: 10 * time.Second, ActiveSince: now.Add(-5 * time.Second), Running: true}, + } + + started, stopped := (Shell{}).syncPlayClock(&view, now) + + if started || !stopped || view.Clock.Running { + t.Fatalf("syncPlayClock while resize-paused started=%t stopped=%t running=%t, want stopped", started, stopped, view.Clock.Running) + } + if got := view.Clock.Elapsed(now.Add(time.Hour)); got != 15*time.Second { + t.Fatalf("elapsed while resize-paused = %s, want 15s", got) + } +} + +func TestUpdateViewHeatIgnoresPausedClockTime(t *testing.T) { + now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + view := viewState{ + Clock: PlayClock{Accumulated: 29 * time.Second, ActiveSince: now.Add(-10 * time.Minute), Running: false}, + GameEpoch: now, + GameTime: now.Add(10 * time.Minute), + RoundStarted: now, + } + + updateViewHeat(&view, now.Add(10*time.Minute)) + + if view.Heat.Level != 1 { + t.Fatalf("Heat level = %d, want paused time ignored at level 1", view.Heat.Level) + } +} diff --git a/internal/game/shell_test.go b/internal/game/shell_test.go index 743ca77..297e9b5 100644 --- a/internal/game/shell_test.go +++ b/internal/game/shell_test.go @@ -1,5 +1,9 @@ //go:build !race +// These tests drive tcell.SimulationScreen and the shell event loop. That +// simulation path is kept out of race runs; race-safe lifecycle tests live in +// shell_lifecycle_test.go. + package game import ( @@ -38,7 +42,39 @@ func TestRunInitializesAndFinalizesScreenOnQuit(t *testing.T) { } } -func TestRunIgnoresActiveQForRunningCommand(t *testing.T) { +func TestRunSyncsScreenBeforeInitialRender(t *testing.T) { + screen := &initialSyncScreen{ + SimulationScreen: tcell.NewSimulationScreen(""), + firstShow: make(chan screenSize, 1), + syncSizeAfter: 2, + } + screen.SetSize(60, 12) + screen.syncedWidth = 100 + screen.syncedHeight = 12 + + shell := Shell{ + NewScreen: func() (tcell.Screen, error) { return screen, nil }, + ReadState: func() (session.State, error) { + return session.State{Status: session.StatusRunning}, nil + }, + PollInterval: time.Hour, + InitialRenderDelay: time.Millisecond, + } + + cancel, errc := runShellCancellable(shell) + + select { + case size := <-screen.firstShow: + if size.width != 100 || size.height != 12 { + t.Fatalf("initial render size = %dx%d, want synced 100x12", size.width, size.height) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for initial render") + } + cancelShell(t, cancel, errc) +} + +func TestRunIgnoresQForRunningCommand(t *testing.T) { screen := tcell.NewSimulationScreen("") screen.SetSize(60, 12) shell := Shell{ @@ -54,7 +90,7 @@ func TestRunIgnoresActiveQForRunningCommand(t *testing.T) { screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'q', tcell.ModNone)) select { case err := <-errc: - t.Fatalf("Run returned after active Q: %v", err) + t.Fatalf("Run returned after q: %v", err) case <-time.After(50 * time.Millisecond): } cancelShell(t, cancel, errc) @@ -63,20 +99,27 @@ func TestRunIgnoresActiveQForRunningCommand(t *testing.T) { func TestRunTogglesPause(t *testing.T) { screen := tcell.NewSimulationScreen("") screen.SetSize(60, 12) + pauseUpdates := make(chan bool, 4) shell := Shell{ NewScreen: func() (tcell.Screen, error) { return screen, nil }, ReadState: func() (session.State, error) { return session.State{Status: session.StatusRunning}, nil }, + UpdatePanePause: func(paused bool) error { + pauseUpdates <- paused + return nil + }, PollInterval: time.Hour, } cancel, errc := runShellCancellable(shell) waitForRenderedText(t, screen, "Command state: running") + assertPauseUpdate(t, pauseUpdates, false) screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'p', tcell.ModNone)) waitForRenderedText(t, screen, "PAUSED - PRESS P TO RESUME") + assertPauseUpdate(t, pauseUpdates, true) cancelShell(t, cancel, errc) } @@ -122,6 +165,47 @@ func TestRunDisplaysQuestModeHUD(t *testing.T) { cancelShell(t, cancel, errc) } +func TestRunShowsCommandPreview(t *testing.T) { + screen := tcell.NewSimulationScreen("") + screen.SetSize(80, 20) + shell := Shell{ + NewScreen: func() (tcell.Screen, error) { return screen, nil }, + ReadState: func() (session.State, error) { + return session.State{Status: session.StatusRunning, Augmented: true}, nil + }, + ReadCommandPreview: func() (string, error) { + return "working step 12/60", nil + }, + PollInterval: time.Hour, + } + + cancel, errc := runShellCancellable(shell) + + waitForRenderedText(t, screen, "CMD working step 12/60") + cancelShell(t, cancel, errc) +} + +func TestCommandPreviewRequiresAugmentedState(t *testing.T) { + called := false + shell := Shell{ + ReadCommandPreview: func() (string, error) { + called = true + return "hidden", nil + }, + } + view := viewState{State: session.State{Status: session.StatusRunning}} + + if changed := shell.syncCommandPreview(&view); changed { + t.Fatal("syncCommandPreview changed view without augmented state") + } + if called { + t.Fatal("ReadCommandPreview called without augmented state") + } + if view.CommandPreview != "" { + t.Fatalf("CommandPreview = %q, want empty", view.CommandPreview) + } +} + func TestRenderCentersQuestHUDLines(t *testing.T) { screen := tcell.NewSimulationScreen("") if err := screen.Init(); err != nil { @@ -165,6 +249,35 @@ func TestRenderCentersQuestHUDLines(t *testing.T) { } } +func TestRenderCentersCommandPreviewInCommandLine(t *testing.T) { + screen := tcell.NewSimulationScreen("") + if err := screen.Init(); err != nil { + t.Fatalf("Init returned error: %v", err) + } + defer screen.Fini() + + width, height := 100, 20 + screen.SetSize(width, height) + + view := viewState{ + SessionState: session.StatusRunning, + Heat: HeatByLevel(1), + CommandPreview: formatCommandPreview("working step 12/60"), + } + + render(screen, view) + + text := "CMD working step 12/60" + got := rowTextIndex(screen, 1, text) + if got < 0 { + t.Fatalf("row 1 missing %q:\n%s", text, screenText(screen)) + } + want := 1 + ((width - 2 - textDisplayWidth(text)) / 2) + if got != want { + t.Fatalf("preview start column = %d, want %d", got, want) + } +} + func TestStatusLineKeepsCompletionChoiceAboveQuestAndHeatMessages(t *testing.T) { now := time.Date(2026, 7, 13, 10, 0, 0, 0, time.UTC) quest := &QuestState{ @@ -202,7 +315,7 @@ func TestStatusLineKeepsCompletionChoiceAboveQuestAndHeatMessages(t *testing.T) for _, test := range tests { got := statusLine(test.view) - if !strings.Contains(got, "Command finished C continue Q quit") { + if !strings.Contains(got, "Command finished C continue") || !strings.Contains(got, "F10 shell") { t.Fatalf("%s statusLine = %q, want completion controls", test.name, got) } for _, unwanted := range []string{"QUEST:", "PICKUP:", "COMMAND HEAT"} { @@ -259,48 +372,6 @@ func TestRenderCompactStatusLineKeepsCompletionPriority(t *testing.T) { } } -func TestRunUpdatesQuestStatsWhenCommandFinishIsQuit(t *testing.T) { - screen := tcell.NewSimulationScreen("") - screen.SetSize(80, 12) - now := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) - started := now.Add(-time.Minute) - var finished atomic.Bool - statsDir := filepath.Join(t.TempDir(), "sidequest") - shell := Shell{ - NewScreen: func() (tcell.Screen, error) { return screen, nil }, - ReadState: func() (session.State, error) { - if finished.Load() { - return session.State{Status: session.StatusCompleted, StartedAt: &started, DurationMillis: int64Ptr(60_000), GameMode: GameModeQuest}, nil - } - return session.State{Status: session.StatusRunning, StartedAt: &started, GameMode: GameModeQuest}, nil - }, - PollInterval: 20 * time.Millisecond, - GameInterval: time.Second, - StatsManager: StatsManager{ - BaseDir: statsDir, - }, - Now: func() time.Time { return now }, - } - - cancel, errc := runShellCancellable(shell) - - waitForRenderedText(t, screen, "QUEST:") - screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'd', tcell.ModNone)) - finished.Store(true) - waitForRenderedText(t, screen, "C Continue") - if _, err := os.Stat(filepath.Join(statsDir, statsFileName)); !os.IsNotExist(err) { - t.Fatalf("stats file exists before completion choice: %v", err) - } - screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'q', tcell.ModNone)) - waitForRenderedText(t, screen, "NEW HIGH SCORE") - screen.PostEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) - waitForRenderedText(t, screen, "COMMAND FINISHED") - if _, err := os.Stat(filepath.Join(statsDir, statsFileName)); err != nil { - t.Fatalf("stats file was not written: %v", err) - } - cancelShell(t, cancel, errc) -} - func TestRunDoesNotWarnBeforeHeatRisesWithoutActivePlay(t *testing.T) { screen := tcell.NewSimulationScreen("") screen.SetSize(70, 12) @@ -347,39 +418,36 @@ func TestRunFreezesCommandHeatWhenCommandAlreadyFinished(t *testing.T) { cancelShell(t, cancel, errc) } -func TestRunCallsTerminalQuitHookForFinishedCommand(t *testing.T) { +func TestRunWaitsForFirstMoveBeforeStartingSnake(t *testing.T) { screen := tcell.NewSimulationScreen("") - screen.SetSize(40, 10) - terminalCalled := false + screen.SetSize(5, 7) + shell := Shell{ NewScreen: func() (tcell.Screen, error) { return screen, nil }, ReadState: func() (session.State, error) { - return session.State{Status: session.StatusCompleted}, nil - }, - OnQuitTerminal: func() error { - terminalCalled = true - return nil + return session.State{Status: session.StatusRunning}, nil }, PollInterval: time.Hour, + GameInterval: 10 * time.Millisecond, + StatsManager: StatsManager{ + BaseDir: filepath.Join(t.TempDir(), "sidequest"), + }, } - errc := make(chan error, 1) - go func() { - errc <- shell.Run(context.Background()) - }() - - waitForRenderedText(t, screen, "Command finished Q quit F9 hide") - screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'q', tcell.ModNone)) + cancel, errc := runShellCancellable(shell) - if err := <-errc; err != nil { - t.Fatalf("Run returned error: %v", err) - } - if !terminalCalled { - t.Fatal("OnQuitTerminal was not called") + waitForRenderedText(t, screen, "Arrows/WASD start") + time.Sleep(80 * time.Millisecond) + if strings.Contains(screenText(screen), "Round over") { + t.Fatalf("snake started before first move:\n%s", screenText(screen)) } + + screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone)) + waitForRenderedText(t, screen, "NEW HIGH SCORE") + cancelShell(t, cancel, errc) } -func TestRunWaitsForFirstMoveBeforeStartingSnake(t *testing.T) { +func TestRunRestartsSnakeAfterRoundOver(t *testing.T) { screen := tcell.NewSimulationScreen("") screen.SetSize(5, 7) @@ -398,19 +466,23 @@ func TestRunWaitsForFirstMoveBeforeStartingSnake(t *testing.T) { cancel, errc := runShellCancellable(shell) waitForRenderedText(t, screen, "Arrows/WASD start") - time.Sleep(80 * time.Millisecond) - if strings.Contains(screenText(screen), "Round over") { - t.Fatalf("snake started before first move:\n%s", screenText(screen)) - } - screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone)) waitForRenderedText(t, screen, "NEW HIGH SCORE") + screen.PostEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + waitForRenderedText(t, screen, "R Restart") + + screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'r', tcell.ModNone)) + waitForRenderedText(t, screen, "Arrows/WASD start") + waitForMissingRenderedText(t, screen, "Round over") cancelShell(t, cancel, errc) } -func TestRunRestartsSnakeAfterRoundOver(t *testing.T) { +func TestRunRestartAfterRoundOverResetsHeat(t *testing.T) { screen := tcell.NewSimulationScreen("") screen.SetSize(5, 7) + base := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + var nowNanos atomic.Int64 + nowNanos.Store(base.UnixNano()) shell := Shell{ NewScreen: func() (tcell.Screen, error) { return screen, nil }, @@ -422,19 +494,89 @@ func TestRunRestartsSnakeAfterRoundOver(t *testing.T) { StatsManager: StatsManager{ BaseDir: filepath.Join(t.TempDir(), "sidequest"), }, + Now: func() time.Time { + return time.Unix(0, nowNanos.Load()) + }, } cancel, errc := runShellCancellable(shell) waitForRenderedText(t, screen, "Arrows/WASD start") + screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 's', tcell.ModNone)) + waitForRenderedText(t, screen, "Arrows/WASD move") + nowNanos.Store(base.Add(35 * time.Second).UnixNano()) + waitForRenderedText(t, screen, "Heat: 2/6") + screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone)) + for step := 0; step < 200 && !strings.Contains(screenText(screen), "NEW HIGH SCORE"); step++ { + nowNanos.Store(base.Add(35*time.Second + time.Duration(step+1)*20*time.Millisecond).UnixNano()) + time.Sleep(10 * time.Millisecond) + } waitForRenderedText(t, screen, "NEW HIGH SCORE") screen.PostEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) waitForRenderedText(t, screen, "R Restart") + nowNanos.Store(base.Add(2 * time.Minute).UnixNano()) screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'r', tcell.ModNone)) waitForRenderedText(t, screen, "Arrows/WASD start") - waitForMissingRenderedText(t, screen, "Round over") + waitForRenderedText(t, screen, "Heat: 1/6") + if strings.Contains(screenText(screen), "Heat: 2/6") { + t.Fatalf("restart carried heat into new round:\n%s", screenText(screen)) + } + + cancelShell(t, cancel, errc) +} + +func TestRunRestartAfterLongQuestRoundResetsGameplayTimeline(t *testing.T) { + screen := tcell.NewSimulationScreen("") + screen.SetSize(5, 7) + base := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) + var nowNanos atomic.Int64 + nowNanos.Store(base.UnixNano()) + + shell := Shell{ + NewScreen: func() (tcell.Screen, error) { return screen, nil }, + ReadState: func() (session.State, error) { + return session.State{Status: session.StatusRunning, GameMode: GameModeQuest}, nil + }, + PollInterval: time.Hour, + GameInterval: 10 * time.Millisecond, + Random: fixedRandom(2), + StatsManager: StatsManager{ + BaseDir: filepath.Join(t.TempDir(), "sidequest"), + }, + Now: func() time.Time { + return time.Unix(0, nowNanos.Load()) + }, + } + + cancel, errc := runShellCancellable(shell) + + waitForRenderedText(t, screen, "QUEST: Survive 60s 0/60") + screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 's', tcell.ModNone)) + time.Sleep(20 * time.Millisecond) + nowNanos.Store(base.Add(61 * time.Second).UnixNano()) + waitForRenderedText(t, screen, "HEAT 3") + + screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'a', tcell.ModNone)) + for step := 0; step < 200 && !strings.Contains(screenText(screen), "NEW HIGH SCORE"); step++ { + nowNanos.Store(base.Add(61*time.Second + time.Duration(step+1)*20*time.Millisecond).UnixNano()) + time.Sleep(10 * time.Millisecond) + } + waitForRenderedText(t, screen, "NEW HIGH SCORE") + screen.PostEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + waitForRenderedText(t, screen, "R Restart") + + restartTime := base.Add(2 * time.Minute) + nowNanos.Store(restartTime.UnixNano()) + screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'r', tcell.ModNone)) + + waitForRenderedText(t, screen, "QUEST: Survive 60s 0/60") + waitForRenderedText(t, screen, "HEAT 1") + if strings.Contains(screenText(screen), "Survive 60s -") { + t.Fatalf("restart produced negative survive progress:\n%s", screenText(screen)) + } + cancelShell(t, cancel, errc) } @@ -658,7 +800,7 @@ func TestRunContinueAfterCommandFinishResumesSameRoundWithFullInterval(t *testin cancelShell(t, cancel, errc) } -func TestRunContinueAfterCommandFinishFreezesCommandHeat(t *testing.T) { +func TestRunContinueAfterCommandFinishKeepsCurrentRoundHeatProgression(t *testing.T) { screen := tcell.NewSimulationScreen("") screen.SetSize(70, 12) base := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) @@ -691,13 +833,17 @@ func TestRunContinueAfterCommandFinishFreezesCommandHeat(t *testing.T) { completed.Store(true) waitForRenderedText(t, screen, "C Continue") - screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'c', tcell.ModNone)) nowNanos.Store(base.Add(70 * time.Second).UnixNano()) time.Sleep(30 * time.Millisecond) if strings.Contains(screenText(screen), "Heat: 3/6") { - t.Fatalf("command heat increased after post-command continue:\n%s", screenText(screen)) + t.Fatalf("heat increased while command-finished modal blocked gameplay:\n%s", screenText(screen)) } waitForRenderedText(t, screen, "Heat: 2/6") + + screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'c', tcell.ModNone)) + waitForMissingRenderedText(t, screen, "C Continue") + nowNanos.Store(base.Add(96 * time.Second).UnixNano()) + waitForRenderedText(t, screen, "Heat: 3/6") cancelShell(t, cancel, errc) } @@ -844,8 +990,9 @@ func TestRunCommandCompletionDuringNameEntryKeepsPendingScore(t *testing.T) { } errc := make(chan error, 1) + ctx, cancel := context.WithCancel(context.Background()) go func() { - errc <- shell.Run(context.Background()) + errc <- shell.Run(ctx) }() waitForRenderedText(t, screen, "Arrows/WASD start") @@ -856,14 +1003,12 @@ func TestRunCommandCompletionDuringNameEntryKeepsPendingScore(t *testing.T) { screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'Z', tcell.ModNone)) screen.PostEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) waitForRenderedText(t, screen, "COMMAND FINISHED") - waitForRenderedText(t, screen, "Q Quit") + waitForRenderedText(t, screen, "F10 Shell") screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'r', tcell.ModNone)) waitForMissingRenderedText(t, screen, "Arrows/WASD start") screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'q', tcell.ModNone)) - - if err := <-errc; err != nil { - t.Fatalf("Run returned error: %v", err) - } + waitForRenderedText(t, screen, "COMMAND FINISHED") + cancelShell(t, cancel, errc) entries := (StatsManager{BaseDir: statsDir}).Leaderboard(GameModeClassic) if len(entries) != 1 || entries[0].PlayerName != "Z" { t.Fatalf("leaderboard = %#v, want saved pending score", entries) @@ -893,21 +1038,21 @@ func TestRunShowsCompletionChoiceWithinPollIntervalWhenCommandFinishes(t *testin } errc := make(chan error, 1) + ctx, cancel := context.WithCancel(context.Background()) go func() { - errc <- shell.Run(context.Background()) + errc <- shell.Run(ctx) }() - waitForRenderedText(t, screen, "Command finished C continue Q quit") + waitForRenderedText(t, screen, "Command finished C continue") + waitForRenderedText(t, screen, "F10 shell") waitForRenderedText(t, screen, "COMMAND FINISHED") waitForRenderedText(t, screen, "C Continue") waitForMissingRenderedText(t, screen, "FINAL SCORE") waitForRenderedText(t, screen, "Exit code: 0") waitForRenderedText(t, screen, "Runtime: 00:00:03") screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'q', tcell.ModNone)) - - if err := <-errc; err != nil { - t.Fatalf("Run returned error: %v", err) - } + waitForRenderedText(t, screen, "COMMAND FINISHED") + cancelShell(t, cancel, errc) } func TestRunDoesNotPersistZeroScoreWhenCommandFailsImmediately(t *testing.T) { @@ -932,8 +1077,9 @@ func TestRunDoesNotPersistZeroScoreWhenCommandFailsImmediately(t *testing.T) { } errc := make(chan error, 1) + ctx, cancel := context.WithCancel(context.Background()) go func() { - errc <- shell.Run(context.Background()) + errc <- shell.Run(ctx) }() waitForRenderedText(t, screen, "Arrows/WASD start") @@ -943,10 +1089,8 @@ func TestRunDoesNotPersistZeroScoreWhenCommandFailsImmediately(t *testing.T) { waitForRenderedText(t, screen, "COMMAND FINISHED") waitForMissingRenderedText(t, screen, "NEW HIGH SCORE") screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'q', tcell.ModNone)) - - if err := <-errc; err != nil { - t.Fatalf("Run returned error: %v", err) - } + waitForRenderedText(t, screen, "COMMAND FINISHED") + cancelShell(t, cancel, errc) if entries := (StatsManager{BaseDir: statsDir}).Leaderboard(GameModeClassic); len(entries) != 0 { t.Fatalf("classic leaderboard = %#v, want empty", entries) } @@ -974,8 +1118,9 @@ func TestRunDoesNotUpdateQuestStatsWhenCommandFailsImmediately(t *testing.T) { } errc := make(chan error, 1) + ctx, cancel := context.WithCancel(context.Background()) go func() { - errc <- shell.Run(context.Background()) + errc <- shell.Run(ctx) }() waitForRenderedText(t, screen, "QUEST:") @@ -984,10 +1129,8 @@ func TestRunDoesNotUpdateQuestStatsWhenCommandFailsImmediately(t *testing.T) { waitForRenderedText(t, screen, "Command state: failed") waitForMissingRenderedText(t, screen, "NEW HIGH SCORE") screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'q', tcell.ModNone)) - - if err := <-errc; err != nil { - t.Fatalf("Run returned error: %v", err) - } + waitForRenderedText(t, screen, "Command state: failed") + cancelShell(t, cancel, errc) if _, err := os.Stat(filepath.Join(statsDir, statsFileName)); !os.IsNotExist(err) { t.Fatalf("stats file exists after immediate command failure: %v", err) } @@ -1047,88 +1190,138 @@ func TestRunDrawsColoredPlayfieldWithThickWalls(t *testing.T) { cancelShell(t, cancel, errc) } -func TestTerminalState(t *testing.T) { - for _, state := range []string{session.StatusCompleted, session.StatusFailed, session.StatusInterrupted, session.StatusStartFailed} { - if !terminalState(state) { - t.Fatalf("terminalState(%q) = false, want true", state) - } - } - if terminalState(session.StatusRunning) { - t.Fatal("terminalState(running) = true, want false") +func TestRunDrawsAsciiCompatiblePlayfieldWhenRequested(t *testing.T) { + t.Setenv("SIDEQUEST_GRAPHICS", "ascii") + screen := tcell.NewSimulationScreen("UTF-8") + screen.SetSize(40, 12) + + shell := Shell{ + NewScreen: func() (tcell.Screen, error) { return screen, nil }, + ReadState: func() (session.State, error) { + return session.State{Status: session.StatusRunning}, nil + }, + PollInterval: time.Hour, + GameInterval: time.Hour, } -} -func TestPauseStateActive(t *testing.T) { - tests := []struct { - pause PauseState - want bool - }{ - {pause: PauseState{}, want: false}, - {pause: PauseState{Manual: true}, want: true}, - {pause: PauseState{Focus: true}, want: true}, - {pause: PauseState{Resize: true}, want: true}, - {pause: PauseState{Manual: true, Focus: true}, want: true}, + cancel, errc := runShellCancellable(shell) + + waitForRenderedText(t, screen, "Arrows/WASD start") + waitForRenderedText(t, screen, "ASCII graphics mode") + waitForRenderedText(t, screen, "Press Arrow Keys/WASD to play") + topWall, _, topStyle, _ := screen.GetContent(20, 4) + sideWall, _, _, _ := screen.GetContent(0, 6) + wallForeground, wallBackground, _ := topStyle.Decompose() + if topWall != '=' || sideWall != '|' { + t.Fatalf("wall runes = %q %q, want ASCII walls", topWall, sideWall) } - for _, test := range tests { - if got := test.pause.Active(); got != test.want { - t.Fatalf("PauseState(%#v).Active() = %t, want %t", test.pause, got, test.want) - } + if wallForeground != tcell.ColorTeal || wallBackground == tcell.ColorTeal { + t.Fatalf("wall colors foreground=%v background=%v, want teal foreground without teal fill", wallForeground, wallBackground) } + screen.PostEvent(tcell.NewEventKey(tcell.KeyRune, 'd', tcell.ModNone)) + waitForMissingRenderedText(t, screen, "ASCII graphics mode") + waitForRenderedText(t, screen, "@@") + + cancelShell(t, cancel, errc) } -func TestPlayClockAccumulatesOnlyWhileRunning(t *testing.T) { - base := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) - var clock PlayClock +func TestRunDrawsMonochromeClassicWithoutColors(t *testing.T) { + screen := tcell.NewSimulationScreen("UTF-8") + screen.SetSize(40, 12) + + shell := Shell{ + NewScreen: func() (tcell.Screen, error) { return screen, nil }, + ReadState: func() (session.State, error) { + return session.State{Status: session.StatusRunning, NoColor: true}, nil + }, + PollInterval: time.Hour, + } + + cancel, errc := runShellCancellable(shell) - clock.Start(base) - if got := clock.Elapsed(base.Add(10 * time.Second)); got != 10*time.Second { - t.Fatalf("elapsed while running = %s, want 10s", got) + waitForRenderedText(t, screen, "MODE classic") + topWall, _, topStyle, _ := screen.GetContent(20, 4) + sideWall, _, _, _ := screen.GetContent(0, 6) + inside, _, insideStyle, _ := screen.GetContent(20, 6) + _, wallBackground, _ := topStyle.Decompose() + _, insideBackground, _ := insideStyle.Decompose() + if topWall != tcell.RuneBlock || sideWall != tcell.RuneBlock { + t.Fatalf("wall runes = %q %q, want block walls", topWall, sideWall) } - clock.Stop(base.Add(10 * time.Second)) - if got := clock.Elapsed(base.Add(time.Hour)); got != 10*time.Second { - t.Fatalf("elapsed while stopped = %s, want 10s", got) + if wallBackground != tcell.ColorDefault { + t.Fatalf("wall background = %v, want default", wallBackground) } - clock.Start(base.Add(time.Hour)) - if got := clock.Elapsed(base.Add(time.Hour + 5*time.Second)); got != 15*time.Second { - t.Fatalf("elapsed after restart = %s, want 15s", got) + if inside != ' ' || insideBackground != tcell.ColorDefault { + t.Fatalf("inside cell = %q background=%v, want monochrome playfield", inside, insideBackground) } + assertScreenHasNoColors(t, screen) + + cancelShell(t, cancel, errc) } -func TestUpdateViewHeatUsesActivePlayClock(t *testing.T) { - now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) +func TestRenderDrawsMonochromeQuestObjectsAndResultPanel(t *testing.T) { + screen := tcell.NewSimulationScreen("UTF-8") + if err := screen.Init(); err != nil { + t.Fatalf("screen.Init returned error: %v", err) + } + defer screen.Fini() + screen.SetSize(80, 24) + + now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) + game := NewSnakeGame(20, 12, func(int) int { return 0 }) + game.Snake = []Point{{X: 3, Y: 3}, {X: 2, Y: 3}} + game.Food = Point{X: 8, Y: 5} + quest := NewQuestState(GameModeQuest, now, fixedRandom(0), 20, 12) + quest.Mission = Mission{ID: MissionFood15, Label: "Collect 15 food", Target: 15} + quest.MissionProgress = 4 + quest.Golden = GoldenByte{Position: Point{X: 10, Y: 5}, Active: true} + quest.Pickup = UpgradePickup{Upgrade: UpgradeDoubleScore, Position: Point{X: 12, Y: 5}, Active: true} view := viewState{ - Clock: PlayClock{Accumulated: 60 * time.Second}, - GameEpoch: now, - GameTime: now.Add(60 * time.Second), - RoundStarted: now, - RoundHeat: 1, + State: session.State{Status: session.StatusRunning, NoColor: true}, + SessionState: session.StatusRunning, + NoColor: true, + Game: game, + Quest: quest, + Heat: HeatByLevel(1), + GameTime: now, } - updateViewHeat(&view, now) + render(screen, view) - if view.Heat.Level != 3 { - t.Fatalf("Heat level = %d, want active-play level 3", view.Heat.Level) + text := screenText(screen) + for _, want := range []string{"QUEST: Collect 15 food 4/15", "()", "<>", "x2"} { + if !strings.Contains(text, want) { + t.Fatalf("monochrome quest render missing %q:\n%s", want, text) + } } -} + assertScreenHasNoColors(t, screen) -func TestResizePauseStopsPlayClock(t *testing.T) { - now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC) - view := viewState{ - Started: true, - Pause: PauseState{Resize: true}, - Game: NewSnakeGame(5, 5, nil), - SessionState: session.StatusRunning, - Clock: PlayClock{Accumulated: 10 * time.Second, ActiveSince: now.Add(-5 * time.Second), Running: true}, + view.Completion = CompletionUndecided + render(screen, view) + + text = screenText(screen) + for _, want := range []string{"COMMAND FINISHED", "C Continue", "F10 Shell"} { + if !strings.Contains(text, want) { + t.Fatalf("monochrome completion render missing %q:\n%s", want, text) + } } + assertScreenHasNoColors(t, screen) - started, stopped := (Shell{}).syncPlayClock(&view, now) + game.Over = true + view.Completion = CompletionNone + view.ResultScore = 920 + view.RoundFinalized = true + view.Leaderboard = []LeaderboardEntry{{Score: 920, PlayerName: "YOU"}} + view.CurrentRank = 1 + render(screen, view) - if started || !stopped || view.Clock.Running { - t.Fatalf("syncPlayClock while resize-paused started=%t stopped=%t running=%t, want stopped", started, stopped, view.Clock.Running) - } - if got := view.Clock.Elapsed(now.Add(time.Hour)); got != 15*time.Second { - t.Fatalf("elapsed while resize-paused = %s, want 15s", got) + text = screenText(screen) + for _, want := range []string{"GAME OVER", "FINAL SCORE 920", "TOP 5", "<- YOU"} { + if !strings.Contains(text, want) { + t.Fatalf("monochrome result render missing %q:\n%s", want, text) + } } + assertScreenHasNoColors(t, screen) } func runShellCancellable(shell Shell) (context.CancelFunc, chan error) { @@ -1219,6 +1412,20 @@ func screenText(screen tcell.SimulationScreen) string { return builder.String() } +func assertScreenHasNoColors(t *testing.T, screen tcell.SimulationScreen) { + t.Helper() + width, height := screen.Size() + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + main, _, style, _ := screen.GetContent(x, y) + foreground, background, _ := style.Decompose() + if foreground != tcell.ColorDefault || background != tcell.ColorDefault { + t.Fatalf("cell (%d,%d) %q has foreground=%v background=%v, want defaults", x, y, main, foreground, background) + } + } + } +} + func rowTextIndex(screen tcell.SimulationScreen, row int, text string) int { haystack := []rune(screenRowText(screen, row)) needle := []rune(text) @@ -1257,6 +1464,18 @@ func screenRowText(screen tcell.SimulationScreen, row int) string { return builder.String() } +func assertPauseUpdate(t *testing.T, updates <-chan bool, want bool) { + t.Helper() + select { + case got := <-updates: + if got != want { + t.Fatalf("pause update = %t, want %t", got, want) + } + case <-time.After(200 * time.Millisecond): + t.Fatalf("pause update timed out, want %t", want) + } +} + func int64Ptr(value int64) *int64 { return &value } @@ -1270,3 +1489,38 @@ func (s *finalizingScreen) Fini() { s.finiCalled = true s.SimulationScreen.Fini() } + +type initialSyncScreen struct { + tcell.SimulationScreen + syncedWidth int + syncedHeight int + syncs int + syncSizeAfter int + firstShow chan screenSize +} + +type screenSize struct { + width int + height int +} + +func (s *initialSyncScreen) Sync() { + s.syncs++ + syncSizeAfter := s.syncSizeAfter + if syncSizeAfter == 0 { + syncSizeAfter = 1 + } + if s.syncs >= syncSizeAfter { + s.SimulationScreen.SetSize(s.syncedWidth, s.syncedHeight) + } + s.SimulationScreen.Sync() +} + +func (s *initialSyncScreen) Show() { + width, height := s.Size() + select { + case s.firstShow <- screenSize{width: width, height: height}: + default: + } + s.SimulationScreen.Show() +} diff --git a/internal/game/snake.go b/internal/game/snake.go index cb4a3cd..e7b3d90 100644 --- a/internal/game/snake.go +++ b/internal/game/snake.go @@ -129,12 +129,18 @@ func (g *SnakeGame) Recover() { score := g.Score foodScore := g.FoodScore foodHeat := g.FoodHeat + length := len(g.Snake) randomInt := g.randomInt *g = *NewSnakeGame(g.Width, g.Height, randomInt) + if snake, ok := reflowedSnake(g.Snake[0], length, g.Width, g.Height); ok { + g.Snake = snake + g.Dir = safeDirection(g.Dir, g.Snake, g.Width, g.Height) + } g.Score = score g.FoodScore = foodScore g.FoodHeat = foodHeat g.Over = false + g.placeFoodIfInvalid() } func (g *SnakeGame) ChangeDirection(direction Direction) bool { @@ -164,16 +170,45 @@ func (g *SnakeGame) StepGrow() StepResult { return g.step(true) } +func (g *SnakeGame) GrowTail(cells int, extraOccupied []Point) int { + if cells <= 0 || len(g.Snake) == 0 { + return 0 + } + occupied := make(map[Point]bool, len(g.Snake)+len(extraOccupied)) + for _, point := range g.Snake { + occupied[point] = true + } + for _, point := range extraOccupied { + occupied[point] = true + } + + grown := 0 + for grown < cells { + candidates := tailGrowthCandidates(g.Snake) + appended := false + for _, candidate := range candidates { + if candidate.X < 0 || candidate.X >= g.Width || candidate.Y < 0 || candidate.Y >= g.Height || occupied[candidate] { + continue + } + g.Snake = append(g.Snake, candidate) + occupied[candidate] = true + grown++ + appended = true + break + } + if !appended { + return grown + } + } + return grown +} + func (g *SnakeGame) step(forceGrow bool) StepResult { if g.Over { return StepMoved } - if len(g.PendingDirs) > 0 { - g.Dir = g.PendingDirs[0] - copy(g.PendingDirs, g.PendingDirs[1:]) - g.PendingDirs = g.PendingDirs[:len(g.PendingDirs)-1] - } + g.applyNextDirection(forceGrow) head := g.Snake[0] next := Point{X: head.X + directionDelta(g.Dir).X, Y: head.Y + directionDelta(g.Dir).Y} @@ -206,6 +241,49 @@ func (g *SnakeGame) step(forceGrow bool) StepResult { return StepMoved } +func (g *SnakeGame) applyNextDirection(forceGrow bool) { + if len(g.PendingDirs) == 0 { + return + } + nextDirection := g.PendingDirs[0] + if g.shouldDiscardUnsafeSideTurn(nextDirection, forceGrow) { + g.discardFirstPendingDirection() + return + } + g.Dir = nextDirection + g.discardFirstPendingDirection() +} + +func (g *SnakeGame) discardFirstPendingDirection() { + copy(g.PendingDirs, g.PendingDirs[1:]) + g.PendingDirs = g.PendingDirs[:len(g.PendingDirs)-1] +} + +func (g *SnakeGame) shouldDiscardUnsafeSideTurn(direction Direction, forceGrow bool) bool { + if direction == g.Dir || oppositeDirections(g.Dir, direction) { + return false + } + turnCollision := g.collisionForDirection(direction, forceGrow) + if turnCollision != StepHitSelf { + return false + } + return g.collisionForDirection(g.Dir, forceGrow) == StepMoved +} + +func (g *SnakeGame) collisionForDirection(direction Direction, forceGrow bool) StepResult { + head := g.Snake[0] + delta := directionDelta(direction) + next := Point{X: head.X + delta.X, Y: head.Y + delta.Y} + if next.X < 0 || next.X >= g.Width || next.Y < 0 || next.Y >= g.Height { + return StepHitWall + } + willGrow := forceGrow || next == g.Food + if g.collidesWithSnake(next, willGrow) { + return StepHitSelf + } + return StepMoved +} + func (g *SnakeGame) NextPoint() Point { head := g.Snake[0] delta := directionDelta(g.nextDirection()) @@ -315,12 +393,31 @@ func (g *SnakeGame) WarpToFreePoint(random RandomSource, extraOccupied []Point) if !ok { return false } - g.Snake = []Point{point} + snake, ok := reflowedSnakeAvoiding(point, len(g.Snake), g.Width, g.Height, extraOccupied) + if !ok { + return false + } + g.Snake = snake + g.Dir = safeDirection(g.Dir, g.Snake, g.Width, g.Height) g.PendingDirs = nil g.Over = false return true } +func tailGrowthCandidates(snake []Point) []Point { + tail := snake[len(snake)-1] + candidates := make([]Point, 0, 5) + if len(snake) > 1 { + beforeTail := snake[len(snake)-2] + candidates = append(candidates, Point{X: tail.X + tail.X - beforeTail.X, Y: tail.Y + tail.Y - beforeTail.Y}) + } + for _, direction := range []Direction{DirectionRight, DirectionDown, DirectionLeft, DirectionUp} { + delta := directionDelta(direction) + candidates = append(candidates, Point{X: tail.X + delta.X, Y: tail.Y + delta.Y}) + } + return candidates +} + func (g *SnakeGame) collidesWithSnake(point Point, willGrow bool) bool { limit := len(g.Snake) if !willGrow { @@ -426,6 +523,42 @@ func reflowedSnake(preferredHead Point, length int, width int, height int) ([]Po return nil, false } +func reflowedSnakeAvoiding(preferredHead Point, length int, width int, height int, blocked []Point) ([]Point, bool) { + blockedCells := make(map[Point]bool, len(blocked)) + for _, point := range blocked { + blockedCells[point] = true + } + if length <= 0 || width*height-len(blockedCells) < length || blockedCells[clampPoint(preferredHead, width, height)] { + return nil, false + } + head := clampPoint(preferredHead, width, height) + for _, path := range serpentinePaths(width, height) { + if snake, ok := slicePathFromHead(path, head, length); ok && !snakeTouchesBlocked(snake, blockedCells) { + return snake, true + } + } + for _, path := range serpentinePaths(width, height) { + if len(path) >= length { + for start := 0; start+length <= len(path); start++ { + snake := append([]Point(nil), path[start:start+length]...) + if !snakeTouchesBlocked(snake, blockedCells) { + return snake, true + } + } + } + } + return nil, false +} + +func snakeTouchesBlocked(snake []Point, blocked map[Point]bool) bool { + for _, point := range snake { + if blocked[point] { + return true + } + } + return false +} + func serpentinePaths(width int, height int) [][]Point { paths := [][]Point{ rowSerpentinePath(width, height, false), diff --git a/internal/game/snake_test.go b/internal/game/snake_test.go index 3a40c91..46c3b03 100644 --- a/internal/game/snake_test.go +++ b/internal/game/snake_test.go @@ -2,6 +2,7 @@ package game import ( "testing" + "time" "github.com/gdamore/tcell/v2" ) @@ -117,8 +118,14 @@ func TestSnakeRetriesMissingFoodAfterRecover(t *testing.T) { game.Recover() - if !game.FoodValid(nil) { - t.Fatalf("Food = %#v, want valid food after recovery reset the snake", game.Food) + if game.Over { + t.Fatal("Over = true after recovery, want false") + } + if len(game.Snake) != 2 { + t.Fatalf("snake length = %d, want preserved length 2", len(game.Snake)) + } + if game.Food != (Point{X: -1, Y: -1}) { + t.Fatalf("Food = %#v on full board, want missing food", game.Food) } } @@ -159,6 +166,96 @@ func TestSnakeEndsRoundOnSelfCollision(t *testing.T) { } } +func TestSnakeIgnoresQueuedSideTurnIntoSelfWhenStraightIsSafe(t *testing.T) { + game := NewSnakeGame(6, 5, func(int) int { return 0 }) + game.Snake = []Point{ + {X: 2, Y: 2}, + {X: 2, Y: 3}, + {X: 1, Y: 3}, + {X: 1, Y: 2}, + } + game.Dir = DirectionRight + game.Food = Point{X: 0, Y: 0} + game.PendingDirs = []Direction{DirectionDown} + + result := game.Step() + + if result != StepMoved { + t.Fatalf("Step result = %v, want moved", result) + } + if game.Over { + t.Fatal("game.Over = true, want false") + } + if game.Dir != DirectionRight { + t.Fatalf("Dir = %v, want straight right", game.Dir) + } + assertSnake(t, game.Snake, []Point{ + {X: 3, Y: 2}, + {X: 2, Y: 2}, + {X: 2, Y: 3}, + {X: 1, Y: 3}, + }) +} + +func TestSnakeExecutesSafeQueuedSideTurnNormally(t *testing.T) { + game := NewSnakeGame(6, 5, func(int) int { return 0 }) + game.Snake = []Point{{X: 2, Y: 2}, {X: 1, Y: 2}, {X: 0, Y: 2}} + game.Dir = DirectionRight + game.Food = Point{X: 0, Y: 0} + game.PendingDirs = []Direction{DirectionDown} + + result := game.Step() + + if result != StepMoved { + t.Fatalf("Step result = %v, want moved", result) + } + if game.Dir != DirectionDown || game.Snake[0] != (Point{X: 2, Y: 3}) { + t.Fatalf("dir=%v head=%#v, want down to 2,3", game.Dir, game.Snake[0]) + } +} + +func TestSnakeDoesNotProtectQueuedSideTurnIntoWall(t *testing.T) { + game := NewSnakeGame(5, 5, func(int) int { return 0 }) + game.Snake = []Point{{X: 2, Y: 0}, {X: 1, Y: 0}, {X: 0, Y: 0}} + game.Dir = DirectionRight + game.Food = Point{X: 0, Y: 4} + game.PendingDirs = []Direction{DirectionUp} + + result := game.Step() + + if result != StepHitWall { + t.Fatalf("Step result = %v, want wall collision", result) + } + if !game.Over { + t.Fatal("game.Over = false, want true") + } +} + +func TestSnakeKeepsQueuedSelfCollisionWhenStraightIsUnsafe(t *testing.T) { + game := NewSnakeGame(5, 5, func(int) int { return 0 }) + game.Snake = []Point{ + {X: 1, Y: 1}, + {X: 2, Y: 1}, + {X: 1, Y: 2}, + {X: 1, Y: 3}, + } + game.Dir = DirectionRight + game.Food = Point{X: 0, Y: 0} + game.PendingDirs = []Direction{DirectionDown} + + result := game.Step() + + if result != StepHitSelf { + t.Fatalf("Step result = %v, want self collision", result) + } + if !game.Over { + t.Fatal("game.Over = false, want true") + } + if game.Dir != DirectionDown { + t.Fatalf("Dir = %v, want queued direction retained for normal collision", game.Dir) + } +} + func TestSnakeMayMoveIntoVacatedTail(t *testing.T) { game := NewSnakeGame(5, 5, func(int) int { return 0 }) game.Snake = []Point{ @@ -179,6 +276,107 @@ func TestSnakeMayMoveIntoVacatedTail(t *testing.T) { } } +func TestSnakeQueuedTurnMayMoveIntoVacatedTail(t *testing.T) { + game := NewSnakeGame(5, 5, func(int) int { return 0 }) + game.Snake = []Point{{X: 2, Y: 2}, {X: 2, Y: 3}} + game.Dir = DirectionRight + game.Food = Point{X: 0, Y: 0} + game.PendingDirs = []Direction{DirectionDown} + + result := game.Step() + + if result != StepMoved { + t.Fatalf("Step result = %v, want moved into vacated tail", result) + } + if game.Dir != DirectionDown || game.Snake[0] != (Point{X: 2, Y: 3}) { + t.Fatalf("dir=%v head=%#v, want down into vacated tail", game.Dir, game.Snake[0]) + } +} + +func TestSnakeQueuedTurnIntoTailDuringGrowthUsesStraightFallback(t *testing.T) { + game := NewSnakeGame(5, 5, func(int) int { return 0 }) + game.Snake = []Point{{X: 2, Y: 2}, {X: 2, Y: 3}} + game.Dir = DirectionRight + game.Food = Point{X: 0, Y: 0} + game.PendingDirs = []Direction{DirectionDown} + + result := game.StepGrow() + + if result != StepAteFood { + t.Fatalf("StepGrow result = %v, want growth move", result) + } + if game.Over { + t.Fatal("game.Over = true, want false") + } + if game.Dir != DirectionRight || game.Snake[0] != (Point{X: 3, Y: 2}) { + t.Fatalf("dir=%v head=%#v, want straight growth fallback", game.Dir, game.Snake[0]) + } + if len(game.Snake) != 3 { + t.Fatalf("snake length = %d, want growth to 3", len(game.Snake)) + } +} + +func TestSnakeUnsafeQueuedTurnFallbackCanEatFoodStraightAhead(t *testing.T) { + game := NewSnakeGame(6, 5, func(int) int { return 0 }) + game.Snake = []Point{ + {X: 2, Y: 2}, + {X: 2, Y: 3}, + {X: 1, Y: 3}, + {X: 1, Y: 2}, + } + game.Dir = DirectionRight + game.Food = Point{X: 3, Y: 2} + game.PendingDirs = []Direction{DirectionDown} + + result := game.Step() + + if result != StepAteFood { + t.Fatalf("Step result = %v, want food eaten straight ahead", result) + } + if game.Score != 1 { + t.Fatalf("Score = %d, want 1", game.Score) + } + if game.Dir != DirectionRight || game.Snake[0] != (Point{X: 3, Y: 2}) { + t.Fatalf("dir=%v head=%#v, want straight food fallback", game.Dir, game.Snake[0]) + } +} + +func TestSnakeUnsafeQueuedTurnFallbackMatchesClassicAndQuest(t *testing.T) { + now := time.Date(2026, 7, 13, 18, 0, 0, 0, time.UTC) + for _, test := range []struct { + name string + quest *QuestState + }{ + {name: "classic"}, + {name: "quest", quest: NewQuestState(GameModeQuest, now, fixedRandom(0), 6, 5)}, + } { + t.Run(test.name, func(t *testing.T) { + game := NewSnakeGame(6, 5, func(int) int { return 0 }) + game.Snake = []Point{ + {X: 2, Y: 2}, + {X: 2, Y: 3}, + {X: 1, Y: 3}, + {X: 1, Y: 2}, + } + game.Dir = DirectionRight + game.Food = Point{X: 0, Y: 0} + game.PendingDirs = []Direction{DirectionDown} + + result := stepGame(game, test.quest, HeatByLevel(1), now) + + if result != StepMoved { + t.Fatalf("Step result = %v, want moved", result) + } + if game.Over { + t.Fatal("game.Over = true, want false") + } + if game.Dir != DirectionRight || game.Snake[0] != (Point{X: 3, Y: 2}) { + t.Fatalf("dir=%v head=%#v, want straight fallback", game.Dir, game.Snake[0]) + } + }) + } +} + func TestSnakeResizePreservesStateWhenCoordinatesRemainValid(t *testing.T) { game := NewSnakeGame(6, 5, func(int) int { return 0 }) game.Snake = []Point{{X: 3, Y: 2}, {X: 2, Y: 2}, {X: 1, Y: 2}} @@ -445,6 +643,34 @@ func TestSnakeConsumesOneQueuedDirectionPerStep(t *testing.T) { } } +func TestSnakeUnsafeQueuedTurnDropsOnlyFirstDirection(t *testing.T) { + game := NewSnakeGame(6, 5, func(int) int { return 0 }) + game.Snake = []Point{ + {X: 2, Y: 2}, + {X: 2, Y: 3}, + {X: 1, Y: 3}, + {X: 1, Y: 2}, + } + game.Dir = DirectionRight + game.Food = Point{X: 0, Y: 0} + game.PendingDirs = []Direction{DirectionDown, DirectionLeft} + + result := game.Step() + + if result != StepMoved { + t.Fatalf("Step result = %v, want straight fallback move", result) + } + if game.Dir != DirectionRight || game.Snake[0] != (Point{X: 3, Y: 2}) { + t.Fatalf("dir=%v head=%#v, want one straight move", game.Dir, game.Snake[0]) + } + if got, want := len(game.PendingDirs), 1; got != want { + t.Fatalf("len(PendingDirs) = %d, want %d", got, want) + } + if game.PendingDirs[0] != DirectionLeft { + t.Fatalf("remaining PendingDirs[0] = %v, want left", game.PendingDirs[0]) + } +} + func TestSnakeNextPointUsesNextQueuedDirection(t *testing.T) { game := NewSnakeGame(5, 5, func(int) int { return 0 }) game.Snake = []Point{{X: 2, Y: 2}, {X: 1, Y: 2}} diff --git a/internal/runhistory/runhistory.go b/internal/runhistory/runhistory.go index 81bc6bf..fb65588 100644 --- a/internal/runhistory/runhistory.go +++ b/internal/runhistory/runhistory.go @@ -20,7 +20,7 @@ import ( const ( DefaultResultFileName = "result.json" DefaultOutputFileName = "output.txt" - DefaultRetentionLimit = 20 + DefaultRetentionLimit = 100 privateDirectoryPerm = 0o700 privateFilePerm = 0o600 ) diff --git a/internal/runhistory/runhistory_test.go b/internal/runhistory/runhistory_test.go index 159aebf..921b929 100644 --- a/internal/runhistory/runhistory_test.go +++ b/internal/runhistory/runhistory_test.go @@ -143,6 +143,27 @@ func TestRetentionKeepsNewestRuns(t *testing.T) { } } +func TestDefaultRetentionKeepsNewestHundredRuns(t *testing.T) { + base := filepath.Join(t.TempDir(), "state", "sidequest") + manager := Manager{BaseDir: base, UID: os.Getuid()} + start := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC) + for index := 0; index < DefaultRetentionLimit+1; index++ { + id := "run-" + strings.Repeat("a", index/26) + string(rune('a'+index%26)) + storeRun(t, manager, id, start.Add(time.Duration(index)*time.Minute), id+"\n") + } + + runs, err := manager.List() + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(runs) != DefaultRetentionLimit { + t.Fatalf("len(runs) = %d, want %d", len(runs), DefaultRetentionLimit) + } + if _, err := os.Stat(filepath.Join(base, "runs", "run-a")); !os.IsNotExist(err) { + t.Fatalf("oldest run stat error = %v, want not exist", err) + } +} + func TestStoreRejectsSymlinkStateRoot(t *testing.T) { tmp := t.TempDir() target := filepath.Join(tmp, "target") diff --git a/internal/session/command.go b/internal/session/command.go index 254fdd2..ea01dcb 100644 --- a/internal/session/command.go +++ b/internal/session/command.go @@ -17,11 +17,32 @@ type Command struct { Arguments []string `json:"arguments"` } +type CommandStartupStatus string + +const ( + CommandStartupStarted CommandStartupStatus = "started" + CommandStartupStartFailed CommandStartupStatus = "start_failed" + CommandStartupCompleted CommandStartupStatus = StatusCompleted + CommandStartupFailed CommandStartupStatus = StatusFailed + CommandStartupInterrupted CommandStartupStatus = StatusInterrupted +) + +type CommandStartup struct { + Status CommandStartupStatus `json:"status"` + Error string `json:"error,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + ExitSignal string `json:"exit_signal,omitempty"` +} + type CommandListener struct { listener *net.UnixListener path string } +type CommandExchange struct { + conn *net.UnixConn +} + func ListenCommand(session Session) (*CommandListener, error) { if err := os.Remove(session.SocketPath); err != nil && !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("remove stale command socket: %w", err) @@ -41,59 +62,103 @@ func ListenCommand(session Session) (*CommandListener, error) { return &CommandListener{listener: listener, path: session.SocketPath}, nil } -func (l *CommandListener) Serve(ctx context.Context, command Command) error { +func (l *CommandListener) Serve(ctx context.Context, command Command) (CommandStartup, error) { if command.Executable == "" { - return ErrEmptyExecutable + return CommandStartup{}, ErrEmptyExecutable } if deadline, ok := ctx.Deadline(); ok { if err := l.listener.SetDeadline(deadline); err != nil { - return fmt.Errorf("set command socket deadline: %w", err) + return CommandStartup{}, fmt.Errorf("set command socket deadline: %w", err) } } conn, err := l.listener.AcceptUnix() if err != nil { - return fmt.Errorf("accept command handoff: %w", err) + return CommandStartup{}, fmt.Errorf("accept command handoff: %w", err) } defer conn.Close() if deadline, ok := ctx.Deadline(); ok { if err := conn.SetDeadline(deadline); err != nil { - return fmt.Errorf("set command handoff deadline: %w", err) + return CommandStartup{}, fmt.Errorf("set command handoff deadline: %w", err) } } if err := json.NewEncoder(conn).Encode(command); err != nil { - return fmt.Errorf("encode command handoff: %w", err) + return CommandStartup{}, fmt.Errorf("encode command handoff: %w", err) } - return nil + var startup CommandStartup + if err := json.NewDecoder(conn).Decode(&startup); err != nil { + return CommandStartup{}, fmt.Errorf("decode command startup status: %w", err) + } + if startup.Status == "" { + return CommandStartup{}, fmt.Errorf("empty command startup status") + } + + return startup, nil } func ReceiveCommand(ctx context.Context, socketPath string) (Command, error) { + command, exchange, err := ReceiveCommandExchange(ctx, socketPath) + if exchange != nil { + _ = exchange.ReportStartup(CommandStartup{Status: CommandStartupStarted}) + _ = exchange.Close() + } + return command, err +} + +func ReceiveCommandExchange(ctx context.Context, socketPath string) (Command, *CommandExchange, error) { dialer := net.Dialer{} conn, err := dialer.DialContext(ctx, "unix", socketPath) if err != nil { - return Command{}, fmt.Errorf("connect to command socket: %w", err) + return Command{}, nil, fmt.Errorf("connect to command socket: %w", err) + } + unixConn, ok := conn.(*net.UnixConn) + if !ok { + conn.Close() + return Command{}, nil, fmt.Errorf("command socket is not a unix connection") } - defer conn.Close() if deadline, ok := ctx.Deadline(); ok { - if err := conn.SetDeadline(deadline); err != nil { - return Command{}, fmt.Errorf("set command handoff deadline: %w", err) + if err := unixConn.SetDeadline(deadline); err != nil { + unixConn.Close() + return Command{}, nil, fmt.Errorf("set command handoff deadline: %w", err) } } else { - _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + _ = unixConn.SetDeadline(time.Now().Add(5 * time.Second)) } var command Command - if err := json.NewDecoder(conn).Decode(&command); err != nil { - return Command{}, fmt.Errorf("decode command handoff: %w", err) + if err := json.NewDecoder(unixConn).Decode(&command); err != nil { + unixConn.Close() + return Command{}, nil, fmt.Errorf("decode command handoff: %w", err) } if command.Executable == "" { - return Command{}, ErrEmptyExecutable + unixConn.Close() + return Command{}, nil, ErrEmptyExecutable + } + + return command, &CommandExchange{conn: unixConn}, nil +} + +func (e *CommandExchange) ReportStartup(startup CommandStartup) error { + if e == nil || e.conn == nil { + return nil + } + if startup.Status == "" { + startup.Status = CommandStartupStarted + } + if err := json.NewEncoder(e.conn).Encode(startup); err != nil { + return fmt.Errorf("encode command startup status: %w", err) } + return nil +} - return command, nil +func (e *CommandExchange) Close() error { + if e == nil || e.conn == nil { + return nil + } + return e.conn.Close() } func (l *CommandListener) Close() error { diff --git a/internal/session/session.go b/internal/session/session.go index 2ec70b5..e973d37 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -55,6 +55,8 @@ type State struct { TmuxSocket string `json:"tmux_socket,omitempty"` GameMode string `json:"game_mode,omitempty"` NoHistory bool `json:"no_history,omitempty"` + NoColor bool `json:"no_color,omitempty"` + Augmented bool `json:"augmented,omitempty"` } type Manager struct { @@ -236,10 +238,18 @@ func WriteState(session Session, state State) error { } data = append(data, '\n') - file, err := os.OpenFile(session.StatePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, regularStateFilePerm) + dir := filepath.Dir(session.StatePath) + file, err := os.CreateTemp(dir, ".state-*.tmp") if err != nil { - return fmt.Errorf("open session state: %w", err) + return fmt.Errorf("create temporary session state: %w", err) } + tempPath := file.Name() + committed := false + defer func() { + if !committed { + _ = os.Remove(tempPath) + } + }() writeErr := func() error { if _, err := file.Write(data); err != nil { @@ -248,12 +258,16 @@ func WriteState(session Session, state State) error { if err := file.Chmod(regularStateFilePerm); err != nil { return err } - return file.Close() + if err := file.Close(); err != nil { + return err + } + return os.Rename(tempPath, session.StatePath) }() if writeErr != nil { _ = file.Close() return fmt.Errorf("write session state: %w", writeErr) } + committed = true return nil } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 48b4929..c65e238 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" ) @@ -97,7 +98,7 @@ func TestCommandIsNeverWrittenToRuntimeFiles(t *testing.T) { errc <- err }() - if err := listener.Serve(ctx, command); err != nil { + if _, err := listener.Serve(ctx, command); err != nil { t.Fatalf("Serve returned error: %v", err) } if err := <-errc; err != nil { @@ -137,7 +138,7 @@ func TestCommandHandoffPreservesArgumentArray(t *testing.T) { errc <- err }() - if err := listener.Serve(ctx, want); err != nil { + if _, err := listener.Serve(ctx, want); err != nil { t.Fatalf("Serve returned error: %v", err) } if err := <-errc; err != nil { @@ -282,6 +283,107 @@ func TestUpdateStatePersistsTmuxSocketWithoutCommand(t *testing.T) { assertRuntimeFilesDoNotContain(t, session.Dir, "bash", "sleep 30", "exit 7") } +func TestWriteStateUsesAtomicReplacementAndPrivatePermissions(t *testing.T) { + manager := Manager{BaseDir: filepath.Join(t.TempDir(), "sidequest"), IDGenerator: fixedID("atomic-state")} + session, err := manager.Create() + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + + if err := WriteState(session, State{ID: session.ID, Status: StatusRunning}); err != nil { + t.Fatalf("WriteState returned error: %v", err) + } + + state, err := ReadState(session) + if err != nil { + t.Fatalf("ReadState returned error: %v", err) + } + if state.Status != StatusRunning { + t.Fatalf("Status = %q, want %q", state.Status, StatusRunning) + } + assertMode(t, session.StatePath, regularStateFilePerm) + assertNoStateTempFiles(t, session.Dir) +} + +func TestWriteStateRemovesTemporaryFileOnFailure(t *testing.T) { + dir := t.TempDir() + session := Session{ + ID: "bad-state", + Dir: dir, + StatePath: filepath.Join(dir, "missing", DefaultStateFileName), + } + + err := WriteState(session, State{Status: StatusRunning}) + if err == nil { + t.Fatal("WriteState succeeded, want error") + } + assertNoStateTempFiles(t, dir) +} + +func TestConcurrentReadStateNeverObservesPartialWrite(t *testing.T) { + manager := Manager{BaseDir: filepath.Join(t.TempDir(), "sidequest"), IDGenerator: fixedID("concurrent-state")} + session, err := manager.Create() + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + + stop := make(chan struct{}) + errc := make(chan error, 1) + var readers sync.WaitGroup + for reader := 0; reader < 4; reader++ { + readers.Add(1) + go func() { + defer readers.Done() + for { + select { + case <-stop: + return + default: + } + if _, err := ReadState(session); err != nil { + select { + case errc <- err: + default: + } + return + } + } + }() + } + + for index := 0; index < 500; index++ { + err := UpdateState(session, time.Now(), func(state *State) { + state.Status = StatusRunning + state.TmuxSocket = strings.Repeat("x", 1024) + if index%2 == 0 { + state.Status = StatusCompleted + state.TmuxSocket = strings.Repeat("y", 1024) + } + }) + if err != nil { + close(stop) + readers.Wait() + t.Fatalf("UpdateState returned error: %v", err) + } + select { + case err := <-errc: + close(stop) + readers.Wait() + t.Fatalf("ReadState observed partial write: %v", err) + default: + } + } + + close(stop) + readers.Wait() + select { + case err := <-errc: + t.Fatalf("ReadState observed partial write: %v", err) + default: + } + assertNoStateTempFiles(t, session.Dir) +} + func TestListReadsOnlySessionMetadata(t *testing.T) { base := filepath.Join(t.TempDir(), "sidequest") manager := Manager{ @@ -359,6 +461,19 @@ func assertMode(t *testing.T, path string, want os.FileMode) { } } +func assertNoStateTempFiles(t *testing.T, dir string) { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read dir %s: %v", dir, err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".state-") && strings.HasSuffix(entry.Name(), ".tmp") { + t.Fatalf("temporary state file was not removed: %s", filepath.Join(dir, entry.Name())) + } + } +} + func assertRuntimeFilesDoNotContain(t *testing.T, root string, values ...string) { t.Helper() err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index a2aafa1..dbc9150 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/WBT112/sidequest/internal/preflight" "github.com/WBT112/sidequest/internal/session" ) @@ -27,8 +28,11 @@ type ExecRunner struct { type Layout struct { TmuxPath string CommandRunner Runner + TerminalSize TerminalSizeReader } +type TerminalSizeReader func() (columns int, rows int, err error) + type BossState struct { Hidden bool PreviousGameFocus bool @@ -40,17 +44,28 @@ type Info struct { } const commandPaneHistoryLimit = 100000 +const commandPanePreviewLines = 20 const ( bossHiddenOption = "@sidequest_boss_hidden" bossPrevGameOption = "@sidequest_boss_prev_game" + gamePausedOption = "@sidequest_game_paused" + commandStatusOption = "@sidequest_command_status" + commandExitOption = "@sidequest_command_exit" bossContinueMessage = "F9 Continue" ) const ( paneBorderStatus = "top" + paneBorderLines = "single" paneBorderStyle = "fg=colour244" - paneActiveBorderStyle = "fg=brightwhite,bold" + paneActiveBorderStyle = "fg=cyan,bold" + monoPaneBorderStyle = "default" + monoActiveBorderStyle = "bold,reverse" + commandPaneTitle = "COMMAND" + gamePaneTitle = "SNAKE" + remainOnExitFormat = "Command finished - F12 Snake - F10 Shell" + fullTitleMinWidth = 72 ) func (l Layout) Start(runtimeSession session.Session, commandRunner []string, gameRunner []string) (Info, error) { @@ -79,8 +94,17 @@ func (l Layout) Start(runtimeSession session.Session, commandRunner []string, ga run := func(args ...string) error { return runner.Run(tmuxPath, append(baseArgs, args...)...) } + runOptional := func(args ...string) { + _ = run(args...) + } + ui := uiPresetForSession(runtimeSession) - if err := run("new-session", "-d", "-s", info.SessionName, "-n", "sidequest", shellJoin(commandRunner)); err != nil { + newSessionArgs := []string{"new-session", "-d"} + if columns, rows, err := l.currentTerminalSize(); err == nil && columns > 0 && rows > 0 { + newSessionArgs = append(newSessionArgs, "-x", strconv.Itoa(columns), "-y", strconv.Itoa(rows)) + } + newSessionArgs = append(newSessionArgs, "-s", info.SessionName, "-n", "sidequest", shellJoin(commandRunner)) + if err := run(newSessionArgs...); err != nil { return Info{}, fmt.Errorf("create tmux session: %w", err) } @@ -92,34 +116,47 @@ func (l Layout) Start(runtimeSession session.Session, commandRunner []string, ga if err := run("set-option", "-t", info.SessionName, "status", "off"); err != nil { return cleanup(fmt.Errorf("disable tmux status bar: %w", err)) } + if err := run("set-option", "-t", info.SessionName, "prefix", "None"); err != nil { + return cleanup(fmt.Errorf("disable tmux prefix: %w", err)) + } + if err := run("set-option", "-t", info.SessionName, "prefix2", "None"); err != nil { + return cleanup(fmt.Errorf("disable tmux secondary prefix: %w", err)) + } if err := run("set-option", "-t", info.SessionName, "history-limit", fmt.Sprintf("%d", commandPaneHistoryLimit)); err != nil { return cleanup(fmt.Errorf("set command pane history limit: %w", err)) } if err := run("set-option", "-t", info.SessionName, "pane-border-status", paneBorderStatus); err != nil { return cleanup(fmt.Errorf("enable pane titles: %w", err)) } - if err := run("set-option", "-t", info.SessionName, "pane-border-format", paneBorderFormat()); err != nil { + runOptional("set-option", "-t", info.SessionName, "pane-border-lines", paneBorderLines) + if err := run("set-option", "-t", info.SessionName, "pane-border-format", paneBorderFormat(ui)); err != nil { return cleanup(fmt.Errorf("configure pane titles: %w", err)) } - if err := run("set-option", "-t", info.SessionName, "pane-border-style", paneBorderStyle); err != nil { + if err := run("set-option", "-t", info.SessionName, "pane-border-style", ui.paneBorderStyle()); err != nil { return cleanup(fmt.Errorf("configure inactive pane border style: %w", err)) } - if err := run("set-option", "-t", info.SessionName, "pane-active-border-style", paneActiveBorderStyle); err != nil { + if err := run("set-option", "-t", info.SessionName, "pane-active-border-style", ui.paneActiveBorderStyle()); err != nil { return cleanup(fmt.Errorf("configure active pane border style: %w", err)) } - if err := run("select-pane", "-t", info.SessionName+":0.0", "-T", "Command - F9 hide, F12 Snake, F10 shell"); err != nil { + if err := run("select-pane", "-t", info.SessionName+":0.0", "-T", commandPaneTitle); err != nil { return cleanup(fmt.Errorf("title command pane: %w", err)) } + if err := run("set-option", "-q", "-t", info.SessionName, gamePausedOption, "0"); err != nil { + return cleanup(fmt.Errorf("initialize game pane state: %w", err)) + } + if err := run("set-option", "-q", "-t", info.SessionName, commandStatusOption, session.StatusRunning); err != nil { + return cleanup(fmt.Errorf("initialize command pane state: %w", err)) + } + if err := run("set-option", "-q", "-t", info.SessionName, commandExitOption, ""); err != nil { + return cleanup(fmt.Errorf("initialize command pane exit code: %w", err)) + } if err := run("set-option", "-t", info.SessionName, "remain-on-exit", "on"); err != nil { return cleanup(fmt.Errorf("enable command pane remain-on-exit: %w", err)) } + runOptional("set-option", "-t", info.SessionName, "remain-on-exit-format", remainOnExitFormat) if err := run("split-window", "-v", "-l", "16", "-t", info.SessionName+":0.0", shellJoin(gameRunner)); err != nil { return cleanup(fmt.Errorf("create placeholder pane: %w", err)) } - gamePaneTitle := "Snake - arrows/WASD, F9 hide, F12 Command, F10 shell" - if outputRunner, ok := runner.(OutputRunner); ok { - gamePaneTitle = centeredPaneTitle(outputRunner, tmuxPath, baseArgs, info.SessionName+":0.1", gamePaneTitle) - } if err := run("select-pane", "-t", info.SessionName+":0.1", "-T", gamePaneTitle); err != nil { return cleanup(fmt.Errorf("title game pane: %w", err)) } @@ -132,6 +169,11 @@ func (l Layout) Start(runtimeSession session.Session, commandRunner []string, ga if err := run("bind-key", "-n", "F9", "if-shell", "-F", "#{==:#{"+bossHiddenOption+"},1}", bossRestoreCommand(info), bossHideCommand(info)); err != nil { return cleanup(fmt.Errorf("bind F9 boss key: %w", err)) } + for _, binding := range commandPaneScrollBindings() { + if err := run("bind-key", "-n", binding.key, "if-shell", "-F", "#{==:#{pane_index},0}", binding.command, "send-keys "+binding.key); err != nil { + return cleanup(fmt.Errorf("bind command pane scroll key %s: %w", binding.key, err)) + } + } if err := run("select-pane", "-t", info.SessionName+":0.1"); err != nil { return cleanup(fmt.Errorf("focus game pane: %w", err)) } @@ -139,6 +181,39 @@ func (l Layout) Start(runtimeSession session.Session, commandRunner []string, ga return info, nil } +func (l Layout) currentTerminalSize() (columns int, rows int, err error) { + if l.TerminalSize != nil { + return l.TerminalSize() + } + env := preflight.DefaultEnvironment() + size, err := env.TerminalSize(env.StdoutFD) + if err != nil { + return 0, 0, err + } + return size.Columns, size.Rows, nil +} + +type commandPaneScrollBinding struct { + key string + command string +} + +func commandPaneScrollBindings() []commandPaneScrollBinding { + return []commandPaneScrollBinding{ + {key: "PPage", command: "copy-mode -e -u"}, + {key: "NPage", command: copyModeScrollDownCommand("page-down")}, + {key: "Up", command: "copy-mode -e ; send-keys -X scroll-up"}, + {key: "Down", command: copyModeScrollDownCommand("scroll-down")}, + } +} + +func copyModeScrollDownCommand(command string) string { + return fmt.Sprintf( + "if-shell -F '#{pane_in_mode}' 'send-keys -X %s ; if-shell -F \"#{==:#{scroll_position},0}\" \"send-keys -X cancel\"' 'display-message -d 1 \"\"'", + command, + ) +} + func bossHideCommand(info Info) string { return strings.Join([]string{ fmt.Sprintf("if-shell -F '#{==:#{pane_index},1}' 'set-option -q -t %s %s 1' 'set-option -q -t %s %s 0'", info.SessionName, bossPrevGameOption, info.SessionName, bossPrevGameOption), @@ -154,14 +229,141 @@ func bossRestoreCommand(info Info) string { return strings.Join([]string{ fmt.Sprintf("if-shell -F '#{window_zoomed_flag}' 'resize-pane -Z -t %s:0.0' ''", info.SessionName), fmt.Sprintf("set-option -q -t %s pane-border-status %s", info.SessionName, paneBorderStatus), - fmt.Sprintf("set-option -q -t %s pane-border-format '%s'", info.SessionName, paneBorderFormat()), fmt.Sprintf("if-shell -F '#{==:#{%s},1}' 'select-pane -t %s:0.1' 'select-pane -t %s:0.0'", bossPrevGameOption, info.SessionName, info.SessionName), fmt.Sprintf("set-option -q -t %s %s 0", info.SessionName, bossHiddenOption), }, " ; ") } -func paneBorderFormat() string { - return "#{?pane_active,#[bold]#[reverse] ▶ #{pane_title} - #{?#{==:#{pane_index},0},INPUT ACTIVE,CONTROLS ACTIVE} #[default],#[dim] #{pane_title} - #{?#{==:#{pane_index},0},RUNNING,PAUSED} #[default]}" +type uiPreset struct { + NoColor bool +} + +func uiPresetForSession(runtimeSession session.Session) uiPreset { + state, err := session.ReadState(runtimeSession) + if err != nil { + return uiPreset{} + } + return uiPreset{NoColor: state.NoColor} +} + +func (p uiPreset) paneBorderStyle() string { + if p.NoColor { + return monoPaneBorderStyle + } + return paneBorderStyle +} + +func (p uiPreset) paneActiveBorderStyle() string { + if p.NoColor { + return monoActiveBorderStyle + } + return paneActiveBorderStyle +} + +func paneBorderFormat(preset uiPreset) string { + activeStyle := "#[align=centre]#[fg=cyan]#[bold]#[reverse]" + inactiveStyle := "#[align=centre]#[fg=colour244]" + if preset.NoColor { + activeStyle = "#[align=centre]#[bold]#[reverse]" + inactiveStyle = "#[default]#[align=centre]" + } + return "#{?pane_active," + activeStyle + "> " + paneTitleFormat(true) + "#[default]," + inactiveStyle + paneTitleFormat(false) + "#[default]}" +} + +func paneTitleFormat(active bool) string { + command := tmuxWidthVariant(commandPaneTitleFormat(active, true), commandPaneTitleFormat(active, false)) + game := tmuxWidthVariant(gamePaneTitleFormat(active, true), gamePaneTitleFormat(active, false)) + return "#{?#{==:#{pane_index},0}," + command + "," + game + "}" +} + +func tmuxWidthVariant(full string, compact string) string { + return fmt.Sprintf("#{?#{>=:#{pane_width},%d},%s,%s}", fullTitleMinWidth, full, compact) +} + +func commandPaneTitleVariant(active bool, width int) string { + return commandPaneTitleVariantFor(active, width, session.StatusRunning, nil) +} + +func commandPaneTitleVariantFor(active bool, width int, status string, exitCode *int) string { + if width < fullTitleMinWidth { + return "COMMAND - F12 Snake - F10 Shell" + } + if session.IsTerminalStatus(status) { + return "COMMAND - " + commandResultLabel(status, exitCode) + " - F12 Snake - F10 Shell" + } + state := "RUNNING" + if active { + state = "INPUT" + } + return "COMMAND - " + state + " - F12 Snake - PgUp/PgDn Scroll - F10 Shell" +} + +func commandPaneTitleFormat(active bool, full bool) string { + if !full { + return commandPaneTitleVariantFor(active, fullTitleMinWidth-1, session.StatusRunning, nil) + } + live := commandPaneTitleVariantFor(active, fullTitleMinWidth, session.StatusRunning, nil) + return "#{?pane_dead,COMMAND - " + commandDeadResultFormat() + " - F12 Snake - F10 Shell," + live + "}" +} + +func commandDeadResultFormat() string { + status := "#{" + commandStatusOption + "}" + exitCode := "#{" + commandExitOption + "}" + fallbackStatus := "#{pane_dead_status}" + fallback := "#{?#{==:" + fallbackStatus + ",0},DONE - EXIT 0," + + "#{?#{==:" + fallbackStatus + ",126},START FAILED," + + "#{?#{==:" + fallbackStatus + ",127},START FAILED," + + "#{?#{==:" + fallbackStatus + ",130},INTERRUPTED,FAILED - EXIT " + fallbackStatus + "}}}}" + return "#{?#{==:" + status + "," + session.StatusCompleted + "},DONE - EXIT " + exitCode + "," + + "#{?#{==:" + status + "," + session.StatusFailed + "},FAILED - EXIT " + exitCode + "," + + "#{?#{==:" + status + "," + session.StatusInterrupted + "},INTERRUPTED," + + "#{?#{==:" + status + "," + session.StatusStartFailed + "},START FAILED," + fallback + "}}}}" +} + +func commandResultLabel(status string, exitCode *int) string { + switch status { + case session.StatusCompleted: + if exitCode != nil { + return fmt.Sprintf("DONE - EXIT %d", *exitCode) + } + return "DONE" + case session.StatusFailed: + if exitCode != nil { + return fmt.Sprintf("FAILED - EXIT %d", *exitCode) + } + return "FAILED" + case session.StatusInterrupted: + return "INTERRUPTED" + case session.StatusStartFailed: + return "START FAILED" + default: + return strings.ToUpper(status) + } +} + +func gamePaneTitleVariant(active bool, width int) string { + return gamePaneTitleVariantFor(active, width, false) +} + +func gamePaneTitleVariantFor(active bool, width int, paused bool) string { + if width < fullTitleMinWidth { + return "SNAKE - F12 Command - P Pause - F10 Shell" + } + state := "PAUSED" + if active && !paused { + state = "ACTIVE" + } + return "SNAKE - " + state + " - F12 Command - P Pause - F10 Shell" +} + +func gamePaneTitleFormat(active bool, full bool) string { + if !full { + return gamePaneTitleVariantFor(active, fullTitleMinWidth-1, false) + } + if !active { + return gamePaneTitleVariantFor(false, fullTitleMinWidth, false) + } + return "SNAKE - #{?#{==:#{@" + strings.TrimPrefix(gamePausedOption, "@") + "},1},PAUSED,ACTIVE} - F12 Command - P Pause - F10 Shell" } func (l Layout) Attach(info Info) error { @@ -251,6 +453,52 @@ func (l Layout) DetachClients(info Info) error { return nil } +func (l Layout) SetGamePaused(info Info, paused bool) error { + tmuxPath := l.TmuxPath + if tmuxPath == "" { + tmuxPath = "tmux" + } + runner := l.CommandRunner + if runner == nil { + runner = quietRunner() + } + + value := "0" + if paused { + value = "1" + } + if err := runner.Run(tmuxPath, "-f", "/dev/null", "-L", info.SocketName, "set-option", "-q", "-t", info.SessionName, gamePausedOption, value); err != nil { + return fmt.Errorf("update game pane state: %w", err) + } + + return nil +} + +func (l Layout) SetCommandState(info Info, state session.State) error { + tmuxPath := l.TmuxPath + if tmuxPath == "" { + tmuxPath = "tmux" + } + runner := l.CommandRunner + if runner == nil { + runner = quietRunner() + } + + exitCode := "" + if state.ExitCode != nil { + exitCode = fmt.Sprintf("%d", *state.ExitCode) + } + baseArgs := []string{"-f", "/dev/null", "-L", info.SocketName, "set-option", "-q", "-t", info.SessionName} + if err := runner.Run(tmuxPath, append(baseArgs, commandStatusOption, state.Status)...); err != nil { + return fmt.Errorf("update command pane state: %w", err) + } + if err := runner.Run(tmuxPath, append(baseArgs, commandExitOption, exitCode)...); err != nil { + return fmt.Errorf("update command pane exit code: %w", err) + } + + return nil +} + func (l Layout) GamePaneActive(info Info) (bool, error) { tmuxPath := l.TmuxPath if tmuxPath == "" { @@ -312,6 +560,47 @@ func (l Layout) CaptureCommandPane(info Info) (string, bool, error) { return text, truncated, nil } +func (l Layout) CaptureCommandPreview(info Info) (string, error) { + tmuxPath := l.TmuxPath + if tmuxPath == "" { + tmuxPath = "tmux" + } + runner := l.CommandRunner + if runner == nil { + runner = quietRunner() + } + outputRunner, ok := runner.(OutputRunner) + if !ok { + return "", fmt.Errorf("tmux runner cannot capture output") + } + + output, err := outputRunner.Output( + tmuxPath, + "-f", "/dev/null", + "-L", info.SocketName, + "capture-pane", + "-p", + "-J", + "-S", fmt.Sprintf("-%d", commandPanePreviewLines), + "-t", info.SessionName+":0.0", + ) + if err != nil { + return "", fmt.Errorf("capture command preview: %w", err) + } + return lastCommandPreviewLine(string(output)), nil +} + +func lastCommandPreviewLine(output string) string { + lines := strings.Split(output, "\n") + for index := len(lines) - 1; index >= 0; index-- { + line := strings.TrimSpace(lines[index]) + if line != "" { + return line + } + } + return "" +} + func (l Layout) HasSession(info Info) bool { tmuxPath := l.TmuxPath if tmuxPath == "" { @@ -363,32 +652,3 @@ func shellQuote(value string) string { } return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } - -func centeredPaneTitle(outputRunner OutputRunner, tmuxPath string, baseArgs []string, paneTarget string, title string) string { - width, err := paneWidth(outputRunner, tmuxPath, baseArgs, paneTarget) - if err != nil || width <= 0 { - return title - } - titleWidth := len([]rune(title)) - if titleWidth >= width { - return title - } - padding := (width - titleWidth) / 2 - if padding <= 0 { - return title - } - return strings.Repeat(" ", padding) + title -} - -func paneWidth(outputRunner OutputRunner, tmuxPath string, baseArgs []string, paneTarget string) (int, error) { - args := append(append([]string{}, baseArgs...), "display-message", "-p", "-t", paneTarget, "#{pane_width}") - output, err := outputRunner.Output(tmuxPath, args...) - if err != nil { - return 0, err - } - width, err := strconv.Atoi(strings.TrimSpace(string(output))) - if err != nil { - return 0, err - } - return width, nil -} diff --git a/internal/tmux/tmux_test.go b/internal/tmux/tmux_test.go index 4f412b1..400f38a 100644 --- a/internal/tmux/tmux_test.go +++ b/internal/tmux/tmux_test.go @@ -2,6 +2,7 @@ package tmux import ( "errors" + "path/filepath" "strings" "testing" @@ -32,18 +33,29 @@ func TestStartCreatesIsolatedLayout(t *testing.T) { wantPrefixes := [][]string{ {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "new-session", "-d", "-s", "sidequest-abc123", "-n", "sidequest"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "status", "off"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "prefix", "None"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "prefix2", "None"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "history-limit", "100000"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "pane-border-status", "top"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "pane-border-lines", "single"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "pane-border-format"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "pane-border-style", "fg=colour244"}, - {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "pane-active-border-style", "fg=brightwhite,bold"}, - {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "select-pane", "-t", "sidequest-abc123:0.0", "-T", "Command - F9 hide, F12 Snake, F10 shell"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "pane-active-border-style", "fg=cyan,bold"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "select-pane", "-t", "sidequest-abc123:0.0", "-T", "COMMAND"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-q", "-t", "sidequest-abc123", "@sidequest_game_paused", "0"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-q", "-t", "sidequest-abc123", "@sidequest_command_status", "running"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-q", "-t", "sidequest-abc123", "@sidequest_command_exit", ""}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "remain-on-exit", "on"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-t", "sidequest-abc123", "remain-on-exit-format", "Command finished - F12 Snake - F10 Shell"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "split-window", "-v", "-l", "16", "-t", "sidequest-abc123:0.0"}, - {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "select-pane", "-t", "sidequest-abc123:0.1", "-T", "Snake - arrows/WASD, F9 hide, F12 Command, F10 shell"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "select-pane", "-t", "sidequest-abc123:0.1", "-T", "SNAKE"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "bind-key", "-n", "F12", "select-pane", "-t", ":.+"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "bind-key", "-n", "F10", "detach-client"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "bind-key", "-n", "F9", "if-shell", "-F", "#{==:#{@sidequest_boss_hidden},1}"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "bind-key", "-n", "PPage", "if-shell", "-F", "#{==:#{pane_index},0}"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "bind-key", "-n", "NPage", "if-shell", "-F", "#{==:#{pane_index},0}"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "bind-key", "-n", "Up", "if-shell", "-F", "#{==:#{pane_index},0}"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "bind-key", "-n", "Down", "if-shell", "-F", "#{==:#{pane_index},0}"}, {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "select-pane", "-t", "sidequest-abc123:0.1"}, } @@ -55,13 +67,41 @@ func TestStartCreatesIsolatedLayout(t *testing.T) { t.Fatalf("call %d = %#v, want prefix %#v", index, runner.calls[index], want) } } - splitCall := runner.calls[9] + splitCall := runner.calls[16] splitCommand := splitCall[len(splitCall)-1] if !strings.Contains(splitCommand, "__sidequest-game") { t.Fatalf("split command = %q, want game runner", splitCommand) } } +func TestStartSeedsDetachedSessionWithCurrentTerminalSize(t *testing.T) { + runner := &recordingRunner{} + layout := Layout{ + CommandRunner: runner, + TerminalSize: func() (int, int, error) { + return 132, 43, nil + }, + } + runtimeSession := session.Session{ID: "sized", SocketPath: "/tmp/sidequest-1000/sized/command.sock"} + + if _, err := layout.Start( + runtimeSession, + []string{"/usr/bin/sidequest", "__sidequest-command-runner", runtimeSession.SocketPath}, + []string{"/usr/bin/sidequest", "__sidequest-game", "/tmp/sidequest-1000/sized/state.json"}, + ); err != nil { + t.Fatalf("Start returned error: %v", err) + } + + want := []string{ + "tmux", "-f", "/dev/null", "-L", "sidequest-sized", + "new-session", "-d", "-x", "132", "-y", "43", + "-s", "sidequest-sized", "-n", "sidequest", + } + if !hasPrefix(runner.calls[0], want) { + t.Fatalf("new-session call = %#v, want prefix %#v", runner.calls[0], want) + } +} + func TestStartConfiguresEnhancedPaneFocusFormatting(t *testing.T) { runner := &recordingRunner{} layout := Layout{CommandRunner: runner} @@ -78,15 +118,25 @@ func TestStartConfiguresEnhancedPaneFocusFormatting(t *testing.T) { joined := runner.joinedCalls() for _, want := range []string{ "pane-border-status top", + "pane-border-lines single", "pane-border-style fg=colour244", - "pane-active-border-style fg=brightwhite,bold", - "▶ #{pane_title}", - "INPUT ACTIVE", - "CONTROLS ACTIVE", + "pane-active-border-style fg=cyan,bold", + "#[align=centre]#[fg=cyan]#[bold]#[reverse]> ", + "COMMAND - INPUT", + "PAUSED,ACTIVE", "RUNNING", "PAUSED", + "@sidequest_game_paused", + "@sidequest_command_status", + "@sidequest_command_exit", + "DONE - EXIT", + "FAILED - EXIT", + "INTERRUPTED", + "START FAILED", "#{?pane_active", + "#{?pane_dead", "#{pane_index}", + "#{pane_width}", } { if !strings.Contains(joined, want) { t.Fatalf("enhanced focus formatting missing %q:\n%s", want, joined) @@ -94,7 +144,7 @@ func TestStartConfiguresEnhancedPaneFocusFormatting(t *testing.T) { } } -func TestStartPreservesPaneTitleStrings(t *testing.T) { +func TestStartUsesConcisePaneTitleStrings(t *testing.T) { runner := &recordingRunner{} layout := Layout{CommandRunner: runner} runtimeSession := session.Session{ID: "titles", SocketPath: "/tmp/sidequest-1000/titles/command.sock"} @@ -109,8 +159,8 @@ func TestStartPreservesPaneTitleStrings(t *testing.T) { joined := runner.joinedCalls() for _, title := range []string{ - "Command - F9 hide, F12 Snake, F10 shell", - "Snake - arrows/WASD, F9 hide, F12 Command, F10 shell", + "select-pane -t sidequest-titles:0.0 -T COMMAND", + "select-pane -t sidequest-titles:0.1 -T SNAKE", } { if !strings.Contains(joined, title) { t.Fatalf("pane title %q was not preserved:\n%s", title, joined) @@ -118,6 +168,32 @@ func TestStartPreservesPaneTitleStrings(t *testing.T) { } } +func TestStartBindsCommandPaneScrollKeysOnlyForCommandPane(t *testing.T) { + runner := &recordingRunner{} + layout := Layout{CommandRunner: runner} + runtimeSession := session.Session{ID: "scroll", SocketPath: "/tmp/sidequest-1000/scroll/command.sock"} + + if _, err := layout.Start( + runtimeSession, + []string{"/usr/bin/sidequest", "__sidequest-command-runner", runtimeSession.SocketPath}, + []string{"/usr/bin/sidequest", "__sidequest-game", "/tmp/sidequest-1000/scroll/state.json"}, + ); err != nil { + t.Fatalf("Start returned error: %v", err) + } + + joined := runner.joinedCalls() + for _, want := range []string{ + "bind-key -n PPage if-shell -F #{==:#{pane_index},0} copy-mode -e -u send-keys PPage", + "bind-key -n NPage if-shell -F #{==:#{pane_index},0} if-shell -F '#{pane_in_mode}' 'send-keys -X page-down ; if-shell -F \"#{==:#{scroll_position},0}\" \"send-keys -X cancel\"' 'display-message -d 1 \"\"' send-keys NPage", + "bind-key -n Up if-shell -F #{==:#{pane_index},0} copy-mode -e ; send-keys -X scroll-up send-keys Up", + "bind-key -n Down if-shell -F #{==:#{pane_index},0} if-shell -F '#{pane_in_mode}' 'send-keys -X scroll-down ; if-shell -F \"#{==:#{scroll_position},0}\" \"send-keys -X cancel\"' 'display-message -d 1 \"\"' send-keys Down", + } { + if !strings.Contains(joined, want) { + t.Fatalf("scroll key binding missing %q:\n%s", want, joined) + } + } +} + func TestStartDoesNotPutUserCommandInTmuxCommands(t *testing.T) { runner := &recordingRunner{} layout := Layout{CommandRunner: runner} @@ -140,7 +216,66 @@ func TestStartDoesNotPutUserCommandInTmuxCommands(t *testing.T) { } } -func TestStartCentersGamePaneTitleWhenWidthAvailable(t *testing.T) { +func TestPaneTitleVariantsUseWidthAndFocusState(t *testing.T) { + tests := []struct { + name string + got string + want string + }{ + { + name: "command active full", + got: commandPaneTitleVariant(true, fullTitleMinWidth), + want: "COMMAND - INPUT - F12 Snake - PgUp/PgDn Scroll - F10 Shell", + }, + { + name: "command inactive compact", + got: commandPaneTitleVariant(false, fullTitleMinWidth-1), + want: "COMMAND - F12 Snake - F10 Shell", + }, + { + name: "command completed full", + got: commandPaneTitleVariantFor(false, fullTitleMinWidth, session.StatusCompleted, intPtr(0)), + want: "COMMAND - DONE - EXIT 0 - F12 Snake - F10 Shell", + }, + { + name: "command failed full", + got: commandPaneTitleVariantFor(false, fullTitleMinWidth, session.StatusFailed, intPtr(2)), + want: "COMMAND - FAILED - EXIT 2 - F12 Snake - F10 Shell", + }, + { + name: "command interrupted full", + got: commandPaneTitleVariantFor(false, fullTitleMinWidth, session.StatusInterrupted, nil), + want: "COMMAND - INTERRUPTED - F12 Snake - F10 Shell", + }, + { + name: "command start failed full", + got: commandPaneTitleVariantFor(false, fullTitleMinWidth, session.StatusStartFailed, nil), + want: "COMMAND - START FAILED - F12 Snake - F10 Shell", + }, + { + name: "game active full", + got: gamePaneTitleVariant(true, fullTitleMinWidth), + want: "SNAKE - ACTIVE - F12 Command - P Pause - F10 Shell", + }, + { + name: "game inactive compact", + got: gamePaneTitleVariant(false, fullTitleMinWidth-1), + want: "SNAKE - F12 Command - P Pause - F10 Shell", + }, + { + name: "game active paused full", + got: gamePaneTitleVariantFor(true, fullTitleMinWidth, true), + want: "SNAKE - PAUSED - F12 Command - P Pause - F10 Shell", + }, + } + for _, test := range tests { + if test.got != test.want { + t.Fatalf("%s = %q, want %q", test.name, test.got, test.want) + } + } +} + +func TestStartDoesNotProbePaneWidthForTitles(t *testing.T) { runner := &recordingRunner{output: "100\n"} layout := Layout{CommandRunner: runner} runtimeSession := session.Session{ID: "abc123", SocketPath: "/run/user/1000/sidequest/abc123/command.sock"} @@ -154,30 +289,126 @@ func TestStartCentersGamePaneTitleWhenWidthAvailable(t *testing.T) { t.Fatalf("Start returned error: %v", err) } - if len(runner.outputCalls) != 1 { - t.Fatalf("output calls = %d, want 1", len(runner.outputCalls)) + if len(runner.outputCalls) != 0 { + t.Fatalf("output calls = %d, want 0: %#v", len(runner.outputCalls), runner.outputCalls) } - wantOutputCall := []string{"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "display-message", "-p", "-t", "sidequest-abc123:0.1", "#{pane_width}"} - if !equalStrings(runner.outputCalls[0], wantOutputCall) { - t.Fatalf("output call = %#v, want %#v", runner.outputCalls[0], wantOutputCall) +} + +func TestStartUsesMonochromeTmuxStylesWhenNoColorIsStored(t *testing.T) { + runner := &recordingRunner{} + layout := Layout{CommandRunner: runner} + runtimeSession := tmuxTestSession(t, "mono", session.State{ID: "mono", Status: session.StatusRunning, NoColor: true}) + + if _, err := layout.Start( + runtimeSession, + []string{"/usr/bin/sidequest", "__sidequest-command-runner", runtimeSession.SocketPath}, + []string{"/usr/bin/sidequest", "__sidequest-game", runtimeSession.StatePath}, + ); err != nil { + t.Fatalf("Start returned error: %v", err) } - const baseTitle = "Snake - arrows/WASD, F9 hide, F12 Command, F10 shell" - title := "" - for _, call := range runner.calls { - if len(call) >= 5 && call[len(call)-3] == "sidequest-abc123:0.1" && call[len(call)-2] == "-T" { - title = call[len(call)-1] - break + joined := runner.joinedCalls() + for _, want := range []string{ + "pane-border-style default", + "pane-active-border-style bold,reverse", + "#[align=centre]#[bold]#[reverse]> ", + } { + if !strings.Contains(joined, want) { + t.Fatalf("monochrome tmux formatting missing %q:\n%s", want, joined) } } - if title == "" { - t.Fatalf("game pane title call not found:\n%#v", runner.calls) + for _, forbidden := range []string{"fg=colour244", "fg=cyan", "fg=brightwhite", "bg="} { + if strings.Contains(joined, forbidden) { + t.Fatalf("monochrome tmux formatting contains fixed color %q:\n%s", forbidden, joined) + } } - if !strings.HasSuffix(title, baseTitle) { - t.Fatalf("game pane title = %q, want suffix %q", title, baseTitle) +} + +func TestStartFallsBackWhenOptionalTmuxUiSettingsAreUnsupported(t *testing.T) { + runner := &recordingRunner{failOptions: map[string]bool{ + "pane-border-lines": true, + "remain-on-exit-format": true, + }} + layout := Layout{CommandRunner: runner} + runtimeSession := session.Session{ID: "fallback", SocketPath: "/tmp/sidequest-1000/fallback/command.sock"} + + if _, err := layout.Start( + runtimeSession, + []string{"/usr/bin/sidequest", "__sidequest-command-runner", runtimeSession.SocketPath}, + []string{"/usr/bin/sidequest", "__sidequest-game", "/tmp/sidequest-1000/fallback/state.json"}, + ); err != nil { + t.Fatalf("Start returned error: %v", err) } - if len(title) <= len(baseTitle) || !strings.HasPrefix(title, " ") { - t.Fatalf("game pane title = %q, want centered title with leading padding", title) + + joined := runner.joinedCalls() + for _, want := range []string{ + "pane-border-lines single", + "remain-on-exit-format Command finished - F12 Snake - F10 Shell", + "pane-border-format", + "select-pane -t sidequest-fallback:0.1", + } { + if !strings.Contains(joined, want) { + t.Fatalf("fallback setup missing %q:\n%s", want, joined) + } + } + if strings.Contains(joined, "kill-session") { + t.Fatalf("fallback setup killed the session:\n%s", joined) + } +} + +func TestStartDoesNotConfigureBroadTerminalFeatures(t *testing.T) { + runner := &recordingRunner{} + layout := Layout{CommandRunner: runner} + runtimeSession := session.Session{ID: "terminal", SocketPath: "/tmp/sidequest-1000/terminal/command.sock"} + + if _, err := layout.Start( + runtimeSession, + []string{"/usr/bin/sidequest", "__sidequest-command-runner", runtimeSession.SocketPath}, + []string{"/usr/bin/sidequest", "__sidequest-game", "/tmp/sidequest-1000/terminal/state.json"}, + ); err != nil { + t.Fatalf("Start returned error: %v", err) + } + + joined := runner.joinedCalls() + for _, forbidden := range []string{" mouse ", "default-terminal", "terminal-overrides", "terminal-features"} { + if strings.Contains(joined, forbidden) { + t.Fatalf("tmux setup contains broad terminal feature %q:\n%s", forbidden, joined) + } + } +} + +func TestSetGamePausedUpdatesOwnedSessionOption(t *testing.T) { + runner := &recordingRunner{} + layout := Layout{CommandRunner: runner} + info := Info{SocketName: "sidequest-abc123", SessionName: "sidequest-abc123"} + + if err := layout.SetGamePaused(info, true); err != nil { + t.Fatalf("SetGamePaused returned error: %v", err) + } + + want := []string{"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-q", "-t", "sidequest-abc123", "@sidequest_game_paused", "1"} + if !equalStrings(runner.calls[0], want) { + t.Fatalf("pause call = %#v, want %#v", runner.calls[0], want) + } +} + +func TestSetCommandStateUpdatesOwnedSessionOptions(t *testing.T) { + runner := &recordingRunner{} + layout := Layout{CommandRunner: runner} + info := Info{SocketName: "sidequest-abc123", SessionName: "sidequest-abc123"} + + if err := layout.SetCommandState(info, session.State{Status: session.StatusFailed, ExitCode: intPtr(2)}); err != nil { + t.Fatalf("SetCommandState returned error: %v", err) + } + + wants := [][]string{ + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-q", "-t", "sidequest-abc123", "@sidequest_command_status", "failed"}, + {"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-option", "-q", "-t", "sidequest-abc123", "@sidequest_command_exit", "2"}, + } + for index, want := range wants { + if !equalStrings(runner.calls[index], want) { + t.Fatalf("command state call %d = %#v, want %#v", index, runner.calls[index], want) + } } } @@ -263,6 +494,24 @@ func TestCaptureCommandPaneReadsPlainCommandPaneOutput(t *testing.T) { } } +func TestCaptureCommandPreviewReadsLastVisibleLine(t *testing.T) { + runner := &recordingRunner{output: "step one\n\nstep two\n"} + layout := Layout{CommandRunner: runner} + info := Info{SocketName: "sidequest-abc123", SessionName: "sidequest-abc123"} + + output, err := layout.CaptureCommandPreview(info) + if err != nil { + t.Fatalf("CaptureCommandPreview returned error: %v", err) + } + if output != "step two" { + t.Fatalf("output = %q, want last non-empty line", output) + } + want := []string{"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "capture-pane", "-p", "-J", "-S", "-20", "-t", "sidequest-abc123:0.0"} + if !equalStrings(runner.outputCalls[0], want) { + t.Fatalf("capture call = %#v, want %#v", runner.outputCalls[0], want) + } +} + func TestGamePaneActiveReadsOwnedGamePane(t *testing.T) { runner := &recordingRunner{output: "1\n"} layout := Layout{CommandRunner: runner} @@ -396,6 +645,25 @@ type recordingRunner struct { outputCalls [][]string output string failAt int + failOptions map[string]bool +} + +func tmuxTestSession(t *testing.T, id string, state session.State) session.Session { + t.Helper() + dir := t.TempDir() + runtimeSession := session.Session{ + ID: id, + Dir: dir, + StatePath: filepath.Join(dir, session.DefaultStateFileName), + SocketPath: filepath.Join(dir, "command.sock"), + } + if state.ID == "" { + state.ID = id + } + if err := session.WriteState(runtimeSession, state); err != nil { + t.Fatalf("WriteState returned error: %v", err) + } + return runtimeSession } func (r *recordingRunner) Run(name string, args ...string) error { @@ -404,6 +672,9 @@ func (r *recordingRunner) Run(name string, args ...string) error { if r.failAt > 0 && len(r.calls) == r.failAt { return errors.New("tmux failed") } + if len(args) >= 6 && args[0] == "-f" && args[4] == "set-option" && r.failOptions[args[len(args)-2]] { + return errors.New("tmux option unsupported") + } return nil } @@ -445,3 +716,7 @@ func equalStrings(a, b []string) bool { } return true } + +func intPtr(value int) *int { + return &value +}