diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2cd3ac5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +name: Production CI/CD Pipeline + +on: + push: + branches: [ "main", "feature/*" ] + pull_request: + branches: [ "main" ] + +jobs: + security-audit: + name: Cybersecurity SAST & Vulnerability Audit + runs-on: ubuntu-latest + steps: + - name: Checkout Codebase + uses: actions/checkout@v4 + + - name: Setup Go Environment + uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + + - name: Install Vulnerability & Security Scanner Tools + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + go install github.com/securego/gosec/v2/cmd/gosec@latest + + - name: Audit Bubbletea Dependencies + run: | + cd bubbletea + govulncheck ./... + + - name: Audit Tview Dependencies + run: | + cd tview + govulncheck ./... + + - name: Scan Bubbletea Code for Vulnerabilities (gosec) + run: | + cd bubbletea + # Exclude test files from security scan + gosec -exclude-dir=tests ./... + + - name: Scan Tview Code for Vulnerabilities (gosec) + run: | + cd tview + gosec ./... + + build-and-test: + name: Cross-Platform Build & Verification + needs: security-audit + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, windows-latest, macos-latest ] + module: [ bubbletea, tview ] + steps: + - name: Checkout Codebase + uses: actions/checkout@v4 + + - name: Setup Go Environment + uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + + - name: Install Dependencies + run: | + cd ${{ matrix.module }} + go mod tidy + + - name: Verify Formatting (gofmt) + if: matrix.os == 'ubuntu-latest' + run: | + cd ${{ matrix.module }} + diff -u <(echo -n) <(gofmt -d .) + + - name: Run Static Analysis (go vet) + run: | + cd ${{ matrix.module }} + go vet ./... + + - name: Execute Test Suite + run: | + cd ${{ matrix.module }} + go test -v -race ./... + + - name: Test Build compilation + run: | + cd ${{ matrix.module }} + go build -v -o ropa-sci-test-build ./... diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index 8c6b837..0000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Go - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - runs-on: ubuntu-latest - defaults: - run: - working-directory: bubbletea # ๐Ÿ‘ˆ run everything inside bubbletea/ - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.22' # match your local Go version - - - name: Install dependencies - run: go mod tidy - - - name: Build - run: go build ./... - - - name: Run tests - run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5b7791 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Binaries and executables +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ +tview/tview +tview/ahmedtoyyib1 +tview/tmp/ + +# Logs +*.log +logs/ +bubbletea/logs/ +app.log + +# OS generated files +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fece258 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +# Ropa-Sci Group Project License + +Copyright (c) 2026 Jezreal Momoh, Ahmed Toyyib, and Ropa-Sci Project Contributors. +All rights reserved. + +## 1. Permission to View and Use +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to view, inspect, study, and run the Software for personal, educational, or evaluation purposes. + +## 2. Restrictions on Alteration and Redistribution +- You **may not** modify, alter, transform, or build upon the Software for the purpose of distributing modified versions as your own work. +- You **may not** claim sole ownership, authorship, or patent rights over the Software. +- You **must** retain all original copyright notices, contributor credits, and this license text in all copies or substantial portions of the Software. + +## 3. Contributions and Collaborative Development +- Contributions (e.g., bug fixes, code optimizations, extensions) are welcome and encouraged via official repository pull requests or merge requests. +- By submitting a contribution to the Software, you agree to license your contribution under this same license, and acknowledge that the original authorship and copyright of the collective project remains with the Ropa-Sci Group Project and its contributors. + +## 4. No Warranty +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index a7c48f3..29ed299 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,47 @@ # ๐ŸŽฎ Ropa-Sci -> Multiplayer Rock-Paper-Scissors CLI game in Go - -## Tech Stack -- Go + Bubbletea + Lipgloss -- WebSocket multiplayer -- ASCII art UI - -## Roadmap - -| Week | Focus | Your Role | -|------|-------|-----------| -| 1 | Setup + team alignment | Consulted | -| 2 | Main menu screen | โ˜… Responsible | -| 3 | ASCII art + game screen | โ˜… Responsible | -| 4 | Single-player mode | โ˜… Responsible | -| 5 | Multiplayer UI (WebSocket) | โ˜… Responsible | -| 6 | Integration testing | Consulted | -| 7 | Bug fixes + polish | Responsible | -| 8 | Docs + release | Accountable | - -## How to Run -\```bash +> A modern, multiplayer Rock-Paper-Scissors CLI gaming platform built in Go. + +Ropa-Sci is designed as a collaborative, group-built terminal game featuring beautiful console-grade visuals, smart game-theory opponent predictive models, peer-to-peer LAN multiplayer rooms, and role-based admin controls. + +--- + +## ๐Ÿ› ๏ธ Tech Stack & Key Technologies +- **Language:** Go (Golang) +- **TUI Frameworks:** [Bubble Tea](https://github.com/charmbracelet/bubbletea), [Lip Gloss](https://github.com/charmbracelet/lipgloss), and `tview` +- **Networking:** P2P Local WebSockets +- **Architecture:** Thread-safe JSON state store + +--- + +## ๐Ÿ“‚ Project Architecture + +The project is split into two primary CLI client engines: + +- **[`bubbletea/`](./bubbletea/README.md):** Features a customized floating-frame cyber-neon interface, Braille animations, predictive game-theory Markov Chain AI, local network multiplayer matchmaking, and a multi-level role-based admin dashboard. +- **`tview/`:** Contains alternative form inputs and navigation panels. + +--- + +## ๐Ÿš€ How to Run + +### Run the Bubble Tea Client +Navigate to the `bubbletea` folder and run: +```bash +cd bubbletea go run cmd/main.go -\``` \ No newline at end of file +``` + +### Run Unit Tests +To run structural tests across models, state validation, and storage: +```bash +cd bubbletea +go test ./... +``` + +--- + +## ๐Ÿ›ก๏ธ License, Contribution & Security +- **License:** Distributed under our custom collaborative [LICENSE](./LICENSE). The code is open to view and you may contribute pull requests, but modifications may not be distributed separately or claimed under different ownership to preserve the rights of all group contributors. +- **Security Policy:** Review our vulnerability reporting guidelines and data handling rules in the [SECURITY.md](./SECURITY.md) document. +- **Developer Walkthrough:** For details on execution flow, MVU state machine, and subsystems, check out [bubbletea/walkthrough.md](./bubbletea/walkthrough.md). \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5a37ea5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Supported Versions + +We support security updates for the following versions: + +| Version | Supported | +|---------|-----------| +| 1.x.x | Yes | +| < 1.0 | No | + +## Reporting a Vulnerability + +If you discover a security vulnerability within this project, please do not open a public issue. Instead, report it directly by contacting the project maintainers: + +- Email: `jezrealglobal@gmail.com` + +We will acknowledge your report within 48 hours and work with you to analyze and resolve the issue. + +## Local Data Security + +Ropa-Sci stores all profile data locally in `data/players.json`. +- **Encryption:** The data is stored in plain-text JSON format. It is recommended to secure the filesystem of the host machine to prevent unauthorized local access. +- **Input Sanitization:** Usernames, names, and game moves are sanitized and validated to prevent injection or terminal escape sequence manipulation. +- **Concurrency Security:** File read/write operations on the JSON database are thread-safe and protected by a read-write sync mutex. diff --git a/bubbletea/README.md b/bubbletea/README.md new file mode 100644 index 0000000..11925e2 --- /dev/null +++ b/bubbletea/README.md @@ -0,0 +1,58 @@ +# Ropa-Sci Bubbletea TUI Engine + +This subdirectory contains the Bubbletea terminal user interface (TUI) implementation of **Ropa-Sci** โ€” a modern, cyber-neon styled Rock-Paper-Scissors game with game-theory AI, local multiplayer, and administration tools. + +## Tech Stack +- **Go** (Golang) +- **Bubble Tea** (TUI framework) +- **Lip Gloss** (Terminal styling and layout) +- **Gorilla WebSocket** (Local P2P Multiplayer connection) + +--- + +## Directory Structure + +- `cmd/main.go`: Application entry point, central update event loop, and screen renderers. +- `models/`: Code relating to game logic, players database, validation, AI models, and logging. + - `ai_engine.go`: Game-theory based predictor using Markov Chain algorithms. + - `logger.go`: Configured structured log system outputting to `logs/app.log`. + - `player.go`: Definitions for player structures, game phases, and state layers. + - `storage.go`: Local JSON-based persistent database with thread-safe mutex locks. +- `server/`: WebSocket service code facilitating local network peer-to-peer multiplayer. +- `ui/`: CSS-like Lipgloss styles, neon colors definitions, and layout configurations. +- `data/players.json`: Plain-text file storing local player accounts and lifetime statistics. + +--- + +## Features + +### 1. Cyber-Neon Layout & UI +Overhauled in 2026 using responsive double-bordered card styling, interactive lists, and `lipgloss.Place` vertical/horizontal centering for a premium, console-grade desktop feel. + +### 2. Local P2P Multiplayer +Allows players on the same local area network (LAN) to host or join match rooms. Connections are brokered over WebSockets with zero dependency on third-party cloud infrastructure. + +### 3. Smart Predictor AI +Includes a predictive Markov Chain AI engine that analyzes player move histories during matches to predict future selections, making the single-player experience highly engaging. + +### 4. Admin Dashboard +Privileged accounts (e.g. `jmomoh`) route to the Admin Panel upon signing in. Admins can view scrollable lists of registered accounts, toggle roles, reset stats, and delete player data. + +--- + +## Getting Started + +### Prerequisites +Make sure you have Go installed on your machine (v1.20+ recommended). + +### Running the App +From the `bubbletea` directory, execute: +```bash +go run cmd/main.go +``` + +### Running Tests +Execute unit tests for database and validation logic: +```bash +go test ./... +``` diff --git a/bubbletea/cmd/main.go b/bubbletea/cmd/main.go index 6243884..05f6df2 100644 --- a/bubbletea/cmd/main.go +++ b/bubbletea/cmd/main.go @@ -1,7 +1,10 @@ package main import ( + "encoding/json" "fmt" + "log/slog" + "net" "os" "strings" "time" @@ -10,9 +13,11 @@ import ( "golang.org/x/text/language" "ropa-sci-frontend/bubbletea/models" + "ropa-sci-frontend/bubbletea/server" "ropa-sci-frontend/bubbletea/ui" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) // โ”€โ”€โ”€ Spinner & AI message types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -28,6 +33,16 @@ type aiDecidedMsg struct { // showResultMsg fires after the reveal pause to transition to result phase type showResultMsg struct{} +// wsMsgMsg wraps incoming LAN server messages +type wsMsgMsg struct { + msg server.WSMessage +} + +// wsErrMsg wraps LAN connection errors +type wsErrMsg struct { + err error +} + // spinnerFrames is the Braille dot animation cycle used during AI think phase var spinnerFrames = []string{"โ ‹", "โ ™", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ‡", "โ "} @@ -40,14 +55,31 @@ func spinnerTick() tea.Cmd { }) } -// aiThink returns a command that fires after 1.5s with a randomly chosen AI move -func aiThink() tea.Cmd { +// aiThinkCmd returns a command that fires after 1.5s with the AI's chosen move +func (m model) aiThinkCmd() tea.Cmd { return tea.Tick(1500*time.Millisecond, func(t time.Time) tea.Msg { - moves := []models.Move{models.Rock, models.Paper, models.Scissors} - return aiDecidedMsg{move: moves[time.Now().UnixNano()%3]} + return aiDecidedMsg{move: m.aiEngine.ChooseMove()} }) } +// readNetMsg reads a single WebSocket message in a background Bubbletea thread +func readNetMsg(conn net.Conn) tea.Cmd { + return func() tea.Msg { + if conn == nil { + return wsErrMsg{err: fmt.Errorf("connection is closed")} + } + payload, err := server.ReadWSFrame(conn) + if err != nil { + return wsErrMsg{err: err} + } + var wsMsg server.WSMessage + if err := json.Unmarshal(payload, &wsMsg); err != nil { + return wsErrMsg{err: err} + } + return wsMsgMsg{msg: wsMsg} + } +} + // โ”€โ”€โ”€ Game Logic Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // calculateOutcome returns "win", "lose", or "tie" from the player's perspective @@ -97,7 +129,13 @@ func indexToMove(cursor int) models.Move { // model wraps GameState as the single source of truth for the entire TUI type model struct { - state models.GameState + state models.GameState + aiEngine *models.AIEngine // runtime AI engine + lanServer *server.LANGameServer // P2P Host Server + wsConn net.Conn // active WebSocket client conn + netOpponentName string // remote player name + nextWinsHost int // temporary host score buffer + nextWinsGuest int // temporary guest score buffer } // initialModel returns the app's starting state โ€” always begins on the welcome screen @@ -107,8 +145,9 @@ func initialModel() model { Screen: "welcome", Score: models.MatchScore{Round: 1}, Cursor: 0, - Phase: "pick", + Phase: models.PhasePick, }, + aiEngine: models.NewAIEngine(models.AIDifficultyHard), } } @@ -134,7 +173,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // โ”€โ”€ Spinner tick โ€” advances the animation frame during AI think phase โ”€โ”€ case spinnerTickMsg: m.state.SpinnerFrame = (m.state.SpinnerFrame + 1) % len(spinnerFrames) - if m.state.Phase == "think" || + if m.state.Phase == models.PhaseThink || m.state.Screen == "create-room" || m.state.Screen == "quick-match" { return m, spinnerTick() @@ -144,7 +183,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // โ”€โ”€ AI decided โ€” transition from think to reveal, then schedule result โ”€โ”€ case aiDecidedMsg: m.state.AIMove = msg.move - m.state.Phase = "reveal" + m.state.Phase = models.PhaseReveal m.state.RoundOutcome = calculateOutcome(m.state.PlayerMove, msg.move) // Brief pause so the player can see both cards before the verdict return m, tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { @@ -153,38 +192,262 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // โ”€โ”€ Show result โ€” update score and transition to result phase โ”€โ”€ case showResultMsg: - m.state.Phase = "result" - switch m.state.RoundOutcome { - case "win": - m.state.Score.PlayerWins++ - case "lose": - m.state.Score.OpponentWins++ - } - m.state.Score.Round++ - - // Check if match is over and update lifetime stats - if m.state.Score.PlayerWins == 2 || m.state.Score.OpponentWins == 2 { - m.state.Player.TotalMatches++ - if m.state.Score.PlayerWins == 2 { - m.state.Player.Wins++ - } else { - m.state.Player.Losses++ - } - // Save updated stats to disk โ€” ignore error silently in UI - // player will still see correct stats this session - _ = models.UpdatePlayer(m.state.Player) - } - return m, nil - - // โ”€โ”€ Mouse clicks โ€” coordinate mapping wired in Week 7 polish phase โ”€โ”€ + m.state.Phase = models.PhaseResult + if m.state.GameMode == "multi" { + m.state.Score.PlayerWins = m.nextWinsHost + m.state.Score.OpponentWins = m.nextWinsGuest + } else { + switch m.state.RoundOutcome { + case "win": + m.state.Score.PlayerWins++ + case "lose": + m.state.Score.OpponentWins++ + } + } + m.state.Score.Round++ + + // Check if match is over and update lifetime stats + if m.state.Score.PlayerWins == 2 || m.state.Score.OpponentWins == 2 { + m.state.Player.TotalMatches++ + if m.state.Score.PlayerWins == 2 { + m.state.Player.Wins++ + } else { + m.state.Player.Losses++ + } + // Save updated stats to disk โ€” ignore error silently in UI + // player will still see correct stats this session + _ = models.UpdatePlayer(m.state.Player) + } + return m, nil + + // โ”€โ”€ Incoming LAN server network packets โ”€โ”€ + case wsMsgMsg: + switch msg.msg.Type { + case "start": + m.state.Screen = "game" + m.state.GameMode = "multi" + m.state.Phase = models.PhasePick + m.state.Score = models.MatchScore{Round: 1} + m.netOpponentName = msg.msg.Payload + m.state.PlayerMove = models.None + m.state.AIMove = models.None + m.state.RoundOutcome = "" + m.state.Cursor = 0 + slog.Info("Multiplayer match started", "opponent", m.netOpponentName) + return m, readNetMsg(m.wsConn) + + case "round": + var payload server.RoundOutcomePayload + if err := json.Unmarshal([]byte(msg.msg.Payload), &payload); err != nil { + slog.Error("Failed to parse round outcome payload", "error", err) + return m, readNetMsg(m.wsConn) + } + m.state.AIMove = models.Move(payload.OpponentMove) + m.state.RoundOutcome = payload.Outcome + m.nextWinsHost = payload.YourWins + m.nextWinsGuest = payload.OpponentWins + m.state.Phase = models.PhaseReveal + slog.Info("Round outcome received", "outcome", payload.Outcome, "wins", payload.YourWins, "losses", payload.OpponentWins) + + return m, tea.Batch( + readNetMsg(m.wsConn), + tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { + return showResultMsg{} + }), + ) + + case "match": + m.state.Player.TotalMatches++ + if msg.msg.Payload == "win" { + m.state.Player.Wins++ + } else { + m.state.Player.Losses++ + } + _ = models.UpdatePlayer(m.state.Player) + return m, readNetMsg(m.wsConn) + + case "error": + m.state.FormError = msg.msg.Payload + m.state.Screen = "multi-menu" + if m.wsConn != nil { + m.wsConn.Close() + m.wsConn = nil + } + if m.lanServer != nil { + m.lanServer.Stop() + m.lanServer = nil + } + return m, nil + } + return m, readNetMsg(m.wsConn) + + // โ”€โ”€ LAN Connection errors โ”€โ”€ + case wsErrMsg: + slog.Error("Network connection error", "error", msg.err) + m.state.FormError = "Connection error: " + msg.err.Error() + m.state.Screen = "multi-menu" + if m.wsConn != nil { + m.wsConn.Close() + m.wsConn = nil + } + if m.lanServer != nil { + m.lanServer.Stop() + m.lanServer = nil + } + return m, nil + + // โ”€โ”€ Mouse clicks โ€” coordinate mapping for menu and game screens โ”€โ”€ case tea.MouseMsg: if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { - _ = msg.X - _ = msg.Y + switch m.state.Screen { + + // Welcome screen โ€” click highlights a menu option + case "welcome": + offset := 8 // wide banner (ASCII art) ends at Y=7, options start at Y=8 + if m.state.TermWidth < 70 { + offset = 3 // narrow banner is just text + } + idx := msg.Y - offset + if idx >= 0 && idx < 3 { + m.state.Cursor = idx + } + + // Main menu โ€” title + greeting + blank = 4 lines of header + case "menu": + idx := msg.Y - 4 + if idx >= 0 && idx < 3 { + m.state.Cursor = idx + } + + // Multiplayer menu โ€” title + subtitle + blanks = 5 lines of header + case "multi-menu": + idx := msg.Y - 5 + if idx >= 0 && idx < 3 { + m.state.Cursor = idx + } + + // Difficulty menu โ€” Y starts at 4 + case "difficulty": + idx := msg.Y - 4 + if idx >= 0 && idx < 4 { + m.state.Cursor = idx + } + + // Game screen โ€” click on a card to play it immediately + case "game": + if m.state.Phase == models.PhasePick { + if m.state.TermWidth >= 60 { + // Wide layout: cards are ~15 chars wide, joined side-by-side + cardIdx := -1 + if msg.X >= 2 && msg.X <= 16 { + cardIdx = 0 + } else if msg.X >= 19 && msg.X <= 33 { + cardIdx = 1 + } else if msg.X >= 36 && msg.X <= 50 { + cardIdx = 2 + } + if cardIdx >= 0 && msg.Y >= 5 && msg.Y <= 12 { + move := indexToMove(cardIdx) + m.state.PlayerMove = move + m.state.Phase = models.PhaseThink + if m.state.GameMode == "multi" { + _ = server.WriteClientMessage(m.wsConn, server.WSMessage{Type: "move", Payload: string(move)}) + return m, spinnerTick() + } else { + m.aiEngine.RecordPlayerMove(move) + return m, tea.Batch(spinnerTick(), m.aiThinkCmd()) + } + } + } else { + // Narrow layout: cards stacked at Y=5,6,7 + idx := msg.Y - 5 + if idx >= 0 && idx < 3 { + m.state.Cursor = idx + } + } + } + // Click anywhere during result phase advances to next round + if m.state.Phase == models.PhaseResult { + if m.state.Score.PlayerWins == 2 || m.state.Score.OpponentWins == 2 { + m.state.Score = models.MatchScore{Round: 1} + if m.state.GameMode == "multi" { + m.state.Screen = "multi-menu" + m.state.Cursor = 0 + if m.wsConn != nil { + m.wsConn.Close() + m.wsConn = nil + } + if m.lanServer != nil { + m.lanServer.Stop() + m.lanServer = nil + } + return m, nil + } + } + m.state.Phase = models.PhasePick + m.state.PlayerMove = models.None + m.state.AIMove = models.None + m.state.RoundOutcome = "" + m.state.Cursor = 0 + } + } } // โ”€โ”€ Keyboard input โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ case tea.KeyMsg: + // โ”€โ”€ Form screen input guard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // On text-input screens (login, register, join-room), intercept + // ALL single printable characters BEFORE game shortcuts (r/p/s) + // and vim navigation keys (h/j/k/l) can silently swallow them. + // Only structural keys (ctrl+c, esc, enter, backspace) pass through. + if isFormScreen(m.state.Screen) { + key := msg.String() + if len(key) == 1 { + if len(m.state.InputBuffer) >= models.MaxInputLength { + return m, nil + } + r := rune(key[0]) + valid := false + + switch m.state.Screen { + case "login": + // Auto-lowercase so capslock doesn't block input + if r >= 'A' && r <= 'Z' { + r = r + 32 // ASCII uppercase โ†’ lowercase + key = string(r) + } + valid = models.IsValidUsernameChar(r) + case "join-room": + valid = (r >= '0' && r <= '9') || r == '.' || r == ':' || + (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '-' + case "register": + switch m.state.ActiveField { + case 0, 1: // First Name, Last Name + valid = models.IsValidNameChar(r) + case 2: // Username โ€” auto-lowercase + if r >= 'A' && r <= 'Z' { + r = r + 32 + key = string(r) + } + valid = models.IsValidUsernameChar(r) + case 3: // State + valid = models.IsValidStateChar(r) + case 4: // Email + valid = models.IsValidEmailChar(r) + } + } + + if valid { + m.state.InputBuffer += key + m.state.FormError = "" + if m.state.Screen == "register" && m.state.ActiveField == 3 { + m.state.StateSuggestions = models.SuggestStates(m.state.InputBuffer) + } + } + return m, nil + } + } + switch msg.String() { // Hard quit โ€” works everywhere, no exceptions @@ -193,12 +456,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Soft quit โ€” blocked on form screens to allow typing the letter q case "q": - if m.state.Screen != "login" && m.state.Screen != "register" { - return m, tea.Quit - } - if len(m.state.InputBuffer) < 20 { - m.state.InputBuffer += "q" - } + // Form screens are already handled by the guard above, so this + // case only fires on non-form screens where q should quit. + return m, tea.Quit // Escape โ€” context-aware back navigation case "esc": @@ -217,79 +477,258 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.InputBuffer = "" m.state.FormError = "" m.state.Cursor = 0 - case "game", "waiting": + + case "game", "waiting", "create-room", "join-room", "quick-match": m.state.Screen = "menu" - m.state.Phase = "pick" + m.state.Phase = models.PhasePick m.state.Cursor = 0 m.state.FormError = "" - - case "create-room", "quick-match": - m.state.Screen = "multi-menu" - m.state.Cursor = 0 m.state.RoomCode = "" + if m.wsConn != nil { + m.wsConn.Close() + m.wsConn = nil + } + if m.lanServer != nil { + m.lanServer.Stop() + m.lanServer = nil + } + case "difficulty": + m.state.Screen = "menu" + m.state.Cursor = 0 case "multi-menu": m.state.Screen = "menu" m.state.Cursor = 1 case "result": - // Return to menu and reset match score for the next game m.state.Screen = "menu" m.state.Cursor = 0 m.state.Score = models.MatchScore{Round: 1} - m.state.Phase = "pick" + m.state.Phase = models.PhasePick + if m.wsConn != nil { + m.wsConn.Close() + m.wsConn = nil + } + if m.lanServer != nil { + m.lanServer.Stop() + m.lanServer = nil + } + case "help": + m.state.Screen = m.state.PreviousScreen + case "admin": + return m, tea.Quit + case "admin-players": + m.state.Screen = "admin" + m.state.Cursor = 0 + m.state.FormError = "" + case "admin-player-detail": + if m.state.AdminConfirm != "" { + m.state.AdminConfirm = "" + } else { + m.state.Screen = "admin-players" + m.state.FormError = "" + } + case "menu": + if m.state.Player.IsAdmin() { + m.state.Screen = "admin" + m.state.Cursor = 0 + m.state.FormError = "" + } } // Menu cursor โ€” up arrow and vim-style k case "up", "k": - if isMenuScreen(m.state.Screen) && m.state.Cursor > 0 { + if m.state.Screen == "admin-players" { + if m.state.AdminSelectedIndex > 0 { + m.state.AdminSelectedIndex-- + } + } else if isMenuScreen(m.state.Screen) && m.state.Cursor > 0 { m.state.Cursor-- } // Menu cursor โ€” down arrow and vim-style j case "down", "j": - if isMenuScreen(m.state.Screen) && m.state.Cursor < menuLength(m.state.Screen)-1 { + if m.state.Screen == "admin-players" { + if m.state.AdminSelectedIndex < len(m.state.AdminPlayers)-1 { + m.state.AdminSelectedIndex++ + } + } else if isMenuScreen(m.state.Screen) && m.state.Cursor < menuLength(m.state.Screen)-1 { m.state.Cursor++ } // Game card cursor โ€” left arrow and vim-style h case "left", "h": - if m.state.Screen == "game" && m.state.Phase == "pick" && m.state.Cursor > 0 { + if m.state.Screen == "game" && m.state.Phase == models.PhasePick && m.state.Cursor > 0 { + m.state.Cursor-- + } else if m.state.Screen == "help" && m.state.Cursor > 0 { m.state.Cursor-- } // Game card cursor โ€” right arrow and vim-style l case "right", "l": - if m.state.Screen == "game" && m.state.Phase == "pick" && m.state.Cursor < 2 { + if m.state.Screen == "game" && m.state.Phase == models.PhasePick && m.state.Cursor < 2 { + m.state.Cursor++ + } else if m.state.Screen == "help" && m.state.Cursor < 1 { m.state.Cursor++ } // Direct move shortcuts โ€” Rock - case "1", "r": - if m.state.Screen == "game" && m.state.Phase == "pick" { + case "1", "r", "R": + if m.state.Screen == "game" && m.state.Phase == models.PhasePick { m.state.PlayerMove = models.Rock - m.state.Phase = "think" - return m, tea.Batch(spinnerTick(), aiThink()) + m.state.Phase = models.PhaseThink + if m.state.GameMode == "multi" { + _ = server.WriteClientMessage(m.wsConn, server.WSMessage{Type: "move", Payload: string(models.Rock)}) + return m, spinnerTick() + } else { + m.aiEngine.RecordPlayerMove(models.Rock) + return m, tea.Batch(spinnerTick(), m.aiThinkCmd()) + } + } else if m.state.Screen == "admin-player-detail" && m.state.AdminConfirm == "" { + target := m.state.AdminPlayers[m.state.AdminSelectedIndex] + if target.IsRootAdmin() { + m.state.FormError = "Permission denied: Root administrator stats cannot be reset" + } else if target.IsAdmin() && !m.state.Player.IsRootAdmin() { + m.state.FormError = "Permission denied: Standard admins cannot modify other admins" + } else { + m.state.AdminConfirm = "reset" + m.state.FormError = "" + } } // Direct move shortcuts โ€” Paper - case "2", "p": - if m.state.Screen == "game" && m.state.Phase == "pick" { + case "2", "p", "P": + if m.state.Screen == "game" && m.state.Phase == models.PhasePick { m.state.PlayerMove = models.Paper - m.state.Phase = "think" - return m, tea.Batch(spinnerTick(), aiThink()) + m.state.Phase = models.PhaseThink + if m.state.GameMode == "multi" { + _ = server.WriteClientMessage(m.wsConn, server.WSMessage{Type: "move", Payload: string(models.Paper)}) + return m, spinnerTick() + } else { + m.aiEngine.RecordPlayerMove(models.Paper) + return m, tea.Batch(spinnerTick(), m.aiThinkCmd()) + } + } else if m.state.Screen == "admin-player-detail" && m.state.AdminConfirm == "" { + target := m.state.AdminPlayers[m.state.AdminSelectedIndex] + if target.IsRootAdmin() { + m.state.FormError = "Permission denied: Root administrator role cannot be modified" + } else if target.Username == m.state.Player.Username { + m.state.FormError = "Permission denied: You cannot demote yourself" + } else if !m.state.Player.IsRootAdmin() { + m.state.FormError = "Permission denied: Only the Root Admin can promote/demote admins" + } else { + m.state.AdminConfirm = "role" + m.state.FormError = "" + } } // Direct move shortcuts โ€” Scissors - case "3", "s": - if m.state.Screen == "game" && m.state.Phase == "pick" { + case "3", "s", "S": + if m.state.Screen == "game" && m.state.Phase == models.PhasePick { m.state.PlayerMove = models.Scissors - m.state.Phase = "think" - return m, tea.Batch(spinnerTick(), aiThink()) + m.state.Phase = models.PhaseThink + if m.state.GameMode == "multi" { + _ = server.WriteClientMessage(m.wsConn, server.WSMessage{Type: "move", Payload: string(models.Scissors)}) + return m, spinnerTick() + } else { + m.aiEngine.RecordPlayerMove(models.Scissors) + return m, tea.Batch(spinnerTick(), m.aiThinkCmd()) + } + } + + // Delete Player shortcut (admin only) + case "d", "D": + if m.state.Screen == "admin-player-detail" && m.state.AdminConfirm == "" { + target := m.state.AdminPlayers[m.state.AdminSelectedIndex] + if target.IsRootAdmin() { + m.state.FormError = "Permission denied: Root administrator cannot be deleted" + } else if target.Username == m.state.Player.Username { + m.state.FormError = "Permission denied: You cannot delete yourself" + } else if target.IsAdmin() && !m.state.Player.IsRootAdmin() { + m.state.FormError = "Permission denied: Standard admins cannot delete other admins" + } else { + m.state.AdminConfirm = "delete" + m.state.FormError = "" + } } // Enter โ€” confirms selections and form fields case "enter": switch m.state.Screen { + case "admin": + switch m.state.Cursor { + case 0: // Manage Players + players, err := models.LoadPlayers() + if err != nil { + m.state.FormError = "Failed to load players: " + err.Error() + return m, nil + } + m.state.AdminPlayers = players + m.state.AdminSelectedIndex = 0 + m.state.Screen = "admin-players" + m.state.FormError = "" + case 1: // Play Game + m.state.PreviousScreen = "admin" + m.state.Screen = "menu" + m.state.Cursor = 0 + m.state.FormError = "" + case 2: // Quit + return m, tea.Quit + } + + case "admin-players": + if len(m.state.AdminPlayers) > 0 { + m.state.Screen = "admin-player-detail" + m.state.FormError = "" + m.state.AdminConfirm = "" + } + + case "admin-player-detail": + if m.state.AdminConfirm != "" { + target := m.state.AdminPlayers[m.state.AdminSelectedIndex] + var err error + + switch m.state.AdminConfirm { + case "reset": + err = models.ResetPlayerStats(target.Username) + if err == nil { + m.state.FormError = "Stats reset successfully!" + } + case "delete": + err = models.DeletePlayer(target.Username) + if err == nil { + m.state.Screen = "admin-players" + m.state.FormError = "Player deleted successfully!" + m.state.AdminSelectedIndex = 0 + } + case "role": + newRole := "admin" + if target.Role == "admin" { + newRole = "player" + } + err = models.SetPlayerRole(target.Username, newRole) + if err == nil { + m.state.FormError = fmt.Sprintf("Role updated to %s!", newRole) + } + } + + if err != nil { + m.state.FormError = "Error: " + err.Error() + } + + // Reload database state + players, loadErr := models.LoadPlayers() + if loadErr == nil { + m.state.AdminPlayers = players + // Ensure index is valid + if m.state.AdminSelectedIndex >= len(players) { + m.state.AdminSelectedIndex = len(players) - 1 + } + if m.state.AdminSelectedIndex < 0 { + m.state.AdminSelectedIndex = 0 + } + } + m.state.AdminConfirm = "" + } // โ”€โ”€ Welcome screen โ€” navigate to register, login, or quit โ”€โ”€ case "welcome": @@ -321,6 +760,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.FormError = "First name must be a single word" return m, nil } + if errMsg := models.ValidateName(input); errMsg != "" { + m.state.FormError = errMsg + return m, nil + } m.state.Player.FirstName = caser.String(strings.ToLower(input)) // normalise then capitalise m.state.ActiveField++ m.state.InputBuffer = "" @@ -331,14 +774,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.FormError = "Last name must be a single word" return m, nil } + if errMsg := models.ValidateName(input); errMsg != "" { + m.state.FormError = errMsg + return m, nil + } m.state.Player.LastName = caser.String(strings.ToLower(input)) // normalise then capitalise m.state.ActiveField++ m.state.InputBuffer = "" m.state.FormError = "" case 2: // Username - if len(strings.Fields(input)) != 1 { - m.state.FormError = "Username must be a single word โ€” no spaces" + if errMsg := models.ValidateUsername(strings.ToLower(input)); errMsg != "" { + m.state.FormError = errMsg return m, nil } if models.UsernameExists(strings.ToLower(input)) { @@ -393,7 +840,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state.FormError = "Username not found โ€” check spelling or register" } else { m.state.Player = player - m.state.Screen = "menu" + if player.IsAdmin() { + m.state.Screen = "admin" + } else { + m.state.Screen = "menu" + } m.state.InputBuffer = "" m.state.FormError = "" m.state.Cursor = 0 @@ -404,10 +855,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.state.Cursor { case 0: m.state.PreviousScreen = "menu" - m.state.Screen = "game" - m.state.GameMode = "single" - m.state.Phase = "pick" - m.state.Score = models.MatchScore{Round: 1} + m.state.Screen = "difficulty" + m.state.Cursor = 0 case 1: m.state.PreviousScreen = "menu" m.state.Screen = "multi-menu" @@ -417,43 +866,120 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } + case "difficulty": + switch m.state.Cursor { + case 0: + m.aiEngine = models.NewAIEngine(models.AIDifficultyEasy) + case 1: + m.aiEngine = models.NewAIEngine(models.AIDifficultyMedium) + case 2: + m.aiEngine = models.NewAIEngine(models.AIDifficultyHard) + case 3: // Back + m.state.Screen = "menu" + m.state.Cursor = 0 + return m, nil + } + m.state.Screen = "game" + m.state.GameMode = "single" + m.state.Phase = models.PhasePick + m.state.Score = models.MatchScore{Round: 1} + m.state.PlayerMove = models.None + m.state.AIMove = models.None + m.state.RoundOutcome = "" + case "multi-menu": switch m.state.Cursor { case 0: - // Create Room โ€” generate code and start spinner + // Create Room (Host) + m.lanServer = server.NewLANServer() + port, err := m.lanServer.Start(8080) + if err != nil { + m.state.FormError = "Host failed: " + err.Error() + return m, nil + } + localIP := getLocalIP() + addr := fmt.Sprintf("%s:%d", localIP, port) + m.state.RoomCode = addr + + conn, err := server.DialLANServer(fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + m.lanServer.Stop() + m.lanServer = nil + m.state.FormError = "Host connect failed: " + err.Error() + return m, nil + } + m.wsConn = conn + _ = server.WriteClientMessage(m.wsConn, server.WSMessage{Type: "join", Payload: m.state.Player.Username}) + m.state.Screen = "create-room" - m.state.RoomCode = models.GenerateRoomCode() m.state.PreviousScreen = "multi-menu" m.state.Cursor = 0 - return m, spinnerTick() + return m, tea.Batch(spinnerTick(), readNetMsg(m.wsConn)) + case 1: - // Quick Match โ€” auto search and start spinner - m.state.Screen = "quick-match" + // Join Room + m.state.Screen = "join-room" + m.state.InputBuffer = "127.0.0.1:8080" + m.state.FormError = "" m.state.PreviousScreen = "multi-menu" m.state.Cursor = 0 - return m, spinnerTick() + case 2: - // Back to main menu m.state.Screen = "menu" m.state.Cursor = 1 } + case "join-room": + input := strings.TrimSpace(m.state.InputBuffer) + if input == "" { + m.state.FormError = "Host address required" + return m, nil + } + conn, err := server.DialLANServer(input) + if err != nil { + m.state.FormError = "Connect failed: " + err.Error() + return m, nil + } + m.wsConn = conn + _ = server.WriteClientMessage(m.wsConn, server.WSMessage{Type: "join", Payload: m.state.Player.Username}) + + m.state.Screen = "waiting" + m.state.FormError = "" + return m, tea.Batch(spinnerTick(), readNetMsg(m.wsConn)) + // โ”€โ”€ Game screen โ€” confirm move or continue after result โ”€โ”€ case "game": switch m.state.Phase { - case "pick": - // Confirm the card currently under the cursor - m.state.PlayerMove = indexToMove(m.state.Cursor) - m.state.Phase = "think" - return m, tea.Batch(spinnerTick(), aiThink()) + case models.PhasePick: + move := indexToMove(m.state.Cursor) + m.state.PlayerMove = move + m.state.Phase = models.PhaseThink + if m.state.GameMode == "multi" { + _ = server.WriteClientMessage(m.wsConn, server.WSMessage{Type: "move", Payload: string(move)}) + return m, spinnerTick() + } else { + m.aiEngine.RecordPlayerMove(move) + return m, tea.Batch(spinnerTick(), m.aiThinkCmd()) + } - case "result": + case models.PhaseResult: if m.state.Score.PlayerWins == 2 || m.state.Score.OpponentWins == 2 { - // Match is over โ€” full reset for a new match m.state.Score = models.MatchScore{Round: 1} + if m.state.GameMode == "multi" { + m.state.Screen = "multi-menu" + m.state.Cursor = 0 + if m.wsConn != nil { + m.wsConn.Close() + m.wsConn = nil + } + if m.lanServer != nil { + m.lanServer.Stop() + m.lanServer = nil + } + return m, nil + } } - // Reset round state, keep player data and lifetime stats - m.state.Phase = "pick" + m.state.Phase = models.PhasePick m.state.PlayerMove = models.None m.state.AIMove = models.None m.state.RoundOutcome = "" @@ -477,14 +1003,58 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(msg.String()) == 1 { char := msg.String() + // ? opens help screen โ€” but only on non-input screens + // On login/register/join-room, ? falls through to normal typing below + if char == "?" && m.state.Screen != "login" && m.state.Screen != "register" && m.state.Screen != "join-room" { + // Block during think/reveal phases โ€” animation is running + if m.state.Screen != "game" || m.state.Phase == models.PhasePick || m.state.Phase == models.PhaseResult { + m.state.PreviousScreen = m.state.Screen + m.state.Screen = "help" + m.state.Cursor = 0 + } + return m, nil + } + + // Enforce maximum input length across all fields + if len(m.state.InputBuffer) >= models.MaxInputLength { + return m, nil + } + + // Login โ€” only accept valid username characters if m.state.Screen == "login" { - m.state.InputBuffer += char + r := rune(char[0]) + if models.IsValidUsernameChar(r) { + m.state.InputBuffer += char + } } + // Join Room โ€” accept IP, port, dots, colons, localhost text + if m.state.Screen == "join-room" { + r := rune(char[0]) + if (r >= '0' && r <= '9') || r == '.' || r == ':' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '-' { + m.state.InputBuffer += char + } + } + + // Register โ€” filter characters based on which field is active if m.state.Screen == "register" { - m.state.InputBuffer += char - if m.state.ActiveField == 3 { - m.state.StateSuggestions = models.SuggestStates(m.state.InputBuffer) + r := rune(char[0]) + valid := false + switch m.state.ActiveField { + case 0, 1: // First Name, Last Name + valid = models.IsValidNameChar(r) + case 2: // Username + valid = models.IsValidUsernameChar(r) + case 3: // State + valid = models.IsValidStateChar(r) + case 4: // Email + valid = models.IsValidEmailChar(r) + } + if valid { + m.state.InputBuffer += char + if m.state.ActiveField == 3 { + m.state.StateSuggestions = models.SuggestStates(m.state.InputBuffer) + } } } } @@ -497,7 +1067,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // isMenuScreen returns true for any screen that uses vertical cursor navigation func isMenuScreen(screen string) bool { - return screen == "welcome" || screen == "menu" || screen == "multi-menu" + return screen == "welcome" || screen == "menu" || screen == "multi-menu" || screen == "difficulty" || screen == "admin" +} + +// isFormScreen returns true for any screen that accepts text input. +// On these screens, single-character keys must be routed to the input buffer +// instead of being consumed by game shortcuts or vim navigation bindings. +func isFormScreen(screen string) bool { + return screen == "login" || screen == "register" || screen == "join-room" } // menuLength returns the number of selectable options for a given menu screen @@ -506,9 +1083,13 @@ func menuLength(screen string) int { case "welcome": return 3 // Register, Sign In, Quit case "menu": - return 3 // Single Player - case "multi-menu": // Multiplayer, Quit - return 3 + return 3 // Single Player, Multiplayer, Quit + case "multi-menu": + return 3 // Host, Join, Back + case "difficulty": + return 4 // Easy, Medium, Hard, Back + case "admin": + return 3 // Manage Players, Play Game, Quit default: return 0 } @@ -516,144 +1097,163 @@ func menuLength(screen string) int { // โ”€โ”€โ”€ View โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -// View renders the current screen as a string โ€” called automatically on every state change +// View renders the current screen as a string โ€” called automatically on every state change. +// All content is wrapped in an AppContainerStyle panel and centered in the terminal. func (m model) View() string { // Global guard โ€” terminal too narrow to render safely if m.state.TermWidth > 0 && m.state.TermWidth < 50 { - return fmt.Sprintf( - "\n โš  Terminal too narrow!\n\n"+ - " Please resize to at least 50 columns.\n"+ - " Current width: %d columns.", - m.state.TermWidth, + return lipgloss.Place( + m.state.TermWidth, m.state.TermHeight, + lipgloss.Center, lipgloss.Center, + fmt.Sprintf("โš  Terminal too narrow!\n\nPlease resize to at least 50 columns.\nCurrent width: %d columns.", m.state.TermWidth), ) } // Too short vertically if m.state.TermHeight > 0 && m.state.TermHeight < 16 { - return "\n โš  Terminal too short!\n\n" + - " Please resize to at least 16 rows." + return lipgloss.Place( + m.state.TermWidth, m.state.TermHeight, + lipgloss.Center, lipgloss.Center, + "โš  Terminal too short!\n\nPlease resize to at least 16 rows.", + ) } + var content string switch m.state.Screen { case "welcome": - return renderWelcome(m) + content = renderWelcome(m) case "register": - return renderRegister(m) + content = renderRegister(m) case "login": - return renderLogin(m) + content = renderLogin(m) case "menu": - return renderMenu(m) + content = renderMenu(m) + case "difficulty": + content = renderDifficulty(m) case "game": - return renderGame(m) + content = renderGame(m) case "multi-menu": - return renderMultiMenu(m) + content = renderMultiMenu(m) case "create-room": - return renderCreateRoom(m) - case "quick-match": - return renderQuickMatch(m) + content = renderCreateRoom(m) + case "join-room": + content = renderJoinRoom(m) case "waiting": - return renderQuickMatch(m) // fallback โ€” reuses quick match screen + content = renderQuickMatch(m) + case "help": + content = renderHelp(m) + case "admin": + content = renderAdmin(m) + case "admin-players": + content = renderAdminPlayers(m) + case "admin-player-detail": + content = renderAdminPlayerDetail(m) default: - return "\n Unknown screen\n\n ctrl+c to quit" + content = "Unknown screen\n\nctrl+c to quit" } + + // Wrap in the master container panel, then center in the full terminal + styledContent := ui.AppContainerStyle.Render(content) + return lipgloss.Place( + m.state.TermWidth, m.state.TermHeight, + lipgloss.Center, lipgloss.Center, + styledContent, + ) } // โ”€โ”€โ”€ Screen Renderers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -// banner returns the full ASCII logo on wide terminals -// and a compact text version on narrow terminals -func banner(termWidth int) string { - if termWidth >= 70 { - return ` - โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•— - โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•— โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•”โ•โ•โ•โ•โ•โ–ˆโ–ˆโ•‘ - โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ - โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•”โ•โ•โ•โ• โ–ˆโ–ˆโ•”โ•โ•โ–ˆโ–ˆโ•‘โ•šโ•โ•โ•โ•โ•โ•šโ•โ•โ•โ•โ–ˆโ–ˆโ•‘โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ - โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•”โ•โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•‘โ•šโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—โ–ˆโ–ˆโ•‘ - โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ• โ•šโ•โ• โ•šโ•โ• โ•šโ•โ• โ•šโ•โ•โ•โ•โ•โ•โ• โ•šโ•โ•โ•โ•โ•โ•โ•šโ•โ• -` - } - return "\n ROPA-SCI\n" +// banner returns a compact styled ASCII logo that fits within the container panel +func banner() string { + return ` + โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— + โ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ•‘ + โ•‘ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ•‘ + โ•‘ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ•‘ + โ•‘ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ•‘ + โ•‘ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ–ˆโ–ˆ โ•‘ + โ•‘โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ S C I โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•‘ + โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•` } // renderWelcome draws the landing screen with register/login/quit options func renderWelcome(m model) string { - options := []string{ - "Register โ€” I am new", - "Sign In โ€” I have an account", - "Quit", - } - - // Banner โ€” purple on wide terminals, plain text on narrow - s := ui.BannerStyle.Render(banner(m.state.TermWidth)) + "\n" + options := []string{ + "โœฆ Register โ€” I am new", + "โœฆ Sign In โ€” I have an account", + "โœฆ Quit", + } - for i, opt := range options { - if m.state.Cursor == i { - s += ui.SelectedStyle.Render(" > " + opt) + "\n" - } else { - s += ui.MutedStyle.Render(" " + opt) + "\n" - } - } + s := ui.BannerStyle.Render(banner()) + "\n\n" + s += ui.HeaderStyle.Render("Rock ยท Paper ยท Scissors") + "\n\n" + s += ui.Divider() + "\n\n" - s += "\n" + ui.Footer("โ†‘/โ†“ to move ยท Enter to select ยท ctrl+c to quit") + for i, opt := range options { + if m.state.Cursor == i { + s += ui.SelectedStyle.Render(" โ–ธ "+opt) + "\n" + } else { + s += ui.MutedStyle.Render(" "+opt) + "\n" + } + } - if m.state.FormError != "" { - s += "\n\n" + ui.ErrorLine(m.state.FormError) - } - return s + if m.state.FormError != "" { + s += "\n" + ui.ErrorLine(m.state.FormError) + } + s += "\n" + ui.Footer("โ†‘/โ†“ navigate ยท Enter select ยท ctrl+c quit") + return s } -// renderLogin draws the sign-in screen with a live username input +// renderRegister draws the registration form with five fields inside a styled panel func renderRegister(m model) string { - fields := []string{ - "First Name", - "Last Name ", - "Username ", - "State ", - "Email ", - } - values := []string{ - m.state.Player.FirstName, - m.state.Player.LastName, - m.state.Player.Username, - m.state.Player.State, - emailDisplay(m.state.Player.Email), - } - - s := ui.BannerStyle.Render(banner(m.state.TermWidth)) - s += "\n" + ui.TitleStyle.Render(" REGISTER โ€” Create your account") + "\n\n" - - for i, field := range fields { - if i == m.state.ActiveField { - // Active field โ€” purple, bold, cursor - s += ui.SelectedStyle.Render(" > "+field+": ") + - m.state.InputBuffer + "_\n" - } else if values[i] != "" { - // Completed field โ€” green checkmark - s += ui.MutedStyle.Render(" "+field+": ") + - values[i] + " " + ui.Checkmark() + "\n" - } else { - // Future field โ€” dimmed - s += ui.MutedStyle.Render(" "+field+": \n") - } - } - - // Live state suggestions - if m.state.ActiveField == 3 && len(m.state.StateSuggestions) > 0 { - s += "\n" + ui.InfoStyle.Render(" Suggestions: ") - for _, state := range m.state.StateSuggestions { - s += ui.DimStyle.Render(state.Name+" ("+state.Abbreviation+") ") - } - s += "\n" - } - - if m.state.FormError != "" { - s += "\n" + ui.ErrorLine(m.state.FormError) + "\n" - } - if m.state.ActiveField == 4 { - s += "\n" + ui.WarningStyle.Render(" Email is optional โ€” press Enter to skip") + "\n" - } - s += "\n" + ui.Footer("Enter to confirm field ยท Esc to go back ยท ctrl+c to quit") - return s + fields := []string{ + "First Name", + "Last Name ", + "Username ", + "State ", + "Email ", + } + values := []string{ + m.state.Player.FirstName, + m.state.Player.LastName, + m.state.Player.Username, + m.state.Player.State, + emailDisplay(m.state.Player.Email), + } + + s := ui.BannerStyle.Render(banner()) + "\n" + s += ui.TitleStyle.Render("REGISTER") + "\n" + s += ui.DimStyle.Render("Create your player account") + "\n\n" + + var formContent string + for i, field := range fields { + if i == m.state.ActiveField { + formContent += ui.SelectedStyle.Render(" โ–ธ "+field+": ") + + m.state.InputBuffer + "โ–Š\n" + } else if values[i] != "" { + formContent += ui.DimStyle.Render(" "+field+": ") + + values[i] + " " + ui.Checkmark() + "\n" + } else { + formContent += ui.MutedStyle.Render(" " + field + ":\n") + } + } + s += ui.FormPanelStyle.Render(formContent) + "\n" + + // Live state suggestions + if m.state.ActiveField == 3 && len(m.state.StateSuggestions) > 0 { + s += "\n" + ui.InfoStyle.Render("Suggestions: ") + for _, state := range m.state.StateSuggestions { + s += ui.DimStyle.Render(state.Name + " (" + state.Abbreviation + ") ") + } + s += "\n" + } + + if m.state.FormError != "" { + s += "\n" + ui.ErrorLine(m.state.FormError) + "\n" + } + if m.state.ActiveField == 4 { + s += "\n" + ui.WarningStyle.Render("Email is optional โ€” press Enter to skip") + "\n" + } + s += "\n" + ui.Footer("Enter confirm ยท Esc back ยท ctrl+c quit") + return s } // emailDisplay safely dereferences an optional email pointer for display @@ -665,270 +1265,356 @@ func emailDisplay(email *string) string { } func renderLogin(m model) string { - s := ui.BannerStyle.Render(banner(m.state.TermWidth)) - s += "\n" + ui.TitleStyle.Render(" SIGN IN โ€” Enter your username") + "\n\n" - s += ui.SelectedStyle.Render(" > Username: ") + m.state.InputBuffer + "_\n" + s := ui.BannerStyle.Render(banner()) + "\n" + s += ui.TitleStyle.Render("SIGN IN") + "\n" + s += ui.DimStyle.Render("Enter your username to continue") + "\n\n" + + formContent := ui.SelectedStyle.Render(" โ–ธ Username: ") + m.state.InputBuffer + "โ–Š\n" + s += ui.FormPanelStyle.Render(formContent) + "\n" - if m.state.FormError != "" { - s += "\n" + ui.ErrorLine(m.state.FormError) + "\n" - } - s += "\n" + ui.Footer("Enter to sign in ยท Esc to go back ยท ctrl+c to quit") - return s + if m.state.FormError != "" { + s += "\n" + ui.ErrorLine(m.state.FormError) + "\n" + } + s += "\n" + ui.Footer("Enter sign in ยท Esc back ยท ctrl+c quit") + return s } -// renderMenu draws the main game menu with a personalised greeting -func renderMenu(m model) string { - options := []string{ - "Single Player", - "Multiplayer", - "Quit", - } - s := "\n" + ui.TitleStyle.Render(" ROPA-SCI") + "\n" - s += ui.DimStyle.Render(" Welcome, "+m.state.Player.FirstName+"!") + "\n\n" - - for i, option := range options { - if m.state.Cursor == i { - s += ui.SelectedStyle.Render(" > " + option) + "\n" - } else { - s += ui.MutedStyle.Render(" " + option) + "\n" - } - } - s += "\n" + ui.Footer("โ†‘/โ†“ or k/j ยท Enter to select ยท Esc to quit") - return s +// renderAdmin renders the admin dashboard landing page +func renderAdmin(m model) string { + options := []string{ + "๐Ÿ›ก๏ธ Manage Players", + "โš”๏ธ Play Game", + "โœฆ Quit", + } + s := ui.BannerStyle.Render(banner()) + "\n" + s += ui.TitleStyle.Render("ADMIN DASHBOARD") + "\n" + s += ui.DimStyle.Render("Logged in as: "+m.state.Player.FirstName+" ("+m.state.Player.Role+")") + "\n\n" + s += ui.Divider() + "\n\n" + + for i, option := range options { + if m.state.Cursor == i { + s += ui.SelectedStyle.Render(" โ–ธ "+option) + "\n" + } else { + s += ui.MutedStyle.Render(" "+option) + "\n" + } + } + + if m.state.FormError != "" { + s += "\n" + ui.ErrorLine(m.state.FormError) + } + s += "\n" + ui.Footer("โ†‘/โ†“ navigate ยท Enter select ยท ctrl+c quit") + return s } +// renderAdminPlayers renders the scrollable list of players in a table format +func renderAdminPlayers(m model) string { + s := ui.TitleStyle.Render("PLAYERS LIST") + "\n" + s += ui.DimStyle.Render(fmt.Sprintf("Total registered: %d", len(m.state.AdminPlayers))) + "\n\n" + + // Table headers - fits in 42 columns + s += ui.HeaderStyle.Render(fmt.Sprintf(" %-10s %-14s %-6s %-10s", "Username", "Name", "Record", "Role")) + "\n" + s += ui.Divider() + "\n" + + if len(m.state.AdminPlayers) == 0 { + s += ui.MutedStyle.Render(" No players registered yet.") + "\n" + } else { + maxVisible := 5 + start := 0 + total := len(m.state.AdminPlayers) + if total > maxVisible { + start = m.state.AdminSelectedIndex - maxVisible/2 + if start < 0 { + start = 0 + } + if start+maxVisible > total { + start = total - maxVisible + } + } + end := start + maxVisible + if end > total { + end = total + } -// โ”€โ”€โ”€ Game Screen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + for i := start; i < end; i++ { + p := m.state.AdminPlayers[i] + fullName := p.FirstName + " " + p.LastName + if len(fullName) > 14 { + fullName = fullName[:11] + "..." + } + record := fmt.Sprintf("%d-%d", p.Wins, p.Losses) + line := fmt.Sprintf(" %-10s %-14s %-6s %-10s", p.Username, fullName, record, p.Role) -// MoveCard returns a 6-line ASCII art card for a given move -// The card border doubles when selected to give clear visual feedback -func MoveCard(move models.Move, selected bool) []string { - b := "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€" - if selected { - b = "โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•" + if m.state.AdminSelectedIndex == i { + s += ui.SelectedStyle.Render(" โ–ธ "+line[3:]) + "\n" + } else { + s += ui.MutedStyle.Render(" "+line[3:]) + "\n" + } + } + if total > maxVisible { + s += ui.DimStyle.Render(fmt.Sprintf(" ... (%d more players) ...", total-maxVisible)) + "\n" + } } - top := "โ”Œ" + b + "โ”" - bot := "โ””" + b + "โ”˜" - - cards := map[models.Move][]string{ - models.Rock: { - " ๐Ÿชจ ", // emoji sits outside the box โ€” no alignment issue - top, - "โ”‚ โ”‚", - "โ”‚ R O C K โ”‚", - "โ”‚ [ 1 / R ] โ”‚", - bot, - }, - models.Paper: { - " ๐Ÿ“„ ", - top, - "โ”‚ โ”‚", - "โ”‚ P A P E R โ”‚", - "โ”‚ [ 2 / P ] โ”‚", - bot, - }, - models.Scissors: { - " โœ‚๏ธ ", - top, - "โ”‚ โ”‚", - "โ”‚ S C I S S โ”‚", - "โ”‚ [ 3 / S ] โ”‚", - bot, - }, - models.None: { - " ", - top, - "โ”‚ ? ? ? ? ? โ”‚", - "โ”‚ โ–“ โ–“ โ–“ โ–“ โ–“ โ”‚", - "โ”‚ ? ? ? ? ? โ”‚", - bot, - }, + s += ui.Divider() + "\n" + if m.state.FormError != "" { + s += ui.ErrorLine(m.state.FormError) + "\n" } - return cards[move] + s += ui.Footer("โ†‘/โ†“ scroll ยท Enter view ยท Esc dashboard") + return s } -// renderGame routes to the correct phase renderer based on current game phase -func renderGame(m model) string { - // Score header is always visible at the top of the game screen - s := fmt.Sprintf("\n Round %d of 3 ยท You: %d ยท AI: %d\n\n", - m.state.Score.Round, - m.state.Score.PlayerWins, - m.state.Score.OpponentWins, - ) - switch m.state.Phase { - case "pick": - // Wide terminal โ€” cards side by side - if m.state.TermWidth >= 60 { - s += renderPick(m) - } else { - // Narrow terminal โ€” cards stacked vertically - s += renderPickNarrow(m) - } - case "think": - if m.state.TermWidth >= 60 { - s += renderThink(m) - } else { - s += renderThinkNarrow(m) +// renderAdminPlayerDetail renders details of a selected player and action shortcuts +func renderAdminPlayerDetail(m model) string { + if m.state.AdminSelectedIndex >= len(m.state.AdminPlayers) { + return "No player selected" + } + p := m.state.AdminPlayers[m.state.AdminSelectedIndex] + + s := ui.TitleStyle.Render("PLAYER PROFILE") + "\n" + s += ui.DimStyle.Render("Review details and perform actions") + "\n\n" + + // Read-only notification + if p.IsAdmin() && !m.state.Player.IsRootAdmin() && p.Username != m.state.Player.Username { + s += ui.WarningStyle.Render("๐Ÿ”’ Read-Only: Standard admin cannot modify other admins") + "\n\n" + } + + // Profile card layout + var profile string + profile += fmt.Sprintf("Username: %s\n", ui.InfoStyle.Render(p.Username)) + profile += fmt.Sprintf("Full Name: %s %s\n", p.FirstName, p.LastName) + profile += fmt.Sprintf("State: %s\n", p.State) + emailStr := "N/A" + if p.Email != nil { + emailStr = *p.Email + } + profile += fmt.Sprintf("Email: %s\n", emailStr) + profile += fmt.Sprintf("Role: %s\n", ui.WarningStyle.Render(p.Role)) + profile += fmt.Sprintf("Lifetime: W:%d L:%d T:%d Total:%d\n", p.Wins, p.Losses, p.Ties, p.TotalMatches) + + s += ui.FormPanelStyle.Render(profile) + "\n\n" + + if m.state.AdminConfirm != "" { + var confirmMsg string + switch m.state.AdminConfirm { + case "reset": + confirmMsg = "โš ๏ธ Are you sure you want to RESET stats to 0?" + case "delete": + confirmMsg = "โš ๏ธ Are you sure you want to DELETE this account?" + case "role": + targetRole := "admin" + if p.Role == "admin" { + targetRole = "player" + } + confirmMsg = fmt.Sprintf("โš ๏ธ Are you sure you want to change role to %s?", targetRole) } - case "reveal": - if m.state.TermWidth >= 60 { - s += renderReveal(m) - } else { - s += renderRevealNarrow(m) + s += ui.WarningStyle.Render(confirmMsg) + "\n" + s += ui.Footer("Enter confirm ยท Esc cancel") + "\n" + } else { + var actions []string + actions = append(actions, "[R] Reset Stats") + actions = append(actions, "[D] Delete Player") + if m.state.Player.IsRootAdmin() { + actions = append(actions, "[P] Toggle Role") } - case "result": - s += renderResult(m) + + s += ui.DimStyle.Render("Actions:") + "\n" + s += " " + strings.Join(actions, " ยท ") + "\n" + s += "\n" + ui.Footer("Esc back to list") + } + + if m.state.FormError != "" { + s += "\n" + ui.ErrorLine(m.state.FormError) } return s } -// renderPickNarrow shows cards stacked vertically for narrow terminals -func renderPickNarrow(m model) string { - moves := []models.Move{models.Rock, models.Paper, models.Scissors} - labels := []string{"[1/R]", "[2/P]", "[3/S]"} - s := " Choose your move:\n\n" - for i, move := range moves { - selected := m.state.Cursor == i - prefix := " " - if selected { - prefix = "> " - } - name := string(move) - if selected { - s += " " + prefix + "[ " + strings.ToUpper(name) + " " + labels[i] + " ] โ—€\n" +// renderMenu draws the main game menu with a personalised greeting +func renderMenu(m model) string { + options := []string{ + "โš” Single Player", + "๐ŸŒ Multiplayer", + "โœฆ Quit", + } + s := ui.TitleStyle.Render("ROPA-SCI") + "\n" + s += ui.DimStyle.Render("Welcome back, "+m.state.Player.FirstName+"!") + "\n" + + // Stats bar + stats := fmt.Sprintf("W: %d L: %d Matches: %d", + m.state.Player.Wins, m.state.Player.Losses, m.state.Player.TotalMatches) + s += ui.InfoStyle.Render(stats) + "\n\n" + s += ui.Divider() + "\n\n" + + for i, option := range options { + if m.state.Cursor == i { + s += ui.SelectedStyle.Render(" โ–ธ "+option) + "\n" } else { - s += " " + prefix + "[ " + strings.ToUpper(name) + " " + labels[i] + " ]\n" + s += ui.MutedStyle.Render(" "+option) + "\n" } } - s += "\n โ†‘/โ†“ to choose ยท Enter or 1/2/3 to confirm\n" - s += " Esc to return to menu\n" + s += "\n" + ui.Footer("โ†‘/โ†“ navigate ยท Enter select ยท ? help") return s } -// renderThinkNarrow shows think phase for narrow terminals -func renderThinkNarrow(m model) string { - spinner := spinnerFrames[m.state.SpinnerFrame] - s := " YOUR MOVE\n\n" - s += " [ " + strings.ToUpper(string(m.state.PlayerMove)) + " ]\n\n" - s += " VS\n\n" - s += " AI's MOVE\n\n" - s += " [ ??? ]\n\n" - s += " " + spinner + " AI is calculating...\n" - return s +// โ”€โ”€โ”€ Game Screen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// MoveCard returns a Lipgloss-styled card string for a given move. +// Selected cards get an accented double border, normal cards get a rounded muted border. +func MoveCard(move models.Move, selected bool) string { + style := ui.NormalCardStyle + if selected { + style = ui.SelectedCardStyle + } + + type cardInfo struct { + emoji string + content string + } + + cards := map[models.Move]cardInfo{ + models.Rock: {"๐Ÿชจ", "R O C K\n\n[ 1 / R ]"}, + models.Paper: {"๐Ÿ“„", "P A P E R\n\n[ 2 / P ]"}, + models.Scissors: {"โœ‚๏ธ", "S C I S S\n\n[ 3 / S ]"}, + models.None: {"๐Ÿ”’", "? ? ? ? ?\n\nโ–“ โ–“ โ–“ โ–“ โ–“"}, + } + + info := cards[move] + card := style.Render(info.content) + + return " " + info.emoji + "\n" + card } -// renderRevealNarrow shows both moves stacked for narrow terminals -func renderRevealNarrow(m model) string { - s := " YOUR MOVE\n\n" - s += " [ " + strings.ToUpper(string(m.state.PlayerMove)) + " ]\n\n" - s += " VS\n\n" - s += " AI's MOVE\n\n" - s += " [ " + strings.ToUpper(string(m.state.AIMove)) + " ]\n\n" +// renderGame routes to the correct phase renderer based on current game phase +func renderGame(m model) string { + opponent := "AI" + if m.state.GameMode == "multi" && m.netOpponentName != "" { + opponent = m.netOpponentName + } + + // Score header โ€” styled persistent banner + scoreText := fmt.Sprintf("Round %d of 3 ยท You: %d ยท %s: %d", + m.state.Score.Round, + m.state.Score.PlayerWins, + opponent, + m.state.Score.OpponentWins, + ) + s := ui.ScoreHeaderStyle.Render(scoreText) + "\n\n" + + switch m.state.Phase { + case models.PhasePick: + s += renderPick(m) + case models.PhaseThink: + s += renderThink(m) + case models.PhaseReveal: + s += renderReveal(m) + case models.PhaseResult: + s += renderResult(m) + } return s } -// renderPick shows three selectable move cards side by side +// renderPick shows three selectable move cards side by side using lipgloss.JoinHorizontal func renderPick(m model) string { - s := " Choose your move:\n\n" + s := ui.HeaderStyle.Render("Choose your move") + "\n\n" moves := []models.Move{models.Rock, models.Paper, models.Scissors} - cards := make([][]string, 3) + cards := make([]string, 3) for i, move := range moves { cards[i] = MoveCard(move, m.state.Cursor == i) } - // Print all three cards row by row so they sit side by side - for row := 0; row < 6; row++ { - s += " " - for col := 0; col < 3; col++ { - s += cards[col][row] + " " - } - s += "\n" - } - s += "\n โ† โ†’ to choose ยท Enter or 1/2/3 or R/P/S to confirm\n" - s += " Esc to return to menu\n" + s += lipgloss.JoinHorizontal(lipgloss.Top, cards[0], " ", cards[1], " ", cards[2]) + "\n" + s += "\n" + ui.Footer("โ† โ†’ choose ยท Enter / 1-3 / R-P-S confirm") return s } -// renderThink shows the player's locked-in move alongside a face-down AI card -// The spinner animates to build tension while the AI "decides" +// renderThink shows the player's locked-in move alongside a face-down opponent card +// The spinner animates to build tension while the opponent "decides" func renderThink(m model) string { - s := " YOUR MOVE AI's MOVE\n\n" - playerCard := MoveCard(m.state.PlayerMove, false) - aiCard := MoveCard(models.None, false) - for i := 0; i < 6; i++ { - s += " " + playerCard[i] + " VS " + aiCard[i] + "\n" + opponent := "AI" + if m.state.GameMode == "multi" && m.netOpponentName != "" { + opponent = m.netOpponentName } + playerCol := ui.DimStyle.Render("YOUR MOVE") + "\n\n" + MoveCard(m.state.PlayerMove, false) + vsCol := ui.HeaderStyle.Render("\n\n\n VS ") + aiCol := ui.DimStyle.Render(strings.ToUpper(opponent)+"'s MOVE") + "\n\n" + MoveCard(models.None, false) + + s := lipgloss.JoinHorizontal(lipgloss.Center, playerCol, vsCol, aiCol) + "\n" spinner := spinnerFrames[m.state.SpinnerFrame] - s += "\n " + spinner + " AI is calculating its move...\n" + s += "\n" + ui.DimStyle.Render(spinner+" "+opponent+" is calculating...") + "\n" return s } // renderReveal shows both moves side by side before the result appears func renderReveal(m model) string { - s := " YOUR MOVE AI's MOVE\n\n" - playerCard := MoveCard(m.state.PlayerMove, false) - aiCard := MoveCard(m.state.AIMove, false) - for i := 0; i < 6; i++ { - s += " " + playerCard[i] + " VS " + aiCard[i] + "\n" + opponent := "AI" + if m.state.GameMode == "multi" && m.netOpponentName != "" { + opponent = m.netOpponentName } + playerCol := ui.DimStyle.Render("YOUR MOVE") + "\n\n" + MoveCard(m.state.PlayerMove, false) + vsCol := ui.HeaderStyle.Render("\n\n\n VS ") + aiCol := ui.DimStyle.Render(strings.ToUpper(opponent)+"'s MOVE") + "\n\n" + MoveCard(m.state.AIMove, false) + + s := lipgloss.JoinHorizontal(lipgloss.Center, playerCol, vsCol, aiCol) + "\n" return s } // renderResult shows the round outcome, score bar, and contextual next-step prompt func renderResult(m model) string { - s := "" - var boxContent string - - switch m.state.RoundOutcome { - case "win": - s += ui.SuccessStyle.Render(" ๐Ÿ† YOU WIN THIS ROUND! ๐Ÿ†") + "\n\n" - boxContent = outcomeMessage(m.state.PlayerMove, m.state.AIMove) - s += " " + ui.WinBoxStyle.Render(boxContent) + "\n" - case "lose": - s += ui.DangerStyle.Render(" ๐Ÿ’€ AI WINS THIS ROUND... ๐Ÿ’€") + "\n\n" - boxContent = outcomeMessage(m.state.PlayerMove, m.state.AIMove) - s += " " + ui.LoseBoxStyle.Render(boxContent) + "\n" - case "tie": - s += ui.WarningStyle.Render(" ๐Ÿค DRAW! ๐Ÿค") + "\n\n" - boxContent = outcomeMessage(m.state.PlayerMove, m.state.AIMove) - s += " " + ui.TieBoxStyle.Render(boxContent) + "\n" - } - - s += "\n " + scoreBar(m.state.Score.PlayerWins, m.state.Score.OpponentWins) + "\n" - - if m.state.Score.PlayerWins == 2 { - s += "\n" + ui.SuccessStyle.Render(" ๐ŸŽ‰๐ŸŽ‰ YOU WIN THE MATCH! ๐ŸŽ‰๐ŸŽ‰") + "\n" - s += "\n" + ui.Footer("Enter to play again ยท Esc for menu") - } else if m.state.Score.OpponentWins == 2 { - s += "\n" + ui.DangerStyle.Render(" ๐Ÿ’€ AI wins the match. Better luck next time.") + "\n" - s += "\n" + ui.Footer("Enter to play again ยท Esc for menu") - } else { - switch m.state.RoundOutcome { - case "win": - s += "\n" + ui.Footer("Enter for next round ยท Esc for menu") - case "lose": - s += "\n" + ui.Footer("Enter to fight back ยท Esc for menu") - case "tie": - s += "\n" + ui.Footer("Enter to break the tie ยท Esc for menu") - } - } - return s + opponent := "AI" + if m.state.GameMode == "multi" && m.netOpponentName != "" { + opponent = m.netOpponentName + } + s := "" + var boxContent string + + switch m.state.RoundOutcome { + case "win": + s += ui.SuccessStyle.Render("๐Ÿ† YOU WIN THIS ROUND! ๐Ÿ†") + "\n\n" + boxContent = outcomeMessage(m.state.PlayerMove, m.state.AIMove) + s += ui.WinBoxStyle.Render(boxContent) + "\n" + case "lose": + s += ui.DangerStyle.Render(fmt.Sprintf("๐Ÿ’€ %s WINS THIS ROUND ๐Ÿ’€", strings.ToUpper(opponent))) + "\n\n" + boxContent = outcomeMessage(m.state.PlayerMove, m.state.AIMove) + s += ui.LoseBoxStyle.Render(boxContent) + "\n" + case "tie": + s += ui.WarningStyle.Render("๐Ÿค DRAW! ๐Ÿค") + "\n\n" + boxContent = outcomeMessage(m.state.PlayerMove, m.state.AIMove) + s += ui.TieBoxStyle.Render(boxContent) + "\n" + } + + s += "\n" + scoreBar(m.state.Score.PlayerWins, m.state.Score.OpponentWins, opponent) + "\n" + + if m.state.Score.PlayerWins == 2 { + s += "\n" + ui.SuccessStyle.Render("๐ŸŽ‰ YOU WIN THE MATCH! ๐ŸŽ‰") + "\n" + s += "\n" + ui.Footer("Enter replay ยท Esc menu") + } else if m.state.Score.OpponentWins == 2 { + s += "\n" + ui.DangerStyle.Render(fmt.Sprintf("๐Ÿ’€ %s wins the match.", opponent)) + "\n" + s += "\n" + ui.Footer("Enter replay ยท Esc menu") + } else { + switch m.state.RoundOutcome { + case "win": + s += "\n" + ui.Footer("Enter next round ยท Esc menu") + case "lose": + s += "\n" + ui.Footer("Enter fight back ยท Esc menu") + case "tie": + s += "\n" + ui.Footer("Enter break tie ยท Esc menu") + } + } + return s } // โ”€โ”€โ”€ Render Utilities โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // scoreBar renders a visual two-pip progress bar for the current match score -// e.g. You: โ–ˆโ–‘ AI: โ–‘โ–‘ -func scoreBar(playerWins, aiWins int) string { - bar := ui.DimStyle.Render("You: ") - for i := 0; i < 2; i++ { - bar += ui.ScorePip(i < playerWins) - } - bar += ui.DimStyle.Render(" AI: ") - for i := 0; i < 2; i++ { - bar += ui.ScorePip(i < aiWins) - } - return bar +// e.g. You: โ—โ—‹ AI: โ—‹โ—‹ +func scoreBar(playerWins, aiWins int, opponent string) string { + bar := ui.DimStyle.Render("You: ") + for i := 0; i < 2; i++ { + bar += ui.ScorePip(i < playerWins) + } + bar += ui.DimStyle.Render(" " + opponent + ": ") + for i := 0; i < 2; i++ { + bar += ui.ScorePip(i < aiWins) + } + return bar } // padCenter centres a string within a fixed width by adding spaces on both sides @@ -944,57 +1630,218 @@ func padCenter(s string, width int) string { // renderMultiMenu draws the multiplayer mode selection screen func renderMultiMenu(m model) string { - options := []string{ - "Create Room โ€” get a code to share", - "Quick Match โ€” find any opponent", - "Back", - } - s := "\n" + ui.TitleStyle.Render(" MULTIPLAYER") + "\n\n" - s += ui.DimStyle.Render(" Choose how you want to play:") + "\n\n" - for i, opt := range options { - if m.state.Cursor == i { - s += ui.SelectedStyle.Render(" > " + opt) + "\n" - } else { - s += ui.MutedStyle.Render(" " + opt) + "\n" - } - } - s += "\n" + ui.Footer("โ†‘/โ†“ to move ยท Enter to select ยท Esc for menu") - return s + options := []string{ + "๐Ÿ  Create Room โ€” share a code", + "๐Ÿ”— Join Room โ€” connect to host", + "โ† Back", + } + s := ui.TitleStyle.Render("MULTIPLAYER") + "\n" + s += ui.DimStyle.Render("Choose how you want to play") + "\n\n" + s += ui.Divider() + "\n\n" + + for i, opt := range options { + if m.state.Cursor == i { + s += ui.SelectedStyle.Render(" โ–ธ "+opt) + "\n" + } else { + s += ui.MutedStyle.Render(" "+opt) + "\n" + } + } + + if m.state.FormError != "" { + s += "\n" + ui.ErrorLine(m.state.FormError) + "\n" + } + s += "\n" + ui.Footer("โ†‘/โ†“ navigate ยท Enter select ยท Esc menu") + return s } + // renderCreateRoom draws the room code waiting screen func renderCreateRoom(m model) string { - spinner := spinnerFrames[m.state.SpinnerFrame] - s := "\n" + ui.TitleStyle.Render(" CREATE ROOM") + "\n\n" - roomContent := ui.TitleStyle.Render("Your room code:\n\n") + - ui.SuccessStyle.Render(" "+m.state.RoomCode) - s += " " + ui.RoomCodeBoxStyle.Render(roomContent) + "\n" - s += "\n" + ui.DimStyle.Render(" Share this code with your opponent.") + "\n" - s += "\n" + ui.DimStyle.Render(" "+spinner+" Waiting for opponent to join...") + "\n" - s += "\n" + ui.Footer("Esc to cancel ยท ctrl+c to quit") - return s + spinner := spinnerFrames[m.state.SpinnerFrame] + s := ui.TitleStyle.Render("CREATE ROOM") + "\n\n" + roomContent := ui.DimStyle.Render("Your room code:\n\n") + + ui.SuccessStyle.Render(m.state.RoomCode) + s += ui.RoomCodeBoxStyle.Render(roomContent) + "\n\n" + s += ui.DimStyle.Render("Share this code with your opponent.") + "\n\n" + s += ui.DimStyle.Render(spinner+" Waiting for opponent to join...") + "\n" + s += "\n" + ui.Footer("Esc cancel ยท ctrl+c quit") + return s } // renderQuickMatch draws the auto matchmaking waiting screen func renderQuickMatch(m model) string { - spinner := spinnerFrames[m.state.SpinnerFrame] - s := "\n" + ui.TitleStyle.Render(" QUICK MATCH") + "\n\n" - s += ui.DimStyle.Render(" "+spinner+" Searching for an opponent...") + "\n\n" - waitContent := ui.DimStyle.Render("This may take a moment.\nStay in the terminal!") - s += " " + ui.WaitingBoxStyle.Render(waitContent) + "\n" - s += "\n" + ui.Footer("Esc to cancel ยท ctrl+c to quit") - return s + spinner := spinnerFrames[m.state.SpinnerFrame] + s := ui.TitleStyle.Render("QUICK MATCH") + "\n\n" + s += ui.DimStyle.Render(spinner+" Searching for an opponent...") + "\n\n" + waitContent := ui.DimStyle.Render("This may take a moment.\nStay in the terminal!") + s += ui.WaitingBoxStyle.Render(waitContent) + "\n" + s += "\n" + ui.Footer("Esc cancel ยท ctrl+c quit") + return s +} + +// โ”€โ”€โ”€ Help Screen โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +// keybindLine formats a single keybind row: blue key + grey description +func keybindLine(key, desc string) string { + padded := key + strings.Repeat(" ", 14-len(key)) + if len(key) >= 14 { + padded = key + " " + } + return ui.InfoStyle.Render(padded) + ui.DimStyle.Render(desc) +} + +// renderHelp draws a styled keybind reference card +func renderHelp(m model) string { + options := []string{ + "โŒจ๏ธ Keyboard Layout", + "๐Ÿ“œ Privacy & Data Policy", + } + + s := ui.TitleStyle.Render("HELP & POLICIES") + "\n" + s += ui.DimStyle.Render("Select a section to view details") + "\n\n" + + // Tab header selector + var tabs []string + for i, opt := range options { + if m.state.Cursor == i { + tabs = append(tabs, ui.SelectedStyle.Render(" โ–ธ "+opt)) + } else { + tabs = append(tabs, ui.MutedStyle.Render(" "+opt)) + } + } + s += strings.Join(tabs, " ") + "\n\n" + s += ui.Divider() + "\n\n" + + var content string + if m.state.Cursor == 0 { + content += ui.SelectedStyle.Render("NAVIGATION") + "\n" + content += keybindLine("โ†‘/โ†“ or k/j", "Move cursor / scroll lists") + "\n" + content += keybindLine("โ†/โ†’ or h/l", "Select cards / switch tabs") + "\n" + content += keybindLine("Enter", "Confirm selection / field") + "\n" + content += keybindLine("Esc", "Go back / cancel") + "\n\n" + + content += ui.SelectedStyle.Render("GAME SHORTCUTS") + "\n" + content += keybindLine("1 or R", "Choose Rock") + "\n" + content += keybindLine("2 or P", "Choose Paper") + "\n" + content += keybindLine("3 or S", "Choose Scissors") + "\n\n" + + content += ui.SelectedStyle.Render("ADMIN PANEL KEYS") + "\n" + content += keybindLine("R", "Reset player stats (on profile)") + "\n" + content += keybindLine("D", "Delete player account (on profile)") + "\n" + content += keybindLine("P", "Promote/demote player role") + } else { + content += ui.SelectedStyle.Render("PRIVACY & DATA PROTECTION") + "\n\n" + content += ui.DimStyle.Render("1. Data Storage:") + "\n" + content += " All player accounts and match records are\n" + content += " stored locally in 'data/players.json'.\n" + content += " No data is transmitted to external servers.\n\n" + content += ui.DimStyle.Render("2. LAN P2P Multiplayer:") + "\n" + content += " Rooms communicate directly over local network\n" + content += " connections. No chat logs or personal details\n" + content += " are shared beside usernames & moves.\n\n" + content += ui.DimStyle.Render("3. Rights & Security:") + "\n" + content += " You can request resetting your stats or\n" + content += " deleting your account at any time in-app." + } + + helpBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(ui.ColorAccent)). + Padding(1, 2). + Width(48). + Render(content) + + s += helpBox + "\n" + s += "\n" + ui.Footer("โ†/โ†’ switch tabs ยท Esc go back") + return s } // โ”€โ”€โ”€ Entry Point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ func main() { + // Initialize structured logging + cleanup, err := models.InitLogger() + if err != nil { + fmt.Println("Warning: Could not initialize logger:", err) + } else { + defer cleanup() + } + + // Graceful panic recovery to restore terminal state + defer func() { + if r := recover(); r != nil { + // Exit alternate screen and show cursor using standard ANSI escape codes + fmt.Print("\x1b[?1049l\x1b[?25h") + slog.Error("CRITICAL SYSTEM PANIC", "error", r) + fmt.Println("\n\x1b[31;1m=== ROPA-SCI CRASH DETECTED ===\x1b[0m") + fmt.Printf("Oops! The application encountered an unexpected error: %v\n", r) + fmt.Println("Terminal state has been restored safely. Please check logs/app.log for details.") + os.Exit(1) + } + }() + + slog.Info("Starting Ropa-Sci Bubbletea TUI application") p := tea.NewProgram( initialModel(), tea.WithAltScreen(), // full-screen mode for a cleaner game feel tea.WithMouseCellMotion(), // mouse infrastructure ready for Week 7 ) if _, err := p.Run(); err != nil { - fmt.Println("Error:", err) + slog.Error("Bubbletea runtime error", "error", err) + fmt.Println("Fatal Error:", err) os.Exit(1) } } + +// renderDifficulty renders the single-player difficulty selection screen +func renderDifficulty(m model) string { + options := []string{ + "๐Ÿ˜Š Easy โ€” Rando-tron AI", + "๐Ÿค” Medium โ€” Cycle-bot AI", + "๐Ÿ”ฅ Hard โ€” Predictor AI", + "โ† Back", + } + s := ui.TitleStyle.Render("AI DIFFICULTY") + "\n" + s += ui.DimStyle.Render("Choose your opponent's skill level") + "\n\n" + s += ui.Divider() + "\n\n" + + for i, opt := range options { + if m.state.Cursor == i { + s += ui.SelectedStyle.Render(" โ–ธ " + opt) + "\n" + } else { + s += ui.MutedStyle.Render(" " + opt) + "\n" + } + } + s += "\n" + ui.Footer("โ†‘/โ†“ navigate ยท Enter select ยท Esc cancel") + return s +} + +// renderJoinRoom renders the LAN room address input form +func renderJoinRoom(m model) string { + s := ui.TitleStyle.Render("JOIN ROOM") + "\n" + s += ui.DimStyle.Render("Enter Host IP:Port (e.g. 192.168.1.15:8080)") + "\n\n" + + formContent := ui.SelectedStyle.Render(" โ–ธ Host: ") + ui.SuccessStyle.Render(m.state.InputBuffer) + "โ–Š\n" + s += ui.FormPanelStyle.Render(formContent) + "\n" + + if m.state.FormError != "" { + s += "\n" + ui.ErrorLine(m.state.FormError) + "\n" + } + s += "\n" + ui.Footer("Enter connect ยท Esc cancel") + return s +} + +// getLocalIP queries network interfaces to find the host's LAN IPv4 address +func getLocalIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "127.0.0.1" + } + for _, address := range addrs { + if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + } + return "127.0.0.1" +} diff --git a/bubbletea/data/players.json b/bubbletea/data/players.json index 09dff87..45302df 100644 --- a/bubbletea/data/players.json +++ b/bubbletea/data/players.json @@ -5,10 +5,10 @@ "Username": "jmomoh", "State": "Edo", "Email": "jezreal4@gmail.com", - "Role": "player", + "Role": "root-admin", "Wins": 2, - "Losses": 2, + "Losses": 3, "Ties": 0, - "TotalMatches": 4 + "TotalMatches": 5 } ] \ No newline at end of file diff --git a/bubbletea/go.mod b/bubbletea/go.mod index 7111a81..212548d 100644 --- a/bubbletea/go.mod +++ b/bubbletea/go.mod @@ -4,13 +4,13 @@ go 1.26.2 require ( github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 golang.org/x/text v0.37.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect diff --git a/bubbletea/models/ai_engine.go b/bubbletea/models/ai_engine.go new file mode 100644 index 0000000..7d81bb2 --- /dev/null +++ b/bubbletea/models/ai_engine.go @@ -0,0 +1,126 @@ +package models + +import ( + "math/rand" + "time" +) + +// AIDifficulty represents the AI opponent profiles +type AIDifficulty string + +const ( + AIDifficultyEasy AIDifficulty = "easy" // Rando-tron + AIDifficultyMedium AIDifficulty = "medium" // Cycle-bot + AIDifficultyHard AIDifficulty = "hard" // Predictor (Markov Chain) +) + +// AIEngine handles prediction calculations and pattern tracking +type AIEngine struct { + Difficulty AIDifficulty + History []Move // Sequence of moves the player has chosen + TransitionMap map[Move]map[Move]int // Transition counts: [PrevMove][NextMove]Count + lastAIMove Move + rng *rand.Rand +} + +// NewAIEngine creates a new initialized AI opponent +func NewAIEngine(difficulty AIDifficulty) *AIEngine { + return &AIEngine{ + Difficulty: difficulty, + History: []Move{}, + TransitionMap: make(map[Move]map[Move]int), + lastAIMove: None, + rng: rand.New(rand.NewSource(time.Now().UnixNano())), + } +} + +// RecordPlayerMove logs the player's choice to update the Markov transition map +func (ae *AIEngine) RecordPlayerMove(playerMove Move) { + ae.History = append(ae.History, playerMove) + + // We need at least 2 moves to establish a transition pattern (e.g. Rock -> Paper) + if len(ae.History) < 2 { + return + } + + prevMove := ae.History[len(ae.History)-2] + if _, exists := ae.TransitionMap[prevMove]; !exists { + ae.TransitionMap[prevMove] = make(map[Move]int) + } + ae.TransitionMap[prevMove][playerMove]++ +} + +// PredictPlayerNextMove determines the player's most likely next choice using history +func (ae *AIEngine) PredictPlayerNextMove() Move { + if len(ae.History) == 0 { + return None + } + + lastMove := ae.History[len(ae.History)-1] + transitions, ok := ae.TransitionMap[lastMove] + if !ok || len(transitions) == 0 { + return None // No transition data for this state yet + } + + // Find the move with the highest occurrence count + var predictedMove Move + maxCount := -1 + for move, count := range transitions { + if count > maxCount { + maxCount = count + predictedMove = move + } + } + return predictedMove +} + +// GetCounterMove returns the winning counter to a given move +func GetCounterMove(move Move) Move { + switch move { + case Rock: + return Paper + case Paper: + return Scissors + case Scissors: + return Rock + default: + return Rock + } +} + +// ChooseMove calculates the AI's move based on the selected difficulty profile +func (ae *AIEngine) ChooseMove() Move { + moves := []Move{Rock, Paper, Scissors} + + switch ae.Difficulty { + case AIDifficultyEasy: + // Rando-tron: Pure random + return moves[ae.rng.Intn(3)] + + case AIDifficultyMedium: + // Cycle-bot: Rocks, Papers, Scissors cycle + switch ae.lastAIMove { + case Rock: + ae.lastAIMove = Paper + case Paper: + ae.lastAIMove = Scissors + case Scissors: + ae.lastAIMove = Rock + default: + ae.lastAIMove = Rock + } + return ae.lastAIMove + + case AIDifficultyHard: + // Predictor: Markov Chain + predicted := ae.PredictPlayerNextMove() + if predicted == None { + // Fall back to random if we have no pattern data yet + return moves[ae.rng.Intn(3)] + } + return GetCounterMove(predicted) + + default: + return moves[ae.rng.Intn(3)] + } +} diff --git a/bubbletea/models/logger.go b/bubbletea/models/logger.go new file mode 100644 index 0000000..37c5023 --- /dev/null +++ b/bubbletea/models/logger.go @@ -0,0 +1,45 @@ +package models + +import ( + "fmt" + "io" + "log/slog" + "os" + "path/filepath" +) + +var logFile *os.File + +// InitLogger initializes a structured slog JSON logger writing to logs/app.log. +// It creates the logs/ directory if it doesn't already exist. +// Returns a function that closes the log file upon termination. +func InitLogger() (func(), error) { + logDir := "logs" + if err := os.MkdirAll(logDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create logs directory: %w", err) + } + + logPath := filepath.Join(logDir, "app.log") + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return nil, fmt.Errorf("failed to open log file: %w", err) + } + logFile = file + + // Setup slog handler + // In production, writing JSON is standard for structured log analysis. + handler := slog.NewJSONHandler(io.MultiWriter(logFile), &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + slog.SetDefault(slog.New(handler)) + + slog.Info("Structured logger initialized successfully") + + cleanup := func() { + slog.Info("Structured logger shutting down") + if logFile != nil { + logFile.Close() + } + } + return cleanup, nil +} diff --git a/bubbletea/models/player.go b/bubbletea/models/player.go index 264aae2..aed0834 100644 --- a/bubbletea/models/player.go +++ b/bubbletea/models/player.go @@ -27,7 +27,7 @@ type Player struct { Email *string // Account role - Role string // "player" or "admin" + Role string // "player", "admin", or "root-admin" // Lifetime Game stats Wins int @@ -36,6 +36,16 @@ type Player struct { TotalMatches int } +// IsAdmin returns true if the player has admin or root-admin privileges +func (p Player) IsAdmin() bool { + return p.Role == "admin" || p.Role == "root-admin" +} + +// IsRootAdmin returns true if the player is the root administrator +func (p Player) IsRootAdmin() bool { + return p.Role == "root-admin" +} + // MatchScore tracks the best-of-three score for current match type MatchScore struct { PlayerWins int @@ -53,10 +63,10 @@ type GameState struct { RoomCode string // generated room code for Create Room mode Phase GamePhase // current game phase - PlayerMove Move // what the player picked - AIMove Move // what the AI picked - SpinnerFrame int // which spinner frame to show - RoundOutcome string // "win", "lose", "tie" + PlayerMove Move // what the player picked + AIMove Move // what the AI picked + SpinnerFrame int // which spinner frame to show + RoundOutcome string // "win", "lose", "tie" // Navigation Cursor int // tracks which menu option is highlighted @@ -71,6 +81,11 @@ type GameState struct { // Terminal dimensions โ€” updated on every resize event TermWidth int TermHeight int + + // Admin dashboard state + AdminPlayers []Player // cached list of all players for the admin view + AdminSelectedIndex int // which player is highlighted in the list + AdminConfirm string // pending action: "delete", "reset", or "" } // GamePhase tracks where we are within the game screen diff --git a/bubbletea/models/storage.go b/bubbletea/models/storage.go index ad0f423..e1650e7 100644 --- a/bubbletea/models/storage.go +++ b/bubbletea/models/storage.go @@ -1,117 +1,247 @@ package models import ( - "encoding/json" - "fmt" - "os" - "strings" - "math/rand" + "encoding/json" + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + "sync" ) -const dataFile = "data/players.json" +var dataFile = "data/players.json" + +// dbMutex protects all concurrent file operations on players.json. +// RWMutex allows parallel reads, but exclusive writes. +var dbMutex sync.RWMutex + +// loadPlayersUnlocked reads all players from disk without acquiring a lock. +// This is an internal helper used inside public locked operations. +func loadPlayersUnlocked() ([]Player, error) { + cleanedPath := filepath.Clean(dataFile) + data, err := os.ReadFile(cleanedPath) + if err != nil { + if os.IsNotExist(err) { + return []Player{}, nil // File doesn't exist yet - fine for bootstrap + } + return nil, err + } + + var players []Player + err = json.Unmarshal(data, &players) + return players, err +} -// SavePlayer writes a new player to disk +// SavePlayer writes a new player to disk thread-safely // Returns an error if the username already exists func SavePlayer(p Player) error { - // Create data/ folder first if it doesn't exist - if err := os.MkdirAll("data", 0755); err != nil { - return fmt.Errorf("could not create data folder: %w", err) - } - - // Load existing players - players, _ := LoadPlayers() - - // Check for duplicate username before appending - for _, existing := range players { - if strings.EqualFold(existing.Username, p.Username) { - return fmt.Errorf("username '%s' already exists", p.Username) - } - } - - // Add the new player - players = append(players, p) - - // Write back to file with readable formatting - data, err := json.MarshalIndent(players, "", " ") - if err != nil { - return err - } - - return os.WriteFile(dataFile, data, 0644) + dbMutex.Lock() + defer dbMutex.Unlock() + + // Create data/ folder first if it doesn't exist + if err := os.MkdirAll("data", 0755); err != nil { + return fmt.Errorf("could not create data folder: %w", err) + } + + // Load existing players + players, err := loadPlayersUnlocked() + if err != nil { + return fmt.Errorf("failed to load players: %w", err) + } + + // Check for duplicate username before appending + for _, existing := range players { + if strings.EqualFold(existing.Username, p.Username) { + return fmt.Errorf("username '%s' already exists", p.Username) + } + } + + // Add the new player + players = append(players, p) + + // Write back to file with readable formatting + data, err := json.MarshalIndent(players, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Clean(dataFile), data, 0600) } -// LoadPlayers reads all players from disk +// LoadPlayers reads all players from disk thread-safely func LoadPlayers() ([]Player, error) { - data, err := os.ReadFile(dataFile) - if err != nil { - return []Player{}, nil // file doesn't exist yet โ€” that's fine - } - - var players []Player - err = json.Unmarshal(data, &players) - return players, err + dbMutex.RLock() + defer dbMutex.RUnlock() + return loadPlayersUnlocked() } -// UsernameExists checks if a Gitea username is already registered +// UsernameExists checks if a username is already registered thread-safely func UsernameExists(username string) bool { - players, err := LoadPlayers() - if err != nil { - return false - } - for _, p := range players { - if strings.EqualFold(p.Username, username) { - return true - } - } - return false + dbMutex.RLock() + defer dbMutex.RUnlock() + + players, err := loadPlayersUnlocked() + if err != nil { + return false + } + for _, p := range players { + if strings.EqualFold(p.Username, username) { + return true + } + } + return false } -// FindPlayerByUsername returns a player if they exist +// FindPlayerByUsername returns a player if they exist thread-safely // Returns the player, a found bool, and any error func FindPlayerByUsername(username string) (Player, bool, error) { - players, err := LoadPlayers() - if err != nil { - return Player{}, false, err - } - for _, p := range players { - if strings.EqualFold(p.Username, username) { - return p, true, nil - } - } - return Player{}, false, nil + dbMutex.RLock() + defer dbMutex.RUnlock() + + players, err := loadPlayersUnlocked() + if err != nil { + return Player{}, false, err + } + for _, p := range players { + if strings.EqualFold(p.Username, username) { + return p, true, nil + } + } + return Player{}, false, nil } -// UpdatePlayer finds an existing player by username and overwrites their record -// Used to save lifetime stats after every match ends +// UpdatePlayer finds an existing player by username and overwrites their record thread-safely. +// Used to save lifetime stats after every match ends. func UpdatePlayer(p Player) error { - players, err := LoadPlayers() - if err != nil { - return fmt.Errorf("could not load players: %w", err) - } - - found := false - for i, existing := range players { - if strings.EqualFold(existing.Username, p.Username) { - players[i] = p // overwrite with updated data - found = true - break - } - } - - if !found { - return fmt.Errorf("player '%s' not found", p.Username) - } - - data, err := json.MarshalIndent(players, "", " ") - if err != nil { - return err - } - - return os.WriteFile(dataFile, data, 0644) + dbMutex.Lock() + defer dbMutex.Unlock() + + players, err := loadPlayersUnlocked() + if err != nil { + return fmt.Errorf("could not load players: %w", err) + } + + found := false + for i, existing := range players { + if strings.EqualFold(existing.Username, p.Username) { + players[i] = p // overwrite with updated data + found = true + break + } + } + + if !found { + return fmt.Errorf("player '%s' not found", p.Username) + } + + data, err := json.MarshalIndent(players, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Clean(dataFile), data, 0600) } // GenerateRoomCode creates a random 4-digit room code func GenerateRoomCode() string { - digits := rand.Intn(9000) + 1000 // always 4 digits: 1000-9999 - return fmt.Sprintf("RPS-%d", digits) + digits := rand.Intn(9000) + 1000 // always 4 digits: 1000-9999 + return fmt.Sprintf("RPS-%d", digits) +} + +// DeletePlayer removes a player from the JSON file thread-safely +func DeletePlayer(username string) error { + dbMutex.Lock() + defer dbMutex.Unlock() + + players, err := loadPlayersUnlocked() + if err != nil { + return fmt.Errorf("could not load players: %w", err) + } + + found := false + var updated []Player + for _, existing := range players { + if strings.EqualFold(existing.Username, username) { + found = true + continue + } + updated = append(updated, existing) + } + + if !found { + return fmt.Errorf("player '%s' not found", username) + } + + data, err := json.MarshalIndent(updated, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Clean(dataFile), data, 0600) +} + +// ResetPlayerStats zeroes W/L/T/TotalMatches for a player thread-safely +func ResetPlayerStats(username string) error { + dbMutex.Lock() + defer dbMutex.Unlock() + + players, err := loadPlayersUnlocked() + if err != nil { + return fmt.Errorf("could not load players: %w", err) + } + + found := false + for i, existing := range players { + if strings.EqualFold(existing.Username, username) { + players[i].Wins = 0 + players[i].Losses = 0 + players[i].Ties = 0 + players[i].TotalMatches = 0 + found = true + break + } + } + + if !found { + return fmt.Errorf("player '%s' not found", username) + } + + data, err := json.MarshalIndent(players, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Clean(dataFile), data, 0644) +} + +// SetPlayerRole changes the role of a player thread-safely +func SetPlayerRole(username, role string) error { + dbMutex.Lock() + defer dbMutex.Unlock() + + players, err := loadPlayersUnlocked() + if err != nil { + return fmt.Errorf("could not load players: %w", err) + } + + found := false + for i, existing := range players { + if strings.EqualFold(existing.Username, username) { + players[i].Role = role + found = true + break + } + } + + if !found { + return fmt.Errorf("player '%s' not found", username) + } + + data, err := json.MarshalIndent(players, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filepath.Clean(dataFile), data, 0644) } \ No newline at end of file diff --git a/bubbletea/models/storage_test.go b/bubbletea/models/storage_test.go new file mode 100644 index 0000000..30d7b57 --- /dev/null +++ b/bubbletea/models/storage_test.go @@ -0,0 +1,107 @@ +package models + +import ( + "os" + "path/filepath" + "testing" +) + +func TestStorageOperations(t *testing.T) { + // Setup temp players file + tmpDir, err := os.MkdirTemp("", "ropa-sci-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldDataFile := dataFile + dataFile = filepath.Join(tmpDir, "players.json") + defer func() { dataFile = oldDataFile }() + + // Test 1: Save player + player := Player{ + FirstName: "Test", + LastName: "User", + Username: "testuser", + State: "Lagos", + Role: "player", + } + + err = SavePlayer(player) + if err != nil { + t.Fatalf("SavePlayer failed: %v", err) + } + + // Save duplicate should fail + err = SavePlayer(player) + if err == nil { + t.Fatal("expected duplicate SavePlayer to fail, but it succeeded") + } + + // Test 2: Find player + foundPlayer, found, err := FindPlayerByUsername("testuser") + if err != nil { + t.Fatalf("FindPlayerByUsername failed: %v", err) + } + if !found { + t.Fatal("expected player to be found") + } + if foundPlayer.Username != "testuser" { + t.Errorf("expected username 'testuser', got '%s'", foundPlayer.Username) + } + + // Test 3: Set role + err = SetPlayerRole("testuser", "admin") + if err != nil { + t.Fatalf("SetPlayerRole failed: %v", err) + } + + foundPlayer, found, err = FindPlayerByUsername("testuser") + if err != nil { + t.Fatalf("FindPlayerByUsername after SetPlayerRole failed: %v", err) + } + if !found { + t.Fatal("expected player to be found") + } + if foundPlayer.Role != "admin" { + t.Errorf("expected role 'admin', got '%s'", foundPlayer.Role) + } + if !foundPlayer.IsAdmin() { + t.Error("expected IsAdmin() to be true") + } + + // Test 4: Reset stats + foundPlayer.Wins = 5 + foundPlayer.Losses = 2 + err = UpdatePlayer(foundPlayer) + if err != nil { + t.Fatalf("UpdatePlayer failed: %v", err) + } + + err = ResetPlayerStats("testuser") + if err != nil { + t.Fatalf("ResetPlayerStats failed: %v", err) + } + + foundPlayer, found, err = FindPlayerByUsername("testuser") + if err != nil { + t.Fatalf("FindPlayerByUsername after ResetPlayerStats failed: %v", err) + } + if foundPlayer.Wins != 0 || foundPlayer.Losses != 0 { + t.Errorf("expected stats to be reset to 0, got wins=%d, losses=%d", foundPlayer.Wins, foundPlayer.Losses) + } + + // Test 5: Delete player + err = DeletePlayer("testuser") + if err != nil { + t.Fatalf("DeletePlayer failed: %v", err) + } + + _, found, err = FindPlayerByUsername("testuser") + if err != nil { + t.Fatalf("FindPlayerByUsername after DeletePlayer failed: %v", err) + } + if found { + t.Fatal("expected player to not be found after deletion") + } +} diff --git a/bubbletea/models/validation.go b/bubbletea/models/validation.go new file mode 100644 index 0000000..ab19cb4 --- /dev/null +++ b/bubbletea/models/validation.go @@ -0,0 +1,72 @@ +package models + +import "unicode" + +// MaxInputLength is the maximum characters allowed in any single input field. +// Prevents runaway input from consuming memory or breaking the UI layout. +const MaxInputLength = 50 + +// โ”€โ”€โ”€ Per-Character Validators โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// These run on every keystroke to reject invalid characters BEFORE they enter +// the input buffer. This gives instant feedback โ€” the character simply doesn't +// appear โ€” which is better UX than showing an error after the user hits Enter. + +// IsValidNameChar checks if a rune is allowed in a first/last name. +// Allows letters (any script), hyphens, and apostrophes for names like O'Brien or Jean-Pierre. +func IsValidNameChar(r rune) bool { + return unicode.IsLetter(r) || r == '-' || r == '\'' +} + +// IsValidUsernameChar checks if a rune is allowed in a username. +// Restricted to lowercase ASCII letters, digits, underscores, and hyphens. +func IsValidUsernameChar(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' +} + +// IsValidStateChar checks if a rune is allowed in the state input field. +// Allows letters and spaces (for multi-word states like "Federal Capital Territory"). +func IsValidStateChar(r rune) bool { + return unicode.IsLetter(r) || r == ' ' +} + +// IsValidEmailChar checks if a rune is allowed in an email address. +func IsValidEmailChar(r rune) bool { + return unicode.IsLetter(r) || unicode.IsDigit(r) || + r == '@' || r == '.' || r == '-' || r == '_' || r == '+' +} + +// โ”€โ”€โ”€ Field-Level Validators โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// These run on Enter to validate the complete field value. +// They return an error message string, or "" if the value is valid. + +// ValidateName validates a first or last name field. +func ValidateName(name string) string { + if len(name) < 2 { + return "Must be at least 2 characters" + } + if len(name) > 30 { + return "Must be 30 characters or less" + } + for _, r := range name { + if !IsValidNameChar(r) { + return "Only letters, hyphens, and apostrophes allowed" + } + } + return "" +} + +// ValidateUsername validates a username field. +func ValidateUsername(username string) string { + if len(username) < 3 { + return "Username must be at least 3 characters" + } + if len(username) > 20 { + return "Username must be 20 characters or less" + } + for _, r := range username { + if !IsValidUsernameChar(r) { + return "Only lowercase letters, numbers, underscores, and hyphens" + } + } + return "" +} diff --git a/bubbletea/models/validation_test.go b/bubbletea/models/validation_test.go new file mode 100644 index 0000000..7a4dacb --- /dev/null +++ b/bubbletea/models/validation_test.go @@ -0,0 +1,128 @@ +package models + +import ( + "testing" +) + +func TestIsValidNameChar(t *testing.T) { + tests := []struct { + r rune + want bool + }{ + {'a', true}, + {'Z', true}, + {'-', true}, + {'\'', true}, + {'1', false}, + {'@', false}, + {' ', false}, + } + for _, tt := range tests { + if got := IsValidNameChar(tt.r); got != tt.want { + t.Errorf("IsValidNameChar(%q) = %v, want %v", tt.r, got, tt.want) + } + } +} + +func TestIsValidUsernameChar(t *testing.T) { + tests := []struct { + r rune + want bool + }{ + {'a', true}, + {'z', true}, + {'0', true}, + {'9', true}, + {'_', true}, + {'-', true}, + {'A', false}, // Username only allows lowercase ASCII letters + {'@', false}, + {' ', false}, + } + for _, tt := range tests { + if got := IsValidUsernameChar(tt.r); got != tt.want { + t.Errorf("IsValidUsernameChar(%q) = %v, want %v", tt.r, got, tt.want) + } + } +} + +func TestIsValidStateChar(t *testing.T) { + tests := []struct { + r rune + want bool + }{ + {'a', true}, + {'Z', true}, + {' ', true}, + {'-', false}, + {'1', false}, + } + for _, tt := range tests { + if got := IsValidStateChar(tt.r); got != tt.want { + t.Errorf("IsValidStateChar(%q) = %v, want %v", tt.r, got, tt.want) + } + } +} + +func TestIsValidEmailChar(t *testing.T) { + tests := []struct { + r rune + want bool + }{ + {'a', true}, + {'9', true}, + {'@', true}, + {'.', true}, + {'-', true}, + {'_', true}, + {'+', true}, + {' ', false}, + {'#', false}, + } + for _, tt := range tests { + if got := IsValidEmailChar(tt.r); got != tt.want { + t.Errorf("IsValidEmailChar(%q) = %v, want %v", tt.r, got, tt.want) + } + } +} + +func TestValidateName(t *testing.T) { + tests := []struct { + name string + want string + }{ + {"Al", ""}, // Min boundary (2) + {"Jean-Pierre", ""}, + {"O'Brien", ""}, + {"A", "Must be at least 2 characters"}, // Below min boundary + {"", "Must be at least 2 characters"}, + {"abcdefghijklmnopqrstuvwxyzabcdef", "Must be 30 characters or less"}, // Above max boundary (32 chars) + {"John123", "Only letters, hyphens, and apostrophes allowed"}, + {"John Doe", "Only letters, hyphens, and apostrophes allowed"}, // Space not allowed in names + } + for _, tt := range tests { + if got := ValidateName(tt.name); got != tt.want { + t.Errorf("ValidateName(%q) = %q, want %q", tt.name, got, tt.want) + } + } +} + +func TestValidateUsername(t *testing.T) { + tests := []struct { + username string + want string + }{ + {"bob", ""}, // Min boundary (3) + {"john_doe-12", ""}, + {"bo", "Username must be at least 3 characters"}, // Below min boundary + {"", "Username must be at least 3 characters"}, + {"abcdefghijklmnopqrstuv", "Username must be 20 characters or less"}, // Above max boundary (22 chars) + {"John_doe", "Only lowercase letters, numbers, underscores, and hyphens"}, // Uppercase not allowed + {"john doe", "Only lowercase letters, numbers, underscores, and hyphens"}, + } + for _, tt := range tests { + if got := ValidateUsername(tt.username); got != tt.want { + t.Errorf("ValidateUsername(%q) = %q, want %q", tt.username, got, tt.want) + } + } +} diff --git a/bubbletea/server/lan_client.go b/bubbletea/server/lan_client.go new file mode 100644 index 0000000..14b5631 --- /dev/null +++ b/bubbletea/server/lan_client.go @@ -0,0 +1,105 @@ +package server + +import ( + "bufio" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "net" + "net/http" +) + +// DialLANServer establishes a TCP connection to the host address and performs WebSocket handshake +func DialLANServer(addr string) (net.Conn, error) { + conn, err := net.Dial("tcp", addr) + if err != nil { + return nil, fmt.Errorf("failed to connect to host %s: %w", addr, err) + } + + // Generate a random 16-byte base64 key + keyBytes := make([]byte, 16) + if _, err := rand.Read(keyBytes); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to generate random secret: %w", err) + } + secKey := base64.StdEncoding.EncodeToString(keyBytes) + + // Send WebSocket upgrade HTTP request + req := fmt.Sprintf("GET /ws HTTP/1.1\r\n"+ + "Host: %s\r\n"+ + "Upgrade: websocket\r\n"+ + "Connection: Upgrade\r\n"+ + "Sec-WebSocket-Key: %s\r\n"+ + "Sec-WebSocket-Version: 13\r\n\r\n", addr, secKey) + + if _, err := conn.Write([]byte(req)); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to send upgrade request: %w", err) + } + + // Read upgrade response + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + conn.Close() + return nil, fmt.Errorf("failed to read upgrade response: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 101 { + conn.Close() + return nil, fmt.Errorf("websocket upgrade rejected: status code %d", resp.StatusCode) + } + + return conn, nil +} + +// WriteClientMessage writes a masked text WebSocket frame to the connection +func WriteClientMessage(conn net.Conn, msg WSMessage) error { + data, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal WS message: %w", err) + } + + var header []byte + header = append(header, 0x81) // FIN + Text frame type + + length := len(data) + if length <= 125 { + header = append(header, byte(length)|0x80) // Set Mask bit (0x80) + } else if length <= 65535 { + header = append(header, 126|0x80) + lenBytes := make([]byte, 2) + binary.BigEndian.PutUint16(lenBytes, uint16(length)) + header = append(header, lenBytes...) + } else { + header = append(header, 127|0x80) + lenBytes := make([]byte, 8) + binary.BigEndian.PutUint64(lenBytes, uint64(length)) + header = append(header, lenBytes...) + } + + // Generate 4-byte random masking key (required for client-to-server frames) + maskKey := make([]byte, 4) + if _, err := rand.Read(maskKey); err != nil { + return fmt.Errorf("failed to generate masking key: %w", err) + } + header = append(header, maskKey...) + + // Mask payload + maskedPayload := make([]byte, length) + for i := 0; i < length; i++ { + maskedPayload[i] = data[i] ^ maskKey[i%4] + } + + // Write header then masked payload + if _, err := conn.Write(header); err != nil { + return fmt.Errorf("failed to write frame header: %w", err) + } + if _, err := conn.Write(maskedPayload); err != nil { + return fmt.Errorf("failed to write frame payload: %w", err) + } + + return nil +} diff --git a/bubbletea/server/lan_server.go b/bubbletea/server/lan_server.go new file mode 100644 index 0000000..eb34ca5 --- /dev/null +++ b/bubbletea/server/lan_server.go @@ -0,0 +1,406 @@ +package server + +import ( + "bufio" + "crypto/sha1" /* #nosec G505 - required by RFC 6455 for WebSocket handshake */ + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "strings" + "sync" +) + +// WSMessage represents the structured JSON packets sent over the P2P connection +type WSMessage struct { + Type string `json:"type"` // e.g. "join", "start", "move", "round", "match", "error" + Payload string `json:"payload"` // e.g. Username, Move, JSON outcome data +} + +// RoundOutcomePayload carries details of a finished round +type RoundOutcomePayload struct { + YourMove string `json:"your_move"` + OpponentMove string `json:"opponent_move"` + Outcome string `json:"outcome"` // "win", "lose", "tie" + YourWins int `json:"your_wins"` + OpponentWins int `json:"opponent_wins"` +} + +// LANGameServer coordinates a single P2P multiplayer match +type LANGameServer struct { + listener net.Listener + port int + mu sync.Mutex + active bool + + // Player states + hostConn net.Conn + guestConn net.Conn + hostName string + guestName string + hostMove string + guestMove string + hostWins int + guestWins int + roundHistory []string +} + +// NewLANServer initializes a LAN game server +func NewLANServer() *LANGameServer { + return &LANGameServer{} +} + +// Start opens a TCP listener on a random port (or the specified default port) +func (s *LANGameServer) Start(requestedPort int) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + addr := fmt.Sprintf("0.0.0.0:%d", requestedPort) + listener, err := net.Listen("tcp", addr) // #nosec G102 - LAN server intended to accept network connections + if err != nil { + // If requested port is busy, fallback to dynamic port assignment + listener, err = net.Listen("tcp", "0.0.0.0:0") // #nosec G102 - LAN server intended to accept network connections + if err != nil { + return 0, fmt.Errorf("failed to bind TCP listener: %w", err) + } + } + + s.listener = listener + s.port = listener.Addr().(*net.TCPAddr).Port + s.active = true + + slog.Info("LAN P2P Server started successfully", "port", s.port) + + // Run connection handler loop in background + go s.listenLoop() + + return s.port, nil +} + +// Stop closes the server listener and active client connections safely +func (s *LANGameServer) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.active { + return + } + + s.active = false + if s.listener != nil { + s.listener.Close() + } + + if s.hostConn != nil { + s.hostConn.Close() + } + if s.guestConn != nil { + _ = s.guestConn.Close() + } + + slog.Info("LAN P2P Server stopped") +} + +func (s *LANGameServer) listenLoop() { + for { + conn, err := s.listener.Accept() + if err != nil { + s.mu.Lock() + active := s.active + s.mu.Unlock() + if !active { + return // Closed normally + } + slog.Error("Server socket accept error", "error", err) + continue + } + + s.mu.Lock() + // First connection is the host client itself + if s.hostConn == nil { + s.hostConn = conn + s.mu.Unlock() + go s.handleConnection(conn, true) + } else if s.guestConn == nil { + // Second connection is the guest client joining + s.guestConn = conn + s.mu.Unlock() + go s.handleConnection(conn, false) + } else { + // Reject third wheels + s.mu.Unlock() + slog.Warn("Rejecting third-wheel connection request") + _ = conn.Close() + } + } +} + +// handleConnection handles HTTP WS upgrade handshakes and reads frame packets +func (s *LANGameServer) handleConnection(conn net.Conn, isHost bool) { + defer conn.Close() + + // 1. Perform WebSocket Upgrade Handshake + reader := bufio.NewReader(conn) + req, err := http.ReadRequest(reader) + if err != nil { + slog.Error("Failed to read WebSocket upgrade request", "isHost", isHost, "error", err) + return + } + + if strings.ToLower(req.Header.Get("Upgrade")) != "websocket" { + slog.Warn("Received non-websocket upgrade HTTP connection") + return + } + + key := req.Header.Get("Sec-WebSocket-Key") + if key == "" { + slog.Warn("Sec-WebSocket-Key missing from request header") + return + } + + // Calculate Accept Key (RFC 6455) + const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + acceptHash := sha1.Sum([]byte(key + wsGUID)) // #nosec G401 - required by RFC 6455 + acceptKey := base64.StdEncoding.EncodeToString(acceptHash[:]) + + // Send Handshake Response + resp := "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + acceptKey + "\r\n\r\n" + + _, err = conn.Write([]byte(resp)) + if err != nil { + slog.Error("Failed to write handshake response", "isHost", isHost, "error", err) + return + } + + slog.Info("Connection upgraded to WebSocket successfully", "isHost", isHost) + + // 2. Read WebSocket Frame Loop + for { + payload, err := ReadWSFrame(conn) + if err != nil { + if err == io.EOF { + slog.Info("Client connection closed cleanly (EOF)", "isHost", isHost) + } else { + slog.Error("Error reading WebSocket frame", "isHost", isHost, "error", err) + } + s.handleDisconnect(isHost) + return + } + + var msg WSMessage + if err := json.Unmarshal(payload, &msg); err != nil { + slog.Error("Failed to parse JSON WS message", "isHost", isHost, "error", err) + continue + } + + s.handleMessage(msg, isHost) + } +} + +// ReadWSFrame reads and decodes a single WebSocket frame (RFC 6455) +func ReadWSFrame(conn net.Conn) ([]byte, error) { + header := make([]byte, 2) + _, err := io.ReadFull(conn, header) + if err != nil { + return nil, err + } + + // Opcode analysis + opcode := header[0] & 0x0F + if opcode == 8 { // Close frame + return nil, io.EOF + } + + mask := (header[1] & 0x80) != 0 + payloadLen := int64(header[1] & 0x7F) + + if payloadLen == 126 { + lenBytes := make([]byte, 2) + if _, err := io.ReadFull(conn, lenBytes); err != nil { + return nil, err + } + payloadLen = int64(binary.BigEndian.Uint16(lenBytes)) + } else if payloadLen == 127 { + lenBytes := make([]byte, 8) + if _, err := io.ReadFull(conn, lenBytes); err != nil { + return nil, err + } + payloadLen = int64(binary.BigEndian.Uint64(lenBytes)) + } + + var maskKey []byte + if mask { + maskKey = make([]byte, 4) + if _, err := io.ReadFull(conn, maskKey); err != nil { + return nil, err + } + } + + payload := make([]byte, payloadLen) + if _, err := io.ReadFull(conn, payload); err != nil { + return nil, err + } + + if mask { + for i := 0; i < len(payload); i++ { + payload[i] ^= maskKey[i%4] + } + } + + return payload, nil +} + +// WriteWSFrame writes an unmasked text WebSocket frame to a client +func WriteWSFrame(conn net.Conn, payload []byte) error { + if conn == nil { + return fmt.Errorf("connection is nil") + } + + var header []byte + header = append(header, 0x81) // FIN + Text frame type + + length := len(payload) + if length <= 125 { + header = append(header, byte(length)) + } else if length <= 65535 { + header = append(header, 126) + lenBytes := make([]byte, 2) + binary.BigEndian.PutUint16(lenBytes, uint16(length)) + header = append(header, lenBytes...) + } else { + header = append(header, 127) + lenBytes := make([]byte, 8) + binary.BigEndian.PutUint64(lenBytes, uint64(length)) + header = append(header, lenBytes...) + } + + // Write header then body + if _, err := conn.Write(header); err != nil { + return err + } + if _, err := conn.Write(payload); err != nil { + return err + } + return nil +} + +func (s *LANGameServer) sendToClient(conn net.Conn, msgType, payload string) { + msg := WSMessage{Type: msgType, Payload: payload} + data, _ := json.Marshal(msg) + _ = WriteWSFrame(conn, data) +} + +func (s *LANGameServer) handleMessage(msg WSMessage, isHost bool) { + s.mu.Lock() + defer s.mu.Unlock() + + switch msg.Type { + case "join": + if isHost { + s.hostName = msg.Payload + slog.Info("Host player registered name", "name", s.hostName) + } else { + s.guestName = msg.Payload + slog.Info("Guest player registered name", "name", s.guestName) + // Trigger game match start since both players are now fully registered + s.sendToClient(s.hostConn, "start", s.guestName) + s.sendToClient(s.guestConn, "start", s.hostName) + slog.Info("P2P Match started", "host", s.hostName, "guest", s.guestName) + } + + case "move": + if isHost { + s.hostMove = msg.Payload + slog.Info("Host move registered", "move", s.hostMove) + } else { + s.guestMove = msg.Payload + slog.Info("Guest move registered", "move", s.guestMove) + } + + // Check if both moves are ready to evaluate round outcome + if s.hostMove != "" && s.guestMove != "" { + s.evaluateRound() + } + } +} + +func (s *LANGameServer) evaluateRound() { + outcomeHost := "tie" + outcomeGuest := "tie" + + if s.hostMove != s.guestMove { + if (s.hostMove == "rock" && s.guestMove == "scissors") || + (s.hostMove == "paper" && s.guestMove == "rock") || + (s.hostMove == "scissors" && s.guestMove == "paper") { + outcomeHost = "win" + outcomeGuest = "lose" + s.hostWins++ + } else { + outcomeHost = "lose" + outcomeGuest = "win" + s.guestWins++ + } + } + + hostPayload, _ := json.Marshal(RoundOutcomePayload{ + YourMove: s.hostMove, + OpponentMove: s.guestMove, + Outcome: outcomeHost, + YourWins: s.hostWins, + OpponentWins: s.guestWins, + }) + + guestPayload, _ := json.Marshal(RoundOutcomePayload{ + YourMove: s.guestMove, + OpponentMove: s.hostMove, + Outcome: outcomeGuest, + YourWins: s.guestWins, + OpponentWins: s.hostWins, + }) + + // Broadcast round details + s.sendToClient(s.hostConn, "round", string(hostPayload)) + s.sendToClient(s.guestConn, "round", string(guestPayload)) + + slog.Info("Round evaluated", "hostMove", s.hostMove, "guestMove", s.guestMove, "hostWins", s.hostWins, "guestWins", s.guestWins) + + // Clear moves for next round + s.hostMove = "" + s.guestMove = "" + + // Check if match is finished (best of 3: first to 2 wins) + if s.hostWins == 2 { + s.sendToClient(s.hostConn, "match", "win") + s.sendToClient(s.guestConn, "match", "lose") + slog.Info("Match finished: Host wins", "host", s.hostName, "guest", s.guestName) + } else if s.guestWins == 2 { + s.sendToClient(s.hostConn, "match", "lose") + s.sendToClient(s.guestConn, "match", "win") + slog.Info("Match finished: Guest wins", "host", s.hostName, "guest", s.guestName) + } +} + +func (s *LANGameServer) handleDisconnect(isHost bool) { + s.mu.Lock() + defer s.mu.Unlock() + + slog.Warn("Player disconnected", "isHost", isHost) + + // If one player leaves, inform the other player and shut down the session + if isHost { + if s.guestConn != nil { + s.sendToClient(s.guestConn, "error", "Opponent disconnected from match.") + } + } else { + if s.hostConn != nil { + s.sendToClient(s.hostConn, "error", "Opponent disconnected from match.") + } + } +} diff --git a/bubbletea/ui/styles.go b/bubbletea/ui/styles.go index 751e119..79351fd 100644 --- a/bubbletea/ui/styles.go +++ b/bubbletea/ui/styles.go @@ -1,18 +1,27 @@ package ui -import "github.com/charmbracelet/lipgloss" +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) // โ”€โ”€โ”€ Brand Colors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const ( - ColorPrimary = "#7C3AED" // purple โ€” brand, titles, active cursor - ColorSuccess = "#10B981" // green โ€” win, confirmed fields - ColorDanger = "#EF4444" // red โ€” lose, errors - ColorWarning = "#F59E0B" // amber โ€” tie, optional hints - ColorInfo = "#3B82F6" // blue โ€” keybind hints, info text - ColorMuted = "#6B7280" // grey โ€” inactive fields, borders - ColorText = "#F9FAFB" // white โ€” primary readable text - ColorDim = "#9CA3AF" // light grey โ€” secondary text + ColorPrimary = "#A78BFA" // Cyber Lavender โ€” brand, titles, active cursor + ColorAccent = "#818CF8" // Neon Electric Indigo โ€” secondary accents, glow + ColorSuccess = "#34D399" // Emerald Jade โ€” win, confirmed fields + ColorDanger = "#FB7185" // Sunset Rose โ€” lose, errors + ColorWarning = "#FBBF24" // Cyber Amber โ€” tie, optional hints + ColorInfo = "#60A5FA" // Sky Blue โ€” keybind hints, info text + ColorMuted = "#475569" // Dark Slate โ€” inactive fields, borders + ColorText = "#F1F5F9" // Pearl White โ€” primary readable text + ColorDim = "#94A3B8" // Slate Grey โ€” secondary text + ColorBg = "#0F172A" // Midnight Navy โ€” deep background + ColorBgPanel = "#1E293B" // Deep Charcoal โ€” card/panel background + ColorBgAccent = "#1E1B4B" // Deep Indigo โ€” highlighted panel bg + ColorGlow = "#C4B5FD" // Soft Lilac โ€” glow effects, subtle highlights ) // โ”€โ”€โ”€ Text Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -21,15 +30,17 @@ var ( // TitleStyle โ€” used for screen headings like "REGISTER", "SIGN IN" TitleStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color(ColorPrimary)). - Bold(true) + Bold(true). + MarginBottom(1) // BannerStyle โ€” used for the ROPA-SCI ASCII logo BannerStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color(ColorPrimary)) + Foreground(lipgloss.Color(ColorPrimary)). + Bold(true) // SelectedStyle โ€” menu cursor and active form field SelectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color(ColorPrimary)). + Foreground(lipgloss.Color(ColorAccent)). Bold(true) // MutedStyle โ€” inactive menu options and future form fields @@ -60,6 +71,50 @@ var ( Foreground(lipgloss.Color(ColorDim)) ) +// โ”€โ”€โ”€ Layout Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +var ( + // AppContainerStyle โ€” the master frame wrapping all screen content + AppContainerStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(ColorMuted)). + Padding(1, 3). + Margin(0). + Width(62). + Align(lipgloss.Center) + + // FormPanelStyle โ€” housing for login/register forms + FormPanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(ColorAccent)). + Padding(1, 2). + Width(54). + Align(lipgloss.Left) + + // ScoreHeaderStyle โ€” the persistent score banner during gameplay + ScoreHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorText)). + Bold(true). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(lipgloss.Color(ColorMuted)). + PaddingBottom(1). + Width(54). + Align(lipgloss.Center) + + // HeaderStyle โ€” centered section headers within panels + HeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorGlow)). + Bold(true). + Align(lipgloss.Center). + Width(54) + + // DividerStyle โ€” horizontal separator + DividerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color(ColorMuted)). + Align(lipgloss.Center). + Width(54) +) + // โ”€โ”€โ”€ Box Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ var ( @@ -67,55 +122,77 @@ var ( WinBoxStyle = lipgloss.NewStyle(). Border(lipgloss.DoubleBorder()). BorderForeground(lipgloss.Color(ColorSuccess)). - Padding(0, 2). - Width(35) + Padding(1, 3). + Width(50). + Background(lipgloss.Color(ColorBgPanel)). + Align(lipgloss.Center) // LoseBoxStyle โ€” result box when AI wins a round LoseBoxStyle = lipgloss.NewStyle(). Border(lipgloss.DoubleBorder()). BorderForeground(lipgloss.Color(ColorDanger)). - Padding(0, 2). - Width(35) + Padding(1, 3). + Width(50). + Background(lipgloss.Color(ColorBgPanel)). + Align(lipgloss.Center) // TieBoxStyle โ€” result box on a draw TieBoxStyle = lipgloss.NewStyle(). Border(lipgloss.DoubleBorder()). BorderForeground(lipgloss.Color(ColorWarning)). - Padding(0, 2). - Width(35) + Padding(1, 3). + Width(50). + Background(lipgloss.Color(ColorBgPanel)). + Align(lipgloss.Center) // RoomCodeBoxStyle โ€” the room code display box RoomCodeBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color(ColorPrimary)). + BorderForeground(lipgloss.Color(ColorAccent)). Padding(1, 3). - Width(35) + Width(50). + Background(lipgloss.Color(ColorBgAccent)). + Align(lipgloss.Center) // WaitingBoxStyle โ€” the quick match waiting box WaitingBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorInfo)). Padding(1, 3). - Width(35) + Width(50). + Align(lipgloss.Center) +) +// โ”€โ”€โ”€ Card Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +var ( // SelectedCardStyle โ€” game move card when selected SelectedCardStyle = lipgloss.NewStyle(). Border(lipgloss.DoubleBorder()). - BorderForeground(lipgloss.Color(ColorPrimary)). - Padding(0, 1) + BorderForeground(lipgloss.Color(ColorAccent)). + Foreground(lipgloss.Color(ColorText)). + Background(lipgloss.Color(ColorBgAccent)). + Padding(1, 2). + Width(16). + Align(lipgloss.Center). + Bold(true) // NormalCardStyle โ€” game move card when not selected NormalCardStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). + Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(ColorMuted)). - Padding(0, 1) + Foreground(lipgloss.Color(ColorDim)). + Padding(1, 2). + Width(16). + Align(lipgloss.Center) ) // โ”€โ”€โ”€ Footer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Footer renders a consistent keybind hint line at the bottom of any screen func Footer(hints string) string { - return InfoStyle.Render(" " + hints) + line := DimStyle.Render(strings.Repeat("โ”€", 54)) + return line + "\n" + InfoStyle.Render(" "+hints) } // โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -127,13 +204,18 @@ func Checkmark() string { // ErrorLine returns a styled red error message func ErrorLine(msg string) string { - return DangerStyle.Render(" โš  " + msg) + return DangerStyle.Render(" โœ– " + msg) } // ScorePip returns a filled or empty pip for the score bar func ScorePip(filled bool) string { if filled { - return SuccessStyle.Render("โ–ˆ") + return SuccessStyle.Render("โ—") } - return MutedStyle.Render("โ–‘") -} \ No newline at end of file + return MutedStyle.Render("โ—‹") +} + +// Divider returns a styled horizontal rule +func Divider() string { + return DividerStyle.Render(strings.Repeat("โ”€", 48)) +} diff --git a/bubbletea/walkthrough.md b/bubbletea/walkthrough.md new file mode 100644 index 0000000..65c69c4 --- /dev/null +++ b/bubbletea/walkthrough.md @@ -0,0 +1,65 @@ +# Developer Walkthrough: Ropa-Sci Bubbletea Engine + +This document provides a technical walkthrough of the Ropa-Sci Bubble Tea codebase. It explains the flow of execution, architecture, state management, and critical systems. + +--- + +## 1. Execution Flow & Architecture + +Ropa-Sci uses the Model-View-Update (MVU) architecture provided by Bubble Tea. + +```mermaid +graph TD + A[main.go: main] -->|NewProgram| B[Init] + B -->|nil cmd| C[Update loop] + C -->|tea.KeyMsg / tea.WindowSizeMsg| D[Update handler] + D -->|New GameState / Cmd| E[View renderer] + E -->|lipgloss string| F[Terminal screen output] +``` + +### Initial Entry Point +The application starts in `cmd/main.go` under the `main()` function. +- Initializes structured logging to `logs/app.log` via `models.InitLogger()`. +- Defers a panic recovery handler to clean up the terminal state and restore output buffers safely. +- Starts `tea.NewProgram` with an initial welcome model screen. + +--- + +## 2. State Management โ€” `models/player.go` + +The `GameState` struct holds the runtime context for all screens: +- **Player Details**: Current logged-in player profile and stats. +- **Match Stats**: Round history and Wins/Losses for the current match. +- **Screen Router**: String tracking active views (`"welcome"`, `"login"`, `"register"`, `"menu"`, `"game"`, `"admin"`, etc.). +- **TUI Cursor**: Highlighted menu indexes and active form input indexes. +- **Admin lists**: Buffered list of registered player profiles loaded from JSON. + +--- + +## 3. UI/UX Styling โ€” `ui/styles.go` + +All styling uses Lipgloss for CSS-in-terminal formatting: +- **Cyber-Neon Color Palette**: tailors a custom HSL layout (Cyber Lavender, Neon Indigo, Emerald Jade, Sunset Rose, Cyber Amber). +- **Master Centering**: The `View()` method retrieves dimensions (`TermWidth`, `TermHeight`) from window resize signals and centers the primary application container panel (`AppContainerStyle`) dynamically using `lipgloss.Place`. +- **Card layout**: Matches game selections with customized single-character key indicator boxes (e.g. `[ 1 / R ]`). + +--- + +## 4. Key Subsystems + +### Thread-Safe Persistence โ€” `models/storage.go` +Saves and loads registered profiles to `data/players.json`. +- Uses a package-level `sync.RWMutex` to guarantee safe concurrent reads and writes, protecting the data store from corruption during parallel network updates. +- Standard validations enforce strict name length bounds and character sanitization rules in `models/validation.go`. + +### Predictive AI Engine โ€” `models/ai_engine.go` +Implements Markov Chain transition frequency matrices: +- Records sequences of the player's last selections (e.g. Rock โ†’ Paper). +- When choosing a move, it analyzes transition probabilities to predict the player's next move and counters it. +- Falls back to Cycle-bot or Random choice difficulty states seamlessly. + +### LAN Multiplayer WebSocket Broker โ€” `server/lan_server.go` +Coordinates local multiplayer. +- **Host mode**: Spins up a local TCP listener on port 8080. +- **Client mode**: Connects to the host using `DialLANServer` and registers player names. +- Uses basic WS frames (`WriteWSFrame`/`ReadWSFrame`) to serialize round states and move outcomes peer-to-peer. diff --git a/tview/main_test.go b/tview/main_test.go new file mode 100644 index 0000000..3aad042 --- /dev/null +++ b/tview/main_test.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + "testing" +) + +func TestRunApp(t *testing.T) { + if os.Getenv("CI") == "true" { + t.Skip("Skipping interactive TUI run in CI") + } + main() +}