diff --git a/index.html b/index.html
new file mode 100644
index 0000000..9fa757a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,730 @@
+
+
+
+
+
+
+ Days Without Strife - Planner
+
+
+
+
+ Days Without Strife — Planner Optimizer
+
+
+ Game
+
+ Turns
+ Extra renown (constant)
+ Airships already launched
+ Max resource
+ Max vat
+ Solver time limit (s)
+
+
+
+
+ Starting resources
+
+
+
+
+ Tradeable into (Trade Goods convert 1-for-1 for scoring)
+
+
+
+
+ Cities
+ The name is the city's unique identifier — it labels the
+ output plan and is what an agent's "forced city" refers to. Upgrades are the
+ ones already installed at the start; the available choices follow the city's type.
+ Adjacent cities lists neighbours by name (adjacency is symmetric); an
+ Industrialist Governor grants free Infrastructure to its city and every adjacent one.
+
+
+ + city
+
+
+
+ Agents
+ Pick a known agent; its governor behaviour is implicit and
+ shown in the Effect column. The name identifies it and is what a
+ forced city refers back to. All effects apply while the agent is Governor of a
+ city (at most one city per turn).
+
+ + agent
+
+
+
+ Objective — scoring terms
+ Each term scores a resource (or renown) at the end of a
+ turn (blank turn = final turn). For a log term, write a JS expression that
+ evals to a one-argument function, e.g. (x) => Math.log2(x + 1).
+ It is called over amounts 0..max_resource in the browser to build the
+ lookup table.
+
+ + term
+
+
+
+ Resource constraints
+
+ + constraint
+
+
+
+ Solve
+
+
+
+
+
+
+
+
+
diff --git a/main.py b/main.py
index b2d39a4..6f2e7b4 100644
--- a/main.py
+++ b/main.py
@@ -14,578 +14,13 @@ from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
from solve import problem_from_dict, solve, solution_to_dict
-INDEX_HTML = r"""
-
-
-
-
-Days Without Strife - Planner
-
-
-
-Days Without Strife — Planner Optimizer
-
-
- Game
-
- Turns
- Extra renown (constant)
- Airships already launched
- Max resource
- Max vat
- Solver time limit (s)
-
-
-
-
- Starting resources
-
-
-
-
- Tradeable into (Trade Goods convert 1-for-1 for scoring)
-
-
-
-
- Cities
- The name is the city's unique identifier — it labels the
- output plan and is what an agent's "forced city" refers to. Upgrades are the
- ones already installed at the start; the available choices follow the city's type.
- Adjacent cities lists neighbours by name (adjacency is symmetric); an
- Industrialist Governor grants free Infrastructure to its city and every adjacent one.
-
- + city
-
-
-
- Agents
- Pick a known agent; its governor behaviour is implicit and
- shown in the Effect column. The name identifies it and is what a
- forced city refers back to. All effects apply while the agent is Governor of a
- city (at most one city per turn).
-
- + agent
-
-
-
- Objective — scoring terms
- Each term scores a resource (or renown) at the end of a
- turn (blank turn = final turn). For a log term, write a JS expression that
- evals to a one-argument function, e.g. (x) => Math.log2(x + 1).
- It is called over amounts 0..max_resource in the browser to build the
- lookup table.
-
- + term
-
-
-
- Resource constraints
-
- + constraint
-
-
-
- Solve
-
-
-
-
-
-
-
-
-"""
+# 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")
class Handler(BaseHTTPRequestHandler):