Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.MD
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ Required behavior:
The MVP requires:

```text
Minimum terminal size: 80 columns × 24 rows
Minimum terminal size: 80 columns × 22 rows
Minimum usable game area: 40 columns × 10 rows
```

Expand Down
28 changes: 21 additions & 7 deletions internal/game/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -1168,7 +1168,7 @@ func drawResultPanel(screen tcell.Screen, view viewState, baseStyle tcell.Style)
return
}

lines := resultPanelLines(view, arena.RenderWidth())
lines := resultPanelLines(view, arena.RenderWidth(), arena.Height)
if len(lines) == 0 {
return
}
Expand Down Expand Up @@ -1305,7 +1305,7 @@ func drawCompletionDecisionPanel(screen tcell.Screen, view viewState, baseStyle
}
}

func resultPanelLines(view viewState, maxWidth int) []string {
func resultPanelLines(view viewState, maxWidth int, maxHeight int) []string {
score := view.ResultScore
if score == 0 && view.PendingScore == nil && !view.RoundFinalized {
if view.Quest.Enabled() && view.FinalScore.FinalScore > 0 {
Expand All @@ -1314,8 +1314,12 @@ func resultPanelLines(view viewState, maxWidth int) []string {
score = view.Game.Score
}
}
maxContentLines := maxHeight - 2
if maxContentLines < 1 {
maxContentLines = 1
}
if view.PendingScore != nil {
if maxWidth < 34 {
if maxWidth < 34 || maxContentLines < 8 {
return []string{
fmt.Sprintf("NEW HIGH SCORE %d", view.PendingScore.Score),
"NAME [" + truncateDisplay(view.PendingScore.Input, maxWidth-8) + "]",
Expand All @@ -1335,17 +1339,27 @@ func resultPanelLines(view viewState, maxWidth int) []string {
}

title := "GAME OVER"
action := "R Restart F9 Hide"
action := "R Restart F10 Shell"
if view.Frozen {
title = "COMMAND FINISHED"
action = "F10 Shell"
}
if maxWidth < 34 {
if maxWidth < 34 || maxContentLines < 11 {
lines := []string{
title,
fmt.Sprintf("%d", score),
fmt.Sprintf("SCORE %d", score),
}
entrySlots := maxContentLines - len(lines) - 1
if view.StatsMessage != "" {
entrySlots--
}
if entrySlots > 0 {
entries := leaderboardLines(view.Leaderboard, view.CurrentRank, maxWidth-2)
if len(entries) > entrySlots {
entries = entries[:entrySlots]
}
lines = append(lines, entries...)
}
lines = append(lines, leaderboardLines(view.Leaderboard, view.CurrentRank, maxWidth-2)...)
lines = append(lines, action)
if view.StatsMessage != "" {
lines = append(lines, truncateDisplay(view.StatsMessage, maxWidth-2))
Expand Down
80 changes: 79 additions & 1 deletion internal/game/shell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ func TestRunShowsCenteredResultPanelAfterRoundOver(t *testing.T) {
waitForRenderedText(t, screen, "NEW HIGH SCORE")
screen.PostEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))
waitForRenderedText(t, screen, "GAME OVER")
waitForRenderedText(t, screen, "FINAL SCORE 0")
waitForRenderedText(t, screen, "FINAL SCORE")
waitForRenderedText(t, screen, "TOP 5")
waitForRenderedText(t, screen, "R Restart")
cancelShell(t, cancel, errc)
Expand Down Expand Up @@ -1324,6 +1324,75 @@ func TestRenderDrawsMonochromeQuestObjectsAndResultPanel(t *testing.T) {
assertScreenHasNoColors(t, screen)
}

func TestResultPanelLinesShowFullLeaderboardWhenTall(t *testing.T) {
view := viewState{
ResultScore: 920,
RoundFinalized: true,
Leaderboard: []LeaderboardEntry{
{Score: 920, PlayerName: "one"},
{Score: 800, PlayerName: "two"},
{Score: 700, PlayerName: "three"},
{Score: 600, PlayerName: "four"},
{Score: 500, PlayerName: "five"},
},
CurrentRank: 1,
}

lines := resultPanelLines(view, 78, 13)

for _, want := range []string{"GAME OVER", "FINAL SCORE 920", "TOP 5", "5.", "R Restart", "F10 Shell"} {
if !linesContain(lines, want) {
t.Fatalf("result panel lines missing %q: %#v", want, lines)
}
}
if len(lines) > 11 {
t.Fatalf("result panel lines len=%d, want at most 11: %#v", len(lines), lines)
}
}

func TestResultPanelLinesKeepActionsVisibleWhenShort(t *testing.T) {
view := viewState{
ResultScore: 920,
RoundFinalized: true,
Leaderboard: []LeaderboardEntry{
{Score: 920, PlayerName: "one"},
{Score: 800, PlayerName: "two"},
{Score: 700, PlayerName: "three"},
{Score: 600, PlayerName: "four"},
{Score: 500, PlayerName: "five"},
},
CurrentRank: 1,
}

lines := resultPanelLines(view, 78, 8)

for _, want := range []string{"GAME OVER", "SCORE 920", "R Restart", "F10 Shell"} {
if !linesContain(lines, want) {
t.Fatalf("compact result panel lines missing %q: %#v", want, lines)
}
}
if len(lines) > 6 {
t.Fatalf("compact result panel lines len=%d, want at most 6: %#v", len(lines), lines)
}
}

func TestResultPanelLinesKeepHighscoreInputVisibleWhenShort(t *testing.T) {
view := viewState{
PendingScore: &PendingHighscore{Score: 920, Input: "mcfeuer"},
}

lines := resultPanelLines(view, 78, 8)

for _, want := range []string{"NEW HIGH SCORE 920", "NAME [mcfeuer]", "Enter confirm"} {
if !linesContain(lines, want) {
t.Fatalf("compact highscore lines missing %q: %#v", want, lines)
}
}
if len(lines) > 6 {
t.Fatalf("compact highscore lines len=%d, want at most 6: %#v", len(lines), lines)
}
}

func runShellCancellable(shell Shell) (context.CancelFunc, chan error) {
ctx, cancel := context.WithCancel(context.Background())
errc := make(chan error, 1)
Expand All @@ -1341,6 +1410,15 @@ func cancelShell(t *testing.T, cancel context.CancelFunc, errc <-chan error) {
}
}

func linesContain(lines []string, want string) bool {
for _, line := range lines {
if strings.Contains(line, want) {
return true
}
}
return false
}

func waitForRenderedText(t *testing.T, screen tcell.SimulationScreen, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
Expand Down
2 changes: 1 addition & 1 deletion internal/preflight/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (

const (
MinimumColumns = 80
MinimumRows = 16
MinimumRows = 22
)

var (
Expand Down
4 changes: 2 additions & 2 deletions internal/preflight/preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func TestValidateRejectsUnavailableTerminalSize(t *testing.T) {
func TestValidateRejectsSmallTerminal(t *testing.T) {
env := validEnvironment()
env.TerminalSize = func(uintptr) (Size, error) {
return Size{Columns: 80, Rows: 15}, nil
return Size{Columns: 80, Rows: 21}, nil
}

err := Validate(env)
Expand All @@ -80,7 +80,7 @@ func TestValidateRejectsSmallTerminal(t *testing.T) {
}

message := err.Error()
for _, fragment := range []string{"required at least 80x16", "detected 80x15"} {
for _, fragment := range []string{"required at least 80x22", "detected 80x21"} {
if !strings.Contains(message, fragment) {
t.Fatalf("error %q does not contain %q", message, fragment)
}
Expand Down
51 changes: 48 additions & 3 deletions internal/tmux/tmux.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ type Info struct {
const commandPaneHistoryLimit = 100000
const commandPanePreviewLines = 20

const (
defaultGamePaneHeight = 16
// 24 arena rows plus HUD/top offset and the bottom wall.
gamePaneMaxHeight = 30
commandPaneMinHeight = 6
)

const (
bossHiddenOption = "@sidequest_boss_hidden"
bossPrevGameOption = "@sidequest_boss_prev_game"
Expand Down Expand Up @@ -99,9 +106,11 @@ func (l Layout) Start(runtimeSession session.Session, commandRunner []string, ga
}
ui := uiPresetForSession(runtimeSession)

terminalRows := 0
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))
terminalRows = rows
}
newSessionArgs = append(newSessionArgs, "-s", info.SessionName, "-n", "sidequest", shellJoin(commandRunner))
if err := run(newSessionArgs...); err != nil {
Expand Down Expand Up @@ -154,19 +163,22 @@ func (l Layout) Start(runtimeSession session.Session, commandRunner []string, ga
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 {
if err := run("split-window", "-v", "-l", strconv.Itoa(gamePaneHeightForWindow(terminalRows)), "-t", info.SessionName+":0.0", shellJoin(gameRunner)); err != nil {
return cleanup(fmt.Errorf("create placeholder pane: %w", err))
}
if err := run("select-pane", "-t", info.SessionName+":0.1", "-T", gamePaneTitle); err != nil {
return cleanup(fmt.Errorf("title game pane: %w", err))
}
if err := run("set-hook", "-t", info.SessionName, "window-resized", gamePaneResizeCommand(tmuxPath, info)); err != nil {
return cleanup(fmt.Errorf("configure game pane resize hook: %w", err))
}
if err := run("bind-key", "-n", "F12", "select-pane", "-t", ":.+"); err != nil {
return cleanup(fmt.Errorf("bind F12 pane switch: %w", err))
}
if err := run("bind-key", "-n", "F10", "detach-client"); err != nil {
return cleanup(fmt.Errorf("bind F10 detach: %w", err))
}
if err := run("bind-key", "-n", "F9", "if-shell", "-F", "#{==:#{"+bossHiddenOption+"},1}", bossRestoreCommand(info), bossHideCommand(info)); err != nil {
if err := run("bind-key", "-n", "F9", "if-shell", "-F", "#{==:#{"+bossHiddenOption+"},1}", bossRestoreCommand(tmuxPath, info), bossHideCommand(info)); err != nil {
return cleanup(fmt.Errorf("bind F9 boss key: %w", err))
}
for _, binding := range commandPaneScrollBindings() {
Expand All @@ -193,6 +205,38 @@ func (l Layout) currentTerminalSize() (columns int, rows int, err error) {
return size.Columns, size.Rows, nil
}

func gamePaneHeightForWindow(windowHeight int) int {
if windowHeight <= 0 {
return defaultGamePaneHeight
}
maxAllowed := windowHeight - commandPaneMinHeight
if maxAllowed < 1 {
return 1
}
if maxAllowed < gamePaneMaxHeight {
return maxAllowed
}
return gamePaneMaxHeight
}

func gamePaneResizeCommand(tmuxPath string, info Info) string {
shell := fmt.Sprintf(
"h=#{window_height}; z=#{window_zoomed_flag}; "+
"case \"$h\" in ''|*[!0-9]*) exit 0;; esac; "+
"[ \"$z\" = 1 ] && exit 0; "+
"target=%d; max_for_game=$((h - %d)); "+
"[ \"$max_for_game\" -lt 1 ] && max_for_game=1; "+
"[ \"$max_for_game\" -lt \"$target\" ] && target=\"$max_for_game\"; "+
"%s -f /dev/null -L %s resize-pane -t %s -y \"$target\"",
gamePaneMaxHeight,
commandPaneMinHeight,
shellQuote(tmuxPath),
shellQuote(info.SocketName),
shellQuote(info.SessionName+":0.1"),
)
return "run-shell -b " + shellQuote(shell)
}

type commandPaneScrollBinding struct {
key string
command string
Expand Down Expand Up @@ -225,9 +269,10 @@ func bossHideCommand(info Info) string {
}, " ; ")
}

func bossRestoreCommand(info Info) string {
func bossRestoreCommand(tmuxPath string, info Info) string {
return strings.Join([]string{
fmt.Sprintf("if-shell -F '#{window_zoomed_flag}' 'resize-pane -Z -t %s:0.0' ''", info.SessionName),
gamePaneResizeCommand(tmuxPath, info),
fmt.Sprintf("set-option -q -t %s pane-border-status %s", info.SessionName, paneBorderStatus),
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),
Expand Down
70 changes: 70 additions & 0 deletions internal/tmux/tmux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func TestStartCreatesIsolatedLayout(t *testing.T) {
{"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"},
{"tmux", "-f", "/dev/null", "-L", "sidequest-abc123", "set-hook", "-t", "sidequest-abc123", "window-resized"},
{"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}"},
Expand Down Expand Up @@ -102,6 +103,75 @@ func TestStartSeedsDetachedSessionWithCurrentTerminalSize(t *testing.T) {
}
}

func TestStartUsesDeterministicGamePaneHeight(t *testing.T) {
tests := []struct {
name string
rows int
want string
}{
{name: "below-max", rows: 24, want: "18"},
{name: "max", rows: 36, want: "30"},
{name: "above-max", rows: 60, want: "30"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runner := &recordingRunner{}
layout := Layout{
CommandRunner: runner,
TerminalSize: func() (int, int, error) {
return 120, test.rows, nil
},
}
runtimeSession := session.Session{ID: "height-" + test.name, SocketPath: "/tmp/sidequest-1000/height/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/height/state.json"},
); err != nil {
t.Fatalf("Start returned error: %v", err)
}

joined := runner.joinedCalls()
want := "split-window -v -l " + test.want
if !strings.Contains(joined, want) {
t.Fatalf("split height missing %q:\n%s", want, joined)
}
})
}
}

func TestStartHooksWindowResizeToGamePaneTarget(t *testing.T) {
runner := &recordingRunner{}
layout := Layout{CommandRunner: runner}
runtimeSession := session.Session{ID: "resize", SocketPath: "/tmp/sidequest-1000/resize/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/resize/state.json"},
); err != nil {
t.Fatalf("Start returned error: %v", err)
}

joined := runner.joinedCalls()
for _, want := range []string{
"set-hook -t sidequest-resize window-resized",
"run-shell -b",
"h=#{window_height}",
"z=#{window_zoomed_flag}",
"target=30",
"max_for_game=$((h - 6))",
"resize-pane -t",
"sidequest-resize:0.1",
} {
if !strings.Contains(joined, want) {
t.Fatalf("resize hook missing %q:\n%s", want, joined)
}
}
}

func TestStartConfiguresEnhancedPaneFocusFormatting(t *testing.T) {
runner := &recordingRunner{}
layout := Layout{CommandRunner: runner}
Expand Down