バックログ
成果物
アプリ(フロント):https://blackjack-web-oec4.onrender.com/
バックエンドサーバー:https://blackjack-api-sfpa.onrender.com/
(無料サーバーのため読み込みには時間がかかります)
この記事時点でのgithub:
https://github.com/morinonusi421/blackjack/tree/d9969a5b0e0566716d3c571c4e1a777955d0fa65
前回の記事までで、
- ブラックジャックが遊べるサイトの作成
- 最適行動の計算ロジックの作成
まで終わりました。
今回の記事では
- 全体的にサイトの見た目をいい感じにする
- 最適行動をサイト上で見られるようにする
- Validationのリファクタ
をやっていきます。
最初の二つは技術的には面白くないですが、ユーザー影響が大きい変更です。
最後のは内部的な変更ですが、技術的には学びがある話です。
全体的にサイトの見た目をいい感じにする
あまりにもサイトの見た目がダサいので、いい感じにしていきます。
本プロジェクトはGoの勉強がmainなので、(今までもそうでしたが)フロントは全体的にAIに任せます。
- ネットのフリー素材からトランプの画像を入手し、リポジトリに適当においておく
- 「これを利用していい感じに見た目整えて!」と今流行りのClaudeに依頼
- 気に食わなかったら「ここをもっと目立たせて」、「ここをもっと大きくして」と指示を繰り返す
これだけでだいぶ綺麗な見た目のUIができました。
さすがAI。
最適行動をサイト上で見られるようにする
最適行動のAPIを作成
前回では、api/strategy/calculator.goに最適行動の計算ロジックを作りましたが、まだAPI化していません。
いつものように、サービスとコントローラーを作る必要がありますね。
サービス
unc (s *strategyService) Advise(g game.Game) (strategy.StrategyExpectedPayouts, error) {
// 前提: ディーラーのアップカードが1枚以上存在すること
if len(g.DealerHand.Cards) < 1 {
return strategy.StrategyExpectedPayouts{}, errors.New("invalid state: dealer must have at least 1 card")
}
if len(g.PlayerHand.Cards) < 2 {
return strategy.StrategyExpectedPayouts{}, errors.New("invalid state: player must have at least 2 cards")
}
// プレイヤー手札
playerSum := 0
playerHasAce := false
for _, c := range g.PlayerHand.Cards {
playerSum += game.RankToScore(c.Rank)
if c.Rank == "A" {
playerHasAce = true
}
}
// ディーラー手札(アップカードのみ使用)
dealerUpcard := g.DealerHand.Cards[0]
dealerSum := game.RankToScore(dealerUpcard.Rank)
dealerHasAce := dealerUpcard.Rank == "A"
hasHit := len(g.PlayerHand.Cards) > 2
st := strategy.StrategyState{
Player: strategy.StrategyHand{Sum: playerSum, HasAce: playerHasAce},
Dealer: strategy.StrategyHand{Sum: dealerSum, HasAce: dealerHasAce},
HasHit: hasHit,
}
payouts := s.calc.CalculateAllExpectedPayouts(st)
// 実際の払戻額を返すために、サービス層でスケーリング
betF := float64(g.Bet)
payouts.HitPayout *= betF
payouts.StandPayout *= betF
payouts.SurrenderPayout *= betF
payouts.BestPayout *= betF
return payouts, nil
}
やっていることをまとめるとこうなります。
- game.Game構造体を受け取る
- 有効なgame状態かバリデーション
- game.Gameをstrategy.StrategyStateに変換
- game.Gameはゲーム状態のすべてを表すリッチな構造体
- StrategyStateは最適行動計算に必要な部分だけを抜き出した小さい構造体
- サービスのcalculator(前回記事で作成)を呼ぶ出して、最適行動を計算
- 帰ってきた結果をスケーリング
- 100円掛けて、期待値110円戻ってくるような状況では、calculatorは
1.1のように倍率を返す仕様 - API的には
110という最終的な期待値を返したいので、サービス層でスケーリング
- 100円掛けて、期待値110円戻ってくるような状況では、calculatorは
・・・記事にするにあたって、あらためてまとめて思いました。
これもう少しやってる仕事分割して関数化するべきですね。あとでやりましょう。
学び:数日経って見返すと改善点に気づくのは個人開発あるある
コントローラ
package handlers
import (
"encoding/json"
"net/http"
"blackjack/api/game"
"blackjack/api/services"
)
// StrategyRequest は現在のゲーム状態を入力として受け取る
type StrategyRequest game.Game
// StrategyResponse は各アクションの期待払い戻しを返す
type StrategyResponse struct {
HitPayout float64 `json:"hit_payout"`
StandPayout float64 `json:"stand_payout"`
SurrenderPayout float64 `json:"surrender_payout"`
}
// StrategyHandler は最適戦略の期待払い戻しを返すハンドラ
func StrategyHandler(advisor services.StrategyAdvisor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var req StrategyRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
g := game.Game(req)
payouts, err := advisor.Advise(g)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := StrategyResponse{
HitPayout: payouts.HitPayout,
StandPayout: payouts.StandPayout,
SurrenderPayout: payouts.SurrenderPayout,
}
json.NewEncoder(w).Encode(resp)
}
}
コントローラーはいつもの型どおりですね
最適行動を見るボタンを作成
APIを作れたので、フロントも作っていきます
最適行動を表示したい時に押すボタンコンポーネントをweb/src/components/ActionButtons.tsxに、
最適行動の結果を表示するコンポーネントをweb/src/components/StrategyAdvice.tsxに、
APIを叩くフックをweb/src/hooks/useGame.tsに作りました。
フロント的には、いつも通りAPIを叩いて表示するだけで目新しさがないので記事上は省略します。
詳細は上記のコミットリンクを参照してください。
では実際画面を見てみましょう!
おお!
ただしくAPIが働いています!
この状況ではサレンダーが最適なようですね〜〜〜
バリデーションのリファクタ
コードを眺めていて、負債になっている部分を見つけたのでリファクタリングをします。
それは不正な入力の検証、"バリデーション"の部分です。
今まで各アクションに独立してバリデーション処理を書いていました。
func (s *gameService) Stand(g *game.Game) error {
// 引数チェック
if g == nil {
return errors.New("game must not be nil")
}
// プレイヤーは 2 枚、ディーラーは 1 枚以上の手札が必要
if len(g.PlayerHand.Cards) < 2 {
return errors.New("invalid state: player must have at least 2 cards")
}
if len(g.DealerHand.Cards) != 1 {
return errors.New("invalid state: dealer must have exactly 1 card")
}
// 以下、処理の本体
func (s *gameService) Hit(g *game.Game) error {
// 引数チェック
if g == nil {
return errors.New("game must not be nil")
}
// ゲーム状態がプレイヤーターンであることを確認
if g.State != game.PlayerTurn {
return errors.New("invalid state: game is not in player turn")
}
// 既に結果が確定していないか確認
if g.Result != game.Pending {
return errors.New("invalid state: game already finished")
}
// 以下処理の本体
それぞれのアクションを実装する時に、その時の思いつきでバリデーションを書いてしまっていたため、抜け落ちだらけです。Standの中でも// 既に結果が確定していないか確認は確認するべきですし、Hitの中でも// プレイヤーは 2 枚、ディーラーは 1 枚以上の手札が必要を確認するべきです。
これらを書き忘れてしまったこと自体も問題ですが、より大きな根本原因はコードがDRYの原則を満たしていなかったことでしょう。
(DRYの原則:同じコードを繰り返してはいけない。どこかにまとめるべきというプログラミングの鉄則)
バリデーションの処理を一箇所にまとめて、どのアクションでもそれを呼び出すようにしましょう。
// ValidateCore は終了状態を含めた基本整合性のみを検証する。
func (g *Game) ValidateCore() error {
if len(g.PlayerHand.Cards) < 2 {
return errors.New("invalid state: player must have at least 2 cards")
}
if len(g.DealerHand.Cards) < 1 {
return errors.New("invalid state: dealer must have at least 1 card")
}
// スコアとカード配列の整合性
computedPlayer := CalculateScore(g.PlayerHand.Cards)
if g.PlayerHand.Score != computedPlayer {
return errors.New("invalid state: player score does not match cards")
}
computedDealer := CalculateScore(g.DealerHand.Cards)
if g.DealerHand.Score != computedDealer {
return errors.New("invalid state: dealer score does not match cards")
}
if g.State == PlayerTurn && g.Result != Pending {
return errors.New("invalid state: player turn but result is not pending")
}
if g.State == Finished && g.Result == Pending {
return errors.New("invalid state: finished but result is pending")
}
if g.Bet <= 0 {
return errors.New("invalid state: bet must be positive")
}
if g.Payout < 0 {
return errors.New("invalid state: payout must be non-negative")
}
return nil
}
// ValidateInProgress は進行中(PlayerTurn/Pending)の前提を検証する。
func (g *Game) ValidateInProgress() error {
if g.State != PlayerTurn {
return errors.New("invalid state: expected player turn in progress")
}
if g.Result != Pending {
return errors.New("invalid state: expected pending result in progress")
}
if len(g.DealerHand.Cards) != 1 {
return errors.New("invalid state: dealer must have exactly 1 card during player turn")
}
if g.PlayerHand.Score == 0 {
return errors.New("invalid state: player already busted during player turn")
}
return nil
}
サービス層ではこれらのValidationに加えて、g == nilなどその他の検証も行うための関数も作り、これを呼び出すことにします。
func (s *gameService) Stand(g *game.Game, config *game.GameConfig) error {
if err := validateActionPreconditions(g, config); err != nil {
return err
}
// 以下、処理の本体
}
func (s *gameService) Hit(g *game.Game, config *game.GameConfig) error {
if err := validateActionPreconditions(g, config); err != nil {
return err
}
// 以下、処理の本体
}
func validateActionPreconditions(g *game.Game, config *game.GameConfig) error {
if g == nil {
return errors.New("invalid argument: game is nil")
}
if err := g.ValidateCore(); err != nil {
return err
}
// 進行中にのみ必要な制約は Game 側の専用バリデーションで検証
if err := g.ValidateInProgress(); err != nil {
return err
}
return nil
}
バリデーションが一行にまとまり、だいぶスッキリました!
これでバリデーションの抜け漏れがなくなり、アプリの堅牢性が大きく向上しました。
今日のまとめ:バリデーションみたいに何度も使う処理はちゃんとDRYにしよう。



