よしたかの日常

日常の事やイラスト投稿、開発している時の備忘録を書いています。

Rust でババ抜きを作ったら、履歴ベースの心理戦 CPU になった

おはこんばんにちは!

先月の頭辺りから、Rust の勉強がてら、ターミナルで遊べるトランプゲームを作っています。
今回は、初めに作ってみたババ抜き(Old Maid)について書いて行こうと思います。

ババ抜きについて、ゲームエンジンや描画より先に「CPU がどう考えるか」から手を入れた結果、 引かれた位置の履歴を記録してジョーカーを隠すという、ちょっと面白い仕組みができました。
その設計の考え方と Rust での実装を紹介して行こうかなと。

cargo run 直後。FIGlet の「Old Maid」タイトルとバージョン行が見える状態。


ババ抜きで CPU に求めるもの

ババ抜きは、相手の手札から 1 枚引いてペアを捨てていくゲームです。
最後にジョーカー(ババ)を残した人が負け。

CPU に求める振る舞いは、大きく 2 つに分かれます。

  1. 自分がジョーカーを持っているとき — どこに隠すか
  2. 相手の手札を引くとき — どの位置を選ぶか

最初は「ランダムに引く」だけでも動きます。
でも実際に遊ぶと、同じ位置ばかり引かれることが起きます。
人間なら「この辺、よく引かれるな」と感じて手札の並びを変えるはずです。

そこで CPU にも 履歴 を持たせることにしました。

▲ 人間プレイヤーのターン。Round: X / Turn: Y / Player (Human) と、相手から引く入力プロンプトが表示されている状態。


履歴の記録 — 「どこから引かれたか」

Player 構造体の中に History を持たせ、ターンごとにインデックスを記録します。

struct History {
    // ...
    choose_index: Vec<usize>,  // 自分が引いた位置
    taken_index: Vec<usize>,   // 相手に引かれた位置
}

ゲーム本編では、カードを引いた直後に「引かれた側」の履歴を更新します。

let pick_card_idx = run_player(players, current, target_player_idx);
players[target_player_idx].add_history_taken_index(pick_card_idx);

左から数えた 0 始まりのインデックス として保存しています。
例えば手札が 7 枚あって、左から 3 枚目が引かれたら 2taken_index に追加されます。


頻度集計 — 「よく引かれる場所」を特定する

手札を並び替えるタイミングで、履歴から 引かれた回数の多い順 にインデックスを並べます。

fn values_by_descending_frequency(&self) -> Vec<usize> {
    let mut counts: HashMap<usize, usize> = HashMap::new();
    for &idx in &self.taken_index {
        *counts.entry(idx).or_insert(0) += 1;
    }
    let mut pairs: Vec<(usize, usize)> = counts.into_iter().collect();
    pairs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));

    pairs.into_iter().map(|(k, _)| k).collect()
}

同じ回数ならインデックスが小さい方を優先する、シンプルなルールです。

ここで大事なのは、完璧な記憶 AI ではない という点。
手札を一度シャッフルしてからジョーカーを移動させるので、他のカードの並びはランダムのまま残ります。
「履歴をヒントにしつつ、完全に計算された並びには見えない」バランスにしています。


ジョーカーの隠し方 — 4 パターン

ジョーカーの移動ロジックは organize_hand.rs にまとめています。

関数 動き 使う相手
joker_in_first 左端(先頭) Beginner CPU
joker_in_last 右端(末尾) Medium CPU
joker_in_center 中央付近 (予備)
joker_in_history_taken よく引かれた位置 Veteran CPU / 人間

Beginner — わざとバレやすく

fn organize_hand(&self, player: &mut Player) {
    player.sort_hand();
    let hand = player.get_hand();
    joker_in_first(hand);
}

手札を数字順にソートしたうえで、ジョーカーを 左端 に置きます。
引く側も「左端か右端」をランダムに選ぶので、初心者 CPU は比較的読みやすい存在になります。

▲ CPU のターン。CPU 1 (Gambler)CPU 2 (Medium)CPU 3 (Veteran) のように、括弧内に難易度名が出ている行。

Medium — 端っこに逃がす

fn organize_hand(&self, player: &mut Player) {
    let hand = player.get_hand();
    hand.shuffle(&mut rand::thread_rng());
    joker_in_last(hand);
}

シャッフルしたあと 右端 にジョーカーを置きます。
引く側は 中央付近 を狙うので、「端に隠す vs 真ん中を引く」の単純な攻防になります。

Veteran / 人間 — 履歴を使う

