Skip to content

Commit 1828d81

Browse files
committed
feat(bot): add yatz bot command for Claude-powered AI player
Adds a bot package that connects to a headless game server via RemoteClient and plays Yahtzee by calling `claude -p` for each decision. Includes prompt building, JSON response parsing, retry logic, and a cobra subcommand with --addr, --name, --strategy flags.
1 parent dcdacac commit 1828d81

7 files changed

Lines changed: 455 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
yatzcli
2-
yatz
2+
/yatz

bot/bot.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package bot
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
8+
"github.com/edge2992/yatzcli/engine"
9+
"github.com/edge2992/yatzcli/p2p"
10+
)
11+
12+
const maxRetries = 3
13+
14+
type Bot struct {
15+
client *p2p.RemoteClient
16+
name string
17+
strategy string
18+
}
19+
20+
func New(addr, name, strategy string) (*Bot, error) {
21+
rc, err := p2p.NewRemoteClient(addr, name)
22+
if err != nil {
23+
return nil, fmt.Errorf("connect to server: %w", err)
24+
}
25+
return &Bot{
26+
client: rc,
27+
name: name,
28+
strategy: strategy,
29+
}, nil
30+
}
31+
32+
func (b *Bot) Run() error {
33+
defer b.client.Close()
34+
35+
state, err := b.client.GetState()
36+
if err != nil {
37+
return fmt.Errorf("get initial state: %w", err)
38+
}
39+
40+
// If it's not our turn at the start, wait
41+
if state.CurrentPlayer != b.client.PlayerID() {
42+
fmt.Fprintf(os.Stderr, "Waiting for our turn...\n")
43+
s, isGameOver, err := b.client.WaitForTurn()
44+
if err != nil {
45+
return fmt.Errorf("wait for turn: %w", err)
46+
}
47+
if isGameOver {
48+
b.printFinalScore(s)
49+
return nil
50+
}
51+
state = s
52+
}
53+
54+
for state.Phase != engine.PhaseFinished {
55+
state, err = b.playTurn(state)
56+
if err != nil {
57+
return err
58+
}
59+
}
60+
61+
b.printFinalScore(state)
62+
return nil
63+
}
64+
65+
func (b *Bot) playTurn(state *engine.GameState) (*engine.GameState, error) {
66+
round := state.Round
67+
var lastErr string
68+
69+
for {
70+
resp, err := b.callClaudeWithRetry(state, lastErr)
71+
if err != nil {
72+
return nil, fmt.Errorf("claude call failed: %w", err)
73+
}
74+
75+
switch resp.Action {
76+
case "roll":
77+
newState, err := b.client.Roll()
78+
if err != nil {
79+
lastErr = err.Error()
80+
fmt.Fprintf(os.Stderr, "[Round %d] Roll error: %s\n", round, lastErr)
81+
continue
82+
}
83+
fmt.Fprintf(os.Stdout, "[Round %d] Roll: %v\n", round, newState.Dice)
84+
state = newState
85+
lastErr = ""
86+
87+
case "hold":
88+
newState, err := b.client.Hold(resp.Indices)
89+
if err != nil {
90+
lastErr = err.Error()
91+
fmt.Fprintf(os.Stderr, "[Round %d] Hold error: %s\n", round, lastErr)
92+
continue
93+
}
94+
fmt.Fprintf(os.Stdout, "[Round %d] Hold %v → %v\n", round, resp.Indices, newState.Dice)
95+
state = newState
96+
lastErr = ""
97+
98+
case "score":
99+
cat := engine.Category(resp.Category)
100+
score := engine.CalcScore(cat, state.Dice)
101+
newState, err := b.client.Score(cat)
102+
if err != nil {
103+
lastErr = err.Error()
104+
fmt.Fprintf(os.Stderr, "[Round %d] Score error: %s\n", round, lastErr)
105+
continue
106+
}
107+
fmt.Fprintf(os.Stdout, "[Round %d] Score: %s (%d pts) — %q\n", round, cat, score, resp.Comment)
108+
109+
// Send chat
110+
if resp.Comment != "" {
111+
_ = b.client.SendChat(b.client.PlayerID(), b.name, resp.Comment)
112+
}
113+
114+
return newState, nil
115+
116+
default:
117+
lastErr = fmt.Sprintf("unknown action %q", resp.Action)
118+
fmt.Fprintf(os.Stderr, "[Round %d] Unknown action: %s\n", round, resp.Action)
119+
}
120+
}
121+
}
122+
123+
func (b *Bot) callClaudeWithRetry(state *engine.GameState, lastErr string) (*ClaudeResponse, error) {
124+
systemPrompt := BuildSystemPrompt(b.strategy)
125+
schemaJSON := ResponseSchemaJSON()
126+
127+
for attempt := 0; attempt < maxRetries; attempt++ {
128+
var userPrompt string
129+
if lastErr != "" && attempt == 0 {
130+
userPrompt = BuildRetryPrompt(state, b.client.PlayerID(), lastErr)
131+
} else {
132+
userPrompt = BuildUserPrompt(state, b.client.PlayerID())
133+
}
134+
135+
output, err := callClaude(systemPrompt, schemaJSON, userPrompt)
136+
if err != nil {
137+
fmt.Fprintf(os.Stderr, "claude command failed (attempt %d/%d): %v\n", attempt+1, maxRetries, err)
138+
continue
139+
}
140+
141+
resp, err := ParseResponse(output)
142+
if err != nil {
143+
fmt.Fprintf(os.Stderr, "parse response failed (attempt %d/%d): %v\n", attempt+1, maxRetries, err)
144+
continue
145+
}
146+
147+
return resp, nil
148+
}
149+
return nil, fmt.Errorf("claude failed after %d retries", maxRetries)
150+
}
151+
152+
func callClaude(systemPrompt, schemaJSON, userPrompt string) ([]byte, error) {
153+
cmd := exec.Command("claude", "-p",
154+
"--output-format", "json",
155+
"--json-schema", schemaJSON,
156+
"--system-prompt", systemPrompt,
157+
userPrompt,
158+
)
159+
cmd.Stderr = os.Stderr
160+
output, err := cmd.Output()
161+
if err != nil {
162+
return nil, fmt.Errorf("execute claude: %w", err)
163+
}
164+
return output, nil
165+
}
166+
167+
func (b *Bot) printFinalScore(state *engine.GameState) {
168+
fmt.Fprintf(os.Stdout, "\n=== Game Over ===\n")
169+
for _, p := range state.Players {
170+
fmt.Fprintf(os.Stdout, "%s: %d pts\n", p.Name, p.Scorecard.Total())
171+
}
172+
}

