diff --git a/README.md b/README.md index 5794b5b..be35d2c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Key settings: - `auth.accept_after` — accept login after N failures per IP (default `10`) - `auth.credential_ttl` — how long to remember accepted credentials (default `24h`) - `auth.static_credentials` — always-accepted username/password pairs (optional `shell` field routes to a specific shell) -- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS), `banking` (80s-style bank terminal TUI), `adventure` (Zork-style text adventure dungeon), `cisco` (Cisco IOS CLI with mode state machine and command abbreviation), `psql` (PostgreSQL psql interactive terminal), `roomba` (iRobot Roomba vacuum robot) +- Available shells: `bash` (fake Linux shell), `fridge` (Samsung Smart Fridge OS), `banking` (80s-style bank terminal TUI), `adventure` (Zork-style text adventure dungeon), `cisco` (Cisco IOS CLI with mode state machine and command abbreviation), `psql` (PostgreSQL psql interactive terminal), `roomba` (iRobot Roomba vacuum robot), `tetris` (Tetris game TUI) - `shell.username_routes` — map usernames to specific shells (e.g. `postgres = "psql"`); credential-specific shell overrides take priority - `storage.db_path` — SQLite database path (default `oubliette.db`) - `storage.retention_days` — auto-prune records older than N days (default `90`) diff --git a/cmd/oubliette/main.go b/cmd/oubliette/main.go index 74f8a65..694f0e4 100644 --- a/cmd/oubliette/main.go +++ b/cmd/oubliette/main.go @@ -20,7 +20,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/web" ) -const Version = "0.16.0" +const Version = "0.17.0" func main() { if err := run(); err != nil { diff --git a/internal/server/server.go b/internal/server/server.go index bf8ff5f..98169e0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -26,6 +26,7 @@ import ( "git.t-juice.club/torjus/oubliette/internal/shell/fridge" psqlshell "git.t-juice.club/torjus/oubliette/internal/shell/psql" "git.t-juice.club/torjus/oubliette/internal/shell/roomba" + "git.t-juice.club/torjus/oubliette/internal/shell/tetris" "git.t-juice.club/torjus/oubliette/internal/storage" "golang.org/x/crypto/ssh" ) @@ -66,6 +67,9 @@ func New(cfg config.Config, store storage.Store, logger *slog.Logger, m *metrics if err := registry.Register(roomba.NewRoombaShell(), 1); err != nil { return nil, fmt.Errorf("registering roomba shell: %w", err) } + if err := registry.Register(tetris.NewTetrisShell(), 1); err != nil { + return nil, fmt.Errorf("registering tetris shell: %w", err) + } geo, err := geoip.New() if err != nil { diff --git a/internal/shell/tetris/data.go b/internal/shell/tetris/data.go new file mode 100644 index 0000000..cb61fc2 --- /dev/null +++ b/internal/shell/tetris/data.go @@ -0,0 +1,101 @@ +package tetris + +import "github.com/charmbracelet/lipgloss" + +// pieceType identifies a tetromino (0–6). +type pieceType int + +const ( + pieceI pieceType = iota + pieceO + pieceT + pieceS + pieceZ + pieceJ + pieceL +) + +const numPieceTypes = 7 + +// Standard Tetris colors. +var pieceColors = [numPieceTypes]lipgloss.Color{ + lipgloss.Color("#00FFFF"), // I — cyan + lipgloss.Color("#FFFF00"), // O — yellow + lipgloss.Color("#AA00FF"), // T — purple + lipgloss.Color("#00FF00"), // S — green + lipgloss.Color("#FF0000"), // Z — red + lipgloss.Color("#0000FF"), // J — blue + lipgloss.Color("#FF8800"), // L — orange +} + +// Each piece has 4 rotations, each rotation is a list of (row, col) offsets +// relative to the piece origin. +type rotation [4][2]int + +var pieces = [numPieceTypes][4]rotation{ + // I + { + {[2]int{0, 0}, [2]int{0, 1}, [2]int{0, 2}, [2]int{0, 3}}, + {[2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0}, [2]int{3, 0}}, + {[2]int{0, 0}, [2]int{0, 1}, [2]int{0, 2}, [2]int{0, 3}}, + {[2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0}, [2]int{3, 0}}, + }, + // O + { + {[2]int{0, 0}, [2]int{0, 1}, [2]int{1, 0}, [2]int{1, 1}}, + {[2]int{0, 0}, [2]int{0, 1}, [2]int{1, 0}, [2]int{1, 1}}, + {[2]int{0, 0}, [2]int{0, 1}, [2]int{1, 0}, [2]int{1, 1}}, + {[2]int{0, 0}, [2]int{0, 1}, [2]int{1, 0}, [2]int{1, 1}}, + }, + // T + { + {[2]int{0, 0}, [2]int{0, 1}, [2]int{0, 2}, [2]int{1, 1}}, + {[2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0}, [2]int{1, 1}}, + {[2]int{0, 1}, [2]int{1, 0}, [2]int{1, 1}, [2]int{1, 2}}, + {[2]int{0, 1}, [2]int{1, 0}, [2]int{1, 1}, [2]int{2, 1}}, + }, + // S + { + {[2]int{0, 1}, [2]int{0, 2}, [2]int{1, 0}, [2]int{1, 1}}, + {[2]int{0, 0}, [2]int{1, 0}, [2]int{1, 1}, [2]int{2, 1}}, + {[2]int{0, 1}, [2]int{0, 2}, [2]int{1, 0}, [2]int{1, 1}}, + {[2]int{0, 0}, [2]int{1, 0}, [2]int{1, 1}, [2]int{2, 1}}, + }, + // Z + { + {[2]int{0, 0}, [2]int{0, 1}, [2]int{1, 1}, [2]int{1, 2}}, + {[2]int{0, 1}, [2]int{1, 0}, [2]int{1, 1}, [2]int{2, 0}}, + {[2]int{0, 0}, [2]int{0, 1}, [2]int{1, 1}, [2]int{1, 2}}, + {[2]int{0, 1}, [2]int{1, 0}, [2]int{1, 1}, [2]int{2, 0}}, + }, + // J + { + {[2]int{0, 0}, [2]int{1, 0}, [2]int{1, 1}, [2]int{1, 2}}, + {[2]int{0, 0}, [2]int{0, 1}, [2]int{1, 0}, [2]int{2, 0}}, + {[2]int{0, 0}, [2]int{0, 1}, [2]int{0, 2}, [2]int{1, 2}}, + {[2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0}, [2]int{2, 1}}, + }, + // L + { + {[2]int{0, 2}, [2]int{1, 0}, [2]int{1, 1}, [2]int{1, 2}}, + {[2]int{0, 0}, [2]int{1, 0}, [2]int{2, 0}, [2]int{2, 1}}, + {[2]int{0, 0}, [2]int{0, 1}, [2]int{0, 2}, [2]int{1, 0}}, + {[2]int{0, 0}, [2]int{0, 1}, [2]int{1, 1}, [2]int{2, 1}}, + }, +} + +// spawnCol returns the starting column for a piece, centering it on the board. +func spawnCol(pt pieceType, rot int) int { + shape := pieces[pt][rot] + minC, maxC := shape[0][1], shape[0][1] + for _, off := range shape { + if off[1] < minC { + minC = off[1] + } + if off[1] > maxC { + maxC = off[1] + } + } + width := maxC - minC + 1 + return (boardCols - width) / 2 +} diff --git a/internal/shell/tetris/game.go b/internal/shell/tetris/game.go new file mode 100644 index 0000000..26a9d90 --- /dev/null +++ b/internal/shell/tetris/game.go @@ -0,0 +1,210 @@ +package tetris + +import "math/rand/v2" + +const ( + boardRows = 20 + boardCols = 10 +) + +// cell represents a single board cell. Zero value is empty. +type cell struct { + filled bool + piece pieceType // which piece type filled this cell (for color) +} + +// gameState holds all mutable state for a Tetris game. +type gameState struct { + board [boardRows][boardCols]cell + current pieceType + currentRot int + currentRow int + currentCol int + next pieceType + score int + level int + lines int + gameOver bool +} + +// newGame creates a new game state, optionally starting at a given level. +func newGame(startLevel int) *gameState { + g := &gameState{ + level: startLevel, + next: pieceType(rand.IntN(numPieceTypes)), + } + g.spawnPiece() + return g +} + +// spawnPiece pulls the next piece and generates a new next. +func (g *gameState) spawnPiece() { + g.current = g.next + g.next = pieceType(rand.IntN(numPieceTypes)) + g.currentRot = 0 + g.currentRow = 0 + g.currentCol = spawnCol(g.current, 0) + + if !g.canPlace(g.current, g.currentRot, g.currentRow, g.currentCol) { + g.gameOver = true + } +} + +// canPlace checks whether the piece fits at the given position. +func (g *gameState) canPlace(pt pieceType, rot, row, col int) bool { + shape := pieces[pt][rot] + for _, off := range shape { + r, c := row+off[0], col+off[1] + if r < 0 || r >= boardRows || c < 0 || c >= boardCols { + return false + } + if g.board[r][c].filled { + return false + } + } + return true +} + +// moveLeft moves the current piece left if possible. +func (g *gameState) moveLeft() bool { + if g.canPlace(g.current, g.currentRot, g.currentRow, g.currentCol-1) { + g.currentCol-- + return true + } + return false +} + +// moveRight moves the current piece right if possible. +func (g *gameState) moveRight() bool { + if g.canPlace(g.current, g.currentRot, g.currentRow, g.currentCol+1) { + g.currentCol++ + return true + } + return false +} + +// moveDown moves the current piece down one row. Returns false if it cannot. +func (g *gameState) moveDown() bool { + if g.canPlace(g.current, g.currentRot, g.currentRow+1, g.currentCol) { + g.currentRow++ + return true + } + return false +} + +// rotate rotates the current piece clockwise with wall kick attempts. +func (g *gameState) rotate() bool { + newRot := (g.currentRot + 1) % 4 + + // Try in-place first. + if g.canPlace(g.current, newRot, g.currentRow, g.currentCol) { + g.currentRot = newRot + return true + } + + // Wall kick: try +-1 column offset. + for _, offset := range []int{-1, 1} { + if g.canPlace(g.current, newRot, g.currentRow, g.currentCol+offset) { + g.currentRot = newRot + g.currentCol += offset + return true + } + } + + // I piece: try +-2. + if g.current == pieceI { + for _, offset := range []int{-2, 2} { + if g.canPlace(g.current, newRot, g.currentRow, g.currentCol+offset) { + g.currentRot = newRot + g.currentCol += offset + return true + } + } + } + + return false +} + +// ghostRow returns the row where the piece would land. +func (g *gameState) ghostRow() int { + row := g.currentRow + for g.canPlace(g.current, g.currentRot, row+1, g.currentCol) { + row++ + } + return row +} + +// hardDrop drops the piece to the bottom and returns the number of rows dropped. +func (g *gameState) hardDrop() int { + ghost := g.ghostRow() + dropped := ghost - g.currentRow + g.currentRow = ghost + return dropped +} + +// lockPiece writes the current piece into the board. +func (g *gameState) lockPiece() { + shape := pieces[g.current][g.currentRot] + for _, off := range shape { + r, c := g.currentRow+off[0], g.currentCol+off[1] + if r >= 0 && r < boardRows && c >= 0 && c < boardCols { + g.board[r][c] = cell{filled: true, piece: g.current} + } + } +} + +// clearLines removes completed rows and returns how many were cleared. +func (g *gameState) clearLines() int { + cleared := 0 + for r := boardRows - 1; r >= 0; r-- { + full := true + for c := range boardCols { + if !g.board[r][c].filled { + full = false + break + } + } + if full { + cleared++ + // Shift everything above down. + for rr := r; rr > 0; rr-- { + g.board[rr] = g.board[rr-1] + } + g.board[0] = [boardCols]cell{} + r++ // re-check this row since we shifted + } + } + return cleared +} + +// NES-style scoring multipliers per lines cleared. +var lineScoreMultipliers = [5]int{0, 40, 100, 300, 1200} + +// addScore updates score, lines, and level after clearing rows. +func (g *gameState) addScore(linesCleared int) { + if linesCleared > 0 && linesCleared <= 4 { + g.score += lineScoreMultipliers[linesCleared] * (g.level + 1) + } + g.lines += linesCleared + + // Level up every 10 lines. + newLevel := g.lines / 10 + if newLevel > g.level { + g.level = newLevel + } +} + +// afterLock locks the piece, clears lines, scores, and spawns the next piece. +// Returns the number of lines cleared. +func (g *gameState) afterLock() int { + g.lockPiece() + cleared := g.clearLines() + g.addScore(cleared) + g.spawnPiece() + return cleared +} + +// tickInterval returns the gravity interval in milliseconds for the current level. +func tickInterval(level int) int { + return max(800-level*60, 100) +} diff --git a/internal/shell/tetris/model.go b/internal/shell/tetris/model.go new file mode 100644 index 0000000..7518e9b --- /dev/null +++ b/internal/shell/tetris/model.go @@ -0,0 +1,331 @@ +package tetris + +import ( + "context" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "git.t-juice.club/torjus/oubliette/internal/shell" +) + +type screen int + +const ( + screenTitle screen = iota + screenGame + screenGameOver +) + +type tickMsg time.Time +type lockMsg time.Time + +const lockDelay = 500 * time.Millisecond + +type model struct { + sess *shell.SessionContext + difficulty string + screen screen + game *gameState + quitting bool + height int + keypresses int + locking bool // true when piece has landed and lock delay is active +} + +func newModel(sess *shell.SessionContext, difficulty string) *model { + return &model{ + sess: sess, + difficulty: difficulty, + screen: screenTitle, + } +} + +func (m *model) Init() tea.Cmd { + return nil +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.quitting { + return m, tea.Quit + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.height = msg.Height + return m, nil + case tea.KeyMsg: + m.keypresses++ + if msg.Type == tea.KeyCtrlC { + m.quitting = true + return m, tea.Batch( + logAction(m.sess, fmt.Sprintf("QUIT score=%d level=%d lines=%d keys=%d", m.gameScore(), m.gameLevel(), m.gameLines(), m.keypresses), "SESSION ENDED"), + tea.Quit, + ) + } + } + + switch m.screen { + case screenTitle: + return m.updateTitle(msg) + case screenGame: + return m.updateGame(msg) + case screenGameOver: + return m.updateGameOver(msg) + } + + return m, nil +} + +func (m *model) View() string { + var content string + switch m.screen { + case screenTitle: + content = m.titleView() + case screenGame: + content = gameView(m.game) + case screenGameOver: + content = m.gameOverView() + } + + return gameFrame(content, m.height) +} + +// --- Title screen --- + +func (m *model) titleView() string { + var b strings.Builder + b.WriteString("\n") + b.WriteString(titleStyle.Render(" ████████╗███████╗████████╗██████╗ ██╗███████╗")) + b.WriteString("\n") + b.WriteString(titleStyle.Render(" ╚══██╔══╝██╔════╝╚══██╔══╝██╔══██╗██║██╔════╝")) + b.WriteString("\n") + b.WriteString(titleStyle.Render(" ██║ █████╗ ██║ ██████╔╝██║███████╗")) + b.WriteString("\n") + b.WriteString(titleStyle.Render(" ██║ ██╔══╝ ██║ ██╔══██╗██║╚════██║")) + b.WriteString("\n") + b.WriteString(titleStyle.Render(" ██║ ███████╗ ██║ ██║ ██║██║███████║")) + b.WriteString("\n") + b.WriteString(titleStyle.Render(" ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝")) + b.WriteString("\n\n") + b.WriteString(baseStyle.Render(" Press any key to start")) + b.WriteString("\n") + return b.String() +} + +func (m *model) updateTitle(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(tea.KeyMsg); ok { + m.screen = screenGame + var startLevel int + if m.difficulty == "hard" { + startLevel = 5 + } + m.game = newGame(startLevel) + return m, tea.Batch( + tea.ClearScreen, + m.scheduleTick(), + logAction(m.sess, "GAME START", fmt.Sprintf("difficulty=%s", m.difficulty)), + ) + } + return m, nil +} + +// --- Game screen --- + +func (m *model) scheduleTick() tea.Cmd { + ms := tickInterval(m.game.level) + if m.difficulty == "easy" { + ms = max(1000-m.game.level*60, 150) + } + return tea.Tick(time.Duration(ms)*time.Millisecond, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func (m *model) scheduleLock() tea.Cmd { + return tea.Tick(lockDelay, func(t time.Time) tea.Msg { + return lockMsg(t) + }) +} + +// performLock locks the piece, clears lines, and returns commands for logging +// and scheduling the next tick. Returns nil if game over (goToGameOver is +// included in the returned batch). +func (m *model) performLock() tea.Cmd { + m.locking = false + cleared := m.game.afterLock() + if m.game.gameOver { + return tea.Batch( + logAction(m.sess, fmt.Sprintf("GAME OVER score=%d level=%d lines=%d keys=%d", m.game.score, m.game.level, m.game.lines, m.keypresses), "GAME OVER"), + m.goToGameOver(), + ) + } + var cmds []tea.Cmd + cmds = append(cmds, m.scheduleTick()) + if cleared > 0 { + cmds = append(cmds, logAction(m.sess, fmt.Sprintf("LINES %d score=%d", cleared, m.game.score), fmt.Sprintf("total=%d", m.game.lines))) + prevLevel := (m.game.lines - cleared) / 10 + if m.game.level > prevLevel { + cmds = append(cmds, logAction(m.sess, fmt.Sprintf("LEVEL UP %d", m.game.level), fmt.Sprintf("score=%d", m.game.score))) + } + } + return tea.Batch(cmds...) +} + +func (m *model) updateGame(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case lockMsg: + if m.game.gameOver || !m.locking { + return m, nil + } + // Lock delay expired — lock the piece now. + return m, m.performLock() + + case tickMsg: + if m.game.gameOver || m.locking { + return m, nil + } + if !m.game.moveDown() { + // Piece landed — start lock delay instead of locking immediately. + m.locking = true + return m, m.scheduleLock() + } + return m, m.scheduleTick() + + case tea.KeyMsg: + if m.game.gameOver { + return m, nil + } + + switch msg.String() { + case "left": + m.game.moveLeft() + // If piece can now drop further, cancel lock delay. + if m.locking && m.game.canPlace(m.game.current, m.game.currentRot, m.game.currentRow+1, m.game.currentCol) { + m.locking = false + } + case "right": + m.game.moveRight() + if m.locking && m.game.canPlace(m.game.current, m.game.currentRot, m.game.currentRow+1, m.game.currentCol) { + m.locking = false + } + case "down": + if m.game.moveDown() { + m.game.score++ // soft drop bonus + if m.locking { + m.locking = false + } + } + case "up", "z": + m.game.rotate() + if m.locking && m.game.canPlace(m.game.current, m.game.currentRot, m.game.currentRow+1, m.game.currentCol) { + m.locking = false + } + case " ": + m.locking = false + dropped := m.game.hardDrop() + m.game.score += dropped * 2 + return m, m.performLock() + case "q": + m.quitting = true + return m, tea.Batch( + logAction(m.sess, fmt.Sprintf("QUIT score=%d level=%d lines=%d keys=%d", m.game.score, m.game.level, m.game.lines, m.keypresses), "PLAYER QUIT"), + tea.Quit, + ) + } + return m, nil + } + + return m, nil +} + +// --- Game over screen --- + +func (m *model) goToGameOver() tea.Cmd { + m.screen = screenGameOver + return tea.ClearScreen +} + +func (m *model) gameOverView() string { + var b strings.Builder + b.WriteString("\n") + b.WriteString(titleStyle.Render(" GAME OVER")) + b.WriteString("\n\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" Score: %s", formatScore(m.game.score)))) + b.WriteString("\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" Level: %d", m.game.level))) + b.WriteString("\n") + b.WriteString(baseStyle.Render(fmt.Sprintf(" Lines: %d", m.game.lines))) + b.WriteString("\n\n") + b.WriteString(dimStyle.Render(" R - Play again")) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" Q - Quit")) + b.WriteString("\n") + return b.String() +} + +func (m *model) updateGameOver(msg tea.Msg) (tea.Model, tea.Cmd) { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "r": + startLevel := 0 + if m.difficulty == "hard" { + startLevel = 5 + } + m.game = newGame(startLevel) + m.screen = screenGame + m.keypresses = 0 + return m, tea.Batch( + tea.ClearScreen, + m.scheduleTick(), + logAction(m.sess, "RESTART", fmt.Sprintf("difficulty=%s", m.difficulty)), + ) + case "q": + m.quitting = true + return m, tea.Batch( + logAction(m.sess, fmt.Sprintf("QUIT score=%d level=%d lines=%d keys=%d", m.game.score, m.game.level, m.game.lines, m.keypresses), "PLAYER QUIT"), + tea.Quit, + ) + } + } + return m, nil +} + +// Helper methods for safe access when game may be nil. +func (m *model) gameScore() int { + if m.game != nil { + return m.game.score + } + return 0 +} + +func (m *model) gameLevel() int { + if m.game != nil { + return m.game.level + } + return 0 +} + +func (m *model) gameLines() int { + if m.game != nil { + return m.game.lines + } + return 0 +} + +// logAction returns a tea.Cmd that logs an action to the session store. +func logAction(sess *shell.SessionContext, input, output string) tea.Cmd { + return func() tea.Msg { + if sess.Store != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = sess.Store.AppendSessionLog(ctx, sess.SessionID, input, output) + } + if sess.OnCommand != nil { + sess.OnCommand("tetris") + } + return nil + } +} diff --git a/internal/shell/tetris/style.go b/internal/shell/tetris/style.go new file mode 100644 index 0000000..8b47258 --- /dev/null +++ b/internal/shell/tetris/style.go @@ -0,0 +1,286 @@ +package tetris + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const termWidth = 80 + +var ( + colorWhite = lipgloss.Color("#FFFFFF") + colorDim = lipgloss.Color("#555555") + colorBlack = lipgloss.Color("#000000") + colorGhost = lipgloss.Color("#333333") +) + +var ( + baseStyle = lipgloss.NewStyle(). + Foreground(colorWhite). + Background(colorBlack) + + dimStyle = lipgloss.NewStyle(). + Foreground(colorDim). + Background(colorBlack) + + titleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00FFFF")). + Background(colorBlack). + Bold(true) + + sidebarLabelStyle = lipgloss.NewStyle(). + Foreground(colorDim). + Background(colorBlack) + + sidebarValueStyle = lipgloss.NewStyle(). + Foreground(colorWhite). + Background(colorBlack). + Bold(true) +) + +// cellStyle returns a style for a filled cell of a given piece type. +func cellStyle(pt pieceType) lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(pieceColors[pt]). + Background(colorBlack) +} + +// ghostStyle returns a dimmed style for the ghost piece. +func ghostCellStyle() lipgloss.Style { + return lipgloss.NewStyle(). + Foreground(colorGhost). + Background(colorBlack) +} + +// renderBoard renders the board, current piece, and ghost piece as a string. +func renderBoard(g *gameState) string { + // Build a display grid that includes the current piece and ghost. + type displayCell struct { + filled bool + ghost bool + piece pieceType + } + var grid [boardRows][boardCols]displayCell + + // Copy locked cells. + for r := range boardRows { + for c := range boardCols { + if g.board[r][c].filled { + grid[r][c] = displayCell{filled: true, piece: g.board[r][c].piece} + } + } + } + + // Ghost piece. + ghostR := g.ghostRow() + if ghostR != g.currentRow { + shape := pieces[g.current][g.currentRot] + for _, off := range shape { + r, c := ghostR+off[0], g.currentCol+off[1] + if r >= 0 && r < boardRows && c >= 0 && c < boardCols && !grid[r][c].filled { + grid[r][c] = displayCell{ghost: true, piece: g.current} + } + } + } + + // Current piece. + shape := pieces[g.current][g.currentRot] + for _, off := range shape { + r, c := g.currentRow+off[0], g.currentCol+off[1] + if r >= 0 && r < boardRows && c >= 0 && c < boardCols { + grid[r][c] = displayCell{filled: true, piece: g.current} + } + } + + // Render grid. + var b strings.Builder + borderStyle := dimStyle + + for _, row := range grid { + b.WriteString(borderStyle.Render("|")) + for _, dc := range row { + switch { + case dc.filled: + b.WriteString(cellStyle(dc.piece).Render("[]")) + case dc.ghost: + b.WriteString(ghostCellStyle().Render("::")) + default: + b.WriteString(baseStyle.Render(" ")) + } + } + b.WriteString(borderStyle.Render("|")) + b.WriteString("\n") + } + b.WriteString(borderStyle.Render("+" + strings.Repeat("--", boardCols) + "+")) + + return b.String() +} + +// renderNextPiece renders the "next piece" preview box. +func renderNextPiece(pt pieceType) string { + shape := pieces[pt][0] + // Determine bounding box. + minR, maxR := shape[0][0], shape[0][0] + minC, maxC := shape[0][1], shape[0][1] + for _, off := range shape { + if off[0] < minR { + minR = off[0] + } + if off[0] > maxR { + maxR = off[0] + } + if off[1] < minC { + minC = off[1] + } + if off[1] > maxC { + maxC = off[1] + } + } + + rows := maxR - minR + 1 + cols := maxC - minC + 1 + + // Build a small grid. + grid := make([][]bool, rows) + for i := range grid { + grid[i] = make([]bool, cols) + } + for _, off := range shape { + grid[off[0]-minR][off[1]-minC] = true + } + + var b strings.Builder + boxWidth := 8 // chars for the box interior + b.WriteString(dimStyle.Render("+" + strings.Repeat("-", boxWidth) + "+")) + b.WriteString("\n") + + for r := range rows { + b.WriteString(dimStyle.Render("|")) + // Center the piece in the box. + pieceWidth := cols * 2 + leftPad := (boxWidth - pieceWidth) / 2 + rightPad := boxWidth - pieceWidth - leftPad + b.WriteString(baseStyle.Render(strings.Repeat(" ", leftPad))) + for c := range cols { + if grid[r][c] { + b.WriteString(cellStyle(pt).Render("[]")) + } else { + b.WriteString(baseStyle.Render(" ")) + } + } + b.WriteString(baseStyle.Render(strings.Repeat(" ", rightPad))) + b.WriteString(dimStyle.Render("|")) + b.WriteString("\n") + } + + // Fill remaining rows in the box (max 4 rows for I piece). + for r := rows; r < 2; r++ { + b.WriteString(dimStyle.Render("|")) + b.WriteString(baseStyle.Render(strings.Repeat(" ", boxWidth))) + b.WriteString(dimStyle.Render("|")) + b.WriteString("\n") + } + + b.WriteString(dimStyle.Render("+" + strings.Repeat("-", boxWidth) + "+")) + return b.String() +} + +// formatScore formats a score with comma separators. +func formatScore(n int) string { + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + var parts []string + for len(s) > 3 { + parts = append([]string{s[len(s)-3:]}, parts...) + s = s[:len(s)-3] + } + parts = append([]string{s}, parts...) + return strings.Join(parts, ",") +} + +// gameView combines the board and sidebar into the game screen. +func gameView(g *gameState) string { + boardStr := renderBoard(g) + boardLines := strings.Split(boardStr, "\n") + + nextStr := renderNextPiece(g.next) + nextLines := strings.Split(nextStr, "\n") + + // Build sidebar lines. + var sidebar []string + sidebar = append(sidebar, sidebarLabelStyle.Render(" NEXT:")) + sidebar = append(sidebar, nextLines...) + sidebar = append(sidebar, "") + sidebar = append(sidebar, sidebarLabelStyle.Render(" SCORE: ")+sidebarValueStyle.Render(formatScore(g.score))) + sidebar = append(sidebar, sidebarLabelStyle.Render(" LEVEL: ")+sidebarValueStyle.Render(fmt.Sprintf("%d", g.level))) + sidebar = append(sidebar, sidebarLabelStyle.Render(" LINES: ")+sidebarValueStyle.Render(fmt.Sprintf("%d", g.lines))) + sidebar = append(sidebar, "") + sidebar = append(sidebar, dimStyle.Render(" Controls:")) + sidebar = append(sidebar, dimStyle.Render(" <- -> Move")) + sidebar = append(sidebar, dimStyle.Render(" Up/Z Rotate")) + sidebar = append(sidebar, dimStyle.Render(" Down Soft drop")) + sidebar = append(sidebar, dimStyle.Render(" Space Hard drop")) + sidebar = append(sidebar, dimStyle.Render(" Q Quit")) + + // Combine board and sidebar side by side. + var b strings.Builder + maxLines := max(len(boardLines), len(sidebar)) + + for i := range maxLines { + boardLine := "" + if i < len(boardLines) { + boardLine = boardLines[i] + } + sidebarLine := "" + if i < len(sidebar) { + sidebarLine = sidebar[i] + } + + // Pad board to fixed width (| + 10*2 + | = 22 chars visual). + b.WriteString(boardLine) + b.WriteString(sidebarLine) + b.WriteString("\n") + } + + return b.String() +} + +// padLine pads a single line to termWidth. +func padLine(line string) string { + w := lipgloss.Width(line) + if w >= termWidth { + return line + } + return line + baseStyle.Render(strings.Repeat(" ", termWidth-w)) +} + +// padLines pads every line in a multi-line string to termWidth. +func padLines(s string) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = padLine(line) + } + return strings.Join(lines, "\n") +} + +// gameFrame wraps content with padding to fill the terminal. +func gameFrame(content string, height int) string { + var b strings.Builder + b.WriteString(content) + + // Pad with blank lines to fill terminal height. + if height > 0 { + contentLines := strings.Count(content, "\n") + 1 + blankLine := baseStyle.Render(strings.Repeat(" ", termWidth)) + for i := contentLines; i < height; i++ { + b.WriteString(blankLine) + b.WriteString("\n") + } + } + + return padLines(b.String()) +} diff --git a/internal/shell/tetris/tetris.go b/internal/shell/tetris/tetris.go new file mode 100644 index 0000000..205ba62 --- /dev/null +++ b/internal/shell/tetris/tetris.go @@ -0,0 +1,66 @@ +package tetris + +import ( + "context" + "io" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "git.t-juice.club/torjus/oubliette/internal/shell" +) + +const sessionTimeout = 10 * time.Minute + +// TetrisShell is a Tetris game TUI for the honeypot. +type TetrisShell struct{} + +// NewTetrisShell returns a new TetrisShell instance. +func NewTetrisShell() *TetrisShell { + return &TetrisShell{} +} + +func (t *TetrisShell) Name() string { return "tetris" } +func (t *TetrisShell) Description() string { return "Tetris game TUI" } + +func (t *TetrisShell) Handle(ctx context.Context, sess *shell.SessionContext, rw io.ReadWriteCloser) error { + ctx, cancel := context.WithTimeout(ctx, sessionTimeout) + defer cancel() + + difficulty := configString(sess.ShellConfig, "difficulty", "normal") + + m := newModel(sess, difficulty) + p := tea.NewProgram(m, + tea.WithInput(rw), + tea.WithOutput(rw), + tea.WithAltScreen(), + ) + + done := make(chan error, 1) + go func() { + _, err := p.Run() + done <- err + }() + + select { + case err := <-done: + return err + case <-ctx.Done(): + p.Quit() + <-done + return nil + } +} + +// configString reads a string from the shell config map with a default. +func configString(cfg map[string]any, key, defaultVal string) string { + if cfg == nil { + return defaultVal + } + if v, ok := cfg[key]; ok { + if s, ok := v.(string); ok && s != "" { + return s + } + } + return defaultVal +} diff --git a/internal/shell/tetris/tetris_test.go b/internal/shell/tetris/tetris_test.go new file mode 100644 index 0000000..670d54f --- /dev/null +++ b/internal/shell/tetris/tetris_test.go @@ -0,0 +1,582 @@ +package tetris + +import ( + "context" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "git.t-juice.club/torjus/oubliette/internal/shell" + "git.t-juice.club/torjus/oubliette/internal/storage" +) + +// newTestModel creates a model with a test session context. +func newTestModel(t *testing.T) (*model, *storage.MemoryStore) { + t.Helper() + store := storage.NewMemoryStore() + sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "player", "tetris", "") + sess := &shell.SessionContext{ + SessionID: sessID, + Username: "player", + Store: store, + } + m := newModel(sess, "normal") + return m, store +} + +// sendKey sends a single key message to the model and returns the command. +func sendKey(m *model, key string) tea.Cmd { + var msg tea.KeyMsg + switch key { + case "enter": + msg = tea.KeyMsg{Type: tea.KeyEnter} + case "up": + msg = tea.KeyMsg{Type: tea.KeyUp} + case "down": + msg = tea.KeyMsg{Type: tea.KeyDown} + case "left": + msg = tea.KeyMsg{Type: tea.KeyLeft} + case "right": + msg = tea.KeyMsg{Type: tea.KeyRight} + case "space": + msg = tea.KeyMsg{Type: tea.KeySpace} + case "ctrl+c": + msg = tea.KeyMsg{Type: tea.KeyCtrlC} + default: + if len(key) == 1 { + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} + } + } + _, cmd := m.Update(msg) + return cmd +} + +// sendTick sends a tick message to the model. +func sendTick(m *model) tea.Cmd { + _, cmd := m.Update(tickMsg(time.Now())) + return cmd +} + +// execCmds recursively executes tea.Cmd functions (including batches). +func execCmds(cmd tea.Cmd) { + if cmd == nil { + return + } + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + for _, c := range batch { + execCmds(c) + } + } +} + +func TestTetrisShellName(t *testing.T) { + sh := NewTetrisShell() + if sh.Name() != "tetris" { + t.Errorf("Name() = %q, want %q", sh.Name(), "tetris") + } + if sh.Description() == "" { + t.Error("Description() should not be empty") + } +} + +func TestConfigString(t *testing.T) { + cfg := map[string]any{ + "difficulty": "hard", + } + if got := configString(cfg, "difficulty", "normal"); got != "hard" { + t.Errorf("configString() = %q, want %q", got, "hard") + } + if got := configString(cfg, "missing", "normal"); got != "normal" { + t.Errorf("configString() = %q, want %q", got, "normal") + } + if got := configString(nil, "difficulty", "normal"); got != "normal" { + t.Errorf("configString(nil) = %q, want %q", got, "normal") + } +} + +func TestTitleScreenRenders(t *testing.T) { + m, _ := newTestModel(t) + view := m.View() + if !strings.Contains(view, "████") { + t.Error("title screen should show TETRIS logo") + } + if !strings.Contains(view, "Press any key") { + t.Error("title screen should show 'Press any key'") + } +} + +func TestTitleToGame(t *testing.T) { + m, _ := newTestModel(t) + if m.screen != screenTitle { + t.Fatalf("expected screenTitle, got %d", m.screen) + } + + sendKey(m, "enter") + if m.screen != screenGame { + t.Errorf("expected screenGame after keypress, got %d", m.screen) + } + if m.game == nil { + t.Fatal("game should be initialized") + } +} + +func TestGameRenders(t *testing.T) { + m, _ := newTestModel(t) + sendKey(m, "enter") // start game + + view := m.View() + if !strings.Contains(view, "|") { + t.Error("game view should contain board borders") + } + if !strings.Contains(view, "SCORE") { + t.Error("game view should show SCORE") + } + if !strings.Contains(view, "LEVEL") { + t.Error("game view should show LEVEL") + } + if !strings.Contains(view, "LINES") { + t.Error("game view should show LINES") + } + if !strings.Contains(view, "NEXT") { + t.Error("game view should show NEXT") + } +} + +// --- Pure game logic tests --- + +func TestNewGame(t *testing.T) { + g := newGame(0) + if g.gameOver { + t.Error("new game should not be game over") + } + if g.score != 0 { + t.Errorf("initial score = %d, want 0", g.score) + } + if g.level != 0 { + t.Errorf("initial level = %d, want 0", g.level) + } + if g.lines != 0 { + t.Errorf("initial lines = %d, want 0", g.lines) + } +} + +func TestNewGameHardLevel(t *testing.T) { + g := newGame(5) + if g.level != 5 { + t.Errorf("hard start level = %d, want 5", g.level) + } +} + +func TestMoveLeft(t *testing.T) { + g := newGame(0) + startCol := g.currentCol + g.moveLeft() + if g.currentCol != startCol-1 { + t.Errorf("after moveLeft: col = %d, want %d", g.currentCol, startCol-1) + } +} + +func TestMoveRight(t *testing.T) { + g := newGame(0) + startCol := g.currentCol + g.moveRight() + if g.currentCol != startCol+1 { + t.Errorf("after moveRight: col = %d, want %d", g.currentCol, startCol+1) + } +} + +func TestMoveDown(t *testing.T) { + g := newGame(0) + startRow := g.currentRow + moved := g.moveDown() + if !moved { + t.Error("moveDown should succeed from starting position") + } + if g.currentRow != startRow+1 { + t.Errorf("after moveDown: row = %d, want %d", g.currentRow, startRow+1) + } +} + +func TestCannotMoveLeftBeyondWall(t *testing.T) { + g := newGame(0) + // Move all the way left. + for range boardCols { + g.moveLeft() + } + col := g.currentCol + g.moveLeft() // should not move further + if g.currentCol != col { + t.Errorf("should not move past left wall: col = %d, was %d", g.currentCol, col) + } +} + +func TestCannotMoveRightBeyondWall(t *testing.T) { + g := newGame(0) + // Move all the way right. + for range boardCols { + g.moveRight() + } + col := g.currentCol + g.moveRight() // should not move further + if g.currentCol != col { + t.Errorf("should not move past right wall: col = %d, was %d", g.currentCol, col) + } +} + +func TestRotate(t *testing.T) { + g := newGame(0) + startRot := g.currentRot + g.rotate() + // Rotation should change (possibly with wall kick). + if g.currentRot == startRot { + // Rotation might legitimately fail in some edge cases, so just check + // that the game state is valid. + if !g.canPlace(g.current, g.currentRot, g.currentRow, g.currentCol) { + t.Error("piece should be in a valid position after rotate attempt") + } + } +} + +func TestHardDrop(t *testing.T) { + g := newGame(0) + startRow := g.currentRow + dropped := g.hardDrop() + if dropped == 0 { + t.Error("hard drop should move piece down at least some rows from top") + } + if g.currentRow <= startRow { + t.Errorf("after hardDrop: row = %d should be > %d", g.currentRow, startRow) + } +} + +func TestGhostRow(t *testing.T) { + g := newGame(0) + ghost := g.ghostRow() + if ghost < g.currentRow { + t.Errorf("ghost row %d should be >= current row %d", ghost, g.currentRow) + } + // Ghost should be at a position where moving down one more is impossible. + if g.canPlace(g.current, g.currentRot, ghost+1, g.currentCol) { + t.Error("ghost row should be the lowest valid position") + } +} + +func TestLockPiece(t *testing.T) { + g := newGame(0) + g.hardDrop() + pt := g.current + row, col, rot := g.currentRow, g.currentCol, g.currentRot + g.lockPiece() + + // Verify that the piece's cells are now filled. + shape := pieces[pt][rot] + for _, off := range shape { + r, c := row+off[0], col+off[1] + if !g.board[r][c].filled { + t.Errorf("cell (%d, %d) should be filled after lockPiece", r, c) + } + } +} + +func TestClearLines(t *testing.T) { + g := newGame(0) + // Fill the bottom row completely. + for c := range boardCols { + g.board[boardRows-1][c] = cell{filled: true, piece: pieceI} + } + cleared := g.clearLines() + if cleared != 1 { + t.Errorf("clearLines() = %d, want 1", cleared) + } + // Bottom row should now be empty (shifted from above). + for c := range boardCols { + if g.board[boardRows-1][c].filled { + t.Errorf("bottom row col %d should be empty after clearing", c) + } + } +} + +func TestClearMultipleLines(t *testing.T) { + g := newGame(0) + // Fill the bottom 4 rows. + for r := boardRows - 4; r < boardRows; r++ { + for c := range boardCols { + g.board[r][c] = cell{filled: true, piece: pieceI} + } + } + cleared := g.clearLines() + if cleared != 4 { + t.Errorf("clearLines() = %d, want 4", cleared) + } +} + +func TestScoring(t *testing.T) { + tests := []struct { + lines int + level int + want int + }{ + {1, 0, 40}, + {2, 0, 100}, + {3, 0, 300}, + {4, 0, 1200}, + {1, 1, 80}, + {4, 2, 3600}, + } + for _, tt := range tests { + g := newGame(tt.level) + g.addScore(tt.lines) + if g.score != tt.want { + t.Errorf("score for %d lines at level %d = %d, want %d", tt.lines, tt.level, g.score, tt.want) + } + } +} + +func TestLevelUp(t *testing.T) { + g := newGame(0) + g.lines = 9 + g.addScore(1) // This should push lines to 10, triggering level 1. + if g.level != 1 { + t.Errorf("level = %d, want 1 after 10 lines", g.level) + } +} + +func TestTickInterval(t *testing.T) { + if got := tickInterval(0); got != 800 { + t.Errorf("tickInterval(0) = %d, want 800", got) + } + if got := tickInterval(5); got != 500 { + t.Errorf("tickInterval(5) = %d, want 500", got) + } + // Floor at 100ms. + if got := tickInterval(20); got != 100 { + t.Errorf("tickInterval(20) = %d, want 100", got) + } +} + +func TestFormatScore(t *testing.T) { + tests := []struct { + n int + want string + }{ + {0, "0"}, + {100, "100"}, + {1250, "1,250"}, + {1000000, "1,000,000"}, + } + for _, tt := range tests { + if got := formatScore(tt.n); got != tt.want { + t.Errorf("formatScore(%d) = %q, want %q", tt.n, got, tt.want) + } + } +} + +func TestGameOverScreen(t *testing.T) { + m, _ := newTestModel(t) + sendKey(m, "enter") // start game + + // Force game over. + m.game.gameOver = true + m.screen = screenGameOver + + view := m.View() + if !strings.Contains(view, "GAME OVER") { + t.Error("game over screen should show GAME OVER") + } + if !strings.Contains(view, "Score") { + t.Error("game over screen should show score") + } +} + +func TestRestartFromGameOver(t *testing.T) { + m, _ := newTestModel(t) + sendKey(m, "enter") // start game + + m.game.gameOver = true + m.screen = screenGameOver + + sendKey(m, "r") + if m.screen != screenGame { + t.Errorf("expected screenGame after restart, got %d", m.screen) + } + if m.game.gameOver { + t.Error("game should not be over after restart") + } +} + +func TestQuitFromGame(t *testing.T) { + m, _ := newTestModel(t) + sendKey(m, "enter") // start game + sendKey(m, "q") + if !m.quitting { + t.Error("should be quitting after pressing q") + } +} + +func TestQuitFromGameOver(t *testing.T) { + m, _ := newTestModel(t) + sendKey(m, "enter") // start game + m.game.gameOver = true + m.screen = screenGameOver + + sendKey(m, "q") + if !m.quitting { + t.Error("should be quitting after pressing q in game over") + } +} + +func TestSoftDropScoring(t *testing.T) { + m, _ := newTestModel(t) + sendKey(m, "enter") // start game + + scoreBefore := m.game.score + sendKey(m, "down") + if m.game.score != scoreBefore+1 { + t.Errorf("score after soft drop = %d, want %d", m.game.score, scoreBefore+1) + } +} + +func TestHardDropScoring(t *testing.T) { + m, _ := newTestModel(t) + sendKey(m, "enter") // start game + + // Hard drop gives 2 points per row dropped. + sendKey(m, "space") + if m.game.score < 2 { + t.Errorf("score after hard drop = %d, should be at least 2", m.game.score) + } +} + +func TestTickMovesDown(t *testing.T) { + m, _ := newTestModel(t) + sendKey(m, "enter") // start game + + rowBefore := m.game.currentRow + sendTick(m) + // Piece should either move down by 1, or lock and spawn a new piece at top. + movedDown := m.game.currentRow == rowBefore+1 + respawned := m.game.currentRow < rowBefore + if !movedDown && !respawned && !m.game.gameOver { + t.Errorf("tick should move piece down or lock+respawn: row was %d, now %d", rowBefore, m.game.currentRow) + } +} + +func TestSessionLogs(t *testing.T) { + m, store := newTestModel(t) + + // Press key to start game — returns a logAction cmd. + cmd := sendKey(m, "enter") + if cmd != nil { + execCmds(cmd) + } + time.Sleep(50 * time.Millisecond) + + found := false + for _, log := range store.SessionLogs { + if strings.Contains(log.Input, "GAME START") { + found = true + break + } + } + if !found { + t.Error("expected GAME START in session logs") + } +} + +func TestKeypressCounter(t *testing.T) { + m, _ := newTestModel(t) + sendKey(m, "enter") // start game + sendKey(m, "left") + sendKey(m, "right") + sendKey(m, "down") + + if m.keypresses != 4 { // enter + 3 game keys + t.Errorf("keypresses = %d, want 4", m.keypresses) + } +} + +func TestLockDelay(t *testing.T) { + m, _ := newTestModel(t) + sendKey(m, "enter") // start game + + // Drop piece to the bottom via ticks until it can't move down. + for range boardRows + 5 { + if m.locking { + break + } + sendTick(m) + } + + if !m.locking { + t.Fatal("piece should be in locking state after hitting bottom") + } + + // During lock delay, we should still be able to move left/right. + colBefore := m.game.currentCol + sendKey(m, "left") + if m.game.currentCol >= colBefore { + // Might not have moved if against wall, try right. + sendKey(m, "right") + } + + // Sending a lockMsg should finalize the piece. + m.Update(lockMsg(time.Now())) + // After lock, a new piece should have spawned (row near top). + if m.game.currentRow > 1 && !m.game.gameOver { + t.Errorf("after lock delay, new piece should spawn near top, got row %d", m.game.currentRow) + } +} + +func TestLockDelayCancelledByDrop(t *testing.T) { + m, _ := newTestModel(t) + sendKey(m, "enter") // start game + + // Build a ledge: fill rows 18-19 but leave column 0 empty. + for r := boardRows - 2; r < boardRows; r++ { + for c := 1; c < boardCols; c++ { + m.game.board[r][c] = cell{filled: true, piece: pieceI} + } + } + + // Move piece to column 0 area and drop it onto the ledge. + for range boardCols { + m.game.moveLeft() + } + // Tick down until locking. + for range boardRows + 5 { + if m.locking { + break + } + sendTick(m) + } + + // If piece is on the ledge and we slide it to col 0 (open column), + // the lock delay should cancel since it can fall further. + // This test just validates the locking flag logic works. + if m.locking { + // Try moving — if piece can drop further, locking should cancel. + sendKey(m, "left") + // Whether locking cancels depends on the board state; just verify no crash. + } +} + +func TestSpawnCol(t *testing.T) { + // All pieces should spawn roughly centered. + for pt := range pieceType(numPieceTypes) { + col := spawnCol(pt, 0) + if col < 0 || col > boardCols-1 { + t.Errorf("spawnCol(%d, 0) = %d, out of range", pt, col) + } + // Verify piece fits at spawn position. + shape := pieces[pt][0] + for _, off := range shape { + c := col + off[1] + if c < 0 || c >= boardCols { + t.Errorf("piece %d overflows board at spawn: col+offset = %d", pt, c) + } + } + } +} diff --git a/oubliette.toml.example b/oubliette.toml.example index 795a2e5..be8b4f1 100644 --- a/oubliette.toml.example +++ b/oubliette.toml.example @@ -39,6 +39,11 @@ password = "admin" # password = "roomba" # shell = "roomba" +# [[auth.static_credentials]] +# username = "player" +# password = "tetris" +# shell = "tetris" + [storage] db_path = "oubliette.db" retention_days = 90 @@ -83,6 +88,9 @@ hostname = "ubuntu-server" # [shell.roomba] # No configuration options currently. +# [shell.tetris] +# difficulty = "normal" # "easy" (slower start), "normal" (standard), "hard" (start at level 5) + # [detection] # enabled = true # threshold = 0.6 # 0.0–1.0, sessions above this trigger notifications