見出し画像

Retro-gaming and so on

Pythonで作るBulls and Cows

iPhoneの解錠パスコードは数値4桁だった(※1)。
0〜9の10個から4桁の数値は10000個作れる。
コンピュータで総当りするとしたらかなり簡単に解錠可能なんだよな。
しかし人間に取っては総当りするのは大変だ。人間向け、になってるからこそ10000個程度で済んだ、とは言えるだろう。
しかし、PCみたいに「インターネットに常時接続してる」状態だと数値だけのパスワードは非常に危険だ、ってのは分かるだろう。4桁なんかも簡単にクラッキング出来る範疇になる。
自然、パスワードは複雑で長くないとならない、って事になるわけだ。

iPhoneのパスコードみたいな、このテの4桁程度の数値だと、人によっては0000とか全部同じ数値にしたり、あるいは誕生日みたいな「予測可能」な数値を使う例も多く危険だ。
一番いいのは順列を計算して乱数で決定する事だろう。そうすれば「人間の予測」自体は難しくなる。
ただし、順列・・・つまり「重複した数を使わない」とすれば10x9x8x7 = 5040通りまで母数は減る。
人間相手だと、これでも十分「可能性」としては大きな数値だが、やっぱりコンピュータ相手では心もとない。



いや、パスコード・クラッキングの方法論に付いて語ってるわけじゃないんだ。
パスコード・クラッキングは当然良くない行為なんだけど、視点を変えるとこれってゲームになるんだよな。
しかも古典的なゲームで言わば「数当て」ゲームだ。決められた回数内で4桁の数値を当てるこのゲームをBulls and Cowsと言う(※2)。
Bulls and Cowsのルールは次の通り。

  1. 出題者は0〜9から4桁の数字を順列で作る。
  2. 回答者はその4桁を当てる。
まぁ、ルールって程じゃない。
ただし、このゲームのポイントは、出題者は「いくつ数が当たってたか」を回答者に教えなきゃならない辺り。
その「教え方」だけど、

  1. 数値とその位置が合致してる場合はBullとしてその個数を教える。
  2. 数値は合ってるけど、位置が違う場合はCowとしてその個数を教える。
となってて、これがゲームのタイトル、「Bulls and Cows」の由来だ。
例えば、解答が「4271」で、回答者が「1234」と答えたとする。2の位置は正しく、1と4は含まれてるが位置が違う。この場合は、1 Bull and 2 Cows、ってのが出題者が回答者に提示せなアカン「情報」となる。
そして回答者側はその「1 Bull and 2 Cows」と言う情報を元に回答をまた予想していかなければならない、と言うゲームだ。

このゲームは元々、紙と鉛筆を使って複数人で行うゲームだったが、1970年代のパソコン前夜時代に、例によってミニコンで実装されて遊ばれてた模様だ。
ルールもシンプルだし、「プログラミングの題材」としては適切なゲームだったんだ。
ではPythonでプログラミングしてみよう。例によって最初はCLI版、そしてそれをGUIに改造する。
CLI版で使用するライブラリは順列を計算するitertoolsのpermutations、乱数を使って生成した順列から出題する為にrandomのchoice、あとは雑事用にossubprocesssysの3モジュールを使用する。

取り敢えず、今回は最大失敗数を8回、としておこう。

# カウンター最大値
max_count = 8

次に、"0123456789"と言う文字列から回答になる文字列リストを生成する。

# データ
data = [''.join(i) for i in permutations('0123456789', 4)]

関数permutationsは第一引数にシーケンスを取る。よって文字列でも構わない。結果、文字列"0123456789"から要素を4つ取った順列を生成する。
それら個々の「解」を全部また文字列に変換して基礎データとする。
文字列にしておくのは、数値だと0が先頭に来た場合、4桁以下の数だと誤認されてしまうから、だ。
また、inputで得る入力も文字列なんで、文字列同士の比較の方が都合がいい。そう言った理由となる。
次に、例によってメッセージを辞書型で作っておく。

# メッセージ
msg = {'at': 'Please input \'Yes\' or \'No\'.',
   'bc': '{} bull{} and {} cow{}.',
   'i': 'Input: ',
   'l': 'Haha, you lose. It is {}.',
   'n': 'New Game?',
   'p': 's',
   's': '',
   'v': 'Please input a 4-digit defferent number.',
   'w': 'You win! 😊'}

まぁ、この辺は実際はプログラムを作りながらメッセージを追加していってるわけだが。
一応メッセージが英語なんで、ヒントのBullやCowが単数形(Singular)なのか複数形(Plural)なのかの為のキー's'と'p'を用意してる。

