Skip to content

feat: rebuild YatzCLI v2 from scratch - #11

Merged
edge2992 merged 30 commits into
mainfrom
feature/v2-rebuild
Mar 22, 2026
Merged

edge2992 merged 30 commits into
mainfrom
feature/v2-rebuild

Conversation

@edge2992

Copy link
Copy Markdown
Owner

Summary

  • Engine: Pure game logic with state machine (4 phases), scoring for all 13 Yahtzee categories, deterministic dice, greedy AI, and GameClient interface abstracting local/remote play
  • CLI: Interactive TUI with bubbletea v2 (rolling/choosing states, keyboard controls)
  • MCP: Server with 6 tools for LLM integration (new_game, roll_dice, hold_dice, score, get_state, get_scorecard)
  • P2P: Host-authority online play with length-prefixed JSON over TCP, handshake, turn management, error handling
  • Matchmaking: Lambda + API Gateway WebSocket + DynamoDB serverless backend, with Go client
  • CI/CD: GitHub Actions (unit + E2E test jobs), GoReleaser for multi-platform releases

Old client/server/game/messages/network packages removed and replaced entirely.

Test plan

  • go test -short ./... runs unit tests only (E2E skipped)
  • go test ./... runs all tests including E2E (MCP full game, P2P full game)
  • go build ./cmd/yatz/ produces working binary
  • yatz play launches local game against AI
  • yatz mcp starts MCP server (verify with Claude Code)
  • CI passes: unit job then e2e job

edge2992 added 27 commits March 22, 2026 14:48
Single binary architecture with three play modes:
- Local AI play via MCP server for Claude Code integration
- Interactive CLI with bubbletea TUI
- P2P online play with serverless matchmaking (Lambda + DynamoDB)
- Clarify Hold+Roll semantics and Roll-only-for-initial-roll
- Add RNG injection for deterministic testing
- Define zero scoring behavior for unmet categories
- Add Yahtzee Bonus to Non-Goals explicitly
- Define Category as string type in GameClient interface
- Cap local AI opponents at 1-3
- Document host's own turn flow and state_update broadcasting
- Add disconnection and invalid action handling policies
- Add endpoint detection via WebSocket sourceIp
- Correct API Gateway WebSocket pricing model
- Add testing strategy section
- Add reconnection/state recovery to Non-Goals
16 tasks across 6 phases: project setup, engine, GameClient+CLI,
MCP server, P2P online play, matchmaking, and distribution.
- Fix advanceTurn() to reset Phase to PhaseRolling
- Fix TestGame_Hold_MaxRolls to need 3 rolls before PhaseChoosing
- Fix NewActionMsg to accept ActionPayload directly
- Fix FindMatch signature to use int for port
Remove all v1 packages (cmd/, client/, server/, game/, messages/, network/).
Initialize fresh Go 1.22 module with cobra for subcommand management.
AIPlayer picks the available category with the highest score for
the current dice on each turn. PlayTurn rolls once and scores
immediately.
LocalClient wraps Game for local play and auto-plays AI opponents
after the human scores via runAITurns.
Implement a bubbletea v2-based terminal UI for local Yahtzee games.
The play command supports configurable AI opponents and player name.
Implement 6 MCP tools (new_game, roll_dice, hold_dice, score,
get_state, get_scorecard) using mark3labs/mcp-go. Add mcp subcommand
to the CLI. All responses are human-readable text with formatted
dice display, scorecards, and game state.
Resolve merge conflicts in cmd/yatz/main.go and go.mod to combine
both play and mcp subcommands.
Define length-prefixed JSON-over-TCP protocol with message types for
handshake, game_start, turn_start, action, state_update, game_over,
and error. Include constructors, decoders, and round-trip tests.
Implement Host that listens on TCP, accepts a guest, runs handshake,
and manages the authoritative game loop. HostGameClient wraps LocalClient
to broadcast state updates to the guest after each action.
Implement RemoteClient that connects to host via TCP, sends actions,
and receives state updates through a background listener goroutine.
Wire up yatz host and yatz join cobra commands.
Add match package with WebSocket-based matchmaking client that connects
to a matchmaking server, sends player info, and waits for a match result.
Add `yatz match` subcommand that auto-selects a free port, finds an
opponent via the matchmaking server, then starts as host or guest.
Implement API Gateway WebSocket + Lambda + DynamoDB matchmaking:
- $connect: accept connection (no-op)
- $default: register as waiting player or match with existing one
- $disconnect: remove from waiting table
Both matched players are notified via PostToConnection with opponent
endpoint, name, and host/guest role.
Add end-to-end tests that play complete 13-round Yahtzee games:
- MCP: full game through tool calls (roll/score and roll/hold/score)
- P2P: host-guest protocol exchange with turn alternation

