diff --git a/CLAUDE.md b/CLAUDE.md index 25c1771..e316bdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,11 @@ make test-all # All tests with verbose output make test-coverage # Unit tests with coverage report make build # Build binary make lint # Static analysis (go vet) + +# AI Battle +yatz battle # Greedy vs Statistical (default) +yatz battle --players "A:llm:personas/aggressive.md,D:llm:personas/defensive.md" +yatz battle --rounds 100 --quiet # 100-game statistics ``` ## Testing Workflow @@ -27,14 +32,15 @@ make lint # Static analysis (go vet) ## Project Structure ``` -cmd/yatz/ Entry point (cobra subcommands: play, mcp, host, join, match) -engine/ Pure game logic (state machine, scoring, dice, AI, GameClient interface) -cli/ Interactive TUI (bubbletea v2) +cmd/yatz/ Entry point (cobra subcommands: play, mcp, host, join, match, battle) +engine/ Pure game logic (state machine, scoring, dice, AI, Strategy, Battle, GameClient interface) +cli/ Interactive TUI (bubbletea v2) + AI battle spectator mcp/ MCP server for LLM integration (mcp-go, stdio transport) p2p/ P2P host-authority online play (length-prefixed JSON over TCP) match/ Matchmaking WebSocket client lambda/ Serverless matchmaking handler (AWS Lambda + API Gateway + DynamoDB) -bot/ LLM bot integration (MCP config, system prompt, Claude API interaction) +bot/ LLM bot integration (MCP config, system prompt, Claude API, LLM Strategy) +personas/ Markdown-based AI persona definitions for LLM Strategy ``` ## Key Design Decisions @@ -45,6 +51,9 @@ bot/ LLM bot integration (MCP config, system prompt, Claude API interacti - **Host-authority model**: Host runs the game engine; guest sends actions over TCP and receives state updates. - **AI auto-play**: `LocalClient.Score()` triggers AI turns automatically via `runAITurns()`. - **Scorecard**: `map[Category]*int` where `nil` = unfilled, `*0` = filled with zero. +- **Strategy pattern** (`engine/strategy.go`): `Strategy` interface abstracts AI decision-making. Implementations: `GreedyStrategy` (immediate best score), `StatisticalStrategy` (expected value), `LLMStrategy` (Claude API via `anthropic-sdk-go`). +- **Battle engine** (`engine/battle.go`): `RunBattle()` drives AI-vs-AI games. `OnTurnDone` callback streams results to TUI spectator. +- **LLM API Key**: `LLMStrategy` calls Claude API directly (not via MCP) for speed. Uses `--api-key` flag or `ANTHROPIC_API_KEY` env var. ## Dependencies @@ -53,4 +62,5 @@ bot/ LLM bot integration (MCP config, system prompt, Claude API interacti - `mark3labs/mcp-go` — MCP server - `gorilla/websocket` — matchmaking client - `aws-lambda-go`, `aws-sdk-go-v2` — serverless matchmaking +- `anthropic-sdk-go` — Claude API client for LLM Strategy - `stretchr/testify` — test assertions diff --git a/README.md b/README.md index 89bbd15..07b0935 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,24 @@ yatz join 192.168.1.10:9876 --name Bob yatz match --server wss://your-api-gateway-url --name Alice ``` +### AI Battle + +Watch AI strategies compete against each other: + +```bash +# Greedy vs Statistical (default) +yatz battle + +# LLM persona battle (requires ANTHROPIC_API_KEY) +yatz battle --players "Attacker:llm:personas/aggressive.md,Defender:llm:personas/defensive.md" + +# Run 100 games and compare statistics +yatz battle --rounds 100 --quiet + +# Three-way battle with fixed seed +yatz battle --players "G:greedy,S:statistical,L:llm" --seed 42 +``` + ## Commands | Command | Description | @@ -61,6 +79,7 @@ yatz match --server wss://your-api-gateway-url --name Alice | `yatz host` | Host a P2P game | | `yatz join ` | Join a P2P game | | `yatz match` | Find opponent via matchmaking | +| `yatz battle` | Watch AI vs AI battle | ## Controls (TUI) @@ -75,6 +94,29 @@ yatz match --server wss://your-api-gateway-url --name Alice - `p2p/` - P2P host-authority online play - `match/` - Matchmaking client - `lambda/` - Serverless matchmaking handler (AWS) +- `bot/` - LLM bot integration (Claude API, LLM Strategy) +- `personas/` - AI persona definitions (Markdown) + +## Personas + +Create custom AI personas as Markdown files: + +```markdown +# My Custom AI +## 性格 +Description of personality... + +## 戦略 +- Strategy point 1 +- Strategy point 2 + +## 口癖 +「Catchphrase」 +``` + +Use with: `yatz battle --players "MyAI:llm:path/to/persona.md"` + +Built-in personas: `personas/aggressive.md`, `personas/defensive.md`, `personas/gambler.md` ## Development diff --git a/bot/persona.go b/bot/persona.go new file mode 100644 index 0000000..41d3e4a --- /dev/null +++ b/bot/persona.go @@ -0,0 +1,80 @@ +package bot + +import ( + "os" + "strings" +) + +// Persona represents a loaded character persona from a markdown file. +type Persona struct { + Name string + Personality string + Strategy string + Catchphrase string + Raw string +} + +// LoadPersona reads and parses a persona markdown file. +// Format: +// +// # Character Name +// ## 性格 +// ... +// ## 戦略 +// ... +// ## 口癖 +// ... +func LoadPersona(path string) (*Persona, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + raw := string(data) + p := &Persona{Raw: raw} + + lines := strings.Split(raw, "\n") + var currentSection string + var sectionBuf strings.Builder + + flushSection := func() { + content := strings.TrimSpace(sectionBuf.String()) + switch currentSection { + case "name": + p.Name = content + case "性格", "personality": + p.Personality = content + case "戦略", "strategy": + p.Strategy = content + case "口癖", "catchphrase": + p.Catchphrase = content + } + sectionBuf.Reset() + } + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "# ") && !strings.HasPrefix(trimmed, "## ") { + flushSection() + currentSection = "name" + sectionBuf.WriteString(strings.TrimPrefix(trimmed, "# ")) + continue + } + if strings.HasPrefix(trimmed, "## ") { + flushSection() + currentSection = strings.ToLower(strings.TrimPrefix(trimmed, "## ")) + continue + } + if currentSection != "" { + sectionBuf.WriteString(line) + sectionBuf.WriteString("\n") + } + } + flushSection() + + if p.Name == "" { + p.Name = "LLM" + } + + return p, nil +} diff --git a/bot/strategy_llm.go b/bot/strategy_llm.go new file mode 100644 index 0000000..e272550 --- /dev/null +++ b/bot/strategy_llm.go @@ -0,0 +1,225 @@ +package bot + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + + "github.com/edge2992/yatzcli/engine" +) + +// LLMStrategy implements engine.Strategy using the Claude API. +type LLMStrategy struct { + client anthropic.Client + model string + persona *Persona +} + +// NewLLMStrategy creates a new LLM-based strategy. +// If apiKey is empty, the SDK reads ANTHROPIC_API_KEY from the environment. +func NewLLMStrategy(apiKey, model string, persona *Persona) *LLMStrategy { + var opts []option.RequestOption + if apiKey != "" { + opts = append(opts, option.WithAPIKey(apiKey)) + } + client := anthropic.NewClient(opts...) + + if persona == nil { + persona = &Persona{ + Name: "LLM", + Strategy: DefaultStrategy, + } + } + + return &LLMStrategy{ + client: client, + model: model, + persona: persona, + } +} + +func (s *LLMStrategy) Name() string { + return "llm:" + s.persona.Name +} + +func (s *LLMStrategy) DecideAction(dice [5]int, rollCount int, scorecard engine.Scorecard, available []engine.Category) engine.TurnAction { + action, err := s.callAPI(dice, rollCount, scorecard, available) + if err != nil { + // Fallback to greedy on error + greedy := &engine.GreedyStrategy{} + return greedy.DecideAction(dice, rollCount, scorecard, available) + } + return action +} + +type llmResponse struct { + Action string `json:"action"` + Indices []int `json:"indices"` + Category string `json:"category"` + Reasoning string `json:"reasoning"` +} + +func (s *LLMStrategy) callAPI(dice [5]int, rollCount int, scorecard engine.Scorecard, available []engine.Category) (engine.TurnAction, error) { + systemPrompt := s.buildSystemPrompt() + userPrompt := s.buildUserPrompt(dice, rollCount, scorecard, available) + + resp, err := s.client.Messages.New(context.Background(), anthropic.MessageNewParams{ + Model: s.model, + MaxTokens: 512, + System: []anthropic.TextBlockParam{ + {Text: systemPrompt}, + }, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage( + anthropic.NewTextBlock(userPrompt), + ), + }, + }) + if err != nil { + return engine.TurnAction{}, fmt.Errorf("API call failed: %w", err) + } + + // Extract text from response + var responseText string + for _, block := range resp.Content { + if block.Type == "text" { + responseText = block.Text + break + } + } + + return s.parseResponse(responseText, available) +} + +func (s *LLMStrategy) buildSystemPrompt() string { + var b strings.Builder + b.WriteString("あなたはヤッツィー(Yahtzee)のプレイヤーです。\n\n") + + if s.persona.Personality != "" { + b.WriteString("## あなたの性格\n") + b.WriteString(s.persona.Personality) + b.WriteString("\n\n") + } + if s.persona.Strategy != "" { + b.WriteString("## あなたの戦略\n") + b.WriteString(s.persona.Strategy) + b.WriteString("\n\n") + } + if s.persona.Catchphrase != "" { + b.WriteString("## 口癖\n") + b.WriteString(s.persona.Catchphrase) + b.WriteString("\n\n") + } + + b.WriteString(`## ルール +- 5つのダイスを振り、最大3回まで振り直せる(1回目は自動ロール) +- holdで指定したダイスをキープし、残りを振り直す +- 13カテゴリから1つ選んでスコアする +- 上段(ones〜sixes)合計63以上で35点ボーナス + +## カテゴリ +ones, twos, threes, fours, fives, sixes: 対応する目の合計 +three_of_a_kind: 同じ目3つ以上→全ダイスの合計 +four_of_a_kind: 同じ目4つ以上→全ダイスの合計 +full_house: 3+2の組み合わせ→25点 +small_straight: 4連続→30点 +large_straight: 5連続→40点 +yahtzee: 全て同じ目→50点 +chance: 全ダイスの合計 + +## 出力形式 +以下のJSON形式のみで回答してください。他の文章は不要です。 +{"action":"hold"|"score", "indices":[0-4のインデックス配列], "category":"カテゴリ名", "reasoning":"理由"} + +- holdの場合: indicesにキープするダイスのインデックスを指定 +- scoreの場合: categoryにスコアするカテゴリ名を指定 +- 3回目のロール(rollCount=3)の場合は必ずscoreを選択 +`) + + return b.String() +} + +func (s *LLMStrategy) buildUserPrompt(dice [5]int, rollCount int, scorecard engine.Scorecard, available []engine.Category) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("ダイス: [%d, %d, %d, %d, %d]\n", dice[0], dice[1], dice[2], dice[3], dice[4])) + b.WriteString(fmt.Sprintf("ロール回数: %d/3\n", rollCount)) + + b.WriteString("利用可能カテゴリ:\n") + for _, c := range available { + score := engine.CalcScore(c, dice) + b.WriteString(fmt.Sprintf(" %s: %d点\n", string(c), score)) + } + + b.WriteString("記入済みカテゴリ:\n") + for _, c := range engine.AllCategories { + if scorecard.IsFilled(c) { + b.WriteString(fmt.Sprintf(" %s: %d点\n", string(c), scorecard.GetScore(c))) + } + } + + b.WriteString(fmt.Sprintf("上段合計: %d/63\n", scorecard.UpperTotal())) + + if rollCount >= engine.MaxRolls { + b.WriteString("\n3回目のロール済みです。必ずscoreを選択してください。\n") + } + + return b.String() +} + +func (s *LLMStrategy) parseResponse(text string, available []engine.Category) (engine.TurnAction, error) { + text = strings.TrimSpace(text) + + // Find JSON in the response (may be wrapped in markdown code blocks) + jsonStr := text + if idx := strings.Index(text, "{"); idx >= 0 { + end := strings.LastIndex(text, "}") + if end > idx { + jsonStr = text[idx : end+1] + } + } + + var resp llmResponse + if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil { + return engine.TurnAction{}, fmt.Errorf("failed to parse LLM response: %w\nraw: %s", err, text) + } + + switch resp.Action { + case "hold": + if len(resp.Indices) == 0 { + return engine.TurnAction{}, fmt.Errorf("hold action with empty indices") + } + for _, idx := range resp.Indices { + if idx < 0 || idx > 4 { + return engine.TurnAction{}, fmt.Errorf("invalid hold index: %d", idx) + } + } + return engine.TurnAction{ + Type: "hold", + Indices: resp.Indices, + }, nil + + case "score": + cat := engine.Category(resp.Category) + valid := false + for _, c := range available { + if c == cat { + valid = true + break + } + } + if !valid { + return engine.TurnAction{}, fmt.Errorf("invalid category %q: not available", resp.Category) + } + return engine.TurnAction{ + Type: "score", + Category: cat, + }, nil + + default: + return engine.TurnAction{}, fmt.Errorf("unknown action %q", resp.Action) + } +} diff --git a/cli/spectator.go b/cli/spectator.go new file mode 100644 index 0000000..cbe26e9 --- /dev/null +++ b/cli/spectator.go @@ -0,0 +1,271 @@ +package cli + +import ( + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/edge2992/yatzcli/engine" +) + +type spectatorState int + +const ( + specWatching spectatorState = iota + specGameOver +) + +type specTickMsg struct{} +type specResultMsg engine.AITurnResult +type specDoneMsg struct{ err error } + +type spectatorModel struct { + results <-chan engine.AITurnResult + errCh <-chan error + players []engine.BattlePlayer + speed time.Duration + state spectatorState + current *engine.AITurnResult + history []engine.AITurnResult + turnCount int + totalTurns int + err error +} + +// RunSpectator launches the spectator TUI for watching AI battles. +func RunSpectator( + results <-chan engine.AITurnResult, + errCh <-chan error, + players []engine.BattlePlayer, + speed time.Duration, +) error { + m := spectatorModel{ + results: results, + errCh: errCh, + players: players, + speed: speed, + totalTurns: 13 * len(players), + } + p := tea.NewProgram(m) + _, err := p.Run() + return err +} + +func (m spectatorModel) Init() tea.Cmd { + return tea.Batch(waitForResult(m.results, m.errCh)) +} + +func waitForResult(results <-chan engine.AITurnResult, errCh <-chan error) tea.Cmd { + return func() tea.Msg { + select { + case result, ok := <-results: + if !ok { + // Channel closed — game done. errCh is buffered (cap=1), + // so this read will not block. + err := <-errCh + return specDoneMsg{err: err} + } + return specResultMsg(result) + case err := <-errCh: + return specDoneMsg{err: err} + } + } +} + +func specTickCmd(d time.Duration) tea.Cmd { + return tea.Tick(d, func(t time.Time) tea.Msg { + return specTickMsg{} + }) +} + +func (m spectatorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case specResultMsg: + r := engine.AITurnResult(msg) + m.current = &r + m.history = append(m.history, r) + m.turnCount++ + return m, specTickCmd(m.speed) + + case specTickMsg: + return m, waitForResult(m.results, m.errCh) + + case specDoneMsg: + if msg.err != nil { + m.err = msg.err + } + m.state = specGameOver + return m, nil + + case tea.KeyPressMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + if m.state == specWatching && m.current != nil { + // Skip to next result + return m, waitForResult(m.results, m.errCh) + } + } + return m, nil +} + +func (m spectatorModel) View() tea.View { + var b strings.Builder + + switch m.state { + case specWatching: + m.viewWatching(&b) + case specGameOver: + m.viewGameOver(&b) + } + + if m.err != nil { + b.WriteString(fmt.Sprintf("\n Error: %v\n", m.err)) + } + + return tea.NewView(b.String()) +} + +func (m spectatorModel) viewWatching(b *strings.Builder) { + b.WriteString(" === AI Battle ===\n\n") + + if m.current == nil { + b.WriteString(" Waiting for first turn...\n") + return + } + + r := m.current + b.WriteString(fmt.Sprintf(" Turn %d/%d | %s (%s)\n\n", + m.turnCount, m.totalTurns, r.PlayerName, r.StrategyName)) + + // Show hold history if any + if len(r.HoldHistory) > 0 { + for i, h := range r.HoldHistory { + b.WriteString(fmt.Sprintf(" Roll %d: ", i+1)) + for j, d := range h.Dice { + held := false + for _, idx := range h.Held { + if idx == j { + held = true + break + } + } + if held { + b.WriteString(fmt.Sprintf("[*%d*]", d)) + } else { + b.WriteString(fmt.Sprintf("[ %d ]", d)) + } + if j < 4 { + b.WriteString(" ") + } + } + b.WriteString("\n") + } + } + + // Final dice + b.WriteString(" Dice: ") + for i, d := range r.Dice { + b.WriteString(fmt.Sprintf("[ %d ]", d)) + if i < 4 { + b.WriteString(" ") + } + } + b.WriteString("\n\n") + + b.WriteString(fmt.Sprintf(" Scored: %-16s %3d pts\n\n", categoryName(r.Category), r.Score)) + + // Scorecard from history + scorecards, names := m.buildScorecards() + writeScorecard(b, scorecards, names, false) + + b.WriteString("\n Press any key to advance, [q] to quit\n") +} + +func (m spectatorModel) viewGameOver(b *strings.Builder) { + b.WriteString(" === BATTLE OVER ===\n\n") + + scorecards, names := m.buildScorecards() + writeScorecard(b, scorecards, names, true) + + // Winner + bestScore := -1 + winner := "" + for _, name := range names { + sc := scorecards[name] + total := sc.Total() + if total > bestScore { + bestScore = total + winner = name + } + } + b.WriteString(fmt.Sprintf("\n Winner: %s with %d points!\n\n", winner, bestScore)) + b.WriteString(" [q] Quit\n") +} + +// buildScorecards reconstructs scorecards from turn history. +func (m spectatorModel) buildScorecards() (map[string]*engine.Scorecard, []string) { + scorecards := make(map[string]*engine.Scorecard) + names := make([]string, len(m.players)) + for i, p := range m.players { + sc := engine.NewScorecard() + scorecards[p.Name] = &sc + names[i] = p.Name + } + for _, r := range m.history { + if sc, ok := scorecards[r.PlayerName]; ok { + sc.Fill(r.Category, r.Score) + } + } + return scorecards, names +} + +// writeScorecard writes a scorecard table to the builder. +// If showBonus is true, the upper bonus row is included. +func writeScorecard(b *strings.Builder, scorecards map[string]*engine.Scorecard, names []string, showBonus bool) { + nameWidth := 16 + b.WriteString(fmt.Sprintf(" %-*s", nameWidth, "Category")) + for _, name := range names { + b.WriteString(fmt.Sprintf(" %8s", name)) + } + b.WriteString("\n") + b.WriteString(" " + strings.Repeat("-", nameWidth+10*len(names)) + "\n") + + for _, cat := range engine.AllCategories { + b.WriteString(fmt.Sprintf(" %-*s", nameWidth, categoryName(cat))) + for _, name := range names { + sc := scorecards[name] + if sc.IsFilled(cat) { + b.WriteString(fmt.Sprintf(" %8d", sc.GetScore(cat))) + } else { + b.WriteString(fmt.Sprintf(" %8s", "-")) + } + } + b.WriteString("\n") + } + + b.WriteString(" " + strings.Repeat("-", nameWidth+10*len(names)) + "\n") + + if showBonus { + b.WriteString(fmt.Sprintf(" %-*s", nameWidth, "Upper Bonus")) + for _, name := range names { + sc := scorecards[name] + if sc.HasUpperBonus() { + b.WriteString(fmt.Sprintf(" %8d", engine.UpperBonusValue)) + } else { + b.WriteString(fmt.Sprintf(" %5d/%d", sc.UpperTotal(), engine.UpperBonusThreshold)) + } + } + b.WriteString("\n") + } + + b.WriteString(fmt.Sprintf(" %-*s", nameWidth, "TOTAL")) + for _, name := range names { + sc := scorecards[name] + b.WriteString(fmt.Sprintf(" %8d", sc.Total())) + } + b.WriteString("\n") +} diff --git a/cmd/yatz/battle.go b/cmd/yatz/battle.go new file mode 100644 index 0000000..341de17 --- /dev/null +++ b/cmd/yatz/battle.go @@ -0,0 +1,198 @@ +package main + +import ( + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/edge2992/yatzcli/bot" + "github.com/edge2992/yatzcli/cli" + "github.com/edge2992/yatzcli/engine" +) + +var battleCmd = &cobra.Command{ + Use: "battle", + Short: "Watch AI vs AI battle", + Long: `Run an AI vs AI battle. Specify players with --players "Name:strategy" format.`, + RunE: runBattle, +} + +func init() { + battleCmd.Flags().StringSlice("players", []string{"Greedy:greedy", "Statistical:statistical"}, `Players in "Name:strategy" format (greedy, statistical, llm:persona.md)`) + battleCmd.Flags().Duration("speed", time.Second, "Turn display speed") + battleCmd.Flags().Int64("seed", 0, "Random seed (0=random)") + battleCmd.Flags().String("api-key", "", "Claude API key (or ANTHROPIC_API_KEY env)") + battleCmd.Flags().String("model", "claude-haiku-4-5-20251001", "Claude model for LLM strategy") + battleCmd.Flags().Int("rounds", 1, "Number of consecutive games") + battleCmd.Flags().Bool("quiet", false, "No TUI, show results only") +} + +func parseBattlePlayers(playerSpecs []string, apiKey string, model string) ([]engine.BattlePlayer, error) { + var players []engine.BattlePlayer + for _, spec := range playerSpecs { + parts := strings.SplitN(spec, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid player spec %q: expected Name:strategy", spec) + } + name := parts[0] + stratSpec := parts[1] + + strategy, err := resolveStrategy(stratSpec, apiKey, model) + if err != nil { + return nil, fmt.Errorf("player %s: %w", name, err) + } + + players = append(players, engine.BattlePlayer{ + Name: name, + Strategy: strategy, + }) + } + return players, nil +} + +func resolveStrategy(spec string, apiKey string, model string) (engine.Strategy, error) { + switch { + case spec == "greedy": + return &engine.GreedyStrategy{}, nil + case spec == "statistical": + return &engine.StatisticalStrategy{}, nil + case spec == "llm": + return bot.NewLLMStrategy(apiKey, model, nil), nil + case strings.HasPrefix(spec, "llm:"): + personaPath := strings.TrimPrefix(spec, "llm:") + persona, err := bot.LoadPersona(personaPath) + if err != nil { + return nil, fmt.Errorf("failed to load persona %s: %w", personaPath, err) + } + return bot.NewLLMStrategy(apiKey, model, persona), nil + default: + return nil, fmt.Errorf("unknown strategy %q (available: greedy, statistical, llm, llm:)", spec) + } +} + +func runBattle(cmd *cobra.Command, args []string) error { + playerSpecs, _ := cmd.Flags().GetStringSlice("players") + speed, _ := cmd.Flags().GetDuration("speed") + seed, _ := cmd.Flags().GetInt64("seed") + rounds, _ := cmd.Flags().GetInt("rounds") + quiet, _ := cmd.Flags().GetBool("quiet") + apiKey, _ := cmd.Flags().GetString("api-key") + model, _ := cmd.Flags().GetString("model") + + if apiKey == "" { + apiKey = os.Getenv("ANTHROPIC_API_KEY") + } + + players, err := parseBattlePlayers(playerSpecs, apiKey, model) + if err != nil { + return err + } + + if quiet { + return runQuietBattle(players, seed, rounds) + } + + if rounds > 1 { + return runQuietBattle(players, seed, rounds) + } + + return runTUIBattle(players, seed, speed) +} + +func runQuietBattle(players []engine.BattlePlayer, seed int64, rounds int) error { + type stats struct { + wins int + total int + maxScore int + } + playerStats := make(map[string]*stats) + for _, p := range players { + playerStats[p.Name] = &stats{} + } + + for r := 0; r < rounds; r++ { + gameSeed := seed + if seed != 0 { + gameSeed = seed + int64(r) + } + + state, err := engine.RunBattle(engine.BattleConfig{ + Players: players, + Seed: gameSeed, + }) + if err != nil { + return fmt.Errorf("game %d failed: %w", r+1, err) + } + + // Find winner + bestScore := -1 + winner := "" + for _, p := range state.Players { + score := p.Scorecard.Total() + st := playerStats[p.Name] + st.total += score + if score > st.maxScore { + st.maxScore = score + } + if score > bestScore { + bestScore = score + winner = p.Name + } + } + playerStats[winner].wins++ + } + + // Sort players by wins descending + type row struct { + name string + wins int + avgScore float64 + maxScore int + } + var rows []row + for _, p := range players { + st := playerStats[p.Name] + rows = append(rows, row{ + name: p.Name, + wins: st.wins, + avgScore: float64(st.total) / float64(rounds), + maxScore: st.maxScore, + }) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].wins > rows[j].wins }) + + fmt.Fprintf(os.Stdout, "\n=== Battle Results (%d games) ===\n", rounds) + fmt.Fprintf(os.Stdout, "%-16s %6s %11s %11s\n", "Player", "Wins", "Avg Score", "Max Score") + for _, r := range rows { + fmt.Fprintf(os.Stdout, "%-16s %6d %11.1f %11d\n", r.name, r.wins, r.avgScore, r.maxScore) + } + + return nil +} + +func runTUIBattle(players []engine.BattlePlayer, seed int64, speed time.Duration) error { + resultCh := make(chan engine.AITurnResult, 64) + + cfg := engine.BattleConfig{ + Players: players, + Seed: seed, + OnTurnDone: func(result engine.AITurnResult) { + resultCh <- result + }, + } + + // Run the battle in a background goroutine. + // errCh is buffered (cap=1) so the goroutine never blocks after closing resultCh. + errCh := make(chan error, 1) + go func() { + _, err := engine.RunBattle(cfg) + close(resultCh) + errCh <- err + }() + + return cli.RunSpectator(resultCh, errCh, players, speed) +} diff --git a/cmd/yatz/main.go b/cmd/yatz/main.go index f7a8909..16a89b7 100644 --- a/cmd/yatz/main.go +++ b/cmd/yatz/main.go @@ -124,6 +124,8 @@ func init() { botCmd.Flags().String("strategy", "", "Path to strategy file (uses built-in if empty)") botCmd.Flags().StringP("model", "m", "claude-haiku-4-5-20251001", "Claude model to use (e.g. claude-haiku-4-5-20251001, claude-sonnet-4-6)") rootCmd.AddCommand(botCmd) + + rootCmd.AddCommand(battleCmd) } func main() { diff --git a/docs/superpowers/plans/2026-03-23-ai-battle.md b/docs/superpowers/plans/2026-03-23-ai-battle.md new file mode 100644 index 0000000..c750ee0 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-ai-battle.md @@ -0,0 +1,184 @@ +# AI Battle Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** AI同士の対戦機能。Strategy パターンによる戦術プラグイン設計で、Greedy・Statistical・LLM 戦術を実装し、TUI 観戦モードと統計比較モードを提供する。 + +**Architecture:** `Strategy` インターフェースで戦術を抽象化。`RunBattle()` がゲームループを駆動し、`OnTurnDone` コールバックで TUI にストリーミング。LLM 戦術は `anthropic-sdk-go` で Claude API に直接接続。 + +**Tech Stack:** Go 1.22+, anthropic-sdk-go, bubbletea v2, cobra + +**Spec:** `docs/superpowers/specs/2026-03-23-ai-battle-design.md` + +--- + +## Phase 1: Strategy インターフェースと基本戦術(Greedy) + +### Task 1: Strategy インターフェース定義 + +**Files:** `engine/strategy.go`, `engine/strategy_greedy.go`, `engine/ai.go` + +- [x] **Step 1: `TurnAction` 構造体と `Strategy` インターフェースを定義** + +`TurnAction` は `Type`("hold" or "score")、`Indices`(ホールド対象)、`Category`(スコア対象)を持つ。`Strategy` は `Name()` と `DecideAction()` を公開。 + +- [x] **Step 2: `GreedyStrategy` を実装** + +常に即座にスコア。`bestCategoryForDice()` で最高得点カテゴリを選択。Hold は使わない。 + +- [x] **Step 3: `AIPlayer` を Strategy 対応にリファクタ** + +`NewAIPlayerWithStrategy()` を追加。既存の `NewAIPlayer()` は `GreedyStrategy` をデフォルト使用。`PlayTurn()` を Strategy ベースのループに書き換え。 + +- [x] **Step 4: `AITurnResult` に `StrategyName` と `HoldHistory` を追加** + +`HoldStep` 構造体(`Dice [5]int`, `Held []int`)を定義。各ホールドの履歴を記録。 + +--- + +## Phase 2: Statistical Strategy(期待値ベース) + +### Task 2: 期待値計算と Statistical Strategy 実装 + +**Files:** `engine/expected_value.go`, `engine/strategy_statistical.go` + +- [x] **Step 1: ホールド組み合わせ列挙** + +5ビットマスクで全32パターンを事前計算(`allHoldCombinations`)。 + +- [x] **Step 2: `expectedValue()` を実装** + +フリーダイスの全出目を列挙し、各パターンで `bestScoreForDice()` を取得して平均化。 + +- [x] **Step 3: `expectedValueWithBonus()` で上段ボーナス補正** + +上段合計が閾値未満かつ残りカテゴリで到達可能な場合、`UpperBonusValue * 0.1` の補正を加算。 + +- [x] **Step 4: `StatisticalStrategy.DecideAction()` を実装** + +3回目ロール後は即スコア。それ以外は全ホールド組み合わせの期待値を計算し、即スコアの得点を上回るものがあれば採用。 + +--- + +## Phase 3: LLM Strategy + Persona System + +### Task 3: LLM Strategy 実装 + +**Files:** `bot/strategy_llm.go`, `bot/persona.go` + +- [x] **Step 1: `Persona` 構造体と `LoadPersona()` パーサー** + +Markdown ファイルから `Name`、`Personality`、`Strategy`、`Catchphrase` を抽出。日本語・英語の両セクション見出しに対応。 + +- [x] **Step 2: 同梱ペルソナの作成** + +`personas/aggressive.md`(アタッカー)、`personas/defensive.md`(ディフェンダー)、`personas/gambler.md`(ギャンブラー)。 + +- [x] **Step 3: `LLMStrategy` を実装** + +`anthropic-sdk-go` で Claude API に接続。システムプロンプトにペルソナ情報を注入。ユーザープロンプトにゲーム状態を提供。JSON レスポンスをパースして `TurnAction` を返却。 + +- [x] **Step 4: フォールバック処理** + +API エラー・パースエラー・無効アクション時は `GreedyStrategy` にフォールバック。ホールドインデックスのバリデーション(0-4 範囲チェック)。 + +--- + +## Phase 4: Battle Engine + AIPlayer + +### Task 4: バトル実行エンジン + +**Files:** `engine/battle.go`, `engine/battle_test.go` + +- [x] **Step 1: `BattleConfig` と `BattlePlayer` を定義** + +`Players`(名前+戦略)、`Seed`(乱数シード)、`OnTurnDone`(コールバック)。 + +- [x] **Step 2: `RunBattle()` を実装** + +2名以上のバリデーション、`NewGame()` でゲーム生成、全プレイヤーに `AIPlayer` を割り当て、`PhaseFinished` までターンループ。 + +- [x] **Step 3: テスト作成** + +シード固定での決定性テスト、2プレイヤー・3プレイヤーのバトル、OnTurnDone コールバックの呼び出し回数検証。 + +--- + +## Phase 5: TUI Spectator Mode + +### Task 5: 観戦 TUI + +**Files:** `cli/spectator.go` + +- [x] **Step 1: `spectatorModel` と `RunSpectator()` を実装** + +チャネル経由で `AITurnResult` を受信。`specResultMsg` / `specTickMsg` / `specDoneMsg` のメッセージ型。 + +- [x] **Step 2: 観戦中ビューの実装** + +ターン情報、ホールド履歴、ダイス表示、スコアカードテーブル。`speed` 間隔での自動進行と任意キーでのスキップ。 + +- [x] **Step 3: ゲーム終了ビューの実装** + +完全スコアカード(上段ボーナス行あり)、勝者表示。 + +- [x] **Step 4: `buildScorecards()` でターン履歴からスコアカード再構築** + +`history` の `AITurnResult` を順に `Scorecard.Fill()` してリアルタイムスコアを表示。 + +--- + +## Phase 6: CLI 統合(`yatz battle` コマンド) + +### Task 6: cobra コマンド定義 + +**Files:** `cmd/yatz/battle.go`, `cmd/yatz/main.go` + +- [x] **Step 1: `battleCmd` の定義とフラグ設定** + +`--players`、`--speed`、`--seed`、`--rounds`、`--quiet`、`--api-key`、`--model` フラグ。 + +- [x] **Step 2: `resolveStrategy()` でストラテジー名からインスタンス生成** + +`greedy`、`statistical`、`llm`、`llm:` の解決ロジック。 + +- [x] **Step 3: `runTUIBattle()` — TUI モード** + +goroutine でバトル実行、チャネル経由で Spectator に結果ストリーミング。`errCh` はバッファ付き(cap=1)。 + +- [x] **Step 4: `rootCmd` に `battleCmd` を追加** + +--- + +## Phase 7: Multi-round + Quiet Mode + +### Task 7: 統計モード + +**Files:** `cmd/yatz/battle.go` + +- [x] **Step 1: `runQuietBattle()` を実装** + +TUI なしで複数ラウンドを実行。勝数・平均スコア・最高スコアを集計。 + +- [x] **Step 2: `--rounds > 1` の場合は自動的に quiet モードに切り替え** + +--- + +## 今後の拡張案 + +### カスタム戦術の追加ガイド + +- [ ] `Strategy` インターフェースの実装ガイドをドキュメント化 +- [ ] サンプル戦術テンプレートの提供 + +### トーナメントモード + +- [ ] 総当たり戦(ラウンドロビン) +- [ ] エリミネーション形式 +- [ ] リーグ結果のテーブル出力 + +### 戦績の永続化・分析 + +- [ ] JSON / CSV での対戦結果エクスポート +- [ ] 戦術別の統計サマリー(カテゴリ選択傾向、上段ボーナス達成率) +- [ ] 対戦履歴のリプレイ機能 diff --git a/docs/superpowers/specs/2026-03-23-ai-battle-design.md b/docs/superpowers/specs/2026-03-23-ai-battle-design.md new file mode 100644 index 0000000..850e10f --- /dev/null +++ b/docs/superpowers/specs/2026-03-23-ai-battle-design.md @@ -0,0 +1,265 @@ +# AI Battle Design Spec + +## Overview + +AI Battle は、異なる戦術を持つAI同士を対戦させて観戦する機能。`yatz battle` コマンド一つで、ヤッツィーの戦術を比較検証できる。 + +**目的:** +- 戦術の強さを定量的に比較(Greedy vs Statistical vs LLM) +- LLM にペルソナを与えて対戦させるエンターテインメント +- 新しい戦術のベンチマーク環境 + +**ビジョン:** 戦術をプラグインとして追加するだけで、既存のバトルエンジン・観戦UIがそのまま使える拡張性の高い設計。 + +## Architecture + +Strategy パターンによる戦術プラグイン設計。全てのAIプレイヤーは `Strategy` インターフェースを通じて意思決定する。 + +``` +┌─────────────┐ ┌──────────────┐ ┌───────────────┐ +│ yatz battle │────▶│ RunBattle() │────▶│ AIPlayer │ +│ (CLI cmd) │ │ (engine) │ │ + Strategy │ +└─────────────┘ └──────┬───────┘ └───────┬───────┘ + │ │ + OnTurnDone callback DecideAction() + │ │ + ┌──────▼───────┐ ┌───────▼───────┐ + │ Spectator │ │ GreedyStrat │ + │ TUI / Quiet │ │ StatStrat │ + └──────────────┘ │ LLMStrat │ + └───────────────┘ +``` + +## Strategy Interface + +`engine/strategy.go` で定義。全ての戦術はこのインターフェースを実装する。 + +```go +type TurnAction struct { + Type string // "hold" or "score" + Indices []int // hold: dice indices to keep + Category Category // score: category to score in +} + +type Strategy interface { + Name() string + DecideAction(dice [5]int, rollCount int, scorecard Scorecard, available []Category) TurnAction +} +``` + +`DecideAction` はターン中に複数回呼ばれる。`"hold"` を返すとダイスをキープして振り直し、`"score"` を返すとカテゴリにスコアして手番終了。3回目のロール後(`rollCount >= MaxRolls`)は必ず `"score"` を返す必要がある。 + +### Greedy Strategy (`engine/strategy_greedy.go`) + +最もシンプルなベースライン戦術。 + +- 常に即座にスコアする(Hold を使わない) +- 利用可能なカテゴリから最高得点のものを選択 +- `bestCategoryForDice()` で全カテゴリのスコアを計算し、最大値を返す + +```go +type GreedyStrategy struct{} +func (s *GreedyStrategy) Name() string { return "greedy" } +``` + +### Statistical Strategy (`engine/strategy_statistical.go`) + +期待値計算に基づく最適判断戦術。 + +- 3回目のロール後は Greedy と同様に即座にスコア +- それ以外のロールでは、全32通りのホールド組み合わせの期待値を計算 +- 即座にスコアする場合の得点と比較し、期待値が上回るホールドがあれば採用 +- 上段ボーナス(63点以上で35点追加)への接近度を加味した補正あり + +**期待値計算 (`engine/expected_value.go`):** +- `holdCombinations()`: 全32通り(5ビットマスク)のホールドパターンを事前計算 +- `expectedValue()`: フリーダイスの全出目パターンを列挙し、各パターンで最高スコアとなるカテゴリの得点を平均化 +- `expectedValueWithBonus()`: 上段ボーナスに近い場合、期待値に `UpperBonusValue * 0.1` の補正を加える +- 計算量: O(6^freeDice × |available|) — 最大 6^5 = 7,776 パターン × 13 カテゴリ + +```go +type StatisticalStrategy struct{} +func (s *StatisticalStrategy) Name() string { return "statistical" } +``` + +### LLM Strategy (`bot/strategy_llm.go`) + +Claude API を直接呼び出すLLM戦術。ペルソナに応じたプレイスタイルを実現。 + +- `anthropic-sdk-go` で Claude API に直接リクエスト +- システムプロンプトにペルソナ情報(性格・戦略・口癖)を注入 +- ユーザープロンプトに現在のダイス・ロール回数・スコアカード状態を提供 +- レスポンスは JSON 形式(`{"action":"hold"|"score", "indices":[...], "category":"...", "reasoning":"..."}`) +- パースエラーや無効なアクション時は `GreedyStrategy` にフォールバック + +```go +type LLMStrategy struct { + client anthropic.Client + model string + persona *Persona +} +func (s *LLMStrategy) Name() string { return "llm:" + s.persona.Name } +``` + +**API Key 方針:** MCP 経由ではなく直接 API Key を使用。理由: +1. バトルは1ゲームで最大39回(13ターン × 最大3アクション)のAPI呼び出しが発生 +2. MCP ラウンドトリップのオーバーヘッドを排除し、高速な対戦を実現 +3. `--api-key` フラグまたは `ANTHROPIC_API_KEY` 環境変数で指定 + +## Persona System + +Markdown ベースのキャラクター定義。`personas/` ディレクトリに配置。 + +### フォーマット (`bot/persona.go`) + +```markdown +# キャラクター名 +## 性格 +性格の説明... + +## 戦略 +- 戦略ポイント1 +- 戦略ポイント2 + +## 口癖 +「セリフ」 +``` + +**パーサー (`LoadPersona`):** +- `# ` で始まる行 → `Name` +- `## 性格` / `## personality` → `Personality` +- `## 戦略` / `## strategy` → `Strategy` +- `## 口癖` / `## catchphrase` → `Catchphrase` +- 名前が空の場合は `"LLM"` をデフォルト値とする + +### 同梱ペルソナ + +| ファイル | キャラクター | 特徴 | +|----------|-------------|------| +| `personas/aggressive.md` | アタッカー | ヤッツィー・ラージストレート最優先。リスク許容型 | +| `personas/defensive.md` | ディフェンダー | 上段ボーナス最優先。堅実・確実な得点積み上げ | +| `personas/gambler.md` | ギャンブラー | 常にヤッツィー狙い。直感重視、確率無視 | + +## Battle Execution + +### BattleConfig (`engine/battle.go`) + +```go +type BattlePlayer struct { + Name string + Strategy Strategy +} + +type BattleConfig struct { + Players []BattlePlayer + Seed int64 + OnTurnDone func(result AITurnResult) +} +``` + +- `Players`: 2名以上のプレイヤー(各自に Strategy を割り当て) +- `Seed`: 乱数シード(0 = 現在時刻ベース)。再現性のあるテストに利用 +- `OnTurnDone`: 各ターン完了時のコールバック。TUI への結果ストリーミングに使用 + +### ターン実行フロー + +``` +RunBattle(cfg) + └─ NewGame(names, src) + └─ for game.Phase != PhaseFinished: + └─ ais[current].PlayTurn() + └─ game.Roll() + └─ loop: + │ └─ strategy.DecideAction(dice, rollCount, scorecard, available) + │ └─ "hold" → game.Hold(indices) → continue + │ └─ "score" → game.Score(category) → break + └─ return AITurnResult{PlayerName, Dice, Category, Score, StrategyName, HoldHistory} + └─ cfg.OnTurnDone(result) +``` + +`AITurnResult` には `HoldHistory` が含まれ、各ロールでどのダイスをキープしたかを記録する。観戦UIでの思考過程の可視化に使用。 + +## TUI Spectator (`cli/spectator.go`) + +Bubbletea v2 による観戦UI。チャネル経由でバトル結果をリアルタイム受信。 + +### アーキテクチャ + +``` +goroutine: RunBattle() ──▶ resultCh ──▶ spectatorModel.Update() + ├─ specResultMsg → 表示更新 + ├─ specTickMsg → 次の結果を取得 + └─ specDoneMsg → ゲーム終了画面 +``` + +### 画面構成 + +**観戦中 (`specWatching`):** +- ターン番号・プレイヤー名・戦術名 +- ホールド履歴(キープしたダイスを `[*N*]` で強調表示) +- 最終ダイス・選択カテゴリ・得点 +- リアルタイムスコアカード(全プレイヤー横並び) +- `speed` で設定した間隔で自動進行、任意キーでスキップ可能 + +**ゲーム終了 (`specGameOver`):** +- 完全なスコアカード(上段ボーナス表示あり) +- 勝者発表 + +### Quiet Mode + +TUI を使わず、結果のみをテーブル形式で出力。複数ラウンドの統計比較に最適。 + +``` +=== Battle Results (100 games) === +Player Wins Avg Score Max Score +Statistical 72 213.4 312 +Greedy 28 187.2 289 +``` + +## CLI Interface (`cmd/yatz/battle.go`) + +### コマンド + +``` +yatz battle [flags] +``` + +### フラグ + +| フラグ | デフォルト | 説明 | +|--------|-----------|------| +| `--players` | `Greedy:greedy,Statistical:statistical` | `Name:strategy` 形式のプレイヤー指定 | +| `--speed` | `1s` | ターン表示間隔 | +| `--seed` | `0` (ランダム) | 乱数シード | +| `--rounds` | `1` | 連続対戦数 | +| `--quiet` | `false` | TUI なし、結果のみ表示 | +| `--api-key` | `""` | Claude API Key(`ANTHROPIC_API_KEY` 環境変数も可) | +| `--model` | `claude-haiku-4-5-20251001` | LLM 戦術用モデル | + +### 使用例 + +```bash +# 基本: Greedy vs Statistical +yatz battle + +# LLM ペルソナ対戦 +yatz battle --players "Attacker:llm:personas/aggressive.md,Defender:llm:personas/defensive.md" + +# 100回対戦の統計 +yatz battle --rounds 100 --quiet + +# 3者対戦(シード固定) +yatz battle --players "G:greedy,S:statistical,L:llm" --seed 42 + +# 表示速度を高速化 +yatz battle --speed 200ms +``` + +### Strategy 指定形式 + +| 指定 | 説明 | +|------|------| +| `greedy` | GreedyStrategy | +| `statistical` | StatisticalStrategy | +| `llm` | LLMStrategy(デフォルトペルソナ) | +| `llm:` | LLMStrategy(カスタムペルソナ) | diff --git a/engine/ai.go b/engine/ai.go index 16d29da..d379c6b 100644 --- a/engine/ai.go +++ b/engine/ai.go @@ -2,13 +2,24 @@ package engine import "errors" +// HoldStep records a hold decision during a turn. +type HoldStep struct { + Dice [5]int + Held []int +} + type AIPlayer struct { game *Game playerID string + strategy Strategy } func NewAIPlayer(game *Game, playerID string) *AIPlayer { - return &AIPlayer{game: game, playerID: playerID} + return &AIPlayer{game: game, playerID: playerID, strategy: &GreedyStrategy{}} +} + +func NewAIPlayerWithStrategy(game *Game, playerID string, strategy Strategy) *AIPlayer { + return &AIPlayer{game: game, playerID: playerID, strategy: strategy} } func (ai *AIPlayer) PlayTurn() (AITurnResult, error) { @@ -18,31 +29,44 @@ func (ai *AIPlayer) PlayTurn() (AITurnResult, error) { if err := ai.game.Roll(); err != nil { return AITurnResult{}, err } - dice := ai.game.Dice - best := ai.bestCategory() - score := CalcScore(best, dice) + playerName := ai.game.Players[ai.game.Current].Name - if err := ai.game.Score(best); err != nil { - return AITurnResult{}, err - } - return AITurnResult{ - PlayerName: playerName, - Dice: dice, - Category: best, - Score: score, - }, nil -} + scorecard := ai.game.Players[ai.game.Current].Scorecard + var holdHistory []HoldStep + + for { + available := scorecard.AvailableCategories() + action := ai.strategy.DecideAction(ai.game.Dice, ai.game.RollCount, scorecard, available) -func (ai *AIPlayer) bestCategory() Category { - avail := ai.game.Players[ai.game.Current].Scorecard.AvailableCategories() - bestCat := avail[0] - bestScore := CalcScore(bestCat, ai.game.Dice) - for _, c := range avail[1:] { - s := CalcScore(c, ai.game.Dice) - if s > bestScore { - bestScore = s - bestCat = c + if action.Type == "hold" && ai.game.RollCount < MaxRolls { + holdHistory = append(holdHistory, HoldStep{ + Dice: ai.game.Dice, + Held: action.Indices, + }) + if err := ai.game.Hold(action.Indices); err != nil { + return AITurnResult{}, err + } + continue + } + + // Score — if strategy returned "hold" but no more rolls remain, + // fall back to best available category. + dice := ai.game.Dice + category := action.Category + if action.Type != "score" || category == "" { + category = bestCategoryForDice(dice, available) + } + score := CalcScore(category, dice) + if err := ai.game.Score(category); err != nil { + return AITurnResult{}, err } + return AITurnResult{ + PlayerName: playerName, + Dice: dice, + Category: category, + Score: score, + StrategyName: ai.strategy.Name(), + HoldHistory: holdHistory, + }, nil } - return bestCat } diff --git a/engine/battle.go b/engine/battle.go new file mode 100644 index 0000000..0760aa3 --- /dev/null +++ b/engine/battle.go @@ -0,0 +1,72 @@ +package engine + +import ( + "fmt" + "math/rand" + "time" +) + +// BattlePlayer represents a player in a battle. +type BattlePlayer struct { + Name string + Strategy Strategy +} + +// BattleConfig holds the configuration for a battle. +type BattleConfig struct { + Players []BattlePlayer + Seed int64 + OnTurnDone func(result AITurnResult) +} + +// BattleResult holds the final results of a battle. +type BattleResult struct { + Players []BattlePlayerResult +} + +// BattlePlayerResult holds the final result for a single player. +type BattlePlayerResult struct { + Name string + Score int +} + +// RunBattle executes a full AI-vs-AI game and returns the final state. +func RunBattle(cfg BattleConfig) (*GameState, error) { + if len(cfg.Players) < 2 { + return nil, fmt.Errorf("battle requires at least 2 players, got %d", len(cfg.Players)) + } + + var src rand.Source + if cfg.Seed != 0 { + src = rand.NewSource(cfg.Seed) + } else { + src = rand.NewSource(time.Now().UnixNano()) + } + + names := make([]string, len(cfg.Players)) + for i, p := range cfg.Players { + names[i] = p.Name + } + + game := NewGame(names, src) + + ais := make([]*AIPlayer, len(cfg.Players)) + for i, p := range cfg.Players { + pid := fmt.Sprintf("player-%d", i) + ais[i] = NewAIPlayerWithStrategy(game, pid, p.Strategy) + } + + for game.Phase != PhaseFinished { + current := game.Current + result, err := ais[current].PlayTurn() + if err != nil { + return nil, fmt.Errorf("player %s turn failed: %w", cfg.Players[current].Name, err) + } + if cfg.OnTurnDone != nil { + cfg.OnTurnDone(result) + } + } + + state := game.GetState() + return &state, nil +} diff --git a/engine/battle_test.go b/engine/battle_test.go new file mode 100644 index 0000000..80fc1fe --- /dev/null +++ b/engine/battle_test.go @@ -0,0 +1,71 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunBattle_TwoPlayers(t *testing.T) { + turnCount := 0 + state, err := RunBattle(BattleConfig{ + Players: []BattlePlayer{ + {Name: "Greedy", Strategy: &GreedyStrategy{}}, + {Name: "Statistical", Strategy: &StatisticalStrategy{}}, + }, + Seed: 42, + OnTurnDone: func(result AITurnResult) { + turnCount++ + }, + }) + require.NoError(t, err) + require.NotNil(t, state) + assert.Equal(t, PhaseFinished, state.Phase) + assert.Equal(t, 26, turnCount, "13 rounds × 2 players = 26 turns") +} + +func TestRunBattle_RequiresTwoPlayers(t *testing.T) { + _, err := RunBattle(BattleConfig{ + Players: []BattlePlayer{ + {Name: "Solo", Strategy: &GreedyStrategy{}}, + }, + }) + assert.Error(t, err) +} + +func TestRunBattle_ThreePlayers(t *testing.T) { + turnCount := 0 + state, err := RunBattle(BattleConfig{ + Players: []BattlePlayer{ + {Name: "A", Strategy: &GreedyStrategy{}}, + {Name: "B", Strategy: &StatisticalStrategy{}}, + {Name: "C", Strategy: &GreedyStrategy{}}, + }, + Seed: 123, + OnTurnDone: func(result AITurnResult) { + turnCount++ + }, + }) + require.NoError(t, err) + assert.Equal(t, PhaseFinished, state.Phase) + assert.Equal(t, 39, turnCount, "13 rounds × 3 players = 39 turns") +} + +func TestRunBattle_AllCategoriesFilled(t *testing.T) { + state, err := RunBattle(BattleConfig{ + Players: []BattlePlayer{ + {Name: "A", Strategy: &GreedyStrategy{}}, + {Name: "B", Strategy: &StatisticalStrategy{}}, + }, + Seed: 42, + }) + require.NoError(t, err) + + for _, p := range state.Players { + for _, cat := range AllCategories { + assert.True(t, p.Scorecard.IsFilled(cat), + "player %s should have %s filled", p.Name, cat) + } + } +} diff --git a/engine/client.go b/engine/client.go index ba07ad7..ddb20c2 100644 --- a/engine/client.go +++ b/engine/client.go @@ -8,10 +8,12 @@ type GameClient interface { } type AITurnResult struct { - PlayerName string - Dice [5]int - Category Category - Score int + PlayerName string + Dice [5]int + Category Category + Score int + StrategyName string + HoldHistory []HoldStep } type LocalClient struct { diff --git a/engine/expected_value.go b/engine/expected_value.go new file mode 100644 index 0000000..d91dbbb --- /dev/null +++ b/engine/expected_value.go @@ -0,0 +1,105 @@ +package engine + +// allHoldCombinations contains all 32 possible hold combinations (subsets of {0,1,2,3,4}). +// Computed once at package init time. +var allHoldCombinations = func() [][]int { + combos := make([][]int, 0, 32) + for mask := 0; mask < 32; mask++ { + var indices []int + for bit := 0; bit < 5; bit++ { + if mask&(1< 0 && remaining <= upperRemaining*5 { + // Close to bonus — small boost + base += float64(UpperBonusValue) * 0.1 + } + } + + return base +} + +func bestScoreForDice(dice [5]int, available []Category) int { + best := 0 + for _, c := range available { + s := CalcScore(c, dice) + if s > best { + best = s + } + } + return best +} + +func pow6(n int) int { + result := 1 + for i := 0; i < n; i++ { + result *= 6 + } + return result +} diff --git a/engine/strategy.go b/engine/strategy.go new file mode 100644 index 0000000..7daba71 --- /dev/null +++ b/engine/strategy.go @@ -0,0 +1,14 @@ +package engine + +// TurnAction represents a decision made by a Strategy during a turn. +type TurnAction struct { + Type string // "hold" or "score" + Indices []int // hold: dice indices to keep + Category Category // score: category to score in +} + +// Strategy defines the interface for AI decision-making. +type Strategy interface { + Name() string + DecideAction(dice [5]int, rollCount int, scorecard Scorecard, available []Category) TurnAction +} diff --git a/engine/strategy_greedy.go b/engine/strategy_greedy.go new file mode 100644 index 0000000..8361be8 --- /dev/null +++ b/engine/strategy_greedy.go @@ -0,0 +1,11 @@ +package engine + +// GreedyStrategy always scores immediately with the highest-scoring available category. +// It never uses Hold to reroll. +type GreedyStrategy struct{} + +func (s *GreedyStrategy) Name() string { return "greedy" } + +func (s *GreedyStrategy) DecideAction(dice [5]int, rollCount int, scorecard Scorecard, available []Category) TurnAction { + return TurnAction{Type: "score", Category: bestCategoryForDice(dice, available)} +} diff --git a/engine/strategy_statistical.go b/engine/strategy_statistical.go new file mode 100644 index 0000000..acb62a1 --- /dev/null +++ b/engine/strategy_statistical.go @@ -0,0 +1,59 @@ +package engine + +// StatisticalStrategy uses expected value calculation to decide whether to hold or score. +// On the 3rd roll it always scores the best category. +// Otherwise it compares immediate scoring vs expected value of each hold combination. +type StatisticalStrategy struct{} + +func (s *StatisticalStrategy) Name() string { return "statistical" } + +func (s *StatisticalStrategy) DecideAction(dice [5]int, rollCount int, scorecard Scorecard, available []Category) TurnAction { + // 3rd roll: must score + if rollCount >= MaxRolls { + return TurnAction{Type: "score", Category: bestCategoryForDice(dice, available)} + } + + // Immediate best score + immediateBest := bestCategoryForDice(dice, available) + immediateScore := float64(CalcScore(immediateBest, dice)) + + // Find the best hold combination by expected value + bestEV := immediateScore + var bestHold []int + + for _, hold := range holdCombinations() { + if len(hold) == 5 { + // Holding all dice is the same as immediate scoring + continue + } + ev := expectedValueWithBonus(dice, hold, available, scorecard) + if ev > bestEV { + bestEV = ev + bestHold = hold + } + } + + if bestHold != nil { + return TurnAction{Type: "hold", Indices: bestHold} + } + + return TurnAction{Type: "score", Category: immediateBest} +} + +// bestCategoryForDice returns the highest-scoring available category. +// Panics if available is empty; callers must ensure at least one category. +func bestCategoryForDice(dice [5]int, available []Category) Category { + if len(available) == 0 { + return Chance + } + bestCat := available[0] + bestScore := CalcScore(bestCat, dice) + for _, c := range available[1:] { + s := CalcScore(c, dice) + if s > bestScore { + bestScore = s + bestCat = c + } + } + return bestCat +} diff --git a/engine/strategy_test.go b/engine/strategy_test.go new file mode 100644 index 0000000..f56b961 --- /dev/null +++ b/engine/strategy_test.go @@ -0,0 +1,110 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGreedyStrategy_AlwaysScores(t *testing.T) { + s := &GreedyStrategy{} + sc := NewScorecard() + avail := sc.AvailableCategories() + + action := s.DecideAction([5]int{6, 6, 6, 6, 6}, 1, sc, avail) + assert.Equal(t, "score", action.Type) + assert.Equal(t, Yahtzee, action.Category) +} + +func TestGreedyStrategy_Name(t *testing.T) { + s := &GreedyStrategy{} + assert.Equal(t, "greedy", s.Name()) +} + +func TestStatisticalStrategy_Name(t *testing.T) { + s := &StatisticalStrategy{} + assert.Equal(t, "statistical", s.Name()) +} + +func TestStatisticalStrategy_ScoresOnThirdRoll(t *testing.T) { + s := &StatisticalStrategy{} + sc := NewScorecard() + avail := sc.AvailableCategories() + + action := s.DecideAction([5]int{1, 2, 3, 4, 5}, 3, sc, avail) + assert.Equal(t, "score", action.Type) + assert.Equal(t, LargeStraight, action.Category) +} + +func TestStatisticalStrategy_HoldsGoodDice(t *testing.T) { + s := &StatisticalStrategy{} + sc := NewScorecard() + avail := sc.AvailableCategories() + + // Four 6s — should hold them and try for Yahtzee + action := s.DecideAction([5]int{6, 6, 6, 6, 1}, 1, sc, avail) + if action.Type == "hold" { + // Should hold at least the four 6s + holdSet := make(map[int]bool) + for _, i := range action.Indices { + holdSet[i] = true + } + assert.True(t, holdSet[0] && holdSet[1] && holdSet[2] && holdSet[3], + "should hold the four 6s") + } + // Also acceptable to score immediately with four_of_a_kind (25 pts) +} + +func TestStatisticalStrategy_ScoresYahtzeeImmediately(t *testing.T) { + s := &StatisticalStrategy{} + sc := NewScorecard() + avail := sc.AvailableCategories() + + // Five 6s — should score Yahtzee immediately + action := s.DecideAction([5]int{6, 6, 6, 6, 6}, 1, sc, avail) + assert.Equal(t, "score", action.Type) + assert.Equal(t, Yahtzee, action.Category) +} + +func TestExpectedValue_AllHeld(t *testing.T) { + sc := NewScorecard() + avail := sc.AvailableCategories() + dice := [5]int{1, 2, 3, 4, 5} + + ev := expectedValue(dice, []int{0, 1, 2, 3, 4}, avail, sc) + best := float64(bestScoreForDice(dice, avail)) + assert.Equal(t, best, ev, "holding all dice should give same as best immediate score") +} + +func TestExpectedValue_Positive(t *testing.T) { + sc := NewScorecard() + avail := sc.AvailableCategories() + dice := [5]int{3, 3, 3, 2, 1} + + ev := expectedValue(dice, []int{0, 1, 2}, avail, sc) + assert.Greater(t, ev, 0.0, "expected value should be positive") +} + +func TestHoldCombinations_Count(t *testing.T) { + combos := holdCombinations() + assert.Len(t, combos, 32, "should have 2^5 = 32 combinations") +} + +func TestBestScoreForDice(t *testing.T) { + avail := []Category{Ones, Yahtzee, Chance} + best := bestScoreForDice([5]int{6, 6, 6, 6, 6}, avail) + assert.Equal(t, 50, best, "Yahtzee should be the best for five 6s") +} + +func TestStatisticalStrategy_FullGame(t *testing.T) { + // Run a full game with statistical strategy to ensure no panics + s := &StatisticalStrategy{} + require.NotNil(t, s) + + sc := NewScorecard() + dice := [5]int{3, 3, 4, 5, 6} + avail := sc.AvailableCategories() + action := s.DecideAction(dice, 1, sc, avail) + require.Contains(t, []string{"hold", "score"}, action.Type) +} diff --git a/go.mod b/go.mod index f67e36a..928ff02 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.2 require ( charm.land/bubbletea/v2 v2.0.2 + github.com/anthropics/anthropic-sdk-go v1.27.1 github.com/aws/aws-lambda-go v1.53.0 github.com/aws/aws-sdk-go-v2 v1.41.4 github.com/aws/aws-sdk-go-v2/config v1.32.12 @@ -52,6 +53,10 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect diff --git a/go.sum b/go.sum index 5bc7fcc..b75931b 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +github.com/anthropics/anthropic-sdk-go v1.27.1 h1:7DgMZ2Ng3C2mPzJGHA30NXQTZolcF07mHd0tGaLwfzk= +github.com/anthropics/anthropic-sdk-go v1.27.1/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= github.com/aws/aws-lambda-go v1.53.0 h1:uAMv6W/vCP/L494BAUSxe+8KVBIPK+SGPyapFt3FuMk= github.com/aws/aws-lambda-go v1.53.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= @@ -65,6 +67,8 @@ github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= @@ -107,6 +111,16 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -122,5 +136,7 @@ golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/personas/aggressive.md b/personas/aggressive.md new file mode 100644 index 0000000..ab46451 --- /dev/null +++ b/personas/aggressive.md @@ -0,0 +1,13 @@ +# アタッカー +## 性格 +常に大きな役を狙う攻撃的なプレイヤー。リスクを恐れず、ヤッツィーやラージストレートに挑む。小さな得点で妥協することを嫌う。 + +## 戦略 +- ヤッツィー(50点)を最優先で狙う。同じ目が2つ以上あればキープして振り直す +- ラージストレート(40点)を積極的に狙う。4連続が見えたら迷わずキープ +- フォーオブアカインドも高得点が期待できるので積極的に狙う +- 上段ボーナスは副次的。大きな役が取れないときだけ上段に記入 +- Chanceは最後の手段。できるだけ使わない + +## 口癖 +「でかい役を取りに行くぜ!」「ヤッツィー来い!」 diff --git a/personas/defensive.md b/personas/defensive.md new file mode 100644 index 0000000..785d8c7 --- /dev/null +++ b/personas/defensive.md @@ -0,0 +1,14 @@ +# ディフェンダー +## 性格 +堅実で慎重なプレイヤー。確実に得点を積み重ねることを信条とする。無駄なリスクを取らない。 + +## 戦略 +- 上段ボーナス(63点)の達成を最優先する +- 各上段カテゴリで目標点(ones=3, twos=6, threes=9, fours=12, fives=15, sixes=18)を意識する +- 確実に得点できるカテゴリがあればすぐにスコアする +- ヤッツィーは狙わない。自然に揃った場合のみスコアする +- フルハウス(25点)とスモールストレート(30点)は確実に取る +- 0点で埋めるカテゴリを最小限にする + +## 口癖 +「確実にいこう」「コツコツ積み重ねるのが大事」 diff --git a/personas/gambler.md b/personas/gambler.md new file mode 100644 index 0000000..8b21786 --- /dev/null +++ b/personas/gambler.md @@ -0,0 +1,13 @@ +# ギャンブラー +## 性格 +運を信じるギャンブラー。直感に従い、確率を無視した大胆な選択をする。退屈な安全策よりもスリルを求める。 + +## 戦略 +- 常に3回とも振り直す。1回目で良い目が出ても振り直しの誘惑に勝てない +- ヤッツィーを毎ターン狙う。同じ目が1つでもあればそれをキープ +- ストレートが見えても、同じ目の方が多ければヤッツィー狙いに切り替える +- 上段ボーナスは気にしない +- 「今日はツイてる」と思ったらさらにリスクを取る + +## 口癖 +「運は俺に味方する!」「もう一回振らせてくれ!」