次は情報用の構造体を作る。
仕組みとしては、環境に保持したリストに情報を追加していく。リストの長さがmax_countに一致した時にゲームオーバーとなるわけだが。
そこで、情報には「現在入力した数値(の文字列)」とそれがどのような「x bull(s) and y cow(s)」なのか、の2つが必要だ、と言う事にする。

# 情報
class Info(object):
 def __init__(self, guess, result):
  self.guess = guess
  self.result = result
 def __repr__(self):
  return f'Guess: {self.guess} Result: {self.result}'

guessには現在の入力、resultには「x bull(s) and y cow(s)」と言う文字列情報を入れる。

次に環境用構造体を定義する。スロットは3つ、としておき、1つ目は「解答」、2つ目はゲーム終了か否かのフラグ、そして3つ目は「情報構造体」を追加するリストを設定する。

# 環境
class Env(object):
 def __init__(self, answer, flag, tries):
  self.answer = answer
  self.flag = flag
  self.tries = tries
 def __repr__(self):
  return f'Answer: {self.answer}, Flag: {self.flag}, Tries: {self.tries}'

フラグは、真の時はゲームに勝つにせよ負けるにせよ「ゲームオーバー」を意図し、偽の時は「ゲーム継続」を意味する。
ここでは次の「ゲーム終了判定関数」を呼び出す事を意図してる。

# ゲーム終了条件
def is_gameover(x, env):
 return x == env.answer or len(env.tries) + 1 == max_count

ゲームは入力と環境のインスタンス変数answerが一致した時(つまり、勝ち)、あるい環境のインスタンス変数(リスト)triesの長さがmax_count - 1と一致した時(つまり、負け)終了する。
環境のインスタンス変数flag内で毎回この終了条件を満たすかどうかをチェックするわけだ。

次は環境の初期化関数を書く。

# 初期化
def init():
 return Env(choice(data), False, [])

解答は基礎データ(data)から乱数で引っ張ってくる。ゲームは当然終了条件を満たさない状態でスタートするんでフラグはFalse、また回答のリスト(tries)は空リストにしておく。

次に、Bullが何匹、Cowが何匹か計算する「判定関数」を書く。これはREPL(Read-Eval-Print Loop)のEval(評価部)で使う補助関数となる。

# 判定関数
def judge(x, env):
 bc = sum([env.answer.
count(i) for i in x])
 bull = sum([1 if i[0] == i[1] else 0 for i in zip(x, env.answer)])
 return bull, bc - bull

この関数がこのゲームのキモだ。なんせ「出題側がヒントを出す」関数だから、だ。
文字列メソッドcountは文字列内に特定の文字が何個あるのかを返すメソッドだ。しかし、このゲームの性質上、「解答」に含まれる「回答にある文字」はある(1)、かない(0)、の2つに1つしかない。
いずれにせよ、回答の文字列から文字を一個一個抜いてきては解答に含まれてるか否かチェックする。そういう処理を一気にやるにはやはりリスト内包表記だ。
例えば解答が"4271"、回答が"1234"の場合、

>>> ['4271'.count(i) for i in '1234']
[1, 1, 0, 1]

となる。返り値のリストの総和を取れば、「回答の文字のうち3つは解答に含まれている」事が分かる。そしてこの値はBullとCowを合わせた数だ。
一方、Bullの場合は「位置まで同じじゃないとならない」。こういう場合は、解答と回答をzipして生成されたイテレータに含まれる各要素内の2つが同一か否かを調べればいい。
当然リスト内包表記を使う。
例えば上の例だと、

>>> [1 if i[0] == i[1] else 0 for i in zip('4271', '1234')]
[0, 1, 0, 0]

となる。解答も回答も2番目の要素が2で一致してる、からだ。
これも総和を取ればBullになり、先程のBullとCowを合わせた総数からBullを引けば、当然Cowになるわけだ。

# 動作テスト
>>> judge('1234', Env('4271', False, []))
(1, 2) 
# 返り値はBullとCowのタプルとなる。

材料は揃った。いよいよREPL(Read-Eval-Print Loop)の読み込み部(Read)を書いていく。
Read部分は文字列を返すんで、単純にはinputのままでいいんだけど、エラーチェックも兼ねて次のように書いてみる。

# Read
def read(env):
 x = input(msg['n' if env.flag else 'i'])[:4]
 if env.flag or (x.isdigit() and len(x) == 4 and x in data and x not in
       [i.guess for i in env.tries]):
  return x
 else:
  raise ValueError

まずinputのプロンプトはメッセージmsgから引っ張ってくる。ゲーム終了時(つまり環境Envのインスタンス変数flagが真の時)には「New Game?」を引っ張ってくるキー'n'、そうじゃない時(ゲーム進行中)は入力を促すキー'i'を使う。
この、辞書型のキーに条件式をツッコむカタチ、