Also: add CLAUDE.md, update README for v2, fix CI Go version to 1.24,
remove accidentally tracked yatz binary from index.
E2E tests (TestE2E_*) now skip under `go test -short`, allowing fast
unit-only runs during development. CI splits into two jobs: unit tests
run first with -short, then E2E tests run separately after passing.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Rebuilds YatzCLI as a single yatz binary with a new engine/ core, Bubble Tea TUI, MCP server integration, and a new P2P + serverless matchmaking networking stack, replacing the legacy gob-based client/server implementation.

Changes:

  • Replaced legacy client/, server/, game/, messages/, network/ with a new pure engine/ (state machine + scoring + AI + GameClient).
  • Added new interactive TUI (cli/), MCP server (mcp/), P2P protocol + host/guest (p2p/), matchmaking client (match/) and AWS Lambda handler (lambda/).
  • Updated module path, docs, and CI/release configuration for the new architecture and distribution.

Reviewed changes

Copilot reviewed 62 out of 66 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
server/server.go Removes legacy TCP server implementation
server/room.go Removes legacy room model
server/room_manager.go Removes legacy room manager
server/room_controller.go Removes legacy room controller
server/room_controller_test.go Removes legacy room controller tests
server/player.go Removes legacy Player model
server/handler.go Removes legacy connection handler
server/gameplay_controller.go Removes legacy gameplay controller
server/gameplay_controller_test.go Removes legacy gameplay controller tests
server/controller.go Removes legacy controller interface
server/controller_test.go Removes legacy commented-out tests
cmd/server/main.go Removes legacy server entrypoint
cmd/client/main.go Removes legacy client entrypoint
client/client.go Removes legacy gob-based client
client/client_test.go Removes legacy client tests
client/console_io_handler.go Removes legacy survey-based UI
client/iohandler.go Removes legacy IO abstraction
client/mock_iohandler.go Removes legacy IO mocks
network/connections.go Removes legacy gob connection abstraction
network/mock_connection.go Removes legacy mock connection
messages/messages.go Removes legacy message schema
game/game.go Removes legacy shared game types
game/score.go Removes legacy scoring implementation
game/yahtzee.go Removes legacy gameplay constants/types
engine/category.go Adds new category definitions/constants
engine/dice.go Adds deterministic dice rolling/rerolling helpers
engine/dice_test.go Adds dice unit tests
engine/scoring.go Adds scoring for all categories
engine/scoring_test.go Adds scoring unit tests
engine/scorecard.go Adds new scorecard representation and helpers
engine/scorecard_test.go Adds scorecard unit tests
engine/game.go Adds new game state machine + turn progression
engine/game_test.go Adds game state machine unit tests
engine/ai.go Adds greedy AI player
engine/ai_test.go Adds AI unit tests
engine/client.go Adds GameClient + LocalClient implementation
engine/client_test.go Adds LocalClient tests
cli/ui.go Adds Bubble Tea entrypoint for the TUI
cli/model.go Implements rolling/choosing/game-over TUI flow
p2p/protocol.go Adds length-prefixed JSON protocol + codecs
p2p/protocol_test.go Adds protocol round-trip tests
p2p/host.go Adds host-authority P2P game runner
p2p/host_test.go Adds P2P host tests
p2p/guest.go Adds RemoteClient guest implementation
p2p/guest_test.go Adds guest/RemoteClient tests
p2p/e2e_test.go Adds P2P full-game E2E tests (skipped in -short)
mcp/server.go Adds MCP server exposing game tools
mcp/server_test.go Adds MCP tool handler tests
mcp/e2e_test.go Adds MCP full-game E2E tests (skipped in -short)
match/client.go Adds WebSocket matchmaking client
match/client_test.go Adds matchmaking client tests
lambda/handler.go Adds AWS Lambda matchmaking handler (DynamoDB + API GW)
lambda/handler_test.go Adds Lambda handler unit tests with mocks
README.md Updates installation/usage/architecture docs for v2
docs/superpowers/specs/2026-03-22-yatzcli-rebuild-design.md Adds design spec for the rebuild
cmd/yatz/main.go Adds cobra-based single-binary CLI entrypoint
CLAUDE.md Adds repo usage + architecture notes for Claude Code
go.mod Updates module path, Go version, and dependencies
go.sum Updates dependency lockfile
.github/workflows/ci.yml Adds unit + E2E GitHub Actions workflows
.github/workflows/release.yml Adds tag-based GoReleaser workflow
.goreleaser.yml Adds GoReleaser build/archive config
.gitignore Ignores new built binary name (yatz)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread engine/scorecard.go
Comment on lines +3 to +5
type Scorecard struct {
scores map[Category]*int
}

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scorecard has only an unexported field (scores), so when engine.GameState is JSON-marshaled (e.g., in P2P state_update / game_start messages) the scorecard will serialize as {} and the guest will never see filled categories or totals. Consider making the field exported (and JSON-tagged) or implementing MarshalJSON/UnmarshalJSON so scorecards round-trip correctly over the wire.

