From 91eeb38e24357279a7617f45ef8197c2220502e7 Mon Sep 17 00:00:00 2001 From: edge2992 Date: Mon, 23 Mar 2026 00:58:38 +0900 Subject: [PATCH 1/4] feat(engine): add AI vs AI battle with multiple strategies Implement Strategy interface and three AI strategies (Greedy, Statistical, LLM) with a battle orchestration system. The StatisticalStrategy evaluates all 32 hold combinations using expected value calculation, significantly outperforming Greedy. - Add Strategy interface and TurnAction type (engine/strategy.go) - Extract GreedyStrategy from existing bestCategory logic - Add StatisticalStrategy with expected value calculation - Add RunBattle orchestrator for automated AI-vs-AI games - Add `yatz battle` command with quiet and TUI spectator modes - Add LLMStrategy using anthropic-sdk-go for Claude API calls - Add persona system for LLM character customization - Add sample personas (aggressive, defensive, gambler) - Refactor AIPlayer to support Strategy and hold loop - Extend AITurnResult with StrategyName and HoldHistory --- bot/persona.go | 80 ++++++++++ bot/strategy_llm.go | 222 +++++++++++++++++++++++++++ cli/spectator.go | 271 +++++++++++++++++++++++++++++++++ cmd/yatz/battle.go | 198 ++++++++++++++++++++++++ cmd/yatz/main.go | 2 + engine/ai.go | 68 ++++++--- engine/battle.go | 72 +++++++++ engine/battle_test.go | 71 +++++++++ engine/client.go | 10 +- engine/expected_value.go | 105 +++++++++++++ engine/strategy.go | 14 ++ engine/strategy_greedy.go | 20 +++ engine/strategy_statistical.go | 54 +++++++ engine/strategy_test.go | 110 +++++++++++++ go.mod | 5 + go.sum | 16 ++ personas/aggressive.md | 13 ++ personas/defensive.md | 14 ++ personas/gambler.md | 13 ++ 19 files changed, 1330 insertions(+), 28 deletions(-) create mode 100644 bot/persona.go create mode 100644 bot/strategy_llm.go create mode 100644 cli/spectator.go create mode 100644 cmd/yatz/battle.go create mode 100644 engine/battle.go create mode 100644 engine/battle_test.go create mode 100644 engine/expected_value.go create mode 100644 engine/strategy.go create mode 100644 engine/strategy_greedy.go create mode 100644 engine/strategy_statistical.go create mode 100644 engine/strategy_test.go create mode 100644 personas/aggressive.md create mode 100644 personas/defensive.md create mode 100644 personas/gambler.md 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..bb37471 --- /dev/null +++ b/bot/strategy_llm.go @@ -0,0 +1,222 @@ +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": + 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/engine/ai.go b/engine/ai.go index 16d29da..6bf1633 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,40 @@ 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) + + 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 + } -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 + // Score + dice := ai.game.Dice + category := action.Category + 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..3252dd9 --- /dev/null +++ b/engine/strategy_greedy.go @@ -0,0 +1,20 @@ +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 { + bestCat := available[0] + bestScore := CalcScore(bestCat, dice) + for _, c := range available[1:] { + s := CalcScore(c, dice) + if s > bestScore { + bestScore = s + bestCat = c + } + } + return TurnAction{Type: "score", Category: bestCat} +} diff --git a/engine/strategy_statistical.go b/engine/strategy_statistical.go new file mode 100644 index 0000000..7f5a76f --- /dev/null +++ b/engine/strategy_statistical.go @@ -0,0 +1,54 @@ +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} +} + +func bestCategoryForDice(dice [5]int, available []Category) Category { + 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つでもあればそれをキープ +- ストレートが見えても、同じ目の方が多ければヤッツィー狙いに切り替える +- 上段ボーナスは気にしない +- 「今日はツイてる」と思ったらさらにリスクを取る + +## 口癖 +「運は俺に味方する!」「もう一回振らせてくれ!」 From 394f5eb8063c9f417906c4e9b70bc74ab7322816 Mon Sep 17 00:00:00 2001 From: edge2992 Date: Mon, 23 Mar 2026 01:06:12 +0900 Subject: [PATCH 2/4] fix(engine): add fallback for invalid strategy actions at max rolls When a Strategy returns "hold" after all rolls are used, PlayTurn now falls back to best available category instead of passing an empty category to Score(). Also deduplicates GreedyStrategy logic by reusing bestCategoryForDice. --- engine/ai.go | 6 +++++- engine/strategy_greedy.go | 11 +---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/engine/ai.go b/engine/ai.go index 6bf1633..d379c6b 100644 --- a/engine/ai.go +++ b/engine/ai.go @@ -49,9 +49,13 @@ func (ai *AIPlayer) PlayTurn() (AITurnResult, error) { continue } - // Score + // 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 diff --git a/engine/strategy_greedy.go b/engine/strategy_greedy.go index 3252dd9..8361be8 100644 --- a/engine/strategy_greedy.go +++ b/engine/strategy_greedy.go @@ -7,14 +7,5 @@ type GreedyStrategy struct{} func (s *GreedyStrategy) Name() string { return "greedy" } func (s *GreedyStrategy) DecideAction(dice [5]int, rollCount int, scorecard Scorecard, available []Category) TurnAction { - bestCat := available[0] - bestScore := CalcScore(bestCat, dice) - for _, c := range available[1:] { - s := CalcScore(c, dice) - if s > bestScore { - bestScore = s - bestCat = c - } - } - return TurnAction{Type: "score", Category: bestCat} + return TurnAction{Type: "score", Category: bestCategoryForDice(dice, available)} } From 939d61eca04ef3ddcc36bcb6097fe5a99e9a881b Mon Sep 17 00:00:00 2001 From: edge2992 Date: Mon, 23 Mar 2026 01:09:46 +0900 Subject: [PATCH 3/4] fix(engine): guard bestCategoryForDice against empty slice and validate LLM hold indices Add empty-slice fallback in bestCategoryForDice to prevent panic. Reject LLM hold actions with empty indices to avoid unintended full rerolls. --- bot/strategy_llm.go | 3 +++ engine/strategy_statistical.go | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/bot/strategy_llm.go b/bot/strategy_llm.go index bb37471..e272550 100644 --- a/bot/strategy_llm.go +++ b/bot/strategy_llm.go @@ -189,6 +189,9 @@ func (s *LLMStrategy) parseResponse(text string, available []engine.Category) (e 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) diff --git a/engine/strategy_statistical.go b/engine/strategy_statistical.go index 7f5a76f..acb62a1 100644 --- a/engine/strategy_statistical.go +++ b/engine/strategy_statistical.go @@ -40,7 +40,12 @@ func (s *StatisticalStrategy) DecideAction(dice [5]int, rollCount int, scorecard 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:] { From f49f2001ffad9668234e4f1a3fba52174c543fe8 Mon Sep 17 00:00:00 2001 From: edge2992 Date: Mon, 23 Mar 2026 01:23:35 +0900 Subject: [PATCH 4/4] docs(ai-battle): add design spec, implementation plan, and update project docs Add comprehensive documentation for the AI Battle feature: - Design spec covering Strategy pattern, battle engine, TUI spectator - Implementation plan with completed phases and future extensions - Update CLAUDE.md with bot/, personas/, Strategy pattern, battle commands - Update README.md with AI Battle section, commands, and persona guide --- CLAUDE.md | 18 +- README.md | 42 +++ .../superpowers/plans/2026-03-23-ai-battle.md | 184 ++++++++++++ .../specs/2026-03-23-ai-battle-design.md | 265 ++++++++++++++++++ 4 files changed, 505 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-23-ai-battle.md create mode 100644 docs/superpowers/specs/2026-03-23-ai-battle-design.md 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/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(カスタムペルソナ) |