From 6662ff6460bfe3a963564adb8fbe0d52370f882c Mon Sep 17 00:00:00 2001 From: pommicket Date: Wed, 17 Sep 2025 15:35:44 -0400 Subject: Basic interface --- .gitignore | 2 + extractor/extractor.py | 142 ++++++++++++++++++++++++++++++++++ pub/blankplays.js | 201 +++++++++++++++++++++++++++++++++++++++++++++++++ pub/index.html | 118 +++++++++++++++++++++++++++++ 4 files changed, 463 insertions(+) create mode 100644 .gitignore create mode 100644 extractor/extractor.py create mode 100644 pub/blankplays.js create mode 100644 pub/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85807a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +challenges +challenges-* diff --git a/extractor/extractor.py b/extractor/extractor.py new file mode 100644 index 0000000..8fac3bf --- /dev/null +++ b/extractor/extractor.py @@ -0,0 +1,142 @@ +import sys +import os +import shutil + +if len(sys.argv) < 4: + print('Usage: extractor.py ') + sys.exit(1) + +alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' +# board size +N = 15 + +word_list = set(line.strip().upper() for line in open(sys.argv[2], encoding='utf-8')) + +# can change these to implement non-ASCII tiles +def encode(tile: str) -> int: + return ord(tile) +def decode(tile: int) -> str: + if tile == 0: return '.' + return chr(tile) + + +class GameState: + def __init__(self) -> None: + self.board = bytearray(N * N) + def make_play(self, play: str) -> None: + if play.startswith('(exch') or play == '(Pass)': + return + coord = play[:3].strip() + tiles = play[4:] + horizontal = coord[0].isdigit() + if horizontal: + row = int(coord[:-1]) - 1 + col = ord(coord[-1]) - ord('A') + else: + row = int(coord[1:]) - 1 + col = ord(coord[0]) - ord('A') + for c in tiles: + assert row >= 0 and col >= 0 and row < N and col < N + if c != '.': + self.board[row * N + col] = encode(c) + if horizontal: + col += 1 + else: + row += 1 + + def board_string(self) -> str: + rows = [] + for row in range(15): + rows.append(''.join(decode(tile) for tile in self.board[row*N:(row+1)*N]) + '\n') + return ''.join(rows) + + def try_place(self, row: int, col: int, letter: str) -> list[str]: + board = self.board[:] + if board[row*N+col]: + # there's already a tile here + return [] + board[row*N+col] = encode(letter) + neighbours = [] + if row > 0: + neighbours.append(board[(row-1)*N+col]) + if row < N-1: + neighbours.append(board[(row+1)*N+col]) + if col > 0: + neighbours.append(board[row*N+col-1]) + if col < N-1: + neighbours.append(board[row*N+col+1]) + if all(n == 0 for n in neighbours): + # doesn't connect to any existing plays + return [] + i = col + while i > 0 and board[row*N+i-1]: + i -= 1 + horizontal_word = '' + while i < 15 and board[row*N+i]: + horizontal_word += decode(board[row*N+i]) + i += 1 + words = [] + if len(horizontal_word) > 1: + words.append(horizontal_word) + i = row + while i > 0 and board[(i-1)*N+col]: + i -= 1 + vertical_word = '' + while i < 15 and board[i*N+col]: + vertical_word += decode(board[i*N+col]) + i += 1 + if len(vertical_word) > 1: + words.append(vertical_word) + if not all(word in word_list for word in words): + return [] + return words + + def blank_plays(self) -> list[tuple[int, str, list[str]]]: + plays = [] + for row in range(N): + for col in range(N): + for letter in alphabet: + words_formed = self.try_place(row, col, letter) + if words_formed: + plays.append((row*15+col, letter, words_formed)) + return plays + + def output(self, filename: str) -> None: + if os.path.exists(filename): + print(filename, 'already exists') + sys.exit(1) + with open('challenge.tmp', 'wb') as out: + out.write(self.board_string().encode()) + for square, letter, _ in self.blank_plays(): + out.write(f'{square} {letter}\n'.encode()) + os.rename('challenge.tmp', filename) +games_file = open(sys.argv[1], encoding='utf-8') +try: + os.mkdir('challenges') +except FileExistsError: + pass +lines = iter(games_file) +next(lines) # skip header +games = {} +count = 0 +game_idx = int(sys.argv[3]) +for line in lines: + line = line.strip() + fields = line.split(',') + game_id = fields[1] + if game_id not in games: + games[game_id] = GameState() + play = fields[4] + games[game_id].make_play(play) + if fields[3] == '?': + game = games[game_id] + plays = game.blank_plays() + nice_plays = [play for play in plays if any(len(w) > 4 for w in play[2])] + # require at least 3 plays that make 5+-letter words + if len(nice_plays) >= 3: + game.output(f'challenges/{game_idx:05}.txt') + print(game.board_string()) + print(len(plays), 'plays') + count += 1 + game_idx += 1 +print(count, 'challenges made.') diff --git a/pub/blankplays.js b/pub/blankplays.js new file mode 100644 index 0000000..4dc05ee --- /dev/null +++ b/pub/blankplays.js @@ -0,0 +1,201 @@ +'use strict'; + +const N = 15; // board size + +let lexicon = 'nwl23'; + +function updateBoardSize() { + let board = document.getElementById('board'); + // sucks for desktop zooming, but there's no way around it. + let width = innerWidth; + let height = innerHeight; + let boardSize = Math.min(width - 20, Math.floor(height * 0.6)); + let fontSize = (boardSize / N - 4) * 0.6; + board.style.fontSize = fontSize + 'px'; + board.style.width = boardSize + 'px'; + board.style.height = boardSize + 'px'; + let selectContainer = document.getElementById('select-container'); + selectContainer.style.fontSize = fontSize + 'px'; + selectContainer.style.width = boardSize + 'px'; + selectContainer.style.height = boardSize / N * 2 + 'px'; +} + +const DOUBLE_LETTER = 'double-letter'; +const TRIPLE_LETTER = 'triple-letter'; +const DOUBLE_WORD = 'double-word'; +const TRIPLE_WORD = 'triple-word'; + +function getBonus(row, col) { + row = Math.min(row, N-1 - row); + col = Math.min(col, N-1 - col); + let id = Math.min(row * N + col, col * N + row); + if (id == 0 || id == 7) { + return TRIPLE_WORD; + } else if (id == N+5 || id == 5*N+5) { + return TRIPLE_LETTER; + } else if (id == 3 || id == 2*N+6 || id == 3*N+7 || id == 6*N+6) { + return DOUBLE_LETTER; + } else if (id == 7*N+7 || id == N+1 || id == 2*N+2 || id == 3*N+3 || id == 4*N+4) { + return DOUBLE_WORD; + } else { + return ''; + } +} + +function pointValue(letter) { + return { + 'A': 1, 'B': 3, 'C': 3, 'D': 2, 'E': 1, + 'F': 4, 'G': 2, 'H': 4, 'I': 1, 'J': 8, + 'K': 5, 'L': 1, 'M': 3, 'N': 1, 'O': 1, + 'P': 3, 'Q': 10,'R': 1, 'S': 1, 'T': 1, + 'U': 1, 'V': 4, 'W': 4, 'X': 8, 'Y': 4, + 'Z': 10, + }[letter]; +} + +let boardSquareElems = []; + +function makeTile(container, letter, showPointValue) { + let tile = document.createElement('span'); + tile.classList.add('tile'); + let blank = false; + if (letter === letter.toLowerCase()) { + blank = true; + } + let text = document.createElement('span'); + text.appendChild(document.createTextNode(letter.toUpperCase())); + let points = document.createElement('span'); + if (showPointValue) + points.appendChild(document.createTextNode(blank ? '0' : pointValue(letter) + '')); + points.classList.add('point-value'); + if (blank) + text.classList.add('blank'); + tile.appendChild(text); + container.appendChild(tile); + container.appendChild(points); +} + +function putTile(row, col, letter) { + let squareElem = boardSquareElems[row][col]; + makeTile(squareElem, letter, true); +} + +function selectTile(elem, row, col) { + deselectTile(); + elem.classList.add('selected'); + document.getElementById('select').style.display = 'block'; +} + +function deselectTile() { + let selected = document.querySelector('.selected'); + if (selected) + selected.classList.remove('selected'); + for (let tile of document.querySelectorAll('.tile.possible')) { + tile.classList.remove('possible'); + } + document.getElementById('select').style.display = 'none'; +} + +function clickedSquare(highlight, row, col) { + return (e) => { + if (e.button === 0) { + if (highlight.classList.contains('selected')) { + deselectTile(); + } else { + selectTile(highlight, row, col); + } + e.preventDefault(); + } else if (e.button === 2) { + if (highlight.classList.contains('nothing')) { + highlight.classList.remove('nothing'); + } else { + highlight.classList.add('nothing'); + if (highlight.classList.contains('selected')) + deselectTile(); + } + e.preventDefault(); + } + }; +} + +// TODO : error handling +async function loadChallenge(id) { + let result = await fetch(`challenges-${lexicon}/${id}.txt`); + let body = await result.text(); + let lines = body.split('\n'); + let board = []; + for (let row = 0; row < 15; row++) { + board.push([]); + for (let col = 0; col < 15; col++) { + board[row].push(lines[row][col]); + } + } + for (let row = 0; row < 15; row++) { + for (let col = 0; col < 15; col++) { + let letter = board[row][col]; + if (letter !== '.') { + putTile(row, col, letter); + continue; + } + let neighbours = []; + if (row > 0) + neighbours.push(board[row-1][col]); + if (row < N-1) + neighbours.push(board[row+1][col]); + if (col > 0) + neighbours.push(board[row][col-1]); + if (col < N-1) + neighbours.push(board[row][col+1]); + if (neighbours.filter((x) => x !== '.').length === 0) { + // not connected + continue; + } + let highlight = document.createElement('div'); + highlight.classList.add('highlight'); + boardSquareElems[row][col].appendChild(highlight); + highlight.addEventListener('contextmenu', (e) => e.preventDefault()); + highlight.addEventListener('mousedown', clickedSquare(highlight, row, col)); + } + } +} + +function startup() { + updateBoardSize(); + for (let row = 0; row < N; row++) { + let rowElem = document.createElement('div'); + rowElem.classList.add('board-row'); + board.appendChild(rowElem); + boardSquareElems.push([]); + for (let col = 0; col < N; col++) { + let squareElem = document.createElement('div'); + squareElem.classList.add('board-square'); + let bonus = getBonus(row, col); + if (bonus) squareElem.classList.add(bonus); + rowElem.appendChild(squareElem); + boardSquareElems[row].push(squareElem); + } + } + let selectContainer = document.getElementById('select-container'); + for (let row = 0; row < 2; row++) { + let rowContainer = selectContainer.querySelectorAll('.select-container-row')[row]; + for (let i = row*N; i < (row+1)*N; i++) { + let tiles = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (i >= tiles.length) break; + let elem = document.createElement('span'); + elem.classList.add('select-tile-container'); + makeTile(elem, tiles[i], false); + let tileElem = elem.querySelector('.tile'); + elem.addEventListener('click', () => { + if (tileElem.classList.contains('possible')) + tileElem.classList.remove('possible'); + else + tileElem.classList.add('possible'); + }); + rowContainer.appendChild(elem); + } + } + loadChallenge('00000'); +} + +window.addEventListener('load', startup); +window.addEventListener('resize', updateBoardSize); diff --git a/pub/index.html b/pub/index.html new file mode 100644 index 0000000..b920617 --- /dev/null +++ b/pub/index.html @@ -0,0 +1,118 @@ + + + + + + Blank Plays + + + + + + Find all the possible plays with a single blank! + +
+ + + -- cgit v1.2.3