From 98b9ed2dba89de869daeb967526aea0eb3f2ecf9 Mon Sep 17 00:00:00 2001 From: Chris Wesseling Date: Mon, 4 Dec 2023 23:24:41 +0100 Subject: [PATCH] =?UTF-8?q?:recycle:=20Refactor=201=20=F0=9F=A5=94=202=20?= =?UTF-8?q?=F0=9F=A5=94=20=E2=9E=B0y=20kind=20of=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds some more Array flavour to the Rust flavoured Python. Scalars 1 and 2 have no Direction, Vectors do. Kept the Direction Enum, but Vector inherits from tuple, because Python Enums are weird. Members of Enums that inherit from object don't get the wrapped dunder methods like the int, str and apparently tuple, do: e.g. we'd have to write `Direction.ACROSS.value` everywhere. --- .gitignore | 2 +- pyproject.toml | 9 +++ scrabble/python/__init__.py | 0 scrabble/python/board.py | 56 +++++++++++++++-- scrabble/python/main.py | 89 +++++++++++++++++---------- scrabble/python/solver.py | 4 +- scrabble/python/tests/test_main.py | 97 ++++++++++++++++++++++++++++++ scrabble/requirements-dev.txt | 2 + scrabble/requirements.txt | 2 + 9 files changed, 221 insertions(+), 40 deletions(-) create mode 100644 pyproject.toml create mode 100644 scrabble/python/__init__.py create mode 100644 scrabble/python/tests/test_main.py diff --git a/.gitignore b/.gitignore index f6fa1ba..c18dd8d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -scrabble/python/__pycache__/* \ No newline at end of file +__pycache__/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ac68ea2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +# TODO: This Rust flavoured Python project has a cmake flavoured layout +# with sprinkles of information in the dictionary directory. +# Someday python packaging will converge and this is file is us +# singing gospel. + +[tool.pytest.ini_options] +pythonpath = [ + "scrabble/python", +] diff --git a/scrabble/python/__init__.py b/scrabble/python/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scrabble/python/board.py b/scrabble/python/board.py index 03121bf..abb6df7 100644 --- a/scrabble/python/board.py +++ b/scrabble/python/board.py @@ -1,18 +1,62 @@ # Inital code taken from https://github.com/boringcactus/Appel-Jacobson-scrabble/blob/canon/board.py +from __future__ import annotations + import copy import itertools as it from dataclasses import dataclass -from enum import IntEnum +from enum import Enum +from typing import SupportsIndex, TypeVar, overload + +from typing_extensions import Self Letter= str # synonym for str, but make it clear that it's a letter CellCoord = tuple[int, int] +_T = TypeVar("_T") + +class Vector(tuple[int, ...]): + def __new__(cls, arg=(), *rest): + return super().__new__(cls, (arg, *rest) if rest else arg) + + @property + def zero(self) -> Self: + "the zero vector with same dimensionality" + return self.__class__(len(self) * (0,)) + + def __repr__(self) -> str: + return f"" + + def __bool__(self) -> bool: + return any(self) + + def __mul__(self, scalar: SupportsIndex) -> Vector: + # returning Vector() not self.__class__() because self might be a Direction + # and Direction is very narrow type + return Vector(scalar.__index__() * c for c in self) + + __rmul__ = __mul__ + + def __neg__(self) -> Vector: + return -1 * self + + @overload + def __add__(self, other: tuple[int, ...]) -> Vector: ... + + @overload + def __add__(self, other: tuple[_T, ...]) -> Vector: ... + + def __add__(self, other): + return Vector(an + bn for an, bn in zip(self, other)) + + __radd__ = __add__ + -class Direction(IntEnum): - ACROSS = 1 - DOWN = 2 +class Direction(Vector, Enum): + NONE = Vector(0, 0) + DOWN = Vector(0, -1) + ACROSS = Vector(1, 0) @dataclass(frozen=True, order=True) @@ -46,7 +90,7 @@ def set_tile(self, pos: CellCoord, tile: Letter) -> None: def in_bounds(self, pos: CellCoord) -> bool: row, col = pos - return row >= 0 and row < self.size and col >= 0 and col < self.size + return 0 <= row < self.size and 0 <= col < self.size def is_empty(self, pos: CellCoord) -> bool: return self.in_bounds(pos) and self.tile(pos) == "." @@ -57,5 +101,5 @@ def is_filled(self, pos: CellCoord) -> bool: def is_first_turn(self) -> bool: return all("." == c for c in it.chain(*self._tiles)) - def copy(self) -> "Board": # This is a recursive type annotation, actually a limitation of mypy + def copy(self) -> Self: return copy.deepcopy(self) diff --git a/scrabble/python/main.py b/scrabble/python/main.py index 51240ed..f0bcc81 100644 --- a/scrabble/python/main.py +++ b/scrabble/python/main.py @@ -4,6 +4,7 @@ Started from https://arcade.academy/examples/array_backed_grid.html#array-backed-grid """ +import itertools as it import os import random import sys @@ -11,14 +12,16 @@ from collections import defaultdict from dataclasses import dataclass from enum import Enum +from typing import Iterator import arcade +import more_itertools as mit from colorama import Fore, Style, init from numpy import sign from result import Err, Ok -from board import Board, CellCoord, Direction, Letter, Position -from solver import CellCoord, SolverState +from board import Board, CellCoord, Direction, Letter, Position, Vector +from solver import SolverState from trie import nwl_2020 Color = tuple[int, int, int] @@ -137,16 +140,31 @@ class Play: class Cursor: x: int y: int - dir: Direction | None + dir: Direction + def __init__(self): - self.dir = None + self.dir = Direction.NONE self.x = 7 self.y = 7 + def __add__(self, dir: Vector) -> Vector: + return Vector(max(0, min(a + d, ROW_COUNT)) for d, a in zip(dir, self)) + + __radd__ = __add__ + + def __iadd__(self, dir: Vector): + self.x, self.y = self + dir + return self + + def __iter__(self) -> Iterator[int]: + yield self.x + yield self.y + def rotate_dir(self): - if self.dir is None: self.dir = Direction.ACROSS - elif self.dir == Direction.ACROSS: self.dir = Direction.DOWN - else: self.dir = None + # That's a rotate... an infinite rotate + directions = it.cycle(Direction) + self.dir = mit.nth(it.dropwhile(lambda d: d != self.dir, directions), 2) + class Player: tiles: list[Letter] @@ -413,7 +431,7 @@ def on_draw(self): yd += 11 # Draw cursor - if self.cursor.dir is not None and len(self.letters_typed) == 0: + if any(self.cursor.dir) and len(self.letters_typed) == 0: arrow = "→" if self.cursor.dir == Direction.ACROSS else "↓" x = (MARGIN + WIDTH) * self.cursor.x + MARGIN + WIDTH // 2 y = (MARGIN + HEIGHT) * self.cursor.y + MARGIN + HEIGHT // 2 + BOTTOM_MARGIN @@ -640,26 +658,33 @@ def on_key_release(self, key, modifiers): self.play_word(self.player_plays[-self.pause_for_analysis_rank], None) else: - if self.cursor.dir is None: + if not any(self.cursor.dir): self.cursor.dir = Direction.ACROSS else: if modifiers == arcade.key.MOD_CTRL: self.cursor.dir = Direction.ACROSS if key in LR_ARROW_KEYS else Direction.DOWN - xd = -1 if key == arcade.key.LEFT else 1 if key == arcade.key.RIGHT else 0 - yd = -1 if key == arcade.key.DOWN else 1 if key == arcade.key.UP else 0 - while self.grid.is_empty((14 - (self.cursor.y + yd), self.cursor.x + xd)): - self.cursor.x += xd - self.cursor.y += yd + direction = ( + -1 if key in [arcade.key.LEFT, arcade.key.UP] else 1 + ) * self.cursor.dir + ray = (self.cursor + s * direction for s in range(1, self.grid.size)) + self.cursor.x, self.cursor.y = mit.last( + it.takewhile( + lambda pos: self.grid.is_empty( + (14 - pos[1], pos[0]) + ), + ray, + ), + default=(self.cursor.x, self.cursor.y) + ) else: if key in LR_ARROW_KEYS and self.cursor.dir == Direction.DOWN: self.cursor.dir = Direction.ACROSS elif key in UD_ARROW_KEYS and self.cursor.dir == Direction.ACROSS: self.cursor.dir = Direction.DOWN else: - if key == arcade.key.LEFT: self.cursor.x = max( 0, self.cursor.x - 1) - if key == arcade.key.RIGHT: self.cursor.x = min(14, self.cursor.x + 1) - if key == arcade.key.UP: self.cursor.y = min(14, self.cursor.y + 1) - if key == arcade.key.DOWN: self.cursor.y = max( 0, self.cursor.y - 1) + self.cursor += ( + -1 if key in [arcade.key.LEFT, arcade.key.UP] else 1 + ) * self.cursor.dir elif str(chr(key)).isalpha(): letter = chr(key - 32) @@ -674,20 +699,22 @@ def on_key_release(self, key, modifiers): letters_remaining.append(letter) if letter in letters_remaining: - while self.grid.is_filled((14-self.cursor.y, self.cursor.x)): - if self.cursor.dir == Direction.ACROSS: self.cursor.x = min(15, self.cursor.x + 1) - if self.cursor.dir == Direction.DOWN: self.cursor.y = max(-1, self.cursor.y - 1) + self.cursor += mit.last( + it.takewhile( + lambda step: self.grid.is_filled((14 - (self.cursor + step)[1], (self.cursor + step)[0])), + (s * self.cursor.dir for s in range(1, self.grid.size)) + ), + default=Vector(0, 0) + ) if not (self.cursor.x > 14 or self.cursor.y < 0): self.letters_typed[(self.cursor.y, self.cursor.x)] = letter if need_blank: self.temp_blank_letters.add((self.cursor.y, self.cursor.x)) - if self.cursor.dir == Direction.ACROSS: self.cursor.x = min(15, self.cursor.x + 1) - if self.cursor.dir == Direction.DOWN: self.cursor.y = max(-1, self.cursor.y - 1) + self.cursor += self.cursor.dir - while self.grid.is_filled((14-self.cursor.y, self.cursor.x)): - if self.cursor.dir == Direction.ACROSS: self.cursor.x = min(15, self.cursor.x + 1) - if self.cursor.dir == Direction.DOWN: self.cursor.y = max(-1, self.cursor.y - 1) + while self.grid.is_filled((14 - self.cursor.y, self.cursor.x)): + self.cursor += self.cursor.dir potential_play = self.is_playable_and_score_and_word() print(potential_play) @@ -712,11 +739,9 @@ def on_key_release(self, key, modifiers): if key == arcade.key.BACKSPACE: if len(self.letters_typed): self.letters_typed.popitem() - if self.cursor.dir == Direction.ACROSS: self.cursor.x -= 1 - if self.cursor.dir == Direction.DOWN: self.cursor.y += 1 - while self.grid.is_filled((14-self.cursor.y, self.cursor.x)): - if self.cursor.dir == Direction.ACROSS: self.cursor.x -= 1 - if self.cursor.dir == Direction.DOWN: self.cursor.y += 1 + self.cursor += -self.cursor.dir + while self.grid.is_filled((14 - self.cursor.y, self.cursor.x)): + self.cursor += -self.cursor.dir pos = (self.cursor.y, self.cursor.x) if pos in self.temp_blank_letters: self.temp_blank_letters.remove(pos) @@ -787,7 +812,7 @@ def on_key_release(self, key, modifiers): self.tile_bag_index += tiles_needed self.phase = Phase.PAUSE_FOR_ANALYSIS self.grid_backup = self.grid.copy() - self.cursor.dir = None + self.cursor.dir = Direction.NONE if play.is_bingo: self.letters_bingoed = self.letters_bingoed.union(self.letters_typed.keys()) self.just_bingoed = True diff --git a/scrabble/python/solver.py b/scrabble/python/solver.py index a37a30d..7e505cc 100644 --- a/scrabble/python/solver.py +++ b/scrabble/python/solver.py @@ -12,7 +12,7 @@ class SolverState: # rack: ??? # original_rack: ??? # cross_check_results: ??? - direction: Direction | None + direction: Direction plays: list[Any] # This should be better defined: List[Tuple[Position, str, Set[CellCoord]]] or List[Play] as defined in main.py??? def __init__(self, dictionary: Trie, board: Board, rack): # What is the type of rack? @@ -167,6 +167,8 @@ def extend_after(self, partial_word: str, current_node: TrieNode, next_pos: Cell def find_all_options(self): for direction in Direction: + if direction == Direction.NONE: + continue self.direction = direction anchors = self.find_anchors() self.cross_check_results = self.cross_check() diff --git a/scrabble/python/tests/test_main.py b/scrabble/python/tests/test_main.py new file mode 100644 index 0000000..d5d54b0 --- /dev/null +++ b/scrabble/python/tests/test_main.py @@ -0,0 +1,97 @@ +from hypothesis import given +from hypothesis import strategies as st +from main import Cursor, Direction, Vector + + +class TestCursor: + def test_rotate(self): + cursor = Cursor() + assert cursor.dir == Direction.NONE + cursor.rotate_dir() + assert cursor.dir == Direction.ACROSS + cursor.rotate_dir() + assert cursor.dir == Direction.DOWN + cursor.rotate_dir() + assert cursor.dir == Direction.NONE + cursor.rotate_dir() + assert cursor.dir == Direction.ACROSS + cursor.rotate_dir() + assert cursor.dir == Direction.DOWN + cursor.rotate_dir() + assert cursor.dir == Direction.NONE + + def test_rotate_after_assignment(self): + cursor = Cursor() + cursor.dir = Direction.DOWN + cursor.rotate_dir() + assert cursor.dir == Direction.NONE + + +def vectors() -> st.SearchStrategy[Vector]: + return ( + st.builds(Vector, st.lists(st.integers())) + | st.just(Direction.NONE) + | st.just(Direction.DOWN) + | st.just(Direction.ACROSS) + ) + + +class TestVector: + @given(x=st.integers(), y=st.integers()) + def test_ctor_of_ints(self, x, y): + v = Vector(x, y) + assert v[0] == x + assert v[1] == y + + @given(st.lists(st.integers())) + def test_ctor_of_iterable(self, elements): + v = Vector(elements) + assert all(a == b for a, b in zip(v, elements)) + + def test_empty_ctor(self): + v = Vector() + v == () + + @given(v=vectors()) + def test_truthiness_zero_vector(self, v): + assert not v.zero + + @given(v=vectors().filter(lambda v: v != v.zero)) + def test_truthiness_any_vector(self, v): + assert v + + @given(l=st.lists(vectors(), min_size=1)) + def test_hashability(self, l): + assert set(l) + + @given(u=vectors(), v=vectors(), w=vectors()) + def test_assocativity(self, u, v, w): + assert u + (v + w) == (u + v) + w + + @given(u=vectors(), v=vectors()) + def test_cumutativity(self, u, v): + assert u + v == v + u + + @given(v=vectors()) + def test_aditive_identity(self, v): + assert v + v.zero == v + + @given(v=vectors()) + def test_aditive_inverse(self, v): + assert v + (-v) == v.zero + + @given(v=vectors()) + def test_scalar_identity(self, v): + assert 1 * v == v + + @given(a=st.integers(), u=vectors(), v=vectors()) + def test_distributivity_of_multiplication_vaddition(self, a, u, v): + assert a * (u + v) == a * u + a * v + + @given(a=st.integers(), b=st.integers(), v=vectors()) + def test_distributivity_of_multiplication_saddition(self, a, b, v): + assert (a + b) * v == a * v + b * v + + @given(a=st.integers(), b=st.integers(), v=vectors()) + def test_scalar_multiplication_and_field_multiplication(self, a, b, v): + assert a * (b * v) == (a * b) * v diff --git a/scrabble/requirements-dev.txt b/scrabble/requirements-dev.txt index f0aa93a..0407b1c 100644 --- a/scrabble/requirements-dev.txt +++ b/scrabble/requirements-dev.txt @@ -1 +1,3 @@ +pytest mypy +hypothesis diff --git a/scrabble/requirements.txt b/scrabble/requirements.txt index 3dfb500..28a8ec0 100644 --- a/scrabble/requirements.txt +++ b/scrabble/requirements.txt @@ -1,3 +1,5 @@ arcade colorama +more-itertools +numpy result