dws-city-res-solve/main.py
Pagwin ae7fef5d7b Optional conversion and cancellation
Added in optional conversions and cancelling searches without just
closing the tab
2026-06-18 21:43:21 -04:00

270 lines
10 KiB
Python

"""Web UI for the Days Without Strife planner (see solve.py).
A dependency-free (stdlib only) HTTP server that exposes every input the
solver accepts: turns, starting resources, cities, agents, scoring terms
(linear or log), resource constraints and the misc Problem knobs.
For log-scored terms the user supplies a JavaScript expression that evals into
a single-argument function (e.g. ``(x) => Math.log2(x)``). The browser evals it
once, then calls it over the amounts it needs (0..max_resource) to build the
``log_mapping`` lookup table, which is sent to the server as a plain array.
"""
from __future__ import annotations
import json
import os
import select
import socket
import sqlite3
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlsplit
from solve import problem_from_dict, solve, solution_to_dict
# The UI is a single static page served from index.html next to this module.
INDEX_HTML = (Path(__file__).resolve().parent / "index.html").read_text(encoding="utf-8")
# Completed solves are persisted to SQLite so they can be looked up later by
# their UUID (GET /solve/<uuid>) and shared via /?solve=<uuid> deep links. Point
# DB_PATH at a mounted volume so history survives container restarts/redeploys.
# Old rows are evicted so the DB stays bounded (keep the newest MAX_SOLVES).
DB_PATH = os.environ.get(
"DB_PATH", str(Path(__file__).resolve().parent / "data" / "solves.db"))
MAX_SOLVES = int(os.environ.get("MAX_SOLVES", "500"))
def _db():
conn = sqlite3.connect(DB_PATH, timeout=5.0)
# WAL lets the concurrent /solve/<uuid> readers run alongside a writer.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def init_db():
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
conn = _db()
try:
with conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS solves ("
"token TEXT PRIMARY KEY, ts REAL NOT NULL, status TEXT, "
"problem TEXT NOT NULL, solution TEXT NOT NULL)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_solves_ts ON solves(ts)")
finally:
conn.close()
def store_solve(token, problem, solution):
# token is client-generated; skip blanks. INSERT OR REPLACE means a repeated
# token overwrites (a client could clobber its own/another's row — acceptable
# for this app; switch to a server-issued id if that ever matters).
if not token:
return
conn = _db()
try:
with conn:
conn.execute(
"INSERT OR REPLACE INTO solves (token, ts, status, problem, solution) "
"VALUES (?, ?, ?, ?, ?)",
(token, time.time(), solution.get("status"),
json.dumps(problem), json.dumps(solution)))
conn.execute(
"DELETE FROM solves WHERE token NOT IN "
"(SELECT token FROM solves ORDER BY ts DESC LIMIT ?)",
(MAX_SOLVES,))
finally:
conn.close()
def fetch_solve(token):
conn = _db()
try:
row = conn.execute(
"SELECT token, ts, status, problem, solution FROM solves WHERE token = ?",
(token,)).fetchone()
finally:
conn.close()
if row is None:
return None
return {"token": row[0], "ts": row[1], "status": row[2],
"problem": json.loads(row[3]), "solution": json.loads(row[4])}
# Only one solve may run at a time across all viewers. While it's held, other
# clients get an immediate 429 from /solve and disable their button (they learn
# the busy state by polling /status).
_solve_lock = threading.Lock()
# Tracks the in-flight solve so /cancel can stop it. /cancel is hit two ways:
# the user clicking Cancel (a normal fetch, tab stays open) and a closing tab
# firing navigator.sendBeacon("/cancel?token=...") — both small requests that
# pass cleanly through reverse proxies, unlike a dropped upstream socket (which
# the proxy keeps alive). Guarded by _active_lock; only one solve runs at a
# time, but the lock keeps the token / solver handoff race-free against a
# concurrent /cancel.
_active_lock = threading.Lock()
_active = {"token": None, "solver": None}
def _stop_search(solver):
# StopSearch() exists in OR-Tools 9.x+; degrade gracefully on older builds
# (the solve then just runs to max_time_seconds).
stop = getattr(solver, "StopSearch", None)
if callable(stop):
stop()
class Handler(BaseHTTPRequestHandler):
def _send(self, code, body, content_type="application/json", no_cache=False):
if isinstance(body, str):
body = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
if no_cache:
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlsplit(self.path).path
if path in ("/", "/index.html"):
self._send(200, INDEX_HTML, "text/html; charset=utf-8")
elif path == "/status":
self._send(200, json.dumps({"solving": _solve_lock.locked()}), no_cache=True)
elif path.startswith("/solve/"):
token = unquote(path[len("/solve/"):])
rec = fetch_solve(token)
if rec is None:
self._send(404, json.dumps({"error": "not found"}), no_cache=True)
else:
self._send(200, json.dumps(rec), no_cache=True)
else:
self._send(404, json.dumps({"error": "not found"}))
def _client_gone(self):
# True once the peer has closed its end. We've already consumed the
# request body, so any readable data here is EOF (b"") for a closed tab.
sock = self.connection
try:
r, _, _ = select.select([sock], [], [], 0)
if r:
return sock.recv(1, socket.MSG_PEEK) == b""
except OSError:
return True
return False
def do_POST(self):
path = urlsplit(self.path)
if path.path == "/cancel":
self._handle_cancel(parse_qs(path.query))
return
if path.path != "/solve":
self._send(404, json.dumps({"error": "not found"}))
return
# Reject immediately if another viewer is already solving.
if not _solve_lock.acquire(blocking=False):
self._send(429, json.dumps({"error": "Another solve is already in progress"}))
return
released = False
token = None
try:
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}")
raw_problem = payload.get("problem", {})
problem = problem_from_dict(raw_problem)
max_time = float(payload.get("max_time_seconds", 30.0))
token = str(payload.get("token") or "")
# Mark this token as the in-flight solve so a matching /cancel beacon
# (sent when the requesting tab closes) can stop it.
with _active_lock:
_active["token"] = token
_active["solver"] = None
# Run the (blocking) solve in a worker thread so this thread is free
# to watch the socket. solver_sink hands us the CpSolver so both the
# local disconnect watcher and /cancel can abort the search.
result = {}
def register(s):
with _active_lock:
if _active["token"] == token:
_active["solver"] = s
def run():
try:
result["sol"] = solve(
problem, max_time_seconds=max_time, solver_sink=register,
)
except Exception as exc:
result["err"] = exc
worker = threading.Thread(target=run, daemon=True)
worker.start()
while worker.is_alive():
worker.join(0.25)
# Direct connections (no proxy) still get fast release via socket
# EOF; behind a proxy the /cancel beacon is what stops the solve.
if worker.is_alive() and self._client_gone():
with _active_lock:
if _active["solver"] is not None:
_stop_search(_active["solver"])
_solve_lock.release()
released = True
return
if "err" in result:
raise result["err"]
sol_dict = solution_to_dict(result["sol"])
# Persist the completed solve so it can be looked up by its UUID.
# (Cancelled solves take the early return above and aren't stored.)
store_solve(token, raw_problem, sol_dict)
self._send(200, json.dumps(sol_dict))
except Exception as exc: # surface errors to the browser
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
finally:
with _active_lock:
if _active["token"] == token:
_active["token"] = None
_active["solver"] = None
if not released:
_solve_lock.release()
def _handle_cancel(self, query):
# Stop the in-flight solve iff the request's token matches it, so a Cancel
# click (or closing tab) can't cancel a *different* viewer's solve. The
# running /solve handler then returns normally — with the best plan found
# so far — and releases the lock.
token = (query.get("token") or [""])[0]
stopped = False
with _active_lock:
if token and token == _active["token"] and _active["solver"] is not None:
_stop_search(_active["solver"])
stopped = True
self._send(200, json.dumps({"cancelled": stopped}), no_cache=True)
def log_message(self, fmt, *args): # quieter console
pass
def main():
init_db()
host = os.environ.get("HOST", "127.0.0.1")
port = int(os.environ.get("PORT", "8000"))
server = ThreadingHTTPServer((host, port), Handler)
print(f"Days Without Strife planner UI: http://{host}:{port}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nshutting down")
server.shutdown()
if __name__ == "__main__":
main()