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:
1 parent
c52f30dc59
commit
ce6b71eb35
7 files changed
+51
-42
No files matched your search
@@ -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
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+25
-4
@@ -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%; }
|
||||||
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.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; }
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user