diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 674130a..b0e7b5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,18 @@ jobs: - name: Drive the page with a real cursor and keyboard run: node web/test/input.mjs --url http://localhost:8123/index.html + # play.html is the same game inlined into one file, for opening from a + # disk with no server. It is generated, so it can go stale, and it loads + # under file:// where fetch is refused — so it is rebuilt, compared, and + # then actually played from a file rather than trusted. + - name: Check the one-file build still matches web/ + run: node web/build/standalone.mjs --check + + - name: Play the one-file build straight off the disk + run: | + node web/test/browser.mjs --url "file://$PWD/play.html" --games 1 + node web/test/input.mjs --url "file://$PWD/play.html" + pipeline: # Proves the data -> train -> export -> serve path still works end to end, # on a dataset small enough to finish in a couple of minutes. diff --git a/.gitignore b/.gitignore index 02134a7..df43fbb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,9 @@ __pycache__/ *.py[cod] .venv/ venv/ -dist/ -build/ +/dist/ +# Anchored: setuptools writes ./build, but web/build holds source. +/build/ *.egg-info/ .pytest_cache/ .mypy_cache/ diff --git a/Makefile b/Makefile index 36e0c54..f36adab 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help setup test lint typecheck check train bench figures facts api ui play \ - parity parity-check selfplay browser input capture web clean all + parity parity-check selfplay browser input capture standalone web clean all .DEFAULT_GOAL := help PY ?= python3 @@ -46,6 +46,10 @@ browser: ## Load the page in Chrome and play a game (needs puppeteer-core) capture: ## Re-record the screenshots and the clip in the README $(NODE) web/test/capture.mjs --url http://localhost:$(PORT)/index.html +standalone: ## Rebuild play.html, the whole game in one openable file + $(NODE) web/build/standalone.mjs + $(NODE) web/test/browser.mjs --url file://$(CURDIR)/play.html --games 1 + web: parity selfplay ## Every check that does not need a browser play: ## Serve the game at http://localhost:$(PORT) diff --git a/README.md b/README.md index 8904350..86c9b4c 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,13 @@ closed-form references. "Quick" playback the page offers, with the bot's search pauses capped at 0.8 s. The shots are the game's, not an animation.* +**Three ways in, none of which need a toolchain.** The link above, `make play` +to serve `web/` yourself, or `play.html` — the whole game inlined into one file +that plays when you open it from a disk, offline, with no server. Download it +from the repository root and double-click it. `web/build/standalone.mjs` builds +it, and CI plays a full game from `file://` on every push so the copy cannot +quietly rot. + No install, no build step, no framework — `web/` is plain ES modules, and the whole thing is served as static files. Aim with the mouse, drag back from the cue ball to strike, click the cue-ball diagram to move the tip off centre. Every diff --git a/play.html b/play.html new file mode 100644 index 0000000..4f32de0 --- /dev/null +++ b/play.html @@ -0,0 +1,4109 @@ + + + +
+ + ++ This is not a pool game with physics bolted on. It is a rigid-body simulator that + was written first, checked against closed-form mechanics, and then given a table + and an opponent so the model can be watched while it runs. Everything below is + measured by something in the repository, and the command that measures it is named. +
+ ++ A struck ball does not simply decelerate. While its contact point is slipping + against the cloth, friction acts opposite the slip velocity rather + than the ball's velocity, and the same force exerts a torque that spins the + ball up: +
+u = v + ω × (−R ẑ), a = −μs g û, α = (−R ẑ × F)/I
+
+ Slip decays 3.5 times faster than the centre of mass slows, so the ball settles
+ into rolling at exactly 5/7 of its launch speed and only then
+ switches to the much smaller rolling resistance. Watch the
+ Cue ball, live panel during a shot: the yellow slip curve collapses to
+ zero and the green speed curve lands on the blue line. That line is a
+ prediction from the mechanics, not a fitted parameter.
+
+ Simulators are easy to make self-consistent and wrong. The test suite compares
+ the integrator against results derived independently on paper, not against its
+ own earlier output: the 5/7 rolling speed, the
+ 12v₀²/(49 μs g) sliding distance, spin decay, cushion
+ restitution, the draw/stun/follow ordering, exact momentum conservation and
+ strictly decreasing energy.
+
+ An earlier version of this repository failed all of that. The contact-point
+ slip and the friction torque had opposite handedness, so friction drove the
+ ball away from rolling: measured rolling speed was zero instead of
+ 5/7 v₀, and stopping distance was four times short.
+
+ Closed-form checks exercise one ball at a time, so a defect that only exists
+ between balls survives them. This one did. The solver counted two balls as
+ touching when the gap between their surfaces was under
+ 1e-4 m — and the rack was built with a 1e-4 m
+ clearance. All thirty contacts in the triangle sat exactly on the threshold,
+ and which side each fell on came down to whether hypot rounded up
+ or down. Sixteen registered. Fourteen did not.
+
+ A break propagated through a contact graph with holes in it: balls in the + middle of the rack came out of a full-power break having barely moved, and the + table opened up less the harder it was struck. It was found by + measuring break spread against cue speed and getting the sign wrong, not by a + test failing. What fixed it was giving the tolerance a name, using the same one + everywhere, and racking the balls actually touching so nothing sits on the + boundary. A test now asserts the property that caught it, because it is + exactly the kind nobody writes down: a harder break opens the table further. +
++ Measuring that turned up something the fix does not solve, and which is worth + stating rather than hiding. A rack is resolved as a chain of about fifteen + pairwise collisions, and each one applies restitution, so the survival factor + compounds: only 48% of the kinetic energy comes through a 10 m/s break, + where the real event is one stress wave that dissipates once. The break you + see below opens the table, but less than a real one would. It is the largest + known departure from reality here, and fixing it means treating a simultaneous + contact set as a single event rather than adjusting a coefficient. +
+
+ web/js/physics.js is a hand port of
+ src/pocket/physics/, and a port nobody measures is a rumour.
+ scripts/export_parity_cases.py runs
+ 35 shots through the Python simulator —
+ draw, follow, english off two rails, thin cuts, clusters, and full sixteen-ball
+ breaks — and records where every ball stopped.
+ web/test/parity.mjs replays them here and compares.
+
+ The largest disagreement across all of them is + 0.0012 mm — a thousandth of a + millimetre, accumulated over seconds of a chaotic sixteen-ball break, and far + below the scale at which the two could be said to behave differently. It runs + in continuous integration, so they cannot quietly drift apart. +
++ Twenty headless bot-against-bot games walk every branch of the rules, and + check on each of the roughly eight hundred thousand physics steps they take + that no two balls are ever sharing space and nothing is ever inside a cushion. + The worst overlap seen is 0.5 mm on a 57 mm ball, a fifth of a pixel at + the size this table is drawn. They also assert that every break moves at + least ten of the fifteen — it moves twelve — because a break that clips the + apex leaves the rack standing and looks exactly like a limitation of the + physics rather than a badly aimed cue. +
++ Aiming a pot needs no learned model: the ghost-ball construction is exact, and + a test asserts that aiming at it drops the ball while half a degree either + side misses. So the bot spends nothing on aiming. It enumerates every + ball-and-pocket pair in closed form, discards whatever is blocked or cut too + thin, and spends its whole budget simulating the survivors to see + what each one leaves behind. +
++ That is the argument this repository makes about fast physics, running in + front of you: the value of a cheap simulation is the number of futures you can + afford to look at. The panel above reports how many candidates the bot + enumerated, how many it actually simulated, and how much table time that was. +
++ The Python side also trains a surrogate that predicts resting positions + directly, at 0.60 ms against + 4.6 s for a full rack in the reference + simulator. It cuts mean endpoint error from + 494 mm to + 376 mm against the closed-form + baseline it corrects, over 20,000 simulated + shots. +
++ The bot above does not use it, and the reason is the more useful result. Ported + to the browser the same physics runs + 65× faster, which is enough to simulate + the shots worth considering exactly. A surrogate is worth its error when you + need to screen far more candidates than you can afford to simulate; inside one + turn, you do not. Reported against interest: once a ball-ball collision has to + be modelled, it loses to plain gradient boosting + (696 mm against + 610 mm). Every number here is rewritten by + the training run. +
+Nothing here is a screenshot of a number that was true once.
+make check # ruff, mypy, the whole test suite
+make parity # export shots from Python, replay them in Node
+make selfplay # 20 headless games, every rule branch
+make browser # load this page in Chrome and play a game
+make all # dataset → training → benchmarks → figures
+
+ The figures on this page are written by
+ scripts/site_facts.py from those runs rather than typed in, and
+ continuous integration fails if they drift. The game itself is dependency-free
+ ES modules: no bundler, no framework, no build step.
+ python3 -m http.server in web/ is enough to run
+ everything you see.
+