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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ jobs:
- 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)
Expand All @@ -54,7 +53,7 @@ jobs:
fail-fast: false
matrix:
os: [ ubuntu-latest, windows-latest, macos-latest ]
module: [ bubbletea, tview ]
module: [ bubbletea ]
steps:
- name: Checkout Codebase
uses: actions/checkout@v4
Expand Down Expand Up @@ -89,4 +88,4 @@ jobs:
- name: Test Build compilation
run: |
cd ${{ matrix.module }}
go build -v -o ropa-sci-test-build ./...
go build -v ./...
27 changes: 12 additions & 15 deletions bubbletea/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gorilla/websocket"
)

// ─── Spinner & AI message types ───────────────────────────────────────────────
Expand Down Expand Up @@ -63,17 +64,13 @@ func (m model) aiThinkCmd() tea.Cmd {
}

// readNetMsg reads a single WebSocket message in a background Bubbletea thread
func readNetMsg(conn net.Conn) tea.Cmd {
func readNetMsg(conn *websocket.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 {
if err := conn.ReadJSON(&wsMsg); err != nil {
return wsErrMsg{err: err}
}
return wsMsgMsg{msg: wsMsg}
Expand Down Expand Up @@ -132,7 +129,7 @@ type model struct {
state models.GameState
aiEngine *models.AIEngine // runtime AI engine
lanServer *server.LANGameServer // P2P Host Server
wsConn net.Conn // active WebSocket client conn
wsConn *websocket.Conn // active WebSocket client conn
netOpponentName string // remote player name
nextWinsHost int // temporary host score buffer
nextWinsGuest int // temporary guest score buffer
Expand Down Expand Up @@ -270,7 +267,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state.FormError = msg.msg.Payload
m.state.Screen = "multi-menu"
if m.wsConn != nil {
m.wsConn.Close()
_ = m.wsConn.Close()
m.wsConn = nil
}
if m.lanServer != nil {
Expand All @@ -287,7 +284,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state.FormError = "Connection error: " + msg.err.Error()
m.state.Screen = "multi-menu"
if m.wsConn != nil {
m.wsConn.Close()
_ = m.wsConn.Close()
m.wsConn = nil
}
if m.lanServer != nil {
Expand Down Expand Up @@ -374,7 +371,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state.Screen = "multi-menu"
m.state.Cursor = 0
if m.wsConn != nil {
m.wsConn.Close()
_ = m.wsConn.Close()
m.wsConn = nil
}
if m.lanServer != nil {
Expand Down Expand Up @@ -485,7 +482,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state.FormError = ""
m.state.RoomCode = ""
if m.wsConn != nil {
m.wsConn.Close()
_ = m.wsConn.Close()
m.wsConn = nil
}
if m.lanServer != nil {
Expand All @@ -505,7 +502,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state.Score = models.MatchScore{Round: 1}
m.state.Phase = models.PhasePick
if m.wsConn != nil {
m.wsConn.Close()
_ = m.wsConn.Close()
m.wsConn = nil
}
if m.lanServer != nil {
Expand Down Expand Up @@ -969,7 +966,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.state.Screen = "multi-menu"
m.state.Cursor = 0
if m.wsConn != nil {
m.wsConn.Close()
_ = m.wsConn.Close()
m.wsConn = nil
}
if m.lanServer != nil {
Expand Down Expand Up @@ -1806,9 +1803,9 @@ func renderDifficulty(m model) string {

for i, opt := range options {
if m.state.Cursor == i {
s += ui.SelectedStyle.Render(" ▸ " + opt) + "\n"
s += ui.SelectedStyle.Render(" ▸ "+opt) + "\n"
} else {
s += ui.MutedStyle.Render(" " + opt) + "\n"
s += ui.MutedStyle.Render(" "+opt) + "\n"
}
}
s += "\n" + ui.Footer("↑/↓ navigate · Enter select · Esc cancel")
Expand Down
1 change: 1 addition & 0 deletions bubbletea/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26.2
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/gorilla/websocket v1.5.3
golang.org/x/text v0.37.0
)

Expand Down
2 changes: 2 additions & 0 deletions bubbletea/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
Expand Down
12 changes: 6 additions & 6 deletions bubbletea/models/ai_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ const (

// 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
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
Expand All @@ -30,7 +30,7 @@ func NewAIEngine(difficulty AIDifficulty) *AIEngine {
History: []Move{},
TransitionMap: make(map[Move]map[Move]int),
lastAIMove: None,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
rng: rand.New(rand.NewSource(time.Now().UnixNano())), // #nosec G404 - game AI does not need crypto RNG
}
}

Expand Down
6 changes: 3 additions & 3 deletions bubbletea/models/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ var logFile *os.File
// Returns a function that closes the log file upon termination.
func InitLogger() (func(), error) {
logDir := "logs"
if err := os.MkdirAll(logDir, 0755); err != nil {
if err := os.MkdirAll(logDir, 0750); 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)
file, err := os.OpenFile(filepath.Clean(logPath), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err)
}
Expand All @@ -38,7 +38,7 @@ func InitLogger() (func(), error) {
cleanup := func() {
slog.Info("Structured logger shutting down")
if logFile != nil {
logFile.Close()
_ = logFile.Close()
}
}
return cleanup, nil
Expand Down
28 changes: 14 additions & 14 deletions bubbletea/models/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,15 @@ type GameState struct {
GameMode string // handles for "single" & "multi"
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"
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"

// Navigation
Cursor int // tracks which menu option is highlighted
ActiveField int // This tracks which field is active during registration
Cursor int // tracks which menu option is highlighted
ActiveField int // This tracks which field is active during registration
PreviousScreen string // tracks where to go back to

// Form handling
Expand All @@ -79,8 +79,8 @@ type GameState struct {
StateSuggestions []State

// Terminal dimensions — updated on every resize event
TermWidth int
TermHeight int
TermWidth int
TermHeight int

// Admin dashboard state
AdminPlayers []Player // cached list of all players for the admin view
Expand All @@ -92,8 +92,8 @@ type GameState struct {
type GamePhase string

const (
PhasePick GamePhase = "pick" // player choosing
PhaseThink GamePhase = "think" // AI spinner
PhaseReveal GamePhase = "reveal" // both cards shown
PhaseResult GamePhase = "result" // win/lose/tie shown
)
PhasePick GamePhase = "pick" // player choosing
PhaseThink GamePhase = "think" // AI spinner
PhaseReveal GamePhase = "reveal" // both cards shown
PhaseResult GamePhase = "result" // win/lose/tie shown
)
10 changes: 5 additions & 5 deletions bubbletea/models/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func SavePlayer(p Player) error {
defer dbMutex.Unlock()

// Create data/ folder first if it doesn't exist
if err := os.MkdirAll("data", 0755); err != nil {
if err := os.MkdirAll("data", 0750); err != nil {
return fmt.Errorf("could not create data folder: %w", err)
}

Expand Down Expand Up @@ -145,7 +145,7 @@ func UpdatePlayer(p Player) error {

// GenerateRoomCode creates a random 4-digit room code
func GenerateRoomCode() string {
digits := rand.Intn(9000) + 1000 // always 4 digits: 1000-9999
digits := rand.Intn(9000) + 1000 // #nosec G404 - room codes are not security-critical
return fmt.Sprintf("RPS-%d", digits)
}

Expand Down Expand Up @@ -212,7 +212,7 @@ func ResetPlayerStats(username string) error {
return err
}

return os.WriteFile(filepath.Clean(dataFile), data, 0644)
return os.WriteFile(filepath.Clean(dataFile), data, 0600)
}

// SetPlayerRole changes the role of a player thread-safely
Expand Down Expand Up @@ -243,5 +243,5 @@ func SetPlayerRole(username, role string) error {
return err
}

return os.WriteFile(filepath.Clean(dataFile), data, 0644)
}
return os.WriteFile(filepath.Clean(dataFile), data, 0600)
}
2 changes: 1 addition & 1 deletion bubbletea/models/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func TestValidateUsername(t *testing.T) {
{"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)
{"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"},
}
Expand Down
Loading
Loading