feat: add tetris shell (Tetris game TUI)

Full-screen Tetris game using Bubbletea with title screen, ghost piece,
lock delay, NES-style scoring, configurable difficulty (easy/normal/hard),
and honeypot event logging. Bumps version to 0.17.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 00:59:46 +01:00
parent c9d143d84b
commit 62de222488
10 changed files with 1590 additions and 2 deletions

View File

@@ -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`)

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -0,0 +1,101 @@
package tetris
import "github.com/charmbracelet/lipgloss"
// pieceType identifies a tetromino (06).
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
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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())
}

View File

@@ -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
}

View File

@@ -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)
}
}
}
}

View File

@@ -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.01.0, sessions above this trigger notifications