#!/usr/bin/env python3 """ neap_puzzle_client.py -- a tiny reference client for the neap native-play channel. Stdlib only, no dependencies. It shows how a program (for instance an AI companion) reads the board and sends moves, so it can play Simon Tatham's puzzles alongside a human. Both players' moves land in the same state file, so you genuinely share a board. The protocol is just three text files under %LOCALAPPDATA%\\neap\\puzzle\\ ; the full description is in README-neap.md. This file is the runnable version of that. Quick start: 1. Launch a game (e.g. slant.exe) and start a puzzle (it writes the files). 2. python neap_puzzle_client.py -> prints the current board and lets you type moves to send. Use the read_id / read_state / send / wait_for_change functions from your own code (or an AI harness) to build a real player. """ import os, time, sys def puzzle_dir(): """The channel directory the game reads and writes.""" base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") return os.path.join(base, "neap", "puzzle") def _read(name): try: with open(os.path.join(puzzle_dir(), name), "r", encoding="utf-8", errors="replace") as f: return f.read() except FileNotFoundError: return None def read_id(): """id.txt: the game ID (seed + geometry). It CHANGES on every new game, so a change is your 'a new puzzle started' signal. Line 1 is 'WxH:clues'; line 2 is 'px ', the board's pixel size (game coords == client coords, origin 0,0).""" return _read("id.txt") def read_state(): """state.txt: the current board in the game's own text format, rewritten after EVERY move -- including the human's mouse clicks, not just yours. This is the ground truth; always read it back rather than assuming your move's effect.""" return _read("state.txt") def send(*commands): """Queue one or more moves. The game polls move.txt about seven times a second, applies every line, then deletes the file. Each ARGUMENT is one full command line (so a command with a space, like 'key 3', is a single string, not two args): send('L 48 48') # left-click at game-pixel (48,48) -- cycles the cell send('key 3') # type a 3 (number games: Towers/Solo/Keen/Unequal/...) send('preset 2') # switch size/difficulty (see 'preset-list') send('new') # new game send('gameid 5:2/1/2/4/...') # load an exact board send('L 48 48', 'L 80 80') # queue several moves at once (one command per argument) Commands: L/R/M , key , new, undo, redo, solve, preset-list, preset , gameid . (tok: a digit/letter, up/down/left/right, bksp/del, space/clear, enter/return)""" d = puzzle_dir() os.makedirs(d, exist_ok=True) with open(os.path.join(d, "move.txt"), "a", encoding="utf-8") as f: # append: queued moves batch if unread for c in commands: f.write(str(c).rstrip("\n") + "\n") def wait_for_change(name, last_mtime=None, poll=0.15, timeout=None): """Block until (e.g. 'id.txt' for a new game, or 'state.txt' for any move) changes or first appears; returns its new mtime, or None on timeout.""" p = os.path.join(puzzle_dir(), name) t0 = time.time() while True: try: m = os.path.getmtime(p) except FileNotFoundError: m = None if m is not None and m != last_mtime: return m if timeout is not None and time.time() - t0 > timeout: return None time.sleep(poll) def slant_cell_xy(px_w, W, c, r): """Example of the per-game coordinate math: the center of Slant cell (c, r) in game pixels, from id.txt's line-2 px width and the WxH from line 1. Other games have their own tile size; read it from the game's .c the same way if you need it. (Number games can be driven entirely by 'key' arrow navigation, no pixels needed.)""" ts = (px_w - 1) // (W + 2) return ts * (c + 1) + ts // 2, ts * (r + 1) + ts // 2 def _demo(): print("neap puzzle channel:", puzzle_dir()) print("\n--- id.txt (the puzzle) ---") print(read_id() or "(no game yet -- launch a puzzle .exe and start a game)") print("\n--- state.txt (the board) ---") print(read_state() or "(no board yet)") print("\nType a move to send it (e.g. 'L 48 48', 'key 3', 'new'); Ctrl-C to quit.") try: last = None while True: line = sys.stdin.readline() if not line: break line = line.strip() if not line: continue send(line) wait_for_change("state.txt", last, timeout=2) # let the game apply + rewrite last = None print(read_state() or "(no board)") except KeyboardInterrupt: pass print("\nbye") if __name__ == "__main__": _demo()