From ce6b71eb35c83fd07c78daf60d59a7ec24b1bc7b Mon Sep 17 00:00:00 2001 From: tukaunu Date: Thu, 22 Oct 2026 01:47:27 +0200 Subject: [PATCH] Serve practice mode from the main server, add mode switch, fix touch Practice mode is now at /practice on server.py instead of a separate server, with a button in the bottom-right corner of each page to the other mode. Touch: no double-tap zoom, no sticky hover after a tap, no long-press selection on the board, and a pressed look on tapped squares. Co-Authored-By: Claude Opus 5.5 --- README.md | 15 +++++++-------- practice.py | 29 ++--------------------------- server.py | 7 +++++++ static/index.html | 1 + static/practice.html | 1 + static/style.css | 29 +++++++++++++++++++++++++---- tests/test_practice.py | 11 ++++++++--- 7 files changed, 51 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index c809474..8d5622d 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,20 @@ python3 -m venv .venv .venv/bin/pip install -r requirements.txt .venv/bin/python server.py # http://127.0.0.1:8080 .venv/bin/python server.py --host 0.0.0.0 --port 8080 # reachable on your LAN -.venv/bin/python practice.py # vs the computer: http://127.0.0.1:8081 ``` +Practice mode (vs the computer) is at `/practice` on the same server. The switch in the +bottom-right corner of each page goes between the two modes. + Tests: `.venv/bin/python -m unittest discover -s tests -t .` ## How it works - `game.py`: the rules, no networking. -- `server.py`: one aiohttp process. `/ws?game=CODE` is the live connection, and - everything else is static files. -- `practice.py`: a separate server for playing against the computer. It holds no state: - the page sends the position to `POST /api/play` and gets the next one back. +- `server.py`: one aiohttp process. `/ws?game=CODE` is the live connection, `/practice` + and `POST /api/play` are practice mode, and everything else is static files. +- `practice.py`: practice mode's move endpoint, mounted by `server.py`. It holds no + state: the page sends the position to `POST /api/play` and gets the next one back. - `solver.py`: solves every reachable position (~67k, about a second at startup) so the computer knows the perfect move everywhere. - `static/`: the pages. Plain HTML/CSS/JS, no build step. `board.js` is the board @@ -66,7 +68,6 @@ your home IP. WebSockets work through it without any extra configuration. cloudflared tunnel login cloudflared tunnel create moonchess cloudflared tunnel route dns moonchess moonchess.example.com - cloudflared tunnel route dns moonchess practice.moonchess.example.com # if running practice.py ``` 3. `~/.cloudflared/config.yml`: ```yaml @@ -75,8 +76,6 @@ your home IP. WebSockets work through it without any extra configuration. ingress: - hostname: moonchess.example.com service: http://localhost:8080 - - hostname: practice.moonchess.example.com # optional, if you run practice.py - service: http://localhost:8081 - service: http_status:404 ``` 4. `cloudflared tunnel run moonchess` (or `sudo cloudflared service install` to run it at boot). diff --git a/practice.py b/practice.py index acfbf7d..6db9c62 100644 --- a/practice.py +++ b/practice.py @@ -1,26 +1,18 @@ -"""Moonchess practice server: play against the computer. +"""Moonchess practice mode: play against the computer. server.py serves it at /practice. Stateless: the page sends the current position with every request and gets the new position back, so there's no session to lose. The page decides whose turn belongs to -the computer; this server only applies moves and picks them. +the computer; this module only applies moves and picks them. """ -import argparse -import logging - from aiohttp import web from game import Game -from server import STATIC_DIR, no_cache from solver import Solver solver = Solver() # solves every position up front; takes about a second -async def index(request): - return web.FileResponse(STATIC_DIR / "practice.html") - - async def play(request): """Body: {history, turn, cell} to play `cell`, or {history, turn, accuracy} for a computer move, where accuracy (0-1) is the chance it plays a perfect move. A computer @@ -41,20 +33,3 @@ async def play(request): if error: return web.json_response({"error": error}, status=400) return web.json_response({**game.to_dict(), "computerMove": computer_move}) - - -def make_app(): - app = web.Application(middlewares=[no_cache]) - app.router.add_get("/", index) - app.router.add_post("/api/play", play) - app.router.add_static("/static", STATIC_DIR) - return app - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Moonchess practice server") - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=8081) - args = parser.parse_args() - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") - web.run_app(make_app(), host=args.host, port=args.port) diff --git a/server.py b/server.py index 25c84e7..8337757 100644 --- a/server.py +++ b/server.py @@ -17,6 +17,7 @@ from pathlib import Path from aiohttp import WSMsgType, web +import practice from game import Game STATIC_DIR = Path(__file__).parent / "static" @@ -122,6 +123,10 @@ async def index(request): return web.FileResponse(STATIC_DIR / "index.html") +async def practice_page(request): + return web.FileResponse(STATIC_DIR / "practice.html") + + async def game_socket(request): code = normalize_code(request.query.get("game", "")) ws = web.WebSocketResponse(max_msg_size=MAX_MESSAGE_SIZE) @@ -229,6 +234,8 @@ def make_app(): app = web.Application(middlewares=[no_cache]) app.router.add_get("/", index) app.router.add_get("/ws", game_socket) + app.router.add_get("/practice", practice_page) + app.router.add_post("/api/play", practice.play) app.router.add_static("/static", STATIC_DIR) app.cleanup_ctx.append(start_reaper) return app diff --git a/static/index.html b/static/index.html index 2bc15a7..a8dd4da 100644 --- a/static/index.html +++ b/static/index.html @@ -46,6 +46,7 @@ + Practice diff --git a/static/practice.html b/static/practice.html index c4afbc6..955d502 100644 --- a/static/practice.html +++ b/static/practice.html @@ -42,6 +42,7 @@