msg['n' if env.flag else 'i']

には慣れておくように、とこの記事で書いた。じゃないと、この一行で済むトコを何行にも渡って書かなければならなくなって逆に視認性が悪くなるんだ。
また、入力は何文字にも渡って入力が可能なんで、ここではスライスを使って4文字以上の入力があったら残り全部を弾いてしまう。これでどんな入力でも4文字以下に抑える事が出来る。
あとは単に入力を返せばいいんだけど、ある程度のエラーチェックを噛ます。

  • ゲーム終了時(つまり環境のflag変数がTrueの時)

あるいは

  • 入力文字列の文字が全部数値である事(isdigitでチェック)
  • 入力文字列の長さが4である事(スライスで弾いてるが、逆に4文字以下の場合もあり得る)
  • 入力文字列の数値が重複の無い、順列を満たすカタチである事(これは元データdata内に存在する形式かどうか調べれば済む)
  • 入力文字列が既に行った入力ではない事(これもリスト内包表記で環境Envに含まれるtriesリスト内の要素、Info.guessを調べれば済む)
を全て同時に満たすか、だ。
どっちにせよ入力文字列を返すんだけど、ゲーム終了時は、ぶっちゃけ'Yes'か'No'かの文字列で、プレイ中は'1234'みたいな4桁の数値文字列が欲しい。
その切り替えの為にこういうエラーチェックを条件として仕込んでるわけ。
ここで引っかかったらValueErrorを取り敢えず例外として投げておけばいい。

次にREPLの心臓部であるEval(評価部)を書く。コードは次のようになる。

# Eval
def interp(x, env):
 if env.flag:
  x = x[0].lower()
  if x == 'y':
   return init()
  elif x == 'n':
   sys.exit()
 else:
  bull, cow = judge(x, env) # ここでアンパック
  return Env(env.answer,
       is_gameover(x, env), # ゲーム終了条件を満たすか調べる
       env.tries +
       [Info(x, msg['bc'].format(bull,
                 msg['p' if bull > 1 else 's'], # 単数か複数か
                 cow,
                 msg['p' if cow > 1 else's']))])
 # 単数か複数か

env.flagTrueの時(つまりゲーム終了時)、ゲーム終了か新しくゲームを始めるか決める。入力(の先頭)が'y'だった時には環境を初期化、'n'だった時にはsys.exit()でゲームを終了する。
そうじゃなければ「ゲーム継続」だ。単に新しくEnv構造体を作って返せばいい。その前にbullとcowが何頭づついるのか、judge関数からの返り値であるタプルをアンパックしておいて、保存しておく。
解答は変わらないのでenv.answerはそのまま。
env.flagはゲーム終了判定関数is_gameoverを使って計算する。ゲーム終了時にはTrueが返ってくるが、そうじゃなきゃFalseが返ってくる。
あとはenv.triesと言うリストにInfo構造体を追加していけばいいだけ、だ。繰り返すがInfo構造体のインスタンス変数guessには現在の入力がそのまま入り、インスタンス変数resultには'x bull(s) and y cow(s)'と言う文字列情報が入る。
その文字列はmsg['bc']を用いて形成する。
msg['bc']は4つプレースホルダーがあり、0番目と2番めはそれぞれbull(の数)とcow(の数)だけど、1番目と3番目はそれぞれ、単数形と複数形の帳尻合わせだ。bullやcowが1より大きかった場合、's'を挿入する。そうじゃなければ''(空文字列)を挿入する。そういう英語のつじつま合わせの為のトリックだ。

最後にREPLの印字部(Print)を作る。

# Print
def display(env):
 subprocess.run('cls'if os.name == 'nt' else 'clear')
 # print(env) デバッグ用
 [print(f'{i} {j}') for i, j in enumerate(env.tries, 1)]
 if env.flag:
  print(msg['w'] if env.answer == env.tries[-1].guess else
    msg['l'].format(env.answer))
 return env