fn organize_hand(&self, player: &mut Player) {
    let history_token = player.get_history_token_frequency();
    let hand = player.get_hand();
    hand.shuffle(&mut rand::thread_rng());
    joker_in_history_taken(hand, history_token);
}

核心部分はここです。

pub fn joker_in_history_taken(hand: &mut Vec<Card>, history_token: Vec<usize>) {
    if let Some(joker_index) = hand.iter().position(|c| c.is_joker()) {
        let insert_index = history_token
            .into_iter()
            .find(|&i| i < hand.len())
            .unwrap_or(0);

        let joker = hand.remove(joker_index);
        hand.insert(insert_index, joker);
    }
}

頻度順のリストの先頭から、現在の手札枚数の範囲内にある最初のインデックス にジョーカーを置きます。

なぜ「よく引かれる場所」に置くのか?

直感と逆に感じるかもしれません。
「引かれにくい場所に隠すべき」では?

この実装の意図は、相手の引き方のクセを利用する ことに近いです。

  • 相手が毎回「左から 2 枚目」を引くクセがある
  • その位置にジョーカーを置いても、相手は 同じ位置を機械的に選び続ける
  • 結果として、ジョーカーが引かれにくくなる

逆に言えば、相手が完全ランダムに引く Veteran CPU 相手 では、この戦略の効果は薄れます。
実際、Veteran の choose_cardrand_range(0..len) でランダムです。

つまりこの設計は、

  • 人間プレイヤー(同じ番号を何度も入力しがち)
  • Beginner / Medium CPU(端や中央など偏った引き方)

に対して効きやすい、というゲームバランスになっています。


データの流れ(全体像)

ターン開始
  ↓
相手が pick_card_idx を決定
  ↓
引かれた側: taken_index に記録
  ↓
ペアがあれば捨てる
  ↓
organize_hand() 呼び出し
  ├─ 手札をシャッフル
  └─ 難易度に応じてジョーカーを移動
       └─ Veteran/人間: 履歴の頻度順インデックスへ
  ↓
次のターンへ

設計で意識したこと

1. 履歴は「ヒント」であって「正解」ではない

履歴をそのまま最適手として使うのではなく、シャッフル後に 1 箇所だけ ジョーカーを動かしています。
全部計算通りに並べると、プレイ感が硬くなるので、あえてランダム性を残しました。

2. 難易度は「隠し方」と「引き方」のセット

難易度 ジョーカーの隠し 引き方
Beginner 左端(ソート後) 左端 or 右端
Medium 右端 中央付近
Veteran 履歴ベース 完全ランダム

「隠すのは賢いのに、引くのはランダム」という Veteran の組み合わせは、博打打ち(Gambler) とも呼べる個性になっています。

3. 人間プレイヤーも同じロジック

Human::organize_handjoker_in_history_taken を使っています。
CPU だけの特権ではなく、プレイヤーも履歴の恩恵を受ける 設計です。

実際に遊ぶと、同じ番号ばかり入力していると、ジョーカーがその付近に移動していく — という挙動が見えてきます。


ジョーカーボーナス — ルールとの連動

このプロジェクトでは、複数ラウンド制のスコアリングも入れています。
ジョーカーを保持していたターン数の合計が最小のプレイヤーに +1 ボーナス が付きます。

履歴ベースの隠し方は、単なる演出ではなく 点数にも直結する ので、 「ジョーカーをいかに短く持つか」というババ抜き本来の緊張感とつながっています。

▲ 複数ラウンド終了後の Total Game Result。各ラウンドの獲得ポイント・ジョーカーボーナス・総合順位(🥇🥈🥉)が見える状態。


動かしてみる

git clone https://github.com/yoshitaka-k/old_maid.git
cd old_maid
cargo run

CPU 難易度は起動時に選べます。

  • 1: Beginner グループ
  • 2: Medium グループ
  • 3: Veteran グループ

同じ位置ばかり引いてみて、手札整理後の挙動の違いを観察するのがおすすめです。


まとめ

ババ抜き CPU を作る過程で、次のような設計にたどり着きました。

  • 引かれた位置の履歴Player に持たせる
  • 頻度順に並べて ジョーカーの移動先 を決める
  • 難易度ごとに 隠し方と引き方 をセットで変える
  • 履歴は完璧な最適解ではなく、シャッフルとの組み合わせで自然さを保つ

大規模な AI フレームワークは使っていませんが、
「相手のクセを記録する」というシンプルな仕組みだけで、
CPU との対戦に少しだけ 読み合い が生まれます。


次回予告

次の記事では、山札を切る ヒンズーシャッフル・リフルシャッフル・ディールシャッフル の実装と、
CPU 難易度ごとのパラメータ設計について書く予定です。


参考リンク