「一攫千金プログラミング」という名のプログラミング教育
Paizaさんが2017年11月ごろから運営している一攫千金プログラミング〜ボットdeジャックポットというコーディングゲームが、プログラミング学習教材としてよろしいのではないかと思い、ゲームの人工知能の初心者むけに開設を書いてみようと思いました。
PaizaさんのプログラミングゲームはCやC#からRuby、最近ではbashなども含めて幅広いプログラミング言語を扱っていらっしゃるのですが、最近自分自身のJavaScriptのリハビリを兼ねて初心者向けコードを公開してみます。
気を付けるべき要素
以下の点に気を付けて解説していきます。
- サンプルコード
- フォーマットとルール
- デバッグできない
- まずはルールを実装すべき
- HIT/STANDの判断
- チップの賭金を工夫する
あえて世間一般に「人工知能」と呼ばれるブラックボックスを用意しないで、人工知能やゲームプログラミングの専門用語の解説などをしながらすすめてみます。
サンプルコード
まずは猫先生を相手にゲームのルールを学びましょう。
右上で言語を選んで「サンプルコード」というボタンを押すと以下のようなコードが貼り付けられます。
process.stdin.resume();
process.stdin.setEncoding('utf8');
var input_string = '';
process.stdin.on('data', function(chunk) {
input_string += chunk;
});
process.stdin.on('end', function() {
var lines = input_string.split('\n');
var line_1 = lines[0];
my_card = line_1.split(" ");
if(my_card[0] == "0"){
console.log("1"); // 賭けチップ数
}else{
var card_total = 0;
for(var i=0; i<my_card.length; i++){
card_total += parseInt(my_card[i]);
}
if(card_total < 10){ //★カードを引く条件の合計値を変えてみよう!★
console.log("HIT");// カード引く
}else{
console.log("STAND");// 勝負
}
}
});
stdin、つまり標準入出力をつかって、ゲームサーバーからの文字列を処理していきます。上のほうでその入出力の処理をしていますので、 process.stdin.on() より前は変更する必要はないと思います。
フォーマットとルール
初級(猫先生、霧島京子、緑川つばめ)の場合はフォーマットが制限されており、相手のカードも見えません。
コメントに書かれている if(card_total <10) の値を17ぐらいにしておくと勝てるようになります。運です。
4人目の「六村リオ」以降は、2手目以降の入力が以下のようになります。
1行目:あなたのカード数値群(1〜10)
2行目:現在何戦目かを示す数字
3行目:現在何連勝かを示す数字
4行目:最大BETチップ数
5行目:ディーラーのカード数値群(1〜10)
データのサンプルとしては以下のようになります。
<2手目>
6 6 → 自分のカードが 「6 6」で始まった(つまり12)
1 → 1戦目
0 → 連勝していない
200 → このステージの最大BETできるチップ数
10 2 → 敵のカードは「10 2」(つまり12)
ここで、自分と敵は同点ですから、追加カードを配る「HIT」をコマンドとして返します。
<3手目>
6 6 3 → 自分のカードに「3」が追加され15になった。
1 → (ここでは不要)
0 → (ここでは不要)
200 → (ここでは不要)
10 2 1 → 相手に「1」が配られた。10+2+1=13。
ここで自分は15、相手は13なので「STAND」します。相手があきらめてくれれば勝利ですし、無理して21を超えれば負けです(バーストといいます)。
なおブラックジャックでは「A(エース)」は1もしくは11で、大きいほうで21を超えないほうを自動的に選択することになります。このゲームではフォーマット上は「1」という文字列で渡されます。なお上の例の場合には21を超えるので1として計算します。
絵札「10, J, Q, K」はすべて10として扱います。
他にも本当のブラックジャックでは絵札とAで「ブラックジャック」という役があるのですが、単に21として計算するようです。スート(マーク)も関係ありません。
なお、ディーラー(相手の女の子)と同点の場合は負けます。引き分けではなく、勝ちにいかなければ負けなのです。
デバッグできない
他のPaizaのゲームにはデバッグができるモードもあるようですが、今回のゲームにはそのような機能はありません。書式やカッコなどのシンタックスエラーは行番号に赤いマークで表示されるのでマウスポインタを合わせることで確認できますが、エラーが起きたり、正しい反応を返さなかった場合は、ゲームプレイ中に「エラー」となり無効試合となります。
具体的には配列のオーバーフローや、存在しない関数を呼んだ場合、「HIT」もしくは「STAND」の文字列を返さなかった場合が該当します。
いろいろやってみたのですが、デバッグする場合は一旦勝負を捨てて、 console.log("here"); といった形でデバッグ文字列をHITやSTANDの代わりに表示してみるとよいです。
まずはルールを実装すべき
上記のサンプルにある基本的な処理を振り返ってみます。
var lines = input_string.split('\n');
var line_1 = lines[0];
my_card = line_1.split(" ");
ここでまず標準入力から文字列を取得し split('\n') を使って、改行毎に配列 lines に格納します。つまりlines[0]とすることで1行目のデータが取得できます。
上記のコードでは取得した1行目をさらに半角スペースでパースし、my_cardに格納しています(正確には1手目の1行目はカードではなくチップの保有数です)。
以下のコードに続きます
if(my_card[0] == "0"){
console.log("1"); // 賭けチップ数
}else{
var card_total = 0;
for(var i=0; i<my_card.length; i++){
card_total += parseInt(my_card[i]);
}
if(card_total < 10){ //★カードを引く条件の合計値を変えてみよう!★
console.log("HIT");// カード引く
}else{
console.log("STAND");// 勝負
}
}
ここでは1手目の場合とそれ以降の場合に処理を分け、2手目以降の場合はカードの解釈を行っています。 parseInt()関数は文字列を整数に変換しますので、半角スペースで区切られたmy_cardの文字列[1-10]に対して整数化を行い、card_totalに加算していきます。
一般的には合計点が10ではなく17ぐらいが境目ですので、カードを引く条件を変えていくとリスクと勝利の状態が変わっていきます。
このままでは相手のカードが評価できませんので、評価関数を書きます。
評価関数を書くためには相手のカードを点数化する必要があります。
ここまでのコードは自分のカードをcard_totalに変換するコードは書かれています。しかし「1」を1とするか11とするかも書かれていませんし、敵と相手の解釈で同じようなコードを書く必要があります。敵と自分でそれぞれ別々に分ける必要はありませんので、一つの関数として扱いましょう。
function ParseCard( arr ) {
var card_total = 0;
var cards = arr.split(" ");
for(var i=0; i<cards.length; i++){
//Aを1とまちがえる対策
if (parseInt(cards[i])==1) {
var temp = card_total + 11;
if (temp>21) {
card_total += 1;
} else {
card_total += 11;
}
} else {
card_total += parseInt(cards[i]);
}//A対策おわり
}
return card_total;
}
カードを解釈するのでParseCard()と命名しました。引数にはカード文字列が入った配列をそのまま渡します。Aはルール通り、いちど11として計算してみて、21を超えるようなら1として加算するように書いています(このようなコードはもっと短く書けますが、あえて目立つように書いておくことで後々見やすくしているます)。
HIT/STANDの判断
「カードを引くか、降りるか?」の判断がブラックジャックの一番おもしろいところですが、まずは勝負にならないので、if文をたくさん書くことよりも、状況を整理してみるといいと思います。サンプルをまっさらにして、コメント行にして書いてみましょう。例えばこんな感じです。
//まず相手がバーストしたならスタンドせねば自爆して負ける
//(勝敗判定が自分のコマンド後に起きるので)
//相手が21以下なら同点は負けなのでバースト覚悟でひきにいく
//個々の状態は自分のカードが何であるかによってswitch-case文で書く
…といった感じです。複雑なアルゴリズムを書く前に日本語でコメントを書いてから実装してみるといいと思います。
今回はswitch-case文で書いていますが、これが必ずしも正しい方法かどうかは難しいところです。if文の組み合わせで書く人も多いでしょうし、私はif文を極力使わず、評価関数の出力だけ判断を書くこともあります。
仮に「とても強い学習アルゴリズム」が別途あったとして、状況を評価した結果を次のコマンドにするような実装が必要になります。これをゲームや人工知能の用語では「ステートマシン」と呼びますが、プログラミングでステートを扱うのに便利なのはクラスかswitch-case文ではないかと思います。それぞれの状態が複雑に絡み合う状態(マルコフモデル)などもありえますが、まずは状態が多岐にわたりすぎて管理できなくなるまではこれでいいでしょう。
チップの賭金を工夫する
ブラックジャックは不完全情報ゲーム(game with incomplete information;不完備情報ゲームともいう)です。将棋やチェスのように盤面を読んだだけですべての情報が存在する「完全情報ゲーム」ではなく、まだ見ぬカードが重要なゲームです。
またゲーム開始直後に親の21点が確定してしまっている場合、勝ち目はありません。これは完全に運なので回避できないです。
このブラックジャックをゲーム理論で説明すると「二人非零和有限不確定不完全情報ゲーム」という表現ができます。
「二人」…プレイヤー2名で対戦
「非零和」…「勝ち:引き分け:負け」=+1-1という配点なのでゼロ和になりません。
「有限」…(画面の外まで行くようですが)カード52枚が最大
「不確定」…度のカードを引くのか、運要素があります。
「不完全情報ゲーム」…その時点ですべての情報が見えているわけではないゲーム。
先ほどのHIT/STAND判断におけるswitch-caseもしくはif文の問題などを考えるときは、このゲーム理論が役に立ちます。興味がある人は「マルコフ連鎖」、「ベイジアンネットワーク」といったキーワードで調べてみると勉強になるでしょう。
さて、非零和で不確定な「負けが回避できないゲーム」においてスコアをあげる方法についても考えてみましょう。具体的には「チップの掛け率」に注目します。いろいろな戦略がありますが、まずは「大きく負けないこと」を目標にしてみたいと思います。具体的には「開始直後に親の21確定」のように絶対負ける条件には低い賭金、逆に連勝中はボーナスが付きますので最大チップを賭けてみたいともいます。
このゲームの勝率を可能な限りあげられたとして、連勝で得られるボーナスと、最初の賭金がいくらであるべきか、確率統計で最適を考えてみるのも楽しそうです。
まず、連勝数を Combo という整数において、その値が0のときと、そうではないときで処理を分け、勝っているときは全力投資します。
if (Combo===0) {
//最初は所持金の半分~最大ベットまでランダム開始する
if (parseInt(MyChip/2)<MAXBET) {
console.log(getRandomInt(1,parseInt(MyChip/2)));
} else {
console.log(getRandomInt(parseInt(MAXBET/2),MAXBET));
}
} else {
//コンボ中は最大にベットする。もちろん残額に注意。
if (parseInt(MyChip/2)<MAXBET) {
console.log(parseInt(MyChip/2));
} else {
console.log(MAXBET);
}
}
break;
連勝中ではないのでどんな賭金で初めてもいいのですが、最大値を投じ続けると親が有利なのでいずれ資金がなくなってしまいます。最大ベット数もしくは現在の所持金の半分を比較して、高いほうを最大とした乱数で決めることにします。
getRandomInt(a, b)は、aからbまでの乱整数を返す関数です。
function getRandomInt(min, max) {
return Math.floor( Math.random() * (max - min + 1) ) + min;
}
ここではMath.floor()を使っていますが、Math.round()を用いると非一様分布になります(本当の賭博であれば賭金ゼロはできませんがこのゲームは可能なようです)。
なお、実際のブラックジャックにはスプリットやダブルダウン、インシュランスといった選択肢や、親もアップカードのみが公開されている状態ですので一般的な必勝法は役に立ちません。
最終的なコード
// 霧島京子バニー(攻略済) 20180223
// 改良Memo: 18以上のときにあきらめるのをやめるかどうか
// 17以下でディールしてSTANDしてしまった場合の逆転負け対策など
process.stdin.resume();
process.stdin.setEncoding('utf8');
var input_string = '';
var MAXBET = 400;
process.stdin.on('data', function(chunk) {
input_string += chunk;
});
process.stdin.on('end', function() {
var lines = input_string.split('\n');
var lines_num = lines.length;
var Line1 = lines[0].split(" ");
var Line2 = lines[1].split(" ");
var Line3 = lines[2].split(" ");
var MyChip = parseInt(Line1[1]);
var NowOnPlay = parseInt(Line2[0]);
var Combo = parseInt(Line3[0]);
//おそらく相手プレイヤーによって処理モードを分けたほうがいい
//初期化とそれ以降の分離
switch (lines_num) {
//初期化(ゲーム開始前)
case 3:
if (Combo===0) {
//最初は所持金の半分~最大ベットまでランダム開始する
if (parseInt(MyChip/2)<MAXBET) {
console.log(getRandomInt(1,parseInt(MyChip/2)));
} else {
console.log(getRandomInt(parseInt(MAXBET/2),MAXBET));
}
} else {
//コンボ中は最大にベットする。もちろん残額に注意。
if (parseInt(MyChip/2)<MAXBET) {
console.log(parseInt(MyChip/2));
} else {
console.log(MAXBET);
}
}
break;
//それ以外(ゲーム中)
default:
//まず相手がバーストしたならスタンドせねば自爆して負ける
//(勝敗判定が自分のコマンド後に起きるので)
//相手が21以下なら同点は負けなのでバースト覚悟でひきにいく
if (ParseCard(lines[4])>21) {
console.log("STAND");
break;
} else { //相手が21より上でなければ自分の手を見て個々に判断
switch (ParseCard(lines[0])) {
case 21:
console.log("STAND");
break;
case 20:
case 19:
case 18:
//それでも負けてるなら引く
if (ParseCard(lines[0])<=ParseCard(lines[4])) {
console.log("HIT");
} else {
console.log("STAND");
}
break;
case 11: //11のときは高確率で21になるので引く
console.log("HIT");
break;
default:
//それ以外の場合は負けてるなら引くが、逆転負けする可能性はあるので対策する。
if (ParseCard(lines[0])<=ParseCard(lines[4])) {
console.log("HIT");
break;
} else {
//ここに対策する予定
console.log("STAND");
break;
}
} //switch
}
} // 21
});
function getRandomInt(min, max) {
return Math.floor( Math.random() * (max - min + 1) ) + min;
}
function ParseCard( arr ) {
var card_total = 0;
var cards = arr.split(" ");
for(var i=0; i<cards.length; i++){
//Aを1とまちがえる対策
if (parseInt(cards[i])==1) {
var temp = card_total + 11;
if (temp>21) {
card_total += 1;
} else {
card_total += 11;
}
} else {
card_total += parseInt(cards[i]);
}//A対策おわり
}
return card_total;
}
霧島京子・バニー相手に1位になりましたのでそこそこ戦えるベースにはなっていると思います。
https://paiza.jp/paizajack/share/P3FL8gFZLNM/dealer_ranking
でも、上に行けば行くほど高度なプレイを要求されるようですので、頑張ってみてください。
Rubyで自動化している人もいるようです。
以上で、ゲームの人工知能っぽいものをJavaScriptで学ぶ解説を終わります。
他の言語と比較して勉強してみることをお勧めします。
目指せ最強のbot調教師!