Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1 @@
scrabble/python/__pycache__/*
__pycache__/
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
]
Empty file added scrabble/python/__init__.py
Empty file.
56 changes: 50 additions & 6 deletions scrabble/python/board.py
Original file line number Diff line number Diff line change
@@ -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"<Vector {super().__repr__()}>"

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)
Expand Down Expand Up @@ -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) == "."
Expand All @@ -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)
89 changes: 57 additions & 32 deletions scrabble/python/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,24 @@
Started from https://arcade.academy/examples/array_backed_grid.html#array-backed-grid
"""

import itertools as it
import os
import random
import sys
import textwrap
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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion scrabble/python/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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()
Expand Down
97 changes: 97 additions & 0 deletions scrabble/python/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions scrabble/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
pytest
mypy
hypothesis
2 changes: 2 additions & 0 deletions scrabble/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
arcade
colorama
more-itertools
numpy
result