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:
@@ -34,7 +34,7 @@ Key settings:
|
|||||||
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
- `auth.accept_after` — accept login after N failures per IP (default `10`)
|
||||||
- `auth.credential_ttl` — how long to remember accepted credentials (default `24h`)
|
- `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)
|
- `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
|
- `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.db_path` — SQLite database path (default `oubliette.db`)
|
||||||
- `storage.retention_days` — auto-prune records older than N days (default `90`)
|
- `storage.retention_days` — auto-prune records older than N days (default `90`)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/oubliette/internal/web"
|
"git.t-juice.club/torjus/oubliette/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.16.0"
|
const Version = "0.17.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"git.t-juice.club/torjus/oubliette/internal/shell/fridge"
|
"git.t-juice.club/torjus/oubliette/internal/shell/fridge"
|
||||||
psqlshell "git.t-juice.club/torjus/oubliette/internal/shell/psql"
|
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/roomba"
|
||||||
|
"git.t-juice.club/torjus/oubliette/internal/shell/tetris"
|
||||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||||
"golang.org/x/crypto/ssh"
|
"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 {
|
if err := registry.Register(roomba.NewRoombaShell(), 1); err != nil {
|
||||||
return nil, fmt.Errorf("registering roomba shell: %w", err)
|
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()
|
geo, err := geoip.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
101
internal/shell/tetris/data.go
Normal file
101
internal/shell/tetris/data.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
210
internal/shell/tetris/game.go
Normal file
210
internal/shell/tetris/game.go
Normal 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)
|
||||||
|
}
|
||||||
331
internal/shell/tetris/model.go
Normal file
331
internal/shell/tetris/model.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
286
internal/shell/tetris/style.go
Normal file
286
internal/shell/tetris/style.go
Normal 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())
|
||||||
|
}
|
||||||
66
internal/shell/tetris/tetris.go
Normal file
66
internal/shell/tetris/tetris.go
Normal 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
|
||||||
|
}
|
||||||
582
internal/shell/tetris/tetris_test.go
Normal file
582
internal/shell/tetris/tetris_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@ password = "admin"
|
|||||||
# password = "roomba"
|
# password = "roomba"
|
||||||
# shell = "roomba"
|
# shell = "roomba"
|
||||||
|
|
||||||
|
# [[auth.static_credentials]]
|
||||||
|
# username = "player"
|
||||||
|
# password = "tetris"
|
||||||
|
# shell = "tetris"
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
db_path = "oubliette.db"
|
db_path = "oubliette.db"
|
||||||
retention_days = 90
|
retention_days = 90
|
||||||
@@ -83,6 +88,9 @@ hostname = "ubuntu-server"
|
|||||||
# [shell.roomba]
|
# [shell.roomba]
|
||||||
# No configuration options currently.
|
# No configuration options currently.
|
||||||
|
|
||||||
|
# [shell.tetris]
|
||||||
|
# difficulty = "normal" # "easy" (slower start), "normal" (standard), "hard" (start at level 5)
|
||||||
|
|
||||||
# [detection]
|
# [detection]
|
||||||
# enabled = true
|
# enabled = true
|
||||||
# threshold = 0.6 # 0.0–1.0, sessions above this trigger notifications
|
# threshold = 0.6 # 0.0–1.0, sessions above this trigger notifications
|
||||||
|
|||||||
Reference in New Issue
Block a user