問題を出すAI

これまで作った囲碁・五目並べ・オセロのAIは、人と対局する相手でした。クロスワードは違います。AIは問題を解いたり人と競ったりせず、人が解く問題を出します。問題は韓国語のクロスワードなので、盤の1マスにはハングル1文字が入ります。

Webはほかのゲームと同じ枠組みで作りました。

  • crossword.mariago.kr です。自宅の Mac の3009番ポートで動き、IDC の nginx がつないでいます。
  • 待合室で難易度を選んでゲームルームに入ると、新しい盤が作られます。
  • 盤と入力の一つ一つを SQLite に記録として残します。

最初の順序: 単語 → 問題 → 盤

最初は、単語バンクから単語を選び、単語ごとに問題を付けてから、それらの単語を盤の上につなげていきました。すでに置かれた単語と同じ文字があれば、その位置にかけて置く方式です。

できた盤はクロスワードではありませんでした。

  • 12×12 の盤に問題が7〜8個しかありませんでした。盤のほとんどが黒マスでした。
  • 答えのほとんどが2文字でした。
  • 単語が最初の文字と最後の文字だけでつながり、斜めの階段のように並びました。크리스마스(クリスマス)と 알프스(アルプス)が共通の文字「스」で交わるような、真ん中の文字での交差はほとんどありませんでした。
  • 単語が盤の真ん中にばかり集まりました。
  • むずかしいの盤を2回続けて解いたら、一つを除いてすべて前の盤に出た単語でした。

初日に保存された24局

記録のDBには、その日に作った盤が24個残っています。すべて 12×12 のむずかしいの盤です。

  • 単語の数は6〜8個、平均6.8個でした。
  • 白マスは盤の平均6.5%でした。
  • 答えのうち76%が2文字でした。
  • 180°対称の盤は一つもありませんでした。

24局のうち22局は、1マスも埋めずにまた新しい盤を押しました。盤を見た瞬間に作り直したのです。

直そうとしたこと

  • ヨコ・タテ合わせて15問以上出るようにしました。テストでは通りましたが、画面には相変わらず8問の盤が出ました。待合室が以前に作った盤を開き続けていたのです。
  • 生成器を直接動かし、盤の図と問題のリストを文字で出力するテキストエージェントを作りました。記録DBの盤も一緒に検査して、2文字の島があれば印を付けます。
  • 3文字の十字の塊を繰り返して盤を埋める方式も出てきました。右上の図です。問題の数は増えましたが、すべての答えが3文字で、塊同士はつながりません。いかにもAIらしい発想で、こんなクロスワードは世の中にありません。

順序を変えることに

単語から置くと、盤の形はその結果としてついてくるだけで、手を入れられません。そこで盤を先に作り、そのマスの長さに合う単語を選び、最後に問題を出すことにしました。

この段階の実際のコード

テキストエージェントが保存された盤を検査する部分です。平均の長さが短いか、真ん中の交差がなければ、新しいゲームが必要だと出力します。

scripts/text-agent.tstypescript · 18行
for (const row of rows) {
  let across = 0;
  let down = 0;
  let mid = 0;
  let avg = 0;
  try {
    const puzzle = JSON.parse(row.puzzle_json) as Puzzle;
    across = puzzle.across?.length ?? 0;
    down = puzzle.down?.length ?? 0;
    mid = countMiddleCrosses(puzzle);
    avg = avgWordLength(puzzle);
  } catch {
    /* ignore */
  }
  const mark = avg >= 2.7 && mid >= 1 ? "교차있음" : "2글자섬 ← 새 게임 필요";
  console.log(
    `${row.code} ${row.difficulty} ${row.size}×${row.size} ${row.status} 가로 ${across} 세로 ${down} 교차 ${mid} 평균 ${avg.toFixed(2)}  ${mark}  ${row.created_at}`,
  );

単語をつなげていく配置器のルールです。新しい単語は、すでに置かれた単語とちょうど1マスだけ重なる必要があり、隣のマスに別の文字が付いてはいけません。この配置器は今も予備の経路として残っていて、3回目の記事で再び出てきます。

lib/gridFill.tstypescript · 5行
  for (const [nr, nc] of neighbors) {
    if (inBoard(size, nr, nc) && grid[idx(size, nr, nc)] != null) return null;
  }
}
if (intersections !== 1 || newCells < 1) return null;

次の記事は「2. 盤が先、単語が次、問題は最後」です。