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