Write readme. Refactor rules generation.

main
Felix Martin 2019-07-28 17:54:25 -04:00
parent e846259282
commit 0a586a33c1
8 changed files with 10455 additions and 3928 deletions

117
bot.py
View File

@ -1,117 +0,0 @@
from functools import lru_cache
from collections import namedtuple
def get_sudoku_moves():
PLAYER_1 = "X"
PLAYER_2 = "O"
EMPTY = "_"
Move = namedtuple("Move", ["ps", "n"])
Draw = namedtuple("Move", ["ps", "t"])
Position = namedtuple("Position", ["t", "i"])
def all_rows(field):
""" Returns all rows of a Sudoku field. """
f = field
return [
f[0:3], f[3:6], f[6:9], # horizontals
f[0:7:3], f[1:8:3], f[2:9:3], # verticals
f[0:9:4], f[2:7:2] # diagonals
]
assert(all_rows(list(range(9))) == [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6]])
def all_equal_to(row, value):
""" Returns True if all elements in row are
equal to value. Returns False otherwise. """
for e in row:
if e != value:
return False
return True
def player_won(field, player):
for row in all_rows(field):
if all_equal_to(row, player):
return True
return False
def player_lost(field, player):
other_player = PLAYER_2 if player == PLAYER_1 else PLAYER_1
return player_won(field, other_player)
def game_over(field):
for f in field:
if f == EMPTY:
return False
return True
def possible_moves(field):
return [i for i in range(len(field)) if field[i] == EMPTY]
@lru_cache(maxsize=2**16)
def get_equity(field, player):
if player_won(field, player):
return 1
elif player_lost(field, player):
return -1
elif game_over(field):
return 0
other_player = PLAYER_2 if player == PLAYER_1 else PLAYER_1
equities = []
for possible_move in possible_moves(field):
new_field = list(field)
new_field[possible_move] = player
new_field = tuple(new_field)
equity = get_equity(new_field, other_player)
equities.append(equity)
return min(equities) * -1
moves_acc = []
draws_acc = []
wins2_acc = []
def get_moves(field, turn, moves, bots_turn):
if player_won(field, PLAYER_1):
raise Exception("If we got here our bot screwed up.")
if player_won(field, PLAYER_2):
moves = Draw(moves.ps, turn)
wins2_acc.append(moves)
return
if game_over(field):
moves = Draw(moves.ps, turn)
draws_acc.append(moves)
return
if not bots_turn:
for move in possible_moves(field):
new_field = list(field)
new_field[move] = PLAYER_1
new_field = tuple(new_field)
new_moves = Move(moves.ps + [Position(turn, move)], None)
get_moves(new_field, turn + 1, new_moves, True)
else:
equities = []
for move in possible_moves(field):
new_field = list(field)
new_field[move] = PLAYER_2
new_field = tuple(new_field)
equity = get_equity(new_field, PLAYER_1)
equities.append((equity, move))
move = min(equities)[1]
new_field = list(field)
new_field[move] = PLAYER_2
new_field = tuple(new_field)
bot_move = Move(moves.ps, Position(turn, move))
moves_acc.append(bot_move)
get_moves(new_field, turn + 1, moves, False)
EMPTY_FIELD = tuple([EMPTY for _ in range(9)])
get_moves(EMPTY_FIELD, 0, Move([], None), False)
return (moves_acc, draws_acc, wins2_acc)

1183
readme.html Normal file

File diff suppressed because one or more lines are too long

151
readme.md
View File