bot/prompt.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package bot
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/edge2992/yatzcli/engine"
9+
)
10+
11+
type ClaudeResponse struct {
12+
Action string `json:"action"`
13+
Indices []int `json:"indices,omitempty"`
14+
Category string `json:"category,omitempty"`
15+
Comment string `json:"comment"`
16+
}
17+
18+
var responseSchema = map[string]interface{}{
19+
"type": "object",
20+
"properties": map[string]interface{}{
21+
"action": map[string]interface{}{"type": "string"},
22+
"indices": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "integer"}},
23+
"category": map[string]interface{}{"type": "string"},
24+
"comment": map[string]interface{}{"type": "string"},
25+
},
26+
"required": []string{"action", "comment"},
27+
}
28+
29+
func ResponseSchemaJSON() string {
30+
b, _ := json.Marshal(responseSchema)
31+
return string(b)
32+
}
33+
34+
func BuildSystemPrompt(strategy string) string {
35+
return fmt.Sprintf(`あなたはヤッツィーの対戦プレイヤーです。ゲーム状態を分析し、次のアクションを選んでください。
36+
37+
戦略:
38+
%s
39+
40+
ルール:
41+
- 1ターンに最大3回ロールできる(初回はroll、2-3回目はhold)
42+
- holdではキープするダイスのインデックス(0-4)を指定し、残りをリロール
43+
- 最終的にscoreでカテゴリを選んでスコアする
44+
- 各カテゴリは1回しか使えない
45+
46+
アクション:
47+
- "roll": ターンの最初のロール(rollCountが0の時のみ)
48+
- "hold": キープするダイスのインデックスをindicesで指定してリロール(rollCountが1以上の時)
49+
- "score": categoryでカテゴリを指定してスコアする
50+
51+
レスポンスはJSON形式で返すこと。commentフィールドには日本語で短い実況を入れること。`, strategy)
52+
}
53+
54+
func BuildUserPrompt(state *engine.GameState, playerID string) string {
55+
var sb strings.Builder
56+
57+
sb.WriteString(fmt.Sprintf("ラウンド: %d/%d\n", state.Round, engine.MaxRounds))
58+
sb.WriteString(fmt.Sprintf("ダイス: %v\n", state.Dice))
59+
sb.WriteString(fmt.Sprintf("ロール: %d/%d\n", state.RollCount, engine.MaxRolls))
60+
61+
// Available categories with potential scores
62+
sb.WriteString("利用可能カテゴリと得点:\n")
63+
for _, cat := range state.AvailableCategories {
64+
score := engine.CalcScore(cat, state.Dice)
65+
sb.WriteString(fmt.Sprintf(" %s: %d点\n", cat, score))
66+
}
67+
68+
// Both players' scorecards
69+
for _, p := range state.Players {
70+
label := "あなた"
71+
if p.ID != playerID {
72+
label = "相手"
73+
}
74+
sb.WriteString(fmt.Sprintf("\n%sのスコアカード (%s):\n", label, p.Name))
75+
for _, cat := range engine.AllCategories {
76+
if p.Scorecard.IsFilled(cat) {
77+
sb.WriteString(fmt.Sprintf(" %s: %d点\n", cat, p.Scorecard.GetScore(cat)))
78+
}
79+
}
80+
sb.WriteString(fmt.Sprintf(" 合計: %d点\n", p.Scorecard.Total()))
81+
}
82+
83+
sb.WriteString("\n次のアクションは?")
84+
return sb.String()
85+
}
86+
87+
func BuildRetryPrompt(state *engine.GameState, playerID string, errMsg string) string {
88+
return fmt.Sprintf("%s\n\n前回のアクションはエラーになりました: %s\n別のアクションを選んでください。", BuildUserPrompt(state, playerID), errMsg)
89+
}
90+
91+
func ParseResponse(data []byte) (*ClaudeResponse, error) {
92+
var resp ClaudeResponse
93+
if err := json.Unmarshal(data, &resp); err != nil {
94+
return nil, fmt.Errorf("parse claude response: %w", err)
95+
}
96+
if resp.Action == "" {
97+
return nil, fmt.Errorf("empty action in response")
98+
}
99+
return &resp, nil
100+
}

0 commit comments

Comments
 (0)