このゲームも端末で遊ぶ事が前提なんで、最初に使用OSがWindowsなのかそれ以外なのをか調べる。Mac OS XもLinuxも「UNIX系OS」なんで例外はWIndowsだけ、なんで取り敢えずはWindowsかそうじゃないか、を調べるだけで済む。
そしてそれに従い端末の表示を「掃除」するわけだ。Windowsの端末表示の全消去コマンドは'cls'、それ以外なら'clear'になる。
あとはまずはenv.triesと言うリストの要素に番号を振って(enumerate)、あとはアンパックしながら表示すればいい。
env.triesの要素は全てInfoクラスだが、そこもそのままprintしても構わない。何故なら特殊メソッド__repr__を設定してるから、だ。
env.triesを表示し終わったら、あとはenv.flagがTrue、つまりゲーム終了時にメッセージを表示すればいい。env.triesリストのケツにある要素(Infoクラス)のguess変数がenv.answerと一致してたら「正解」で「プレイヤーが勝った」と言う事で、勝利用メッセージmsg['w']を引っ張ってくる。そうじゃなきゃ負け、だ。
いずれにせよ、「勝ち負け」はenv.flagがTrue以外の時は表示する必要がない。まぁ、当たり前、だよな。
最後に環境envをそのまま返しておく。

さて、あとはREPLを組むだけ、だ。

# REPL
if __name__ == '__main__':
 env = init()
 subprocess.run('cls' if os.name == 'nt' else 'clear')
 while True:
  try:
   env = display(interp(read(env), env))
  except AttributeError:
   print(msg['at'])
  except ValueError:
   print(msg['v'])

envを初期化した後に一旦端末表示を掃除する。
あとは、REPLする。飛んでくるエラーはRead(interp関数)に設定した例外、あとは、ゲーム終了時の入力の先頭が'y'か'n'以外だった時、AttributeErrorってのが飛んでくる。それらが飛んできた時に適切な入力を促すメッセージを表示すれば完成、だ。
CLI版のソースコードはここに置いておこう。





さて、あとはこれをGUI版に改造する、んだけど・・・。
ぶっちゃけ、このブログを読んでる人はもうそろそろ慣れたと思う。
「プログラミングのロジック」はCLIの時は重要なんだけど、GUIの「ガワ」ってのには特別な「プログラミング上のテクニック」ってのが必要とされない。GUIのライブラリの「使い方」に習熟する、とか、あるいは「調べる」事は重要なんだけど、やってるこたぁ定形で、毎回毎回同じような事を書くだけ、なんだよ(笑)。
よってGUI版への改造はアッサリと書く。
wxGladeで生成するウィジェットのヒエラルキーは次のようなモノだ。



Viewはゲーム画面、dialogは「ゲーム終了時点」でポップアップするモノ、とする。dialogのYESボタンは「新ゲームを立ち上げる」ボタン、NOボタンは「ゲーム終了」ボタンだ。
Viewの外観は次のようなモノ、を想定してる。



TextCtrlで入力を受け取り、OKボタンでそれをControllerへとメッセージ送信する。
また、「行った入力」と「x bull(s) and y cow(s)」はListCtrlを用いて表示する。
Modelが送ってきたenvのインスタンス変数、triesの情報をListCtrlにハメ込んで行けばいいわけだ。

ControllerとModelは次のようになる。

class Controller(object):
 def __init__(self):
  pub.subscribe(self.read, 'Controller')
 def read(self, x, env):
  x == x[:4]
  if env.flag or (x.isdigit() and len(x) == 4 and x in data):
   pub.sendMessage('Model.eval', x = x, env = env)
  else:
   raise ValueError


class Model(object):
 def __init__(self):
  pub.subscribe(self.eval, 'Model')
 def eval(self, x, env):
  pub.sendMessage('View.setProperty', env = interp(x, env))

相変わらず、ModelはCLIのEval(interp関数)をラッピングしてるだけ、だ。
今回のControllerはぶっちゃけ、Readと殆ど変わらん。違うのは標準入力から文字列を得るのではなく、ViewのTextCtrlから文字列がメッセージとして送られてくるところだけ、だ。

あとはテクニカルには「いつもどおり」だ。ここで解説するよかコードを実際に読んでもらった方が早いだろう。
「ワンパターンだよな」ってぇのならまさにその通りなんだ。GUIの「ガワ」の記述はプログラミングって呼ぶほどプログラミングでもない。毎回毎回「同じような事を書く」だけとなる。
特にPythonで、pypubsubと言うライブラリを用いてMVCで構築する以上、「特に変わった事」は起きないんだよ(笑)。ロジックは全てCLI版に詰まっている。従って、GUI側では「特に何か真新しい事をする必要はない」んだ。
と言うわけで、GUI版を含めたソースコード一式はここに置いておこう。

以上。

※1: 初期のiPhoneだと3桁以下のパスコードでも良く、最大で4桁、だった。

※2: マスターマインド、と言うボードゲームの元ネタだ。

マスターマインドもコンピュータ・ゲーム化されていて、たくさんのフリーソフトがある。
  • Xでシェアする
  • Facebookでシェアする
  • はてなブックマークに追加する
  • LINEでシェアする

最近の「プログラミング」カテゴリーもっと見る

最近の記事
バックナンバー
人気記事