0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Go + Next.jsでブラックジャックが遊べるサイト】day8: 最適行動をサイト上で表示+Validationのリファクタリング

Last updated at Posted at 2025-09-28

バックログ

成果物

アプリ(フロント):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。

Before
スクリーンショット 2025-09-18 16.56.05.png

After
image.png

最適行動をサイト上で見られるようにする

最適行動のAPIを作成

前回では、api/strategy/calculator.goに最適行動の計算ロジックを作りましたが、まだAPI化していません。
いつものように、サービスとコントローラーを作る必要がありますね。

サービス

api/services/strategy_service.go
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という最終的な期待値を返したいので、サービス層でスケーリング

・・・記事にするにあたって、あらためてまとめて思いました。
これもう少しやってる仕事分割して関数化するべきですね。あとでやりましょう。
学び:数日経って見返すと改善点に気づくのは個人開発あるある

コントローラ

api/handlers/strategy.go
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を叩いて表示するだけで目新しさがないので記事上は省略します。
詳細は上記のコミットリンクを参照してください。

では実際画面を見てみましょう!

「答えを見る」を押す前
image.png

「答えを見る」を押した後
image.png

おお!
ただしくAPIが働いています!
この状況ではサレンダーが最適なようですね〜〜〜

バリデーションのリファクタ

コードを眺めていて、負債になっている部分を見つけたのでリファクタリングをします。
それは不正な入力の検証、"バリデーション"の部分です。

今まで各アクションに独立してバリデーション処理を書いていました。

api/services/game_service.go
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")
	}

    // 以下、処理の本体

api/services/game_service.go
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の原則:同じコードを繰り返してはいけない。どこかにまとめるべきというプログラミングの鉄則)

バリデーションの処理を一箇所にまとめて、どのアクションでもそれを呼び出すようにしましょう。

api/game/game.go

// 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などその他の検証も行うための関数も作り、これを呼び出すことにします。

api/services/game_service.go

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にしよう。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?