From 17075632d07c82c1c692071dd8b2861f965f62bf Mon Sep 17 00:00:00 2001 From: Davte Date: Sun, 9 Jul 2023 21:17:00 +0200 Subject: [PATCH] Final project: chessboard puzzle game --- README.md | 45 ++++++++++++++++- project.py | 126 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 ++ test_project.py | 69 ++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 project.py create mode 100644 requirements.txt create mode 100644 test_project.py diff --git a/README.md b/README.md index 9332b73..c93b4f2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,44 @@ -# cs50p +# Chessboard puzzle game +#### Video Demo: +#### Background information +This script implements a puzzle game described +[here](http://datagenetics.com/blog/december12014/index.html). +I heard about it thanks to 3blue1brown and Stand-up Maths who played +it and explained it [here](https://youtu.be/as7Gkm7Y7h4). -CS50P: CS50's Introduction to Programming with Python \ No newline at end of file +#### Overview of the puzzle: +- Two players are faced with a challenge by a judge. +- The judge will show player 1 (P1) a square chessboard covered in coins. + Each coin will be put heads or tails by the judge at their will. +- The judge will show player 1 a tile of the chessboard where a key will + be hidden. Player 1 must then flip one and only one coin and then leave + the room without communicating with Player 2. +- Player 2 is then shown the chessboard (as modified by the single flip made + by Player 1) and must guess the correct tile where the key is hidden. +- Player 1 and 2 can agree upon a strategy before starting, but the judge + will know what they said and can act accordingly when choosing the coin + disposition on the chessboard. +- No tricks: the players can not communicate after Player 1 is told where the + key is; each coin may be only H or T, its orientation is meaningless, and + it cannot be put in other states (e.g. on its side). + +#### Description +The script generates three pdf files: Problem, Key and Solution. +- `Key.pdf` shows the initial 8x8 grid, filled with random heads (H) or tails + (T), with an orange coin where the invisible key is hidden. + This is how the grid looks like when the judge shows it to Player 1 + asking them to flip one and only one coin. +- `Problem.pdf` shows the same grid with just one coin flipped by Player 1. + This is how the grid looks like when the judge shows it to Player 2 after + Player 1 has flipped one coin. +- `Solution.pdf` shows the same grid, with an orange coin and a purple coin. + The orange one is where the key is hidden, the purple-background coin + is the one to flip to communicate where the key is. + +##### How to play +- Show player 1 the `Key.pdf`: a grid with one key hidden. +- Ask player 1 to flip one and only one coin. They should choose the + purple-background coin in `Solution.pdf`, thus producing the same grid + shown in `Problem.pdf`. +- Show Player 2 `Problem.pdf` and ask them to identify where the key is. + They should point to the orange coin in `Key.pdf` and `Solution.pdf`. diff --git a/project.py b/project.py new file mode 100644 index 0000000..1350bf2 --- /dev/null +++ b/project.py @@ -0,0 +1,126 @@ +"""Final project for CS50P - Davte. See README.md for more information.""" +import math +import random +from typing import Tuple + +import fpdf +import fpdf.table +from fpdf.fonts import FontFace +from fpdf.enums import TextEmphasis + + +BOLD = TextEmphasis.coerce('B') + + +def draw_table(pdf: fpdf.FPDF, state: str, key_position: int = -1, coin_to_flip: int = -1, + highlight_parity_bits: bool = False) -> fpdf.table.Table: + square_side = int(len(state)**0.5) + with pdf.table(text_align='CENTER', + first_row_as_headings=False) as table: + for i, v in enumerate(state): + if i % square_side == 0: + row = table.row() + cell_value = {'0': 'H', '1': 'T'}[v] + cell_style = FontFace() + if i == coin_to_flip: + cell_style.fill_color = (153, 0, 153) + cell_style.emphasis = BOLD + if i == key_position: + cell_style.color = (255, 128, 0) + cell_style.emphasis = BOLD + elif highlight_parity_bits and i in (1, 2, 4, 8, 16, 32): + cell_style.color = (119, 136, 153) + cell_style.emphasis = BOLD + row.cell(cell_value, style=cell_style) + return table + + +def get_parity(n: str) -> int: + num_bits = int(math.log2(len(n))) + parity = 0 + for i in range(num_bits): + block_parity = 0 + for j, val in enumerate(n): + if j & (2**i) == 2**i: + block_parity = block_parity ^ int(val) + parity += block_parity * (2**i) + return parity + + +def get_coin_to_flip(initial_state: str, key_position: int) -> int: + current_value = get_parity(initial_state) + return current_value ^ key_position + + +def store_pdf(file_name, state, key_position: int = -1, coin_to_flip: int = -1): + pdf = fpdf.FPDF(orientation="P", unit="mm", format="A4") + pdf.set_auto_page_break(False) + pdf.add_page() + pdf.set_font("Times", size=100 // math.log2(math.sqrt(len(state)))) + draw_table(pdf=pdf, state=state, key_position=key_position, coin_to_flip=coin_to_flip) + pdf.output(file_name) + + +def solve(initial_state: str, coin_to_flip: int) -> str: + """Return the chessboard in `initial_state` after flipping `coin_to_flip`.""" + result = list(initial_state) + result[coin_to_flip] = '0' if result[coin_to_flip] == '1' else '1' + return ''.join(result) + + +def is_power_of_two(n: int) -> bool: + k = 1 + while k <= n: + if k == n: + return True + k *= 2 + return False + + +def get_parameters(board_side: int = 8) -> Tuple[str, int, int]: + """Generate a random chessboard and a random key position and solve the puzzle. + + Return a board of side `board_side`, a key position and the coin to flip. + """ + if not is_power_of_two(board_side): + raise ValueError("Board side must be a power of two!") + random.seed() + initial_state = ''.join(map(str, (random.randint(0, 1) for _ in range(board_side ** 2)))) + key_position = random.randint(0, board_side ** 2 - 1) + coin_to_flip = get_coin_to_flip(initial_state, key_position) + return initial_state, key_position, coin_to_flip + + +def main() -> None: + board_side = 0 + while board_side < 2: + try: + board_side = input("Choose a side length for the chessboard (press enter for default value 8)\t\t") + if not board_side: + board_side = 8 + board_side = int(board_side) + if not is_power_of_two(board_side): + raise ValueError + except (ValueError, TypeError): + board_side = 0 + print(f"Invalid input `{board_side}`. Please enter a power of two.") + continue + except KeyboardInterrupt: + print("\nExiting...") + return + print(f"Generating a random {board_side} x {board_side} chessboard...") + initial_state, key_position, coin_to_flip = get_parameters(board_side=board_side) + print("Show Player 1 the file `Key.pdf`.") + store_pdf(file_name='Key.pdf', state=initial_state, + key_position=key_position) + final_state = solve(initial_state, coin_to_flip) + print("Once Player 1 has flipped a coin, the chessboard should look like " + "the one in `Problem.pdf`. Show it to Player 2.") + store_pdf(file_name='Problem.pdf', state=final_state) + print("You can use `Solution.pdf` to validate the answer of Player 2.") + store_pdf(file_name='Solution.pdf', state=final_state, + key_position=key_position, coin_to_flip=coin_to_flip) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2f9ca18 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fpdf2 +Pillow~=10.0.0 +pytest~=7.4.0 diff --git a/test_project.py b/test_project.py new file mode 100644 index 0000000..4d1792d --- /dev/null +++ b/test_project.py @@ -0,0 +1,69 @@ +import pytest + +from project import get_coin_to_flip, get_parameters, get_parity, is_power_of_two, solve + + +def main(): + test_get_coin_to_flip() + test_get_parameters() + test_get_parity() + test_is_power_of_two() + test_solve() + + +def test_get_coin_to_flip(): + for i in (4, 8, 16): + initial_state, key_position, _ = get_parameters(board_side=i) + c = get_coin_to_flip(initial_state=initial_state, key_position=key_position) + assert 0 <= c < i**2 + final_state = solve(initial_state, c) + assert get_parity(final_state) == key_position + + +def test_get_parameters(): + for i in (4, 8, 16): + initial_state, key_position, coin_to_flip = get_parameters(board_side=i) + assert len(initial_state) == i ** 2 + assert 0 <= key_position <= 2**i - 1 + assert 0 <= coin_to_flip <= 2**i - 1 + for i in range(3, 5, 7): + with pytest.raises(ValueError): + get_parameters(board_side=i) + + +def test_get_parity(): + assert get_parity('0100') == 1 + assert get_parity('0010') == 2 + assert get_parity('0' * 64) == 0 + assert get_parity('1' * 64) == 0 + assert get_parity('0000' + '0010' + '0000' + '0000') == 6 + assert get_parity('00111011' + '00001001' + '10010101' + '11011011' + '01001110' + '01110000' + '10001101' + '11101001') == 17 + + +def test_is_power_of_two(): + assert not is_power_of_two(0) + for i in range(1, 13): + assert is_power_of_two(2**i) + assert not is_power_of_two(2 ** i + 3) + + +def test_solve(): + for i in (4, 8, 16): + initial_state, _, coin_to_flip = get_parameters(board_side=i) + final_state = solve(initial_state=initial_state, coin_to_flip=coin_to_flip) + for j, (c0, c1) in enumerate(zip(initial_state, final_state)): + assert j == coin_to_flip or c0 == c1 + + +if __name__ == '__main__': + main()