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 <[email protected]>
This commit is contained in:
tukaunuandClaude Opus 5.5 committed 2026-10-22 01:47:27 +02:00
1 parent c52f30dc59
commit ce6b71eb35
7 files changed
+50 -41

No files matched your search

+7 -8
View File
@@ -12,18 +12,20 @@ python3 -m venv .venv
.venv/bin/pip install -r requirements.txt .venv/bin/pip install -r requirements.txt
.venv/bin/python server.py # http://127.0.0.1:8080 .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 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 .` Tests: `.venv/bin/python -m unittest discover -s tests -t .`
## How it works ## How it works
- `game.py`: the rules, no networking. - `game.py`: the rules, no networking.
- `server.py`: one aiohttp process. `/ws?game=CODE` is the live connection, and - `server.py`: one aiohttp process. `/ws?game=CODE` is the live connection, `/practice`
everything else is static files. and `POST /api/play` are practice mode, and everything else is static files.
- `practice.py`: a separate server for playing against the computer. It holds no state: - `practice.py`: practice mode's move endpoint, mounted by `server.py`. It holds no
the page sends the position to `POST /api/play` and gets the next one back. 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 - `solver.py`: solves every reachable position (~67k, about a second at startup) so the
computer knows the perfect move everywhere. computer knows the perfect move everywhere.
- `static/`: the pages. Plain HTML/CSS/JS, no build step. `board.js` is the board - `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 login
cloudflared tunnel create moonchess cloudflared tunnel create moonchess
cloudflared tunnel route dns moonchess moonchess.example.com 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`: 3. `~/.cloudflared/config.yml`:
```yaml ```yaml
@@ -75,8 +76,6 @@ your home IP. WebSockets work through it without any extra configuration.
ingress: ingress:
- hostname: moonchess.example.com - hostname: moonchess.example.com
service: http://localhost:8080 service: http://localhost:8080
- hostname: practice.moonchess.example.com # optional, if you run practice.py
service: http://localhost:8081
- service: http_status:404 - service: http_status:404
``` ```
4. `cloudflared tunnel run moonchess` (or `sudo cloudflared service install` to run it at boot). 4. `cloudflared tunnel run moonchess` (or `sudo cloudflared service install` to run it at boot).
+2 -27
View File
@@ -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 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 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 aiohttp import web
from game import Game from game import Game
from server import STATIC_DIR, no_cache
from solver import Solver from solver import Solver
solver = Solver() # solves every position up front; takes about a second 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): async def play(request):
"""Body: {history, turn, cell} to play `cell`, or {history, turn, accuracy} for a """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 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: if error:
return web.json_response({"error": error}, status=400) return web.json_response({"error": error}, status=400)
return web.json_response({**game.to_dict(), "computerMove": computer_move}) 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)
+7
View File
@@ -17,6 +17,7 @@ from pathlib import Path
from aiohttp import WSMsgType, web from aiohttp import WSMsgType, web
import practice
from game import Game from game import Game
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
@@ -122,6 +123,10 @@ async def index(request):
return web.FileResponse(STATIC_DIR / "index.html") return web.FileResponse(STATIC_DIR / "index.html")
async def practice_page(request):
return web.FileResponse(STATIC_DIR / "practice.html")
async def game_socket(request): async def game_socket(request):
code = normalize_code(request.query.get("game", "")) code = normalize_code(request.query.get("game", ""))
ws = web.WebSocketResponse(max_msg_size=MAX_MESSAGE_SIZE) ws = web.WebSocketResponse(max_msg_size=MAX_MESSAGE_SIZE)
@@ -229,6 +234,8 @@ def make_app():
app = web.Application(middlewares=[no_cache]) app = web.Application(middlewares=[no_cache])
app.router.add_get("/", index) app.router.add_get("/", index)
app.router.add_get("/ws", game_socket) 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.router.add_static("/static", STATIC_DIR)
app.cleanup_ctx.append(start_reaper) app.cleanup_ctx.append(start_reaper)
return app return app
+1
View File
@@ -46,6 +46,7 @@
</div> </div>
</section> </section>
</main> </main>
<a class="mode-switch" href="/practice">Practice</a>
<script src="/static/board.js"></script> <script src="/static/board.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
+1
View File
@@ -42,6 +42,7 @@
<p id="meta" class="meta"></p> <p id="meta" class="meta"></p>
</div> </div>
</main> </main>
<a class="mode-switch" href="/">Multiplayer</a>
<script src="/static/board.js"></script> <script src="/static/board.js"></script>
<script src="/static/practice.js"></script> <script src="/static/practice.js"></script>
</body> </body>
+24 -3
View File
@@ -42,7 +42,7 @@ body {
main { main {
width: 100%; width: 100%;
max-width: 420px; 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; } h1 { font-size: 2.2rem; margin: 0 0 8px; letter-spacing: -0.02em; }
@@ -57,10 +57,15 @@ button {
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
cursor: pointer; 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 { background: var(--text); color: var(--bg); border-color: var(--text); width: 100%; }
/* 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; } button.primary:hover { opacity: 0.9; }
.cell.playable:hover { border-color: var(--muted); }
}
button.small { padding: 6px 12px; font-size: 0.875rem; } button.small { padding: 6px 12px; font-size: 0.875rem; }
button.ghost { background: transparent; } 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-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(3, minmax(0, 1fr));
aspect-ratio: 1; width: min(100%, 60vh); margin: 0 auto; 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 { .cell {
position: relative; position: relative;
@@ -148,9 +155,10 @@ button.player[aria-pressed="true"] .who { font-weight: 600; }
border: 1px solid var(--border); background: var(--surface); border: 1px solid var(--border); background: var(--surface);
display: grid; place-items: center; display: grid; place-items: center;
cursor: default; cursor: default;
-webkit-tap-highlight-color: transparent;
} }
.cell.playable { cursor: pointer; } .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.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 { position: absolute; inset: 21%; width: 58%; height: 58%; overflow: visible; }
.cell svg * { fill: none; stroke-width: 12; stroke-linecap: round; } .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 { flex: 1; }
.actions > button.primary { flex: 2; } .actions > button.primary { flex: 2; }
.actions > button:disabled { opacity: 0.45; cursor: default; border-color: var(--border); } .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; } .meta { color: var(--muted); font-size: 0.875rem; text-align: center; margin: 12px 0 0; min-height: 1.5em; }
+8 -3
View File
@@ -1,23 +1,28 @@
from aiohttp.test_utils import AioHTTPTestCase from aiohttp.test_utils import AioHTTPTestCase
import practice import server
EMPTY = {"history": [[], []], "turn": 0} EMPTY = {"history": [[], []], "turn": 0}
class PracticeTest(AioHTTPTestCase): class PracticeTest(AioHTTPTestCase):
async def get_application(self): async def get_application(self):
return practice.make_app() server.rooms.clear()
return server.make_app()
async def play(self, body): async def play(self, body):
res = await self.client.post("/api/play", json=body) res = await self.client.post("/api/play", json=body)
return res.status, await res.json() return res.status, await res.json()
async def test_page_served(self): async def test_page_served(self):
res = await self.client.get("/") res = await self.client.get("/practice")
self.assertEqual(res.status, 200) self.assertEqual(res.status, 200)
self.assertIn("practice.js", await res.text()) 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): async def test_human_move(self):
status, s = await self.play({**EMPTY, "cell": 4}) status, s = await self.play({**EMPTY, "cell": 4})
self.assertEqual(status, 200) self.assertEqual(status, 200)