diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml new file mode 100644 index 0000000..3b4b82c --- /dev/null +++ b/.github/workflows/workflow.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [ "develop" ] + pull_request: + branches: [ "develop" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: make install + + - name: Testing + run: make test + + - name: Refactoring + run: make refactor diff --git a/2048/.coverage b/2048/.coverage new file mode 100644 index 0000000..a30cbeb Binary files /dev/null and b/2048/.coverage differ diff --git a/2048/.gitignore b/2048/.gitignore new file mode 100644 index 0000000..2ec1dba --- /dev/null +++ b/2048/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.pytest_cache/ +*.egg-info/ diff --git a/2048/__pycache__/AI.cpython-36.pyc b/2048/__pycache__/AI.cpython-36.pyc deleted file mode 100644 index 337f0ea..0000000 Binary files a/2048/__pycache__/AI.cpython-36.pyc and /dev/null differ diff --git a/2048/__pycache__/AI.cpython-38.pyc b/2048/__pycache__/AI.cpython-38.pyc deleted file mode 100644 index dfd937b..0000000 Binary files a/2048/__pycache__/AI.cpython-38.pyc and /dev/null differ diff --git a/2048/__pycache__/AI.cpython-39.pyc b/2048/__pycache__/AI.cpython-39.pyc deleted file mode 100644 index 064ba0c..0000000 Binary files a/2048/__pycache__/AI.cpython-39.pyc and /dev/null differ diff --git a/2048/__pycache__/UI.cpython-36.pyc b/2048/__pycache__/UI.cpython-36.pyc deleted file mode 100644 index 6623f25..0000000 Binary files a/2048/__pycache__/UI.cpython-36.pyc and /dev/null differ diff --git a/2048/__pycache__/UI.cpython-38.pyc b/2048/__pycache__/UI.cpython-38.pyc deleted file mode 100644 index 822390c..0000000 Binary files a/2048/__pycache__/UI.cpython-38.pyc and /dev/null differ diff --git a/2048/__pycache__/UI.cpython-39.pyc b/2048/__pycache__/UI.cpython-39.pyc deleted file mode 100644 index b460081..0000000 Binary files a/2048/__pycache__/UI.cpython-39.pyc and /dev/null differ diff --git a/2048/__pycache__/board.cpython-36.pyc b/2048/__pycache__/board.cpython-36.pyc deleted file mode 100644 index 6fb3590..0000000 Binary files a/2048/__pycache__/board.cpython-36.pyc and /dev/null differ diff --git a/2048/__pycache__/board.cpython-38.pyc b/2048/__pycache__/board.cpython-38.pyc deleted file mode 100644 index fa89577..0000000 Binary files a/2048/__pycache__/board.cpython-38.pyc and /dev/null differ diff --git a/2048/__pycache__/board.cpython-39.pyc b/2048/__pycache__/board.cpython-39.pyc deleted file mode 100644 index 8fce823..0000000 Binary files a/2048/__pycache__/board.cpython-39.pyc and /dev/null differ diff --git a/2048/__pycache__/image_util.cpython-36.pyc b/2048/__pycache__/image_util.cpython-36.pyc deleted file mode 100644 index a0a869b..0000000 Binary files a/2048/__pycache__/image_util.cpython-36.pyc and /dev/null differ diff --git a/2048/pyproject.toml b/2048/pyproject.toml new file mode 100644 index 0000000..a254627 --- /dev/null +++ b/2048/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "game2048" +version = "0.1.0" +requires-python = ">=3.8" +dependencies = [ + "numpy", + "torch", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "black", + "pylint", +] + +[project.scripts] +play2048 = "game2048.main:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["test"] diff --git a/2048/AI.py b/2048/src/game2048/AI.py similarity index 91% rename from 2048/AI.py rename to 2048/src/game2048/AI.py index 5021cac..f89b9e2 100644 --- a/2048/AI.py +++ b/2048/src/game2048/AI.py @@ -3,7 +3,7 @@ import random import copy import math -from board import Board +from .board import Board class AI(object): def __init__(self, GameBoard, level): @@ -440,58 +440,3 @@ def playTheGame(self): if self.GameBoard.reaches2048(): return 1, self.GameBoard.score else: return 0, self.GameBoard.score -# test -if __name__ == "__main__": - # # novice AI play 100 times - # record = [] - # scores = [] - # for i in range(100): - # testBoard = Board(4) - # noviceAI = AI(testBoard, 0) - # res = noviceAI.playTheGame() - # record.append(res[0]) - # scores.append(res[1]) - # print("Novice AI:") - # print("record:", record) - # print("scores:", scores) - # avgscore = sum(scores)/len(scores) - # print("average score: ", avgscore) - # winrate = sum(record)/len(record) - # print("winrate: ", winrate) - - # competent AI play 20 times - record = [] - scores = [] - for i in range(20): - testBoard = Board(4) - competentAI = AI(testBoard, 2) - res = competentAI.playTheGame() - record.append(res[0]) - scores.append(res[1]) - print("Competent AI:") - print("record:", record) - print("scores:", scores) - avgscore = sum(scores)/len(scores) - print("average score: ", avgscore) - winrate = sum(record)/len(record) - print("winrate: ", winrate) - - # # proficient AI play 20 times - # record = [] - # scores = [] - # for i in range(20): - # testBoard = Board(4) - # competentAI = AI(testBoard, 2) - # res = competentAI.playTheGame() - # record.append(res[0]) - # scores.append(res[1]) - # print("Proficient AI:") - # print("record:", record) - # print("scores:", scores) - # avgscore = sum(scores)/len(scores) - # print("average score: ", avgscore) - # winrate = sum(record)/len(record) - # print("winrate: ", winrate) - - - diff --git a/2048/RL.py b/2048/src/game2048/RL.py similarity index 99% rename from 2048/RL.py rename to 2048/src/game2048/RL.py index 763f055..339d44a 100644 --- a/2048/RL.py +++ b/2048/src/game2048/RL.py @@ -10,7 +10,7 @@ import torch.nn.functional as F import numpy as np -from board import Board +from .board import Board # Reference: https://github.com/navjindervirdee/2048-deep-reinforcement-learning # for deep reinforcement learning: currently only consider board with size 4 (can be improved later) diff --git a/2048/UI.py b/2048/src/game2048/UI.py similarity index 72% rename from 2048/UI.py rename to 2048/src/game2048/UI.py index 9c1f23f..f2fdfc4 100644 --- a/2048/UI.py +++ b/2048/src/game2048/UI.py @@ -1,7 +1,7 @@ ## 2048 game user interface (UI), using tkinter from tkinter import * -from board import Board -from AI import AI +from .board import Board +from .AI import AI import copy import string @@ -43,8 +43,10 @@ def init(self, data): data.paused = False # time settings - data.timerDelay = 1000 - data.timeCounter = 0 + data.timerDelay = 50 # base clock tick (ms), drives the elapsed-time display + data.aiMoveIntervalMs = 200 # how often the AI actually makes a move + data.msSinceLastAIMove = 0 + data.elapsedMs = 0 # color settings data.colors = {0 : "#D7CCC8", @@ -70,14 +72,19 @@ def init(self, data): data.AIstep = 0 data.AI = None + # player settings + data.playerStep = 0 + def resetGameBoard(self, data): self.GameBoard = Board(data.size) data.board = self.GameBoard.board data.cellSize = (data.width - data.margin * 2) / data.size - data.timeCounter = 0 + data.elapsedMs = 0 + data.msSinceLastAIMove = 0 data.reach2048 = False data.continuePlaying = False data.cannotMove = False + data.playerStep = 0 def mousePressed(self, event, data): # three buttons in the start page @@ -100,12 +107,16 @@ def mousePressed(self, event, data): # customize size page elif data.customizeSizeMode: + layout = self.customizeSizeLayout(data) + sizeLeft, sizeTop, sizeRight, sizeBottom = layout["sizeBox"] + finishLeft, finishTop, finishRight, finishBottom = layout["finishBox"] + # press the empty "size button" to enter the board size - if data.width//2 <= event.x <= data.width*(3/4) and data.height//2 <= event.y <= data.height*(3/5): + if sizeLeft <= event.x <= sizeRight and sizeTop <= event.y <= sizeBottom: data.selectingBoardSize = True # press the finish button to enter the game state - elif data.width*(3/8) <= event.x <= data.width*(5/8) and data.height*(1/4+1/15) <= event.y <= data.height*(7/20+1/15): + elif finishLeft <= event.x <= finishRight and finishTop <= event.y <= finishBottom: self.resetGameBoard(data) if data.AImode: data.levelSelectionMode = True else: data.inGame = True @@ -134,6 +145,15 @@ def mousePressed(self, event, data): data.inGame = True def keyPressed(self, event, data): + # Esc: leave a paused game or a finished game (win or game over) and + # return to the home page + if event.keysym == "Escape" and ( + (data.inGame and data.paused) + or (not data.inGame and (data.reach2048 or data.cannotMove)) + ): + self.init(data) + return + # customize size mode if data.customizeSizeMode: if data.selectingBoardSize and event.keysym in string.digits: @@ -168,9 +188,10 @@ def keyPressed(self, event, data): elif direction == "Right": canMove = self.GameBoard.moveRight() # add a new number after each legal move - if canMove: - data.newTileIndex, data.newTileNum = self.GameBoard.addNewTile() + if canMove: + data.newTileIndex, data.newTileNum = self.GameBoard.addNewTile() print("data.newTileIndex: ", data.newTileIndex) + data.playerStep += 1 else: print("cannot move in this direction") self.GameBoard.printBoard() data.board = self.GameBoard.board @@ -216,40 +237,63 @@ def drawStartPage(self, canvas, data): canvas.create_rectangle(data.width*(5/6), data.height*(9/10), data.width*(29/30), data.height*(29/30), fill="light grey") canvas.create_text(data.width*(9/10), data.height*(14/15), text="Quit", font="Arial 30 bold") + # scale a font size off canvas width so text never overflows regardless of window size + def scaledFont(self, data, divisor): + return max(10, int(data.width / divisor)) + + # shared geometry for the customize-size page, used by both drawing and click handling + def customizeSizeLayout(self, data): + return { + "sizeBox": (data.width*(1/4), data.height*(2/5), data.width*(3/4), data.height*(1/2)), + "finishBox": (data.width*(3/8), data.height*(7/10), data.width*(5/8), data.height*(4/5)), + } + # customize size page def drawCustomizeSizePage(self, canvas, data): - canvas.create_rectangle(0, 0, data.width, data.height, fill="cyan") - canvas.create_text(data.width//2, data.height//4, text="Select Your Size Here!", font="Arial 50 bold", fill="purple") + canvas.create_rectangle(0, 0, data.width, data.height, fill="#ECEFF1") + canvas.create_text(data.width//2, data.height*(1/6), text="Choose Your Board Size", + font=("Arial", self.scaledFont(data, 16), "bold"), fill="#FDD835") - # sizes (4-10) - canvas.create_text(data.width//4, data.height*(11/20), text="Board Size(Width):", font="Arial 30") - if data.selectingBoardSize: - canvas.create_rectangle(data.width//2, data.height//2, data.width*(3/4), data.height*(3/5), fill="light goldenrod") - else: - canvas.create_rectangle(data.width//2, data.height//2, data.width*(3/4), data.height*(3/5), fill="lemon chiffon") - canvas.create_text(data.width*(5/8), data.height*(11/20), text=data.size, font="Arial 30") - canvas.create_text(data.width*(7/8), data.height*(11/20), text="(4-10)", font="Arial 30") + layout = self.customizeSizeLayout(data) + sizeLeft, sizeTop, sizeRight, sizeBottom = layout["sizeBox"] + finishLeft, finishTop, finishRight, finishBottom = layout["finishBox"] + sizeCenterX, sizeCenterY = (sizeLeft+sizeRight)/2, (sizeTop+sizeBottom)/2 + finishCenterX, finishCenterY = (finishLeft+finishRight)/2, (finishTop+finishBottom)/2 - # finish button - canvas.create_rectangle(data.width*(3/8), data.height*(1/4+1/15), data.width*(5/8), data.height*(7/20+1/15), fill="lemon chiffon") - canvas.create_text(data.width//2, data.height*(3/10+1/15), text="Finish!", font="Arial 35") + # label above the size box + canvas.create_text(sizeCenterX, sizeTop - data.height*0.06, text="Board Size (Width)", + font=("Arial", self.scaledFont(data, 32)), fill="#546E7A") - # AI mode level selection page - def drawLevelSelectionPage(self, canvas, data): - canvas.create_rectangle(0, 0, data.width, data.height, fill="cyan") - canvas.create_text(data.width//2, data.height//4, text="Select a Level!", font="Arial 55 bold", fill="purple") + # size box itself, highlighted while the player is entering a number + boxFill = "light goldenrod" if data.selectingBoardSize else "lemon chiffon" + canvas.create_rectangle(sizeLeft, sizeTop, sizeRight, sizeBottom, fill=boxFill, outline="#795548", width=2) + canvas.create_text(sizeCenterX, sizeCenterY, text=str(data.size), + font=("Arial", self.scaledFont(data, 12), "bold"), fill="#795548") - # novice mode - canvas.create_rectangle(data.width//4, data.height//2, data.width*(3/4), data.height*(3/5), fill="lemon chiffon") - canvas.create_text(data.width//2, data.height*(11/20), text="Novice", font="Arial 35 bold") + # hint below the size box + canvas.create_text(sizeCenterX, sizeBottom + data.height*0.04, text="press a number key: 4-9, or 1 for 10", + font=("Arial", self.scaledFont(data, 34)), fill="#78909C") - # competent mode - canvas.create_rectangle(data.width//4, data.height*(3/5+1/30), data.width*(3/4), data.height*(7/10+1/30), fill="lemon chiffon") - canvas.create_text(data.width//2, data.height*(13/20+1/30), text="Competent", font="Arial 35 bold") + # finish button + canvas.create_rectangle(finishLeft, finishTop, finishRight, finishBottom, fill="lemon chiffon", outline="#795548", width=2) + canvas.create_text(finishCenterX, finishCenterY, text="Finish!", + font=("Arial", self.scaledFont(data, 20), "bold")) - # expert mode - canvas.create_rectangle(data.width//4, data.height*(7/10+1/15), data.width*(3/4), data.height*(4/5+1/15), fill="lemon chiffon") - canvas.create_text(data.width//2, data.height*(3/4+1/15), text="Expert", font="Arial 35 bold") + # AI mode level selection page + def drawLevelSelectionPage(self, canvas, data): + canvas.create_rectangle(0, 0, data.width, data.height, fill="#ECEFF1") + canvas.create_text(data.width//2, data.height*(1/6), text="Select a Level!", + font=("Arial", self.scaledFont(data, 15), "bold"), fill="#FDD835") + + # (label, box top, box bottom, label y) - same regions used by mousePressed + levels = [ + ("Novice", data.height//2, data.height*(3/5), data.height*(11/20)), + ("Competent", data.height*(3/5+1/30), data.height*(7/10+1/30), data.height*(13/20+1/30)), + ("Expert", data.height*(7/10+1/15), data.height*(4/5+1/15), data.height*(3/4+1/15)), + ] + for label, top, bottom, labelY in levels: + canvas.create_rectangle(data.width//4, top, data.width*(3/4), bottom, fill="lemon chiffon", outline="#795548", width=2) + canvas.create_text(data.width//2, labelY, text=label, font=("Arial", self.scaledFont(data, 20), "bold")) # game page def drawCell(self, canvas, data, row, col): @@ -273,16 +317,22 @@ def drawBoard(self, canvas, data): canvas.create_text(data.margin+data.cellSize/2+col*data.cellSize, data.titlePlace+data.margin+data.cellSize/2+row*data.cellSize, text=data.board[row][col], font = ("Arial", textSize), fill="black") + def formatElapsedTime(self, elapsedMs): + totalSeconds, ms = divmod(elapsedMs, 1000) + minutes, seconds = divmod(totalSeconds, 60) + return "%02d:%02d.%03d" % (minutes, seconds, ms) + def drawGamePage(self, canvas, data): canvas.create_rectangle(0, 0, data.width, data.height, fill="#EFEBE9") canvas.create_text(data.width//4, data.titlePlace//2, text="2048", font="Arial 60 bold", fill="#795548") if data.reach2048: canvas.create_text(data.width//4, data.titlePlace*(3/4), text="Reached 2048", font="TimesNewRoman 30 bold", fill="red") + canvas.create_text(data.width*(3/4), data.titlePlace*(1/4), text="Score:"+str(self.GameBoard.score), font="Arial 23 bold", fill="purple") if data.AImode: # AI mode - canvas.create_text(data.width//2, data.titlePlace//2, text="Step:"+str(data.AIstep), font="Arial 23 bold", fill="purple") + canvas.create_text(data.width*(3/4), data.titlePlace*(2/4), text="Step:"+str(data.AIstep), font="Arial 23 bold", fill="purple") else: # player mode - canvas.create_text(data.width//2, data.titlePlace//2, text= "Time:"+str(data.timeCounter), font="Arial 23 bold", fill="purple") - canvas.create_text(data.width*(3/4), data.titlePlace//2, text="Score:"+str(self.GameBoard.score), font="Arial 23 bold", fill="purple") + canvas.create_text(data.width*(3/4), data.titlePlace*(2/4), text="Step:"+str(data.playerStep), font="Arial 23 bold", fill="purple") + canvas.create_text(data.width*(3/4), data.titlePlace*(3/4), text="Time:"+self.formatElapsedTime(data.elapsedMs), font="Arial 23 bold", fill="purple") self.drawBoard(canvas, data) # Game paused @@ -340,17 +390,21 @@ def AImove(self, data): def timerFired(self, data): if data.inGame and not (data.reach2048 or data.cannotMove) and not data.paused: - data.timeCounter += 1 + # data.timerDelay is a fast, constant clock tick (in both modes) so the + # elapsed-time display can show smooth sub-second precision; the AI's + # actual move rate is throttled separately via aiMoveIntervalMs + data.elapsedMs += data.timerDelay if data.AImode: - data.timerDelay = 200 - if not self.GameBoard.GameOver(): - # move twice in each timer delay period - self.AImove(data) - else: - data.inGame = False - if self.GameBoard.reaches2048(): data.reach2048 = True - else: data.cannotMove = True + data.msSinceLastAIMove += data.timerDelay + if data.msSinceLastAIMove >= data.aiMoveIntervalMs: + data.msSinceLastAIMove = 0 + if not self.GameBoard.GameOver(): + self.AImove(data) + else: + data.inGame = False + if self.GameBoard.reaches2048(): data.reach2048 = True + else: data.cannotMove = True def runGame(self, width, height): # tkinter starter code def redrawAllWrapper(canvas, data): diff --git a/2048/src/game2048/__init__.py b/2048/src/game2048/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/2048/board.py b/2048/src/game2048/board.py similarity index 80% rename from 2048/board.py rename to 2048/src/game2048/board.py index 79b04bd..4ade788 100644 --- a/2048/board.py +++ b/2048/src/game2048/board.py @@ -205,72 +205,3 @@ def move(self): print("Game Over!\n") break print("reach here after game is over!") - - -# test -if __name__ == "__main__": - board = Board(4) - board.printBoard() - board.addNewTile() - board.printBoard() - board.addNewTile() - board.addNewTile() - board.addNewTile() - board.addNewTile() - board.addNewTile() - board.printBoard() - print("after moving left:") - board.moveLeft() - board.printBoard() - - board1 = copy.deepcopy(board) - print("-----------after transpose:") - board1.transpose() - board1.printBoard() - print("after mirror:") - board1.mirror() - board1.printBoard() - - print("------------after rotation:") - board.rotate90Clockwise() - board.printBoard() - - print("------------rotate back:") - board.rotate90CounterClockwise() - board.printBoard() - - print("after moving down:") - board.moveDown() - board.printBoard() - - print("after moving up:") - board.moveUp() - board.printBoard() - - print("after moving right:") - board.moveRight() - board.printBoard() - - print("game over tests:") - # test with infinite random moves - board = Board(6) - while not board.GameOver(): - canMove = False - direction = random.choice(board.directionList) - print("direction:", direction) - if direction == "Up": - canMoveUp = board.moveUp() - canMove = canMoveUp - elif direction == "Down": - canMoveDown = board.moveDown() - canMove = canMoveDown - elif direction == "Left": - canMoveLeft = board.moveLeft() - canMove = canMoveLeft - elif direction == "Right": - canMoveRight = board.moveRight() - canMove = canMoveRight - # add a new number after each legal move - if canMove: board.addNewTile() - else: print("cannot move in this direction") - board.printBoard() diff --git a/2048/2048.py b/2048/src/game2048/main.py similarity index 72% rename from 2048/2048.py rename to 2048/src/game2048/main.py index fb9dcf1..3189095 100644 --- a/2048/2048.py +++ b/2048/src/game2048/main.py @@ -1,5 +1,5 @@ ## 2048 game -from UI import UI +from .UI import UI class Game(object): def __init__(self): @@ -8,5 +8,8 @@ def __init__(self): def run2048Game(self): self.GameUI.runGame(600, 750) +def main(): + Game().run2048Game() + if __name__ == "__main__": - Game().run2048Game() \ No newline at end of file + main() \ No newline at end of file diff --git a/2048/test/test_ai.py b/2048/test/test_ai.py new file mode 100644 index 0000000..004754e --- /dev/null +++ b/2048/test/test_ai.py @@ -0,0 +1,114 @@ +## pytest tests for AI.py + +import pytest + +from game2048.board import Board +from game2048.AI import AI + + +def make_board(rows, score=0): + """Build a Board with a fixed size and layout, bypassing the random init tile.""" + size = len(rows) + board = Board(size) + board.board = [row[:] for row in rows] + board.score = score + return board + + +def make_ai(rows, level=2, score=0): + board = make_board(rows, score=score) + return AI(board, level) + + +def test_evaluate_uses_weight_board(): + ai = make_ai([ + [2, 0], + [0, 0], + ]) + assert ai.evaluate() == 2 * ai.weightBoard[0][0] + + +def test_evaluate_sums_weighted_tiles(): + ai = make_ai([ + [2, 4], + [0, 8], + ]) + expected = ( + 2 * ai.weightBoard[0][0] + + 4 * ai.weightBoard[0][1] + + 8 * ai.weightBoard[1][1] + ) + assert ai.evaluate() == expected + + +def test_get_legal_moves_excludes_blocked_directions(): + # only row 0 is filled with a mergeable pair: Up is blocked (already at + # the top), Down/Left/Right all change the board. + ai = make_ai([ + [2, 2, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + legalMoves = ai.getLegalMoves() + assert sorted(legalMoves) == [1, 2, 3] # Down, Left, Right + + +def test_get_legal_moves_empty_when_board_is_stuck(): + ai = make_ai([ + [2, 4, 2, 4], + [4, 2, 4, 2], + [2, 4, 2, 4], + [4, 2, 4, 2], + ]) + assert ai.getLegalMoves() == [] + + +def test_perform_action_applies_move_and_adds_new_tile(): + ai = make_ai([ + [2, 2, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + ai.performAction(2) # Left + + # the merge always lands in the leftmost column; the newly added tile + # can land on any empty cell, so only check what performAction guarantees. + assert ai.GameBoard.board[0][0] == 4 + assert ai.GameBoard.score == 4 + nonEmpty = sum(1 for row in ai.GameBoard.board for x in row if x != 0) + assert nonEmpty == 2 # merged tile + the newly added tile + + +def test_add_new_num_places_requested_value(): + ai = make_ai([ + [0, 0], + [0, 0], + ]) + ai.addNewNum(((1, 1), 4)) + assert ai.GameBoard.board[1][1] == 4 + + +def test_add_new_num_rejects_non_empty_tile(): + ai = make_ai([ + [2, 0], + [0, 0], + ]) + with pytest.raises(AssertionError): + ai.addNewNum(((0, 0), 4)) + + +def test_get_important_tiles_returns_highest_weight_empty_tiles(): + ai = make_ai([ + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + importantTiles = ai.getImporantTiles(3) + allTiles = [(i, j) for i in range(4) for j in range(4)] + expected = sorted( + allTiles, key=lambda coord: ai.weightBoard[coord[0]][coord[1]], reverse=True + )[:3] + assert importantTiles == expected diff --git a/2048/test/test_board.py b/2048/test/test_board.py new file mode 100644 index 0000000..b6e83a0 --- /dev/null +++ b/2048/test/test_board.py @@ -0,0 +1,227 @@ +## pytest tests for board.py + +import copy +import pytest + +from game2048.board import Board + + +def make_board(rows, score=0): + """Build a Board with a fixed size and layout, bypassing the random init tile.""" + size = len(rows) + board = Board(size) + board.board = [row[:] for row in rows] + board.score = score + return board + + +def test_init_board_has_exactly_one_starting_tile(): + board = Board(4) + assert board.size == 4 + assert len(board.board) == 4 + assert all(len(row) == 4 for row in board.board) + + nonEmpty = [(i, j) for i in range(4) for j in range(4) if board.board[i][j] != 0] + assert len(nonEmpty) == 1 + i, j = nonEmpty[0] + assert board.board[i][j] in (2, 4) + assert board.score == 0 + + +def test_add_new_tile_fills_an_empty_cell(): + board = make_board([ + [2, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + addIndex, addNum = board.addNewTile() + + assert addNum in (2, 4) + assert board.board[addIndex[0]][addIndex[1]] == addNum + nonEmpty = sum(1 for row in board.board for x in row if x != 0) + assert nonEmpty == 2 + + +def test_is_same_board(): + board = Board(4) + b1 = [[1, 2], [3, 4]] + b2 = [[1, 2], [3, 4]] + b3 = [[1, 2], [3, 5]] + assert board.isSameBoard(b1, b2) is True + assert board.isSameBoard(b1, b3) is False + + +def test_move_left_merges_and_shifts(): + board = make_board([ + [2, 2, 0, 4], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + canMove = board.moveLeft() + assert canMove is True + assert board.board[0] == [4, 4, 0, 0] + assert board.score == 4 + + +def test_move_left_merges_only_once_per_pair(): + board = make_board([ + [2, 2, 2, 2], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + board.moveLeft() + assert board.board[0] == [4, 4, 0, 0] + assert board.score == 8 + + +def test_move_left_returns_false_when_no_move_possible(): + board = make_board([ + [2, 4, 8, 16], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + canMove = board.moveLeft() + assert canMove is False + assert board.board[0] == [2, 4, 8, 16] + + +def test_transpose(): + board = make_board([ + [1, 2], + [3, 4], + ]) + board.transpose() + assert board.board == [[1, 3], [2, 4]] + + +def test_mirror(): + board = make_board([ + [1, 2], + [3, 4], + ]) + board.mirror() + assert board.board == [[2, 1], [4, 3]] + + +def test_rotate90_clockwise(): + board = make_board([ + [1, 2], + [3, 4], + ]) + board.rotate90Clockwise() + assert board.board == [[3, 1], [4, 2]] + + +def test_rotate180(): + board = make_board([ + [1, 2], + [3, 4], + ]) + board.rotate180() + assert board.board == [[4, 3], [2, 1]] + + +def test_rotate90_counterclockwise(): + board = make_board([ + [1, 2], + [3, 4], + ]) + board.rotate90CounterClockwise() + assert board.board == [[2, 4], [1, 3]] + + +def test_rotate90_clockwise_then_counterclockwise_is_identity(): + original = [ + [1, 2, 3, 4], + [5, 6, 7, 8], + [9, 10, 11, 12], + [13, 14, 15, 16], + ] + board = make_board(original) + board.rotate90Clockwise() + board.rotate90CounterClockwise() + assert board.board == original + + +def test_move_down_merges_column(): + board = make_board([ + [2, 0, 0, 0], + [2, 0, 0, 0], + [0, 0, 0, 0], + [4, 0, 0, 0], + ]) + canMove = board.moveDown() + assert canMove is True + expectedCol0 = [0, 0, 4, 4] + assert [board.board[i][0] for i in range(4)] == expectedCol0 + assert board.score == 4 + + +def test_move_up_merges_column(): + board = make_board([ + [2, 0, 0, 0], + [0, 0, 0, 0], + [2, 0, 0, 0], + [4, 0, 0, 0], + ]) + canMove = board.moveUp() + assert canMove is True + expectedCol0 = [4, 4, 0, 0] + assert [board.board[i][0] for i in range(4)] == expectedCol0 + assert board.score == 4 + + +def test_move_right_merges_and_shifts(): + board = make_board([ + [4, 0, 2, 2], + [0, 0, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ]) + canMove = board.moveRight() + assert canMove is True + assert board.board[0] == [0, 0, 4, 4] + assert board.score == 4 + + +def test_reaches2048_false_when_below_threshold(): + board = make_board([ + [1024, 0], + [0, 0], + ]) + assert board.reaches2048() is False + + +def test_reaches2048_true_when_tile_at_or_above_threshold(): + board = make_board([ + [2048, 0], + [0, 0], + ]) + assert board.reaches2048() is True + + +def test_game_over_false_when_a_move_is_available(): + board = make_board([ + [2, 4, 8, 16], + [4, 8, 16, 32], + [8, 16, 32, 64], + [16, 32, 2, 2], + ]) + assert board.GameOver() is False + # GameOver should restore the board and score after probing moves + assert board.board[3][2:] == [2, 2] + assert board.score == 0 + + +def test_game_over_true_when_no_moves_and_no_merges_possible(): + board = make_board([ + [2, 4, 2, 4], + [4, 2, 4, 2], + [2, 4, 2, 4], + [4, 2, 4, 2], + ]) + assert board.GameOver() is True diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..00c2cd6 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +install: + pip install --upgrade pip + pip install -e "./2048[dev]" + +test: + cd 2048 && python -m pytest -vv --cov=game2048 test + +format: + black 2048/src 2048/test + +lint: + pylint --disable=R,C,pointless-string-statement,undefined-variable,no-member,function-redefined,unused-variable,unused-import,redefined-outer-name --ignore=UI.py --ignore-patterns=test_.*?py 2048/src/game2048 + +refactor: format lint + +all: install test refactor diff --git a/README.md b/README.md index 4091ae7..94ed265 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,63 @@ 2048 game implemented by python3 and tkinter AI part: Expectiminimax with alpha-beta pruning (Normal level), Reinforcement Learning (Hard level) + +## Requirements + +- Python 3.8+ +- tkinter (bundled with the standard Windows/macOS Python installers; on Linux you may need to install it separately, e.g. `sudo apt install python3-tk`) + +## Installation + +The game lives in the `2048/` folder, which is a self-contained Python package (`game2048`, `src/` layout). Install it in editable mode: + +```bash +cd 2048 +pip install -e . +``` + +This installs the `game2048` package plus a `play2048` console command. + +## Running the game + +After installing, either of these will launch the game window: + +```bash +play2048 +``` + +```bash +python -m game2048.main +``` + +### Controls + +- **Arrow keys** — move tiles (Player mode) +- **p** — pause / unpause +- **Esc** — while paused, or after winning/losing, return to the home page +- **r** — restart (once the game is over) +- **c** — keep playing after reaching 2048 + +## Running the tests + +From the `2048/` folder (after installing the package as above): + +```bash +pytest +``` + +## Project structure + +``` +2048/ + pyproject.toml # package metadata, editable-install config, pytest config + src/game2048/ + board.py # board state and move/merge logic + AI.py # Expectiminimax / alpha-beta AI opponent + RL.py # reinforcement-learning experiments (not wired into the UI yet) + UI.py # tkinter UI and game loop + main.py # entry point (play2048 / python -m game2048.main) + test/ + test_board.py + test_ai.py +```