@ -1,9 +1,150 @@
# Tic Tac Toe CSS
# Pure CSS Tic-tac-toe Bot
https://eddyerburgh.me/make-responsive-tic-tac-toe-board
Tic-tac-toe CSS is a static Tic-tac-toe *bot* in pure CSS *without* any JavaScript.
I had the idea for this project when I learned the basics of CSS many years ago.
For me the hard part of this project was to figure out a way to respond to different board constellations in CSS.
Once I had understood how to make rule based decisions it was easy to generate the necessary HTML and CSS for the bot in Python using the [Jinja template engine](http://jinja.pocoo.org).
https://css-tricks.com/game-life/
## HTML and CSS Generation
https://codepen.io/ziga-miklic/post/pure-css-tic-tac-toe
Luckily, [Žiga Miklič](https://codepen.io/ziga-miklic/)
has created a dual player
[Tic-tac-toe](https://codepen.io/ziga-miklic/post/pure-css-tic-tac-toe)
version in pure CSS.
Without his example it would have been hard for me to come up with a solution as elegant as his (or possibly a solution at all).
The idea is to generate a complete Tic-tac-toe board for each turn.
Each field of the board consists of an `<input>` tag to mark the field as checked and a `<label>` tag to visualize it.
This Jinja template generates nine boards - one for each turn.
The classes `.field-n` and `.turn-n` help to control the game flow
while `.row-n` and `.col-n` place the field on the board.
https://jsfiddle.net/stackmanoz/r6E9p/
~~~html
{% for turn in range(9) %}
<!-- turn-{{turn}} -->
{% for row in range(3) %}
{% for col in range(3) %}
<input class="field-{{row * 3 + col}} row-{{row}} col-{{col}} turn-{{turn}}"
id="block-{{turn}}-{{row}}-{{col}}" type="radio">
<label class="turn-{{turn}}" for="block-{{turn}}-{{row}}-{{col}}"></label>
{% endfor %}
{% endfor %}
{% endfor %}
~~~
At the start of the game the CSS rules hide all fields but `.turn-0`.
For each turn the checked field moves to the front and the next board becomes visible.
In fact, only every second board moves to the front because the other move is done by the bot (note that the z-index is incremented by two).
~~~css
{% for turn in turns_player %}
.tic-tac-toe input.turn-{{turn}}:checked + label {
cursor: default;
opacity: 1.0;
z-index: 10 !important;
}
.tic-tac-toe input.turn-{{turn}}:checked ~ .turn-{{turn + 2}} + label {
z-index: {{turn + 2}};
display: block;
}
{% endfor %}
~~~
Using the `.turn` and `.field` classes we can now tell the bot to make certain moves.
For example, let's say the player marks the first field `.field-0` in turn `.turn-0` and the second field `.field-1` in turn `.turn-2`.
We do not want the player to win by marking all fields in the first row and tell the bot to mark the third field (`.field-2`) of that row.
~~~css
.tic-tac-toe
input.turn-0.field-0:checked ~ input.turn-2.field-1:checked ~
input.turn-3.field-2 + label {
display: block;
cursor: default;
opacity: 1.0;
z-index: 10 !important;
}
~~~
We do not care which field has been checked in `.turn-1` because the bot rule generation algorithm keeps track of the bot's moves.
The CSS file contains a couple of other rules to create the Tic-tac-toe grid, pretty check marks, and a message for when the game has ended.
Credits to this [JS fiddle](https://jsfiddle.net/stackmanoz/r6E9p/) for teaching me how to make crosses in CSS.
I was not aware of the rotate property.
The next step is to generate the CSS rules for the bot.
## Bot Rules Generation
There has to be a CSS rule for every possible sequence of moves by the human player.
We define a `Move` as an integer two-tuple.
The first element represents the turn and the second element the index of the field that is selected by the move.
~~~python
Move = namedtuple("Move", ["turn", "field"])
~~~
We define a `Rule` as a two-tuple where the first element is a sequence of moves and the second element is the next move, i.e. the response to the player moves by the bot.
~~~python
Rule = namedtuple("Rule", ["moves", "next_move"])
~~~
One might think that there are too many rules to cover all potential player moves statically.
However, it can be shown that this is not an issue.
Tic-tac-toe is a simple game with only *9!* possible sequences of moves.
In the first round there are nine free fields, then eight, then seven and so on.
The bot can only select one of its available options per turn.
Hence, the number of possible sequences further reduces to *9 * 7 * 5 * 3 * 1 = 945*.
To compute the rules we implement a recursive procedure `get_rules`.
The procedure takes a board, the current turn, a partial rule (a rule including all moves till this point), and a flag whether indicating if it is the bot's or the player's turn.
The procedure terminates when either of the players has won or when the board is full.
In either case we create a rule which we append to `draw_rules` and `win_rules` which we use to display a nice message at the end of the game.
If the game is still open and it is not the bot's turn we iterate over all potential moves by the player.
For each move we make another call to `get_rules` with the new board and partial rule.
If it is the bot's turn we also iterate over all potential moves and call a procedure `get_equity` for every new potential board.
We choose the move with the highest equity for us and the lowest equity for our opponent.
For this move we then create a new final rule and call `get_rules` recursively with the updated board and the current rule (not the new rule!).
The procedure `get_equity` takes a board and a player and returns the expected value for the constellation.
If the player wins the game the equity is one, if they lose it is minus one, and a draw means zero.
If the game is undecided `get_equity` calls itself recursively with every potential move and the opposite player.
The procedure assumes that the other player always selects the move that maximizes their equity.
By choosing the move with the minimal equity for the other player the current player can maximize their own equity:
~~~python
def get_equity(board, player):
if player_won(board, player):
return 1
elif player_lost(board, player):
return -1
elif game_over(board):
return 0
other_player = PLAYER_2 if player == PLAYER_1 else PLAYER_1
equities = []
for field_index in get_unchecked_fields(board):
new_board = get_new_board(board, field_index, player)
equity = get_equity(new_board, other_player)
equities.append(equity)
return min(equities) * -1
~~~
To avoid recomputing of equities we use `lru_cache` from `functools` to cache existing equities.
Once we have computed all rules we can feed them to Jinja to generate the respective CSS code and thus have generated a static Tic-tac-toe bot in pure CSS.
~~~css
{% for rule in bot_move_rules %}
.tic-tac-toe
{% for move in rule.moves -%}
input.turn-{{move.turn}}.field-{{move.field}}:checked ~
{% endfor -%}
input.turn-{{rule.next_move.turn}}.field-{{rule.next_move.field}} + label {
display: block;
cursor: default;
opacity: 1.0;
z-index: 10 !important;
}
{% endfor %}
~~~

120
rules.py Normal file
View File

@ -0,0 +1,120 @@
from functools import lru_cache
from collections import namedtuple
def get_tictactoe_rules():
PLAYER_1 = "X"
PLAYER_2 = "O"
UNCHECKED = "_"
Rule = namedtuple("Rule", ["moves", "next_move"])
Move = namedtuple("Move", ["turn", "field"])
def all_rows(board):
""" Returns all rows of a Sudoku field. """
f = board
return [
f[0:3], f[3:6], f[6:9], # horizontals
f[0:7:3], f[1:8:3], f[2:9:3], # verticals
f[0:9:4], f[2:7:2] # diagonals
]
assert(all_rows(list(range(9))) == [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6]])
def all_equal_to(row, value):
""" Returns True if all elements in row are
equal to value. Returns False otherwise. """
for elem in row:
if elem != value:
return False
return True
def player_won(board, player):
for row in all_rows(board):
if all_equal_to(row, player):
return True
return False
def player_lost(board, player):
other_player = PLAYER_2 if player == PLAYER_1 else PLAYER_1
return player_won(board, other_player)
def game_over(board):
for field in board:
if field == UNCHECKED:
return False
return True
def get_unchecked_fields(board):
return [field_index for field_index in range(len(board))
if board[field_index] == UNCHECKED]
def get_new_board(board, field_index, player):
new_board = list(board)
new_board[field_index] = player
return tuple(new_board)
def get_empty_board():
return tuple([UNCHECKED for _ in range(9)])
@lru_cache(maxsize=2**16)
def get_equity(board, player):
if player_won(board, player):
return 1
elif player_lost(board, player):
return -1
elif game_over(board):
return 0
other_player = PLAYER_2 if player == PLAYER_1 else PLAYER_1
equities = []
for field_index in get_unchecked_fields(board):
new_board = get_new_board(board, field_index, player)
equity = get_equity(new_board, other_player)
equities.append(equity)
return min(equities) * -1
bot_move_rules = []
draw_rules = []
win_rules = []
def get_rules(current_board, turn, current_rule, bots_turn):
if player_won(current_board, PLAYER_1):
raise Exception("If we got here our bot screwed up.")
if player_won(current_board, PLAYER_2):
rule = Rule(current_rule.moves, Move(turn, None))
win_rules.append(rule)
return
if game_over(current_board):
rule = Rule(current_rule.moves, Move(turn, None))
draw_rules.append(rule)
return
if not bots_turn:
for field_index in get_unchecked_fields(current_board):
new_board = get_new_board(current_board, field_index, PLAYER_1)
new_moves = current_rule.moves + [Move(turn, field_index)]
new_current_rule = Rule(new_moves, None)
get_rules(new_board, turn + 1, new_current_rule, True)
else:
equities = []
for field_index in get_unchecked_fields(current_board):
new_board = get_new_board(current_board, field_index, PLAYER_2)
equity = get_equity(new_board, PLAYER_1)
equities.append((equity, field_index))
new_index = min(equities)[1]
new_board = get_new_board(current_board, new_index, PLAYER_2)
bot_rule = Rule(current_rule.moves, Move(turn, new_index))
bot_move_rules.append(bot_rule)
get_rules(new_board, turn + 1, current_rule, False)
EMPTY_BOARD = get_empty_board()
get_rules(EMPTY_BOARD, 0, Rule([], None), False)
return (bot_move_rules, draw_rules, win_rules)

View File

@ -63,46 +63,13 @@ body {
opacity: 0.2;
}
.circle {
position: absolute;
height: 100px;
width: 100px;
margin: 10px;
border: 10px solid #dc685a;
border-radius: 50%;
.tic-tac-toe input {
display: none;
}
.cross {
position: absolute;
width: 140px;
height: 140px;
.tic-tac-toe input.turn-0 + label {
z-index: 0;
display: block;
transform:rotate(45deg);
-ms-transform:rotate(45deg);
-webkit-transform:rotate(45deg);
}
.cross:before, .cross:after {
content: "";
position: absolute;
background: #78bec5;
border-radius: 3px;
}
.cross:before {
top: 65px;
bottom: 65px;
left: 0px;
right: 0px;
height: 10px;
width: 140px;
}
.cross:after {
left: 65px;
right: 65px;
height: 140px;
width: 10px;
}
.tic-tac-toe input.col-0 + label {
@ -135,39 +102,49 @@ body {
bottom: 0px;
}
.tic-tac-toe input {
display: none;
/* CSS for creating the O and X check marks on the field. */
.tic-tac-toe .circle {
position: absolute;
height: 100px;
width: 100px;
margin: 10px;
border: 10px solid #dc685a;
border-radius: 50%;
}
.tic-tac-toe input.turn-0 + label {
z-index: 0;
.tic-tac-toe .cross {
position: absolute;
width: 140px;
height: 140px;
display: block;
transform:rotate(45deg);
-ms-transform:rotate(45deg);
-webkit-transform:rotate(45deg);
}
{% for turn in turns_player %}
.tic-tac-toe input.turn-{{turn}}:checked + label {
cursor: default;
opacity: 1.0;
z-index: 10 !important;
.tic-tac-toe .cross:before, .cross:after {
content: "";
position: absolute;
background: #78bec5;
border-radius: 3px;
}
.tic-tac-toe input.turn-{{turn}}:checked ~ .turn-{{turn + 2}} + label {
z-index: {{turn + 2}};
display: block;
.tic-tac-toe .cross:before {
top: 65px;
bottom: 65px;
left: 0px;
right: 0px;
height: 10px;
width: 140px;
}
{% endfor %}
{% for m in moves %}
.tic-tac-toe
{% for p in m.ps %}input.turn-{{p.t}}.field-{{p.i}}:checked ~ {% endfor %}
input.turn-{{m.n.t}}.field-{{m.n.i}} + label {
display: block;
cursor: default;
opacity: 1.0;
z-index: 10 !important;
.tic-tac-toe .cross:after {
left: 65px;
right: 65px;
height: 140px;
width: 10px;
}
{% endfor %}
.tic-tac-toe .end {
width: 440px;
@ -195,34 +172,77 @@ input.turn-{{m.n.t}}.field-{{m.n.i}} + label {
padding: 10px;
}
{% for m in wins2 %}
.tic-tac-toe
{% for p in m.ps %}input.turn-{{p.t}}.field-{{p.i}}:checked ~ {% endfor %}
.end {
display: block;
/*
Rules for player moves. Make selected field permanently visible and make next
board visible. The next player board is the current turn + 2 because every
second board is for the bot.
*/
{%- for turn in turns_player %}
.tic-tac-toe input.turn-{{turn}}:checked + label {
cursor: default;
opacity: 1.0;
z-index: 10 !important;
}
.tic-tac-toe input.turn-{{turn}}:checked ~ .turn-{{turn + 2}} + label {
z-index: {{turn + 2}};
display: block;
}
{% endfor %}
/* BEGIN: bot_move_rules */
{% for rule in bot_move_rules %}
.tic-tac-toe
{% for p in m.ps %}input.turn-{{p.t}}.field-{{p.i}}:checked ~ {% endfor %}
{% for move in rule.moves -%}
input.turn-{{move.turn}}.field-{{move.field}}:checked ~
{% endfor -%}
input.turn-{{rule.next_move.turn}}.field-{{rule.next_move.field}} + label {
display: block;
cursor: default;
opacity: 1.0;
z-index: 10 !important;
}
{% endfor %}
/* END: bot_move_rules */
/* BEGING: Rules for when the bot has won. */
{% for rule in win_rules %}
.tic-tac-toe
{% for move in rule.moves -%}
input.turn-{{move.turn}}.field-{{move.field}}:checked ~
{% endfor -%}
.end {
display: block;
}
.tic-tac-toe
{% for move in rule.moves -%}
input.turn-{{move.turn}}.field-{{move.field}}:checked ~
{% endfor -%}
.end > h1:before {
content: "Bot wins!" !important;
}
{% endfor %}
/* END: Rules for when the bot has won. */
{% endfor %}
{% for m in draws %}
/* BEGING: draws */
{% for rule in draw_rules %}
.tic-tac-toe
{% for p in m.ps %}input.turn-{{p.t}}.field-{{p.i}}:checked ~ {% endfor %}
{% for move in rule.moves -%}
input.turn-{{move.turn}}.field-{{move.field}}:checked ~
{% endfor -%}
.end {
display: block;
z-index: 10 !important;
}
.tic-tac-toe
{% for p in m.ps %}input.turn-{{p.t}}.field-{{p.i}}:checked ~ {% endfor %}
{% for move in rule.moves -%}
input.turn-{{move.turn}}.field-{{move.field}}:checked ~
{% endfor -%}
.end > h1:before {
content: "Tied!" !important;
}
{% endfor %}
{% endfor %}
/* END: draws */

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
from jinja2 import Environment, FileSystemLoader
from bot import get_sudoku_moves
from rules import get_tictactoe_rules
def write_html(html_file):
@ -10,13 +10,13 @@ def write_html(html_file):
def write_css(css_file):
moves, draws, wins2 = get_sudoku_moves()
bot_move_rules, draw_rules, win_rules = get_tictactoe_rules()
kwargs = {
"turns_player": [0, 2, 4, 6, 8],
"turns_bot": [1, 3, 5, 7],
"moves": moves,
"draws": draws,
"wins2": wins2,
"bot_move_rules": bot_move_rules,
"draw_rules": draw_rules,
"win_rules": win_rules,
}
env = Environment(loader=FileSystemLoader("."))
template = env.get_template('template.' + css_file)

View File