+ Multiplayer diff --git a/static/style.css b/static/style.css index 997b6e7..7defc1b 100644 --- a/static/style.css +++ b/static/style.css @@ -42,7 +42,7 @@ body { main { width: 100%; max-width: 420px; - padding: 48px 16px; + padding: 48px 16px 96px; /* room at the bottom to scroll clear of the mode switch */ } h1 { font-size: 2.2rem; margin: 0 0 8px; letter-spacing: -0.02em; } @@ -57,10 +57,15 @@ button { background: var(--surface); color: var(--text); cursor: pointer; + touch-action: manipulation; /* no double-tap zoom or tap delay on touch screens */ } -button:hover { border-color: var(--muted); } button.primary { background: var(--text); color: var(--bg); border-color: var(--text); width: 100%; } -button.primary:hover { opacity: 0.9; } +/* Hover styles only for real pointers: on touch screens they stick after a tap */ +@media (hover: hover) { + button:hover { border-color: var(--muted); } + button.primary:hover { opacity: 0.9; } + .cell.playable:hover { border-color: var(--muted); } +} button.small { padding: 6px 12px; font-size: 0.875rem; } button.ghost { background: transparent; } @@ -141,6 +146,8 @@ button.player[aria-pressed="true"] .who { font-weight: 600; } grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(3, minmax(0, 1fr)); aspect-ratio: 1; width: min(100%, 60vh); margin: 0 auto; + touch-action: manipulation; + user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; /* no long-press selection or menu */ } .cell { position: relative; @@ -148,9 +155,10 @@ button.player[aria-pressed="true"] .who { font-weight: 600; } border: 1px solid var(--border); background: var(--surface); display: grid; place-items: center; cursor: default; + -webkit-tap-highlight-color: transparent; } .cell.playable { cursor: pointer; } -.cell.playable:hover { border-color: var(--muted); } +.cell.playable:active { border-color: var(--muted); background: color-mix(in srgb, var(--border) 40%, var(--surface)); } .cell.win { background: color-mix(in srgb, var(--win) 30%, var(--surface)); border-color: var(--win); } .cell svg { position: absolute; inset: 21%; width: 58%; height: 58%; overflow: visible; } .cell svg * { fill: none; stroke-width: 12; stroke-linecap: round; } @@ -178,4 +186,17 @@ button.player[aria-pressed="true"] .who { font-weight: 600; } .actions > button { flex: 1; } .actions > button.primary { flex: 2; } .actions > button:disabled { opacity: 0.45; cursor: default; border-color: var(--border); } +/* Fixed bottom-right button to the other mode (multiplayer <-> practice) */ +.mode-switch { + position: fixed; + right: calc(16px + env(safe-area-inset-right, 0px)); + bottom: calc(16px + env(safe-area-inset-bottom, 0px)); + padding: 8px 16px; + border: 1px solid var(--border); border-radius: 999px; background: var(--surface); + box-shadow: 0 2px 10px rgb(0 0 0 / 0.12); + color: var(--text); font-size: 0.875rem; font-weight: 600; text-decoration: none; + touch-action: manipulation; +} +@media (hover: hover) { .mode-switch:hover { border-color: var(--muted); } } + .meta { color: var(--muted); font-size: 0.875rem; text-align: center; margin: 12px 0 0; min-height: 1.5em; } diff --git a/tests/test_practice.py b/tests/test_practice.py index bf0ebba..26cee1a 100644 --- a/tests/test_practice.py +++ b/tests/test_practice.py @@ -1,23 +1,28 @@ from aiohttp.test_utils import AioHTTPTestCase -import practice +import server EMPTY = {"history": [[], []], "turn": 0} class PracticeTest(AioHTTPTestCase): async def get_application(self): - return practice.make_app() + server.rooms.clear() + return server.make_app() async def play(self, body): res = await self.client.post("/api/play", json=body) return res.status, await res.json() async def test_page_served(self): - res = await self.client.get("/") + res = await self.client.get("/practice") self.assertEqual(res.status, 200) self.assertIn("practice.js", await res.text()) + async def test_pages_link_to_each_other(self): + self.assertIn('href="/practice"', await (await self.client.get("/")).text()) + self.assertIn('href="/"', await (await self.client.get("/practice")).text()) + async def test_human_move(self): status, s = await self.play({**EMPTY, "cell": 4}) self.assertEqual(status, 200)