Copilot uses AI. Check for mistakes.
Comment thread p2p/guest.go
Comment on lines +124 to +133
switch msg.Type {
case MsgStateUpdate:
sp, err := DecodeState(msg)
if err != nil {
rc.responseCh <- responseResult{err: fmt.Errorf("decode state_update: %w", err)}
continue
}
rc.setLastState(&sp.State)
rc.responseCh <- responseResult{state: &sp.State}

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

listen() writes to responseCh with a blocking send for every state_update. When the guest is waiting for turn_start (e.g., inside Score() after ending their turn), there may be no goroutine receiving from responseCh, so the listener can deadlock and stop processing further messages (including turn_start / game_over). Consider making these sends non-blocking (drop/overwrite when no waiter), or gating delivery to responseCh only when an action request is in-flight, while still updating lastState for UI.

Copilot uses AI. Check for mistakes.
Comment thread p2p/protocol.go
Comment on lines +66 to +75
// ReadMessage reads a length-prefixed JSON message from r.
func ReadMessage(r io.Reader) (*Message, error) {
var length uint32
if err := binary.Read(r, binary.BigEndian, &length); err != nil {
return nil, fmt.Errorf("read length: %w", err)
}
data := make([]byte, length)
if _, err := io.ReadFull(r, data); err != nil {
return nil, fmt.Errorf("read payload: %w", err)
}

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReadMessage trusts the peer-provided length and allocates make([]byte, length) with no upper bound. A malicious/buggy peer can send a huge length prefix and trigger excessive memory allocation/DoS. Introduce a reasonable max message size (e.g., 1MB) and reject frames exceeding it before allocating/reading.

Copilot uses AI. Check for mistakes.
Comment thread engine/game.go
Comment on lines +106 to +120
func (g *Game) Score(category Category) error {
if g.Phase == PhaseFinished {
return errors.New("cannot score: game is finished")
}
if g.RollCount == 0 {
return errors.New("cannot score: must roll first")
}
player := &g.Players[g.Current]
if player.Scorecard.IsFilled(category) {
return fmt.Errorf("cannot score: category %s already filled", category)
}
score := CalcScore(category, g.Dice)
player.Scorecard.Fill(category, score)
g.advanceTurn()
return nil

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Score() accepts any Category string and will happily fill unknown categories (since CalcScore returns 0 for unrecognized values). This allows clients (MCP/P2P) to send arbitrary category names, bypass normal scorecard filling, and potentially invalidate game results. Consider validating category is one of AllCategories (and ideally currently available for the player) before scoring.

Copilot uses AI. Check for mistakes.
Comment thread mcp/server.go
Comment on lines +44 to +48
holdDiceTool := mcp.NewTool("hold_dice",
mcp.WithDescription("Hold specified dice and reroll the others. Indices are 0-4."),
mcp.WithString("indices", mcp.Required(), mcp.Description("JSON array of dice indices to hold, e.g. [0,2,4]")),
)
s.AddTool(holdDiceTool, gs.handleHoldDice)

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hold_dice tool declares indices as a string parameter, but handleHoldDice reads it via GetIntSlice. This schema/handler mismatch can break real MCP clients that validate arguments against the tool schema. Define indices as an array of numbers/ints in the tool schema (instead of a stringified JSON array), or update the handler to parse a string.

Copilot uses AI. Check for mistakes.
- Plays as a participant and serves game state to guest
- Host uses `LocalClient`, guest uses `RemoteClient`

**Host's own turn:** Host operates the engine directly via `LocalClient`. After each action (Roll/Hold/Score), host sends `state_update` to guest. Guest receives `turn_start` at the beginning of every turn (both host's and guest's) so it can display the current game state.

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This spec says the guest receives turn_start at the beginning of every turn, but the current host implementation only sends turn_start when the guest’s turn begins (see Host.handleGuestTurn()). Either update the spec to match the implementation, or adjust the host to emit turn_start for host turns too.

Copilot uses AI. Check for mistakes.
Comment thread .github/workflows/release.yml Outdated
Comment thread lambda/handler.go
Comment on lines +121 to +130
func (h *Handler) handleMessage(ctx context.Context, event events.APIGatewayWebsocketProxyRequest) error {
var msg ClientMessage
if err := json.Unmarshal([]byte(event.Body), &msg); err != nil {
return fmt.Errorf("parsing client message: %w", err)
}

connectionID := event.RequestContext.ConnectionID
sourceIP := event.RequestContext.Identity.SourceIP
endpoint := sourceIP + ":" + strconv.Itoa(msg.Port)

Copilot AI Mar 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleMessage trusts msg.Port from the client and blindly constructs endpoint := sourceIP + ":" + strconv.Itoa(msg.Port). This can produce invalid endpoints (0/negative/out-of-range ports) and could be abused to poison matchmaking results. Consider validating Port is within 1-65535 (and optionally rejecting empty sourceIP) before storing/notifying.

Copilot uses AI. Check for mistakes.
@edge2992

Copy link
Copy Markdown
Owner Author

Code review

Found 5 issues:

  1. Scorecard unexported field prevents JSON serialization over P2P. The Scorecard struct uses an unexported scores field (map[Category]*int). Since encoding/json cannot access unexported fields, all scorecard data is silently lost when GameState is serialized over the P2P protocol. No custom MarshalJSON/UnmarshalJSON methods are defined. The P2P round-trip test only checks CurrentPlayer, Dice, and player count -- never scorecard contents -- masking this bug.

}
func NewScorecard() Scorecard {

  1. RemoteClient deadlocks during host's turn due to responseCh buffer overflow. responseCh has buffer size 1. During the host's turn, each HostGameClient.Roll(), Hold(), and Score() sends a state_update to the guest. After the guest's Score() returns, nothing consumes from responseCh. A typical host turn (Roll + Score = 2 state_updates) fills the buffer on the first, blocks the listener goroutine on the second, and prevents turn_start from ever being delivered to turnCh. The guest hangs forever. This deadlock occurs in every P2P game after the first guest turn. The E2E test avoids this because the host plays directly via game.Roll()/game.Score() (bypassing HostGameClient), so no state_updates are sent during host turns in the test.

yatzcli/p2p/guest.go

Lines 86 to 95 in 41904e1

rc := &RemoteClient{
conn: conn,
lastState: &sp.State,
playerID: "player-1",
playerName: name,
responseCh: make(chan responseResult, 1),
turnCh: make(chan *engine.GameState, 1),
gameOverCh: make(chan *engine.GameState, 1),
doneCh: make(chan struct{}),
}

yatzcli/p2p/host.go

Lines 30 to 50 in 41904e1

func (h *HostGameClient) Roll() (*engine.GameState, error) {
gs, err := h.local.Roll()
if err != nil {
return nil, err
}
if err := h.host.sendStateUpdate(*gs); err != nil {
return nil, fmt.Errorf("send state update: %w", err)
}
return gs, nil
}
func (h *HostGameClient) Hold(indices []int) (*engine.GameState, error) {
gs, err := h.local.Hold(indices)
if err != nil {
return nil, err
}
if err := h.host.sendStateUpdate(*gs); err != nil {
return nil, fmt.Errorf("send state update: %w", err)
}
return gs, nil
}

  1. release.yml uses Go 1.22 but go.mod requires Go 1.24.2. The release workflow will fail to build because Go 1.22 cannot compile code requiring Go 1.24.2. ci.yml was updated to 1.24 but release.yml was not.

with:
go-version: '1.22'
- uses: goreleaser/goreleaser-action@v6

  1. MCP hold_dice tool schema/handler type mismatch. The tool defines indices as mcp.WithString (string type) but the handler retrieves it with req.GetIntSlice(). Tests pass because the in-process client sends native Go slices that bypass JSON schema validation. A real MCP client (e.g., Claude Desktop) would send "[0,2,4]" as a string per the schema, and GetIntSlice would return nil, causing the tool to always error.

yatzcli/mcp/server.go

Lines 44 to 48 in 41904e1

holdDiceTool := mcp.NewTool("hold_dice",
mcp.WithDescription("Hold specified dice and reroll the others. Indices are 0-4."),
mcp.WithString("indices", mcp.Required(), mcp.Description("JSON array of dice indices to hold, e.g. [0,2,4]")),
)
s.AddTool(holdDiceTool, gs.handleHoldDice)

yatzcli/mcp/server.go

Lines 114 to 118 in 41904e1

}
indices := req.GetIntSlice("indices", nil)
if indices == nil {
return mcp.NewToolResultError("indices parameter is required (JSON array of ints 0-4)"), nil
}

  1. No maximum message size in ReadMessage. ReadMessage reads a uint32 length prefix and allocates make([]byte, length) without an upper bound. A malicious peer can send a length up to ~4GB, causing OOM. This is on a TCP network boundary accepting untrusted input. (CLAUDE.md says "Validate inputs at system boundaries; trust internal code.")

yatzcli/p2p/protocol.go

Lines 67 to 78 in 41904e1

func ReadMessage(r io.Reader) (*Message, error) {
var length uint32
if err := binary.Read(r, binary.BigEndian, &length); err != nil {
return nil, fmt.Errorf("read length: %w", err)
}
data := make([]byte, length)
if _, err := io.ReadFull(r, data); err != nil {
return nil, fmt.Errorf("read payload: %w", err)
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return nil, fmt.Errorf("unmarshal message: %w", err)


Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

1. Scorecard JSON serialization: add MarshalJSON/UnmarshalJSON so
   scorecard data survives P2P state transfer (unexported field fix)
2. RemoteClient deadlock: use expectResponse flag so state_updates
   during host's turn are drained instead of blocking responseCh
3. release.yml Go version: update from 1.22 to 1.24 to match go.mod
4. MCP hold_dice schema: change WithString to WithArray with integer
   items so real MCP clients send proper arrays
5. ReadMessage max size: reject messages over 1 MB to prevent OOM
   from malicious peers
@edge2992

Copy link
Copy Markdown
Owner Author

Code review (re-review after fixes)

Previous 5 issues were all addressed. Found 2 remaining issues:

  1. MsgError not guarded by expectResponse -- the deadlock fix for MsgStateUpdate (adding expectResponse check before sending to responseCh) was not applied to the MsgError case. If the host sends an error while expectResponse is false, the listener goroutine blocks on the buffered channel, causing deadlock. Same pattern as the original MsgStateUpdate bug that was just fixed.

yatzcli/p2p/guest.go

Lines 147 to 155 in adae23b

case MsgError:
ep, err := DecodeError(msg)
if err != nil {
rc.responseCh <- responseResult{err: fmt.Errorf("decode error response: %w", err)}
continue
}
rc.responseCh <- responseResult{err: fmt.Errorf("%s", ep.Message)}

  1. DynamoDB Scan uses Limit: 1 with FilterExpression: "PlayerID <> :self". DynamoDB applies Limit before the filter expression, so if the first scanned item happens to be the requesting player, the result is 0 items even when other waiting players exist. This causes matchmaking to intermittently fail.

yatzcli/lambda/handler.go

Lines 131 to 140 in adae23b

// Scan for a waiting player (exclude self)
scanOut, err := h.db.Scan(ctx, &dynamodb.ScanInput{
TableName: aws.String(h.table),
FilterExpression: aws.String("PlayerID <> :self"),
ExpressionAttributeValues: map[string]types.AttributeValue{
":self": &types.AttributeValueMemberS{Value: connectionID},
},
Limit: aws.Int32(1),
})
if err != nil {


Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

1. MsgError in RemoteClient.listen() now checks expectResponse before
   sending to responseCh, matching the MsgStateUpdate fix and preventing
   listener deadlock from unsolicited error messages.
2. Remove Limit:1 from DynamoDB Scan in matchmaking handler. DynamoDB
   applies Limit before FilterExpression, so Limit:1 could return 0
   results even when matching players exist.
@edge2992

Copy link
Copy Markdown
Owner Author

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

- Validate category names in Score() against AllCategories
- Validate dice indices in Hold() are within 0-4 range
- Skip zero-value dice in counts() to prevent false Yahtzee on unrolled dice
- Panic on NewGame with 0 players to prevent index-out-of-range
@edge2992
edge2992 merged commit 7f4b034 into main Mar 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants