diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 711302a..674130a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: run: python scripts/site_facts.py --check browser-physics: - # The browser runs a hand port of src/cueai/physics/. This regenerates the + # The browser runs a hand port of src/pocket/physics/. This regenerates the # reference outcomes from the Python simulator and replays them in Node, so # the two implementations cannot drift apart without the build going red. runs-on: ubuntu-latest @@ -104,7 +104,7 @@ jobs: pip install -e ".[dev]" - name: Train on a small dataset - run: python -m cueai.ml.train --n-samples 400 --epochs 10 + run: python -m pocket.ml.train --n-samples 400 --epochs 10 - name: Benchmark run: python scripts/benchmark.py --repeats 50 @@ -114,7 +114,7 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: cueai-pipeline-output + name: pocket-pipeline-output path: | models/metrics.json models/latency.json diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index fdc57d8..605e976 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -56,7 +56,11 @@ jobs: steps: - uses: actions/checkout@v4 + # enablement turns Pages on for the repository if it is off, so the + # published link works without anyone visiting the settings page first. - uses: actions/configure-pages@v5 + with: + enablement: true - uses: actions/upload-pages-artifact@v3 with: diff --git a/LICENSE b/LICENSE index 8bbc8b5..d1e0faa 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 CueAI contributors +Copyright (c) 2026 Pocket Physics contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index d4e8240..36e0c54 100644 --- a/Makefile +++ b/Makefile @@ -49,11 +49,11 @@ capture: ## Re-record the screenshots and the clip in the README web: parity selfplay ## Every check that does not need a browser play: ## Serve the game at http://localhost:$(PORT) - @echo "CueAI is at http://localhost:$(PORT)" + @echo "Pocket Physics is at http://localhost:$(PORT)" @cd web && $(PY) -m http.server $(PORT) train: ## Generate data and train the residual model - $(PY) -m cueai.ml.train --n-samples $(SAMPLES) --epochs $(EPOCHS) + $(PY) -m pocket.ml.train --n-samples $(SAMPLES) --epochs $(EPOCHS) bench: ## Measure latency and rewrite docs/BENCHMARKS.md $(PY) scripts/benchmark.py @@ -65,10 +65,10 @@ facts: ## Rewrite the numbers the playable page quotes $(PY) scripts/site_facts.py api: ## Serve the prediction API on :8000 - $(PY) -m uvicorn cueai.api.main:app --reload --port 8000 + $(PY) -m uvicorn pocket.api.main:app --reload --port 8000 ui: ## Launch the desktop table (needs the ui extra) - $(PY) -m cueai.ui.app + $(PY) -m pocket.ui.app all: train bench figures facts ## Reproduce every published number and figure diff --git a/README.md b/README.md index 5cf97bf..8904350 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ -# CueAI +# Pocket Physics +### ▶ [Play eight-ball against the bot][play] — in the browser, nothing to install + +[![Play](https://img.shields.io/badge/play-eight--ball%20in%20the%20browser-12594a)][play] [![CI](https://github.com/BruceMoseti/cueai/actions/workflows/ci.yml/badge.svg)](https://github.com/BruceMoseti/cueai/actions/workflows/ci.yml) ![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue) ![License](https://img.shields.io/badge/license-MIT-green) +Pocket billiards, simulated properly — then made small enough to fit in a +browser tab. + **A physics simulator for billiards, a closed-form solution that replaces it, and a learned model that corrects what the closed form misses — roughly 7,600x faster than integration, with the accuracy measured and the failure mode stated. @@ -22,7 +28,7 @@ closed-form references. ## Play it -### **[▶ Play eight-ball against the bot](https://brucemoseti.github.io/cueai/)** +**[▶ Open the table and break][play]** ![Eight-ball against the search bot, in the browser](docs/assets/web_demo.gif) @@ -40,7 +46,7 @@ found afterwards, and which of those facts made a shot a foul. ![The whole interface: table, live cue-ball trace, the bot's report and the shot log](docs/assets/web_game.png) The browser is not running a lookalike physics engine. `web/js/physics.js` is a -hand port of `src/cueai/physics/`, and the port is measured rather than +hand port of `src/pocket/physics/`, and the port is measured rather than asserted: `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 `web/test/parity.mjs` replays every one of them in @@ -106,7 +112,7 @@ else is a ball rolling.
**And there is prose under the table.** -[The explainer](https://brucemoseti.github.io/cueai/#how) covers the cloth +[The explainer][explainer] covers the cloth model, the parity harness, the bot's search, where the learned surrogate helps and where it does not, and [the multi-ball contact bug](#the-bug-the-tests-could-not-see) the single-ball @@ -406,7 +412,7 @@ parity check and a run of headless games have passed. ## Repository map ``` -src/cueai/ +src/pocket/ physics/ ball.py four-state cloth dynamics for one ball collisions.py frictional ball-ball impulses, cushions, pockets @@ -425,7 +431,7 @@ src/cueai/ web/ the playable table: dependency-free ES modules js/ - physics.js hand port of src/cueai/physics/, checked against it + physics.js hand port of src/pocket/physics/, checked against it rack.js the same rack geometry, ported game.js eight-ball rules, fouls, group assignment bot.js closed-form candidate pots, then simulated rollouts @@ -491,3 +497,8 @@ published ranges in those sources. ## License MIT — see [LICENSE](LICENSE). + + + +[play]: https://brucemoseti.github.io/cueai/#play +[explainer]: https://brucemoseti.github.io/cueai/#how diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index ca6e22b..5998b4e 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(cueai_cpp LANGUAGES CXX) +project(pocket_cpp LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) -add_executable(cueai_demo src/demo.cpp) -target_include_directories(cueai_demo PRIVATE include) +add_executable(pocket_demo src/demo.cpp) +target_include_directories(pocket_demo PRIVATE include) diff --git a/cpp/include/cueai/physics.hpp b/cpp/include/pocket/physics.hpp similarity index 92% rename from cpp/include/cueai/physics.hpp rename to cpp/include/pocket/physics.hpp index 66753d9..f1ec72b 100644 --- a/cpp/include/cueai/physics.hpp +++ b/cpp/include/pocket/physics.hpp @@ -1,9 +1,9 @@ -// CueAI C++ physics core — cloth sliding / rolling step (NumPy-parity helpers) +// C++ physics core — cloth sliding / rolling step (NumPy-parity helpers) #pragma once #include #include -namespace cueai { +namespace pocket { constexpr double G = 9.81; @@ -55,4 +55,4 @@ inline void integrate_rolling(BallState& b, double mu_r, double mu_sp, double dt b.x += b.vx * dt; b.y += b.vy * dt; } -} // namespace cueai +} // namespace pocket diff --git a/cpp/src/demo.cpp b/cpp/src/demo.cpp index 3224c9f..12b7b9b 100644 --- a/cpp/src/demo.cpp +++ b/cpp/src/demo.cpp @@ -1,9 +1,9 @@ -#include "cueai/physics.hpp" +#include "pocket/physics.hpp" #include #include int main() { - cueai::BallState b; + pocket::BallState b; b.x = 0.5; b.y = 0.5; b.vx = 2.0; b.vy = 0.5; b.wy = -2.0 / b.R; // backspin-ish slip @@ -13,9 +13,9 @@ int main() { auto u = b.slip(); double um = std::hypot(u[0], u[1]); if (um > 1e-3) - cueai::integrate_sliding(b, 0.2, 0.044, dt); + pocket::integrate_sliding(b, 0.2, 0.044, dt); else - cueai::integrate_rolling(b, 0.01, 0.044, dt); + pocket::integrate_rolling(b, 0.01, 0.044, dt); if (b.speed() < 1e-4) break; } std::cout << std::fixed << std::setprecision(4) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1a0eef1..a7bacc5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,5 +1,8 @@ # Design +▶ [Play the game](https://brucemoseti.github.io/cueai/#play) — the fourth tier +described below, running in the browser. + ## The problem this solves Simulating a billiards shot accurately is slow. Sixteen bodies, each in one of @@ -33,13 +36,13 @@ shot parameters (speed, angle, tip offset, cue position, cloth μ, cushion e) ## Three tiers, on purpose -**Tier 1: the numerical simulator** (`cueai.physics.simulator`). Explicit Euler +**Tier 1: the numerical simulator** (`pocket.physics.simulator`). Explicit Euler integration at 1 ms, four-state cloth dynamics, frictional ball-ball impulses with spin transfer, cushion rebound, pocket capture. This is the definition of truth for everything else, and it is validated against closed-form results in [VALIDATION.md](VALIDATION.md). -**Tier 2: the closed-form solver** (`cueai.physics.analytic`). No integration at +**Tier 2: the closed-form solver** (`pocket.physics.analytic`). No integration at all. The observation that makes this possible: while a ball slides, the slip velocity `u` decays along a *fixed direction*, so the friction force is constant and the path over that phase is exactly a parabola of known duration @@ -51,7 +54,7 @@ that detail right matters: a plain mirror-reflection approximation, which assume the ball leaves the rail rolling, disagreed with the simulator by about a metre, while this solver lands within 114 mm on direct shots and 225 mm across one rail. -**Tier 3: the learned residual** (`cueai.ml`). A small MLP predicts the vector +**Tier 3: the learned residual** (`pocket.ml`). A small MLP predicts the vector from the closed-form endpoint to the simulated endpoint. Its head is initialised to zero, so training starts from "trust the physics exactly" and moves away only where the data insists. diff --git a/docs/assets/web_bot.png b/docs/assets/web_bot.png index cb4a04a..86ec154 100644 Binary files a/docs/assets/web_bot.png and b/docs/assets/web_bot.png differ diff --git a/docs/assets/web_demo.gif b/docs/assets/web_demo.gif index 1502fe0..f39eb05 100644 Binary files a/docs/assets/web_demo.gif and b/docs/assets/web_demo.gif differ diff --git a/docs/assets/web_demo.mp4 b/docs/assets/web_demo.mp4 index 68a01f1..cc51a10 100644 Binary files a/docs/assets/web_demo.mp4 and b/docs/assets/web_demo.mp4 differ diff --git a/docs/assets/web_game.png b/docs/assets/web_game.png index 1a89014..f9e3ff7 100644 Binary files a/docs/assets/web_game.png and b/docs/assets/web_game.png differ diff --git a/docs/assets/web_inspector.png b/docs/assets/web_inspector.png index 3e94777..57ddfa3 100644 Binary files a/docs/assets/web_inspector.png and b/docs/assets/web_inspector.png differ diff --git a/models/.gitkeep b/models/.gitkeep index c74b2d2..2c6d3cc 100644 --- a/models/.gitkeep +++ b/models/.gitkeep @@ -1,3 +1,3 @@ { - "note": "Run: python -m cueai.ml.train --n-samples 3000 --epochs 40" + "note": "Run: python -m pocket.ml.train --n-samples 3000 --epochs 40" } diff --git a/pyproject.toml b/pyproject.toml index 3a2ad25..e05c740 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "cueai" +name = "pocket-physics" version = "0.3.0" description = "Physics-informed billiards simulation with a learned fast surrogate" readme = "README.md" @@ -36,9 +36,9 @@ dev = [ ] [project.scripts] -cueai-ui = "cueai.ui.app:main" -cueai-train = "cueai.ml.train:main" -cueai-api = "cueai.api.main:run" +pocket-ui = "pocket.ui.app:main" +pocket-train = "pocket.ml.train:main" +pocket-api = "pocket.api.main:run" [tool.setuptools.packages.find] where = ["src"] @@ -70,7 +70,7 @@ ignore = [ # No python_version pin: mypy targets whichever interpreter runs it, so each entry # in the CI matrix checks its own version against the numpy stubs built for it. # Ruff's py310 target is what holds the floor for our own syntax. -files = ["src/cueai"] +files = ["src/pocket"] ignore_missing_imports = true warn_unused_ignores = true warn_redundant_casts = true diff --git a/scripts/benchmark.py b/scripts/benchmark.py index cf6d4e9..b0ebcbb 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -3,7 +3,7 @@ Measure prediction latency and accuracy, and write docs/BENCHMARKS.md. Everything the README claims about speed comes from this script. Run it after -``cueai-train`` so the accuracy section can read models/metrics.json: +``pocket-train`` so the accuracy section can read models/metrics.json: python scripts/benchmark.py """ @@ -23,10 +23,10 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) -from cueai.ml.infer import TrajectoryPredictor # noqa: E402 -from cueai.physics.analytic import predict_endpoint # noqa: E402 -from cueai.physics.constants import ShotParams, TableParams # noqa: E402 -from cueai.physics.simulator import Simulator # noqa: E402 +from pocket.ml.infer import TrajectoryPredictor # noqa: E402 +from pocket.physics.analytic import predict_endpoint # noqa: E402 +from pocket.physics.constants import ShotParams, TableParams # noqa: E402 +from pocket.physics.simulator import Simulator # noqa: E402 def time_calls(fn, repeats: int) -> dict[str, float]: @@ -69,7 +69,7 @@ def measure_latency(repeats: int) -> dict[str, dict[str, float]]: if gbm_path.exists(): import joblib - from cueai.ml.features import build_features + from pocket.ml.features import build_features bundle = joblib.load(gbm_path) row = build_features(shot, cue, (1.4, 0.7), table)[None, :] diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 81358f7..e40c959 100644 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -6,8 +6,8 @@ python3 -m venv .venv source .venv/bin/activate pip install -U pip pip install -e ".[dev]" 2>/dev/null || pip install -r requirements.txt && pip install -e . -python -m cueai.ml.train --n-samples 800 --epochs 15 +python -m pocket.ml.train --n-samples 800 --epochs 15 python -m pytest tests/ -q -echo "CueAI setup complete." -echo " API: uvicorn cueai.api.main:app --reload --port 8000" -echo " UI: python -m cueai.ui.app" +echo "Pocket Physics setup complete." +echo " API: uvicorn pocket.api.main:app --reload --port 8000" +echo " UI: python -m pocket.ui.app" diff --git a/scripts/export_parity_cases.py b/scripts/export_parity_cases.py index 6a738f6..cfcdce5 100644 --- a/scripts/export_parity_cases.py +++ b/scripts/export_parity_cases.py @@ -2,7 +2,7 @@ """ Export reference shots so the browser physics can be checked against Python. -The Python package under `src/cueai/physics/` is the definition of correct: it +The Python package under `src/pocket/physics/` is the definition of correct: it is what `tests/test_validation.py` pins to closed-form mechanics. The browser runs a hand port of it, and a port is only worth anything if someone measures the difference. This writes the initial conditions and the reference outcome @@ -25,10 +25,10 @@ import numpy as np -from cueai.physics.ball import Ball -from cueai.physics.constants import BallParams, ShotParams, TableParams -from cueai.physics.rack import identity_for, make_full_rack -from cueai.physics.simulator import Simulator +from pocket.physics.ball import Ball +from pocket.physics.constants import BallParams, ShotParams, TableParams +from pocket.physics.rack import identity_for, make_full_rack +from pocket.physics.simulator import Simulator PERTURBATION_M = 1e-12 diff --git a/scripts/make_figures.py b/scripts/make_figures.py index 9f2edcb..e1c5fa7 100644 --- a/scripts/make_figures.py +++ b/scripts/make_figures.py @@ -25,10 +25,10 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) -from cueai.physics import analytic # noqa: E402 -from cueai.physics.ball import Ball, MotionState, integrate_ball # noqa: E402 -from cueai.physics.constants import BallParams, ShotParams, TableParams # noqa: E402 -from cueai.physics.simulator import Simulator # noqa: E402 +from pocket.physics import analytic # noqa: E402 +from pocket.physics.ball import Ball, MotionState, integrate_ball # noqa: E402 +from pocket.physics.constants import BallParams, ShotParams, TableParams # noqa: E402 +from pocket.physics.simulator import Simulator # noqa: E402 ASSETS = ROOT / "docs" / "assets" CLOTH = "#12764a" diff --git a/src/cueai/__init__.py b/src/cueai/__init__.py deleted file mode 100644 index 89621ad..0000000 --- a/src/cueai/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""CueAI — physics-informed billiards simulation + ML trajectory prediction.""" - -__version__ = "0.1.0" diff --git a/src/cueai/physics/__init__.py b/src/cueai/physics/__init__.py deleted file mode 100644 index 2807080..0000000 --- a/src/cueai/physics/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Physics package.""" - -from cueai.physics.analytic import predict_endpoint, straight_shot -from cueai.physics.constants import BallParams, ShotParams, TableParams -from cueai.physics.rack import make_full_rack -from cueai.physics.simulator import SimResult, Simulator, shot_feature_vector - -__all__ = [ - "BallParams", - "ShotParams", - "SimResult", - "Simulator", - "TableParams", - "make_full_rack", - "predict_endpoint", - "shot_feature_vector", - "straight_shot", -] diff --git a/src/pocket/__init__.py b/src/pocket/__init__.py new file mode 100644 index 0000000..d610600 --- /dev/null +++ b/src/pocket/__init__.py @@ -0,0 +1,8 @@ +"""Pocket Physics — physics-informed billiards simulation + ML trajectory prediction.""" + +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("pocket-physics") +except PackageNotFoundError: # a source tree that was never installed + __version__ = "0.0.0+unknown" diff --git a/src/cueai/api/__init__.py b/src/pocket/api/__init__.py similarity index 100% rename from src/cueai/api/__init__.py rename to src/pocket/api/__init__.py diff --git a/src/cueai/api/main.py b/src/pocket/api/main.py similarity index 91% rename from src/cueai/api/main.py rename to src/pocket/api/main.py index 999bc1a..0b02810 100644 --- a/src/cueai/api/main.py +++ b/src/pocket/api/main.py @@ -1,4 +1,4 @@ -"""FastAPI backend for CueAI full-rack simulation + prediction.""" +"""FastAPI backend for Pocket Physics full-rack simulation + prediction.""" from __future__ import annotations @@ -10,15 +10,15 @@ from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field -from cueai.ml.dataset import MAX_TIP_OFFSET -from cueai.ml.infer import TrajectoryPredictor -from cueai.physics.constants import ShotParams, TableParams -from cueai.physics.rack import make_full_rack +from pocket.ml.dataset import MAX_TIP_OFFSET +from pocket.ml.infer import TrajectoryPredictor +from pocket.physics.constants import ShotParams, TableParams +from pocket.physics.rack import make_full_rack API_VERSION = "0.3.0" app = FastAPI( - title="CueAI API", + title="Pocket Physics API", description=( "Physics-informed billiards prediction. /predict runs the full numerical " "simulation; /predict/fast returns the closed-form plus learned-residual " @@ -149,7 +149,7 @@ def table_info() -> dict: def run() -> None: import uvicorn - uvicorn.run("cueai.api.main:app", host="0.0.0.0", port=8000, reload=True) + uvicorn.run("pocket.api.main:app", host="0.0.0.0", port=8000, reload=True) if __name__ == "__main__": diff --git a/src/cueai/ml/__init__.py b/src/pocket/ml/__init__.py similarity index 52% rename from src/cueai/ml/__init__.py rename to src/pocket/ml/__init__.py index c11bc78..a030104 100644 --- a/src/cueai/ml/__init__.py +++ b/src/pocket/ml/__init__.py @@ -1,5 +1,5 @@ """ML package.""" -from cueai.ml.infer import TrajectoryPredictor +from pocket.ml.infer import TrajectoryPredictor __all__ = ["TrajectoryPredictor"] diff --git a/src/cueai/ml/dataset.py b/src/pocket/ml/dataset.py similarity index 96% rename from src/cueai/ml/dataset.py rename to src/pocket/ml/dataset.py index 630b85e..07ab327 100644 --- a/src/cueai/ml/dataset.py +++ b/src/pocket/ml/dataset.py @@ -25,9 +25,9 @@ import pandas as pd from tqdm import tqdm -from cueai.physics.analytic import predict_endpoint -from cueai.physics.constants import ShotParams, TableParams -from cueai.physics.simulator import FEATURE_NAMES, Simulator, shot_feature_vector +from pocket.physics.analytic import predict_endpoint +from pocket.physics.constants import ShotParams, TableParams +from pocket.physics.simulator import FEATURE_NAMES, Simulator, shot_feature_vector TARGET_NAMES = [ "cue_end_x", diff --git a/src/cueai/ml/features.py b/src/pocket/ml/features.py similarity index 94% rename from src/cueai/ml/features.py rename to src/pocket/ml/features.py index bae88b2..a1737c4 100644 --- a/src/cueai/ml/features.py +++ b/src/pocket/ml/features.py @@ -22,9 +22,9 @@ import numpy as np import pandas as pd -from cueai.physics.analytic import solve_free_ball -from cueai.physics.constants import BallParams, ShotParams, TableParams -from cueai.physics.simulator import FEATURE_NAMES as SHOT_FEATURE_NAMES +from pocket.physics.analytic import solve_free_ball +from pocket.physics.constants import BallParams, ShotParams, TableParams +from pocket.physics.simulator import FEATURE_NAMES as SHOT_FEATURE_NAMES BASELINE_FEATURE_NAMES = [ "base_cue_x", # closed-form resting position of the cue ball @@ -54,7 +54,7 @@ def build_features( radius: float = 0.028575, ) -> np.ndarray: """Model input for a single shot. Order matches :data:`FEATURE_NAMES`.""" - from cueai.physics.simulator import shot_feature_vector + from pocket.physics.simulator import shot_feature_vector cue = np.asarray(cue_pos, dtype=np.float64) obj = np.asarray(obj_pos, dtype=np.float64) if obj_pos is not None else None diff --git a/src/cueai/ml/infer.py b/src/pocket/ml/infer.py similarity index 95% rename from src/cueai/ml/infer.py rename to src/pocket/ml/infer.py index 59e000b..d3c9291 100644 --- a/src/cueai/ml/infer.py +++ b/src/pocket/ml/infer.py @@ -20,12 +20,12 @@ import numpy as np import torch -from cueai.ml.features import FEATURE_NAMES, build_features -from cueai.ml.model import CueNet -from cueai.physics.analytic import predict_endpoint -from cueai.physics.ball import Ball -from cueai.physics.constants import ShotParams, TableParams -from cueai.physics.simulator import Simulator +from pocket.ml.features import FEATURE_NAMES, build_features +from pocket.ml.model import CueNet +from pocket.physics.analytic import predict_endpoint +from pocket.physics.ball import Ball +from pocket.physics.constants import ShotParams, TableParams +from pocket.physics.simulator import Simulator class TrajectoryPredictor: @@ -82,7 +82,7 @@ def feature_vector( obj_pos: tuple[float, float] | np.ndarray | None, table: TableParams, ) -> np.ndarray: - """Identical construction to training; see :mod:`cueai.ml.features`.""" + """Identical construction to training; see :mod:`pocket.ml.features`.""" return build_features( shot, cue_pos, obj_pos, table, radius=self.sim.ball_params.radius ) diff --git a/src/cueai/ml/model.py b/src/pocket/ml/model.py similarity index 100% rename from src/cueai/ml/model.py rename to src/pocket/ml/model.py diff --git a/src/cueai/ml/train.py b/src/pocket/ml/train.py similarity index 98% rename from src/cueai/ml/train.py rename to src/pocket/ml/train.py index 7548277..4bc489e 100644 --- a/src/cueai/ml/train.py +++ b/src/pocket/ml/train.py @@ -30,9 +30,9 @@ from sklearn.preprocessing import StandardScaler from torch.utils.data import DataLoader, TensorDataset -from cueai.ml.dataset import BASELINE_NAMES, TARGET_NAMES, generate_dataset -from cueai.ml.features import BASELINE_FEATURE_NAMES, FEATURE_NAMES, build_feature_frame -from cueai.ml.model import CueNet +from pocket.ml.dataset import BASELINE_NAMES, TARGET_NAMES, generate_dataset +from pocket.ml.features import BASELINE_FEATURE_NAMES, FEATURE_NAMES, build_feature_frame +from pocket.ml.model import CueNet # Endpoint targets, in metres: (cue_x, cue_y, obj_x, obj_y) ENDPOINT_TARGETS = TARGET_NAMES[:4] @@ -291,7 +291,7 @@ def _export_onnx(net: CueNet, in_dim: int, path: Path) -> bool: def main(argv: list[str] | None = None) -> dict: - parser = argparse.ArgumentParser(description="Train CueAI models") + parser = argparse.ArgumentParser(description="Train Pocket Physics models") parser.add_argument("--n-samples", type=int, default=4000) parser.add_argument("--epochs", type=int, default=120) parser.add_argument("--batch-size", type=int, default=128) diff --git a/src/pocket/physics/__init__.py b/src/pocket/physics/__init__.py new file mode 100644 index 0000000..e180494 --- /dev/null +++ b/src/pocket/physics/__init__.py @@ -0,0 +1,18 @@ +"""Physics package.""" + +from pocket.physics.analytic import predict_endpoint, straight_shot +from pocket.physics.constants import BallParams, ShotParams, TableParams +from pocket.physics.rack import make_full_rack +from pocket.physics.simulator import SimResult, Simulator, shot_feature_vector + +__all__ = [ + "BallParams", + "ShotParams", + "SimResult", + "Simulator", + "TableParams", + "make_full_rack", + "predict_endpoint", + "shot_feature_vector", + "straight_shot", +] diff --git a/src/cueai/physics/analytic.py b/src/pocket/physics/analytic.py similarity index 98% rename from src/cueai/physics/analytic.py rename to src/pocket/physics/analytic.py index c21b99d..6c92c94 100644 --- a/src/cueai/physics/analytic.py +++ b/src/pocket/physics/analytic.py @@ -1,12 +1,12 @@ """ Closed-form solutions for the straight-shot limit of the cloth model. -These are the textbook results the numerical integrator in :mod:`cueai.physics.ball` +These are the textbook results the numerical integrator in :mod:`pocket.physics.ball` must reproduce, so they serve two purposes: 1. Ground truth for the validation suite (``tests/test_validation.py``). 2. A microsecond-cost baseline predictor that the learned residual model - corrects (:mod:`cueai.ml`), which is what makes the "physics-informed" + corrects (:mod:`pocket.ml`), which is what makes the "physics-informed" framing measurable rather than decorative. Derivation (ball of mass m, radius R, I = 2/5 mR², struck along +x) @@ -34,7 +34,7 @@ import numpy as np -from cueai.physics.constants import BallParams, G, ShotParams, TableParams +from pocket.physics.constants import BallParams, G, ShotParams, TableParams # Slip decays 3.5x faster than the centre of mass because the friction torque # also spins the ball up: 1 + mR²/I = 1 + 5/2. diff --git a/src/cueai/physics/ball.py b/src/pocket/physics/ball.py similarity index 98% rename from src/cueai/physics/ball.py rename to src/pocket/physics/ball.py index 5567de1..b595a1d 100644 --- a/src/cueai/physics/ball.py +++ b/src/pocket/physics/ball.py @@ -9,10 +9,10 @@ import numpy as np -from cueai.physics.constants import BallParams, G, TableParams +from pocket.physics.constants import BallParams, G, TableParams if TYPE_CHECKING: - from cueai.physics.rack import BallIdentity + from pocket.physics.rack import BallIdentity class MotionState(Enum): diff --git a/src/cueai/physics/collisions.py b/src/pocket/physics/collisions.py similarity index 99% rename from src/cueai/physics/collisions.py rename to src/pocket/physics/collisions.py index 4fce6c5..9cf959b 100644 --- a/src/cueai/physics/collisions.py +++ b/src/pocket/physics/collisions.py @@ -4,8 +4,8 @@ import numpy as np -from cueai.physics.ball import Ball -from cueai.physics.constants import TableParams +from pocket.physics.ball import Ball +from pocket.physics.constants import TableParams # Two balls count as touching once their surfaces are inside this band. # diff --git a/src/cueai/physics/constants.py b/src/pocket/physics/constants.py similarity index 100% rename from src/cueai/physics/constants.py rename to src/pocket/physics/constants.py diff --git a/src/cueai/physics/rack.py b/src/pocket/physics/rack.py similarity index 97% rename from src/cueai/physics/rack.py rename to src/pocket/physics/rack.py index 901d8d1..2696724 100644 --- a/src/cueai/physics/rack.py +++ b/src/pocket/physics/rack.py @@ -6,8 +6,8 @@ import numpy as np -from cueai.physics.ball import Ball -from cueai.physics.constants import BallParams, TableParams +from pocket.physics.ball import Ball +from pocket.physics.constants import BallParams, TableParams # Standard pool ball colors (RGB 0-255) BALL_COLORS: dict[int, tuple[int, int, int]] = { diff --git a/src/cueai/physics/simulator.py b/src/pocket/physics/simulator.py similarity index 97% rename from src/cueai/physics/simulator.py rename to src/pocket/physics/simulator.py index fb7344a..40a4167 100644 --- a/src/cueai/physics/simulator.py +++ b/src/pocket/physics/simulator.py @@ -6,14 +6,14 @@ import numpy as np -from cueai.physics.ball import Ball, integrate_ball -from cueai.physics.collisions import ( +from pocket.physics.ball import Ball, integrate_ball +from pocket.physics.collisions import ( check_pocket, resolve_all_ball_collisions, resolve_cushion, ) -from cueai.physics.constants import BallParams, ShotParams, TableParams -from cueai.physics.rack import make_full_rack +from pocket.physics.constants import BallParams, ShotParams, TableParams +from pocket.physics.rack import make_full_rack @dataclass @@ -56,7 +56,7 @@ def rack_cue_and_object( obj_pos: tuple[float, float] | None = None, ) -> list[Ball]: """Legacy 2-ball setup (kept for ML dataset compatibility).""" - from cueai.physics.rack import identity_for + from pocket.physics.rack import identity_for L, W = self.table.length, self.table.width if cue_pos is None: diff --git a/src/cueai/ui/__init__.py b/src/pocket/ui/__init__.py similarity index 100% rename from src/cueai/ui/__init__.py rename to src/pocket/ui/__init__.py diff --git a/src/cueai/ui/app.py b/src/pocket/ui/app.py similarity index 97% rename from src/cueai/ui/app.py rename to src/pocket/ui/app.py index 2280bcb..a6f9095 100644 --- a/src/cueai/ui/app.py +++ b/src/pocket/ui/app.py @@ -1,4 +1,4 @@ -"""PyQt6 interactive CueAI — full 16-ball table.""" +"""PyQt6 interactive Pocket Physics — full 16-ball table.""" from __future__ import annotations @@ -23,10 +23,10 @@ QWidget, ) -from cueai.ml.infer import TrajectoryPredictor -from cueai.physics.ball import Ball -from cueai.physics.constants import ShotParams, TableParams -from cueai.physics.rack import make_full_rack +from pocket.ml.infer import TrajectoryPredictor +from pocket.physics.ball import Ball +from pocket.physics.constants import ShotParams, TableParams +from pocket.physics.rack import make_full_rack class TableCanvas(QWidget): @@ -211,7 +211,7 @@ def paintEvent(self, _event) -> None: p.setPen(QColor(210, 230, 210)) p.setFont(QFont("Avenir Next", 12, QFont.Weight.DemiBold)) - p.drawText(18, 24, "CueAI — full rack · spin · throw · multi-collision") + p.drawText(18, 24, "Pocket Physics — full rack · spin · throw · multi-collision") def _draw_ball(self, p: QPainter, b: Ball, pos: np.ndarray, br_px: float) -> None: c = self.world_to_screen(*pos) @@ -303,7 +303,7 @@ def mouseReleaseEvent(self, _event) -> None: class MainWindow(QMainWindow): def __init__(self): super().__init__() - self.setWindowTitle("CueAI — Full Rack Billiards") + self.setWindowTitle("Pocket Physics — Full Rack Billiards") self.resize(1280, 700) root = Path(__file__).resolve().parents[3] self.predictor = TrajectoryPredictor(model_dir=root / "models") diff --git a/src/cueai/vision/__init__.py b/src/pocket/vision/__init__.py similarity index 100% rename from src/cueai/vision/__init__.py rename to src/pocket/vision/__init__.py diff --git a/src/cueai/vision/overlay.py b/src/pocket/vision/overlay.py similarity index 96% rename from src/cueai/vision/overlay.py rename to src/pocket/vision/overlay.py index 9dbfcc9..42d223e 100644 --- a/src/cueai/vision/overlay.py +++ b/src/pocket/vision/overlay.py @@ -9,7 +9,7 @@ except ImportError: # pragma: no cover cv2 = None -from cueai.physics.constants import TableParams +from pocket.physics.constants import TableParams def render_trajectory_frame( diff --git a/tests/test_api.py b/tests/test_api.py index 30d324b..5af85ad 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,7 +6,7 @@ import pytest from fastapi.testclient import TestClient -from cueai.api.main import app +from pocket.api.main import app SOFT_SHOT = {"speed": 1.5, "angle_deg": 5.0, "full_rack": False, "cue_x": 0.6, "cue_y": 0.6} diff --git a/tests/test_features.py b/tests/test_features.py index df808a4..218f00d 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -12,9 +12,9 @@ import pandas as pd import pytest -from cueai.ml import features as feat -from cueai.ml.dataset import simulate_sample -from cueai.physics.constants import ShotParams, TableParams +from pocket.ml import features as feat +from pocket.ml.dataset import simulate_sample +from pocket.physics.constants import ShotParams, TableParams def test_feature_names_match_vector_length() -> None: diff --git a/tests/test_metrics.py b/tests/test_metrics.py index a216de2..e2430de 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -10,7 +10,7 @@ import numpy as np -from cueai.ml.train import endpoint_errors, risk_coverage +from pocket.ml.train import endpoint_errors, risk_coverage def test_endpoint_errors_measures_distance_not_axes() -> None: diff --git a/tests/test_physics.py b/tests/test_physics.py index 58c95a9..455c3fc 100644 --- a/tests/test_physics.py +++ b/tests/test_physics.py @@ -1,13 +1,13 @@ -"""Unit tests for CueAI physics + full rack.""" +"""Unit tests for the physics core + full rack.""" from __future__ import annotations import numpy as np -from cueai.physics.ball import Ball, MotionState, integrate_ball -from cueai.physics.constants import BallParams, ShotParams, TableParams -from cueai.physics.rack import make_full_rack -from cueai.physics.simulator import Simulator, shot_feature_vector +from pocket.physics.ball import Ball, MotionState, integrate_ball +from pocket.physics.constants import BallParams, ShotParams, TableParams +from pocket.physics.rack import make_full_rack +from pocket.physics.simulator import Simulator, shot_feature_vector def test_ball_comes_to_rest(): diff --git a/tests/test_validation.py b/tests/test_validation.py index 481ccb6..2957100 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -12,17 +12,17 @@ import numpy as np import pytest -from cueai.physics import analytic -from cueai.physics.ball import Ball, MotionState, integrate_ball -from cueai.physics.collisions import ( +from pocket.physics import analytic +from pocket.physics.ball import Ball, MotionState, integrate_ball +from pocket.physics.collisions import ( CONTACT_BAND, resolve_all_ball_collisions, resolve_ball_ball, resolve_cushion, ) -from cueai.physics.constants import BallParams, G, ShotParams, TableParams -from cueai.physics.rack import make_full_rack -from cueai.physics.simulator import Simulator +from pocket.physics.constants import BallParams, G, ShotParams, TableParams +from pocket.physics.rack import make_full_rack +from pocket.physics.simulator import Simulator DT = 1e-4 SMOOTH_CLOTH = {"friction_noise_amp": 0.0} diff --git a/web/css/style.css b/web/css/style.css index 2e98a4e..bd42a1c 100644 --- a/web/css/style.css +++ b/web/css/style.css @@ -84,6 +84,8 @@ header.masthead { grid-template-columns: minmax(0, 1fr) 330px; gap: 20px; align-items: start; + /* Landing on #play should not put the table flush against the viewport. */ + scroll-margin-top: 14px; } @media (max-width: 1080px) { diff --git a/web/index.html b/web/index.html index 43389f6..bf7c225 100644 --- a/web/index.html +++ b/web/index.html @@ -3,10 +3,10 @@ - CueAI — play the simulator + Pocket Physics — play the simulator
-

CueAI

+

Pocket Physics

Eight-ball against a search-based opponent, on the same rigid-body physics the Python reference is validated against.
-
+
@@ -236,7 +237,7 @@

The bug the tests could not see

The browser runs the reference, not a lookalike

web/js/physics.js is a hand port of - src/cueai/physics/, and a port nobody measures is a rumour. + 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 @@ -338,8 +339,9 @@

Reproducing all of it

diff --git a/web/js/main.js b/web/js/main.js index 6837fa3..bbd6595 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -736,7 +736,7 @@ function syncUI() { // Test seam. `web/test/browser.mjs` drives a real Chrome through this rather // than through synthetic mouse maths, so the end-to-end test exercises the // same functions the buttons call. -window.cueai = { +window.pocket = { get state() { return state; }, diff --git a/web/js/physics.js b/web/js/physics.js index 75cb4e3..c3e6f18 100644 --- a/web/js/physics.js +++ b/web/js/physics.js @@ -1,5 +1,5 @@ /** - * Billiards physics, ported line for line from `src/cueai/physics/`. + * Billiards physics, ported line for line from `src/pocket/physics/`. * * This is deliberately a transcription rather than a reimplementation: the * Python package is the reference that `tests/test_validation.py` checks @@ -15,7 +15,7 @@ export const G = 9.81; /** * Two balls count as touching once their surfaces are inside this band. * - * Mirrors `CONTACT_BAND` in `cueai/physics/collisions.py`, where the reasoning + * Mirrors `CONTACT_BAND` in `pocket/physics/collisions.py`, where the reasoning * is written out: the value has to sit far above the floating-point noise in a * position and far below anything physical, and it must not coincide with the * gap balls are racked at, or which of a rack's thirty contacts exist becomes diff --git a/web/js/rack.js b/web/js/rack.js index ebaff66..3194f03 100644 --- a/web/js/rack.js +++ b/web/js/rack.js @@ -1,4 +1,4 @@ -/** Ball identities, colours, and the opening rack. Mirrors `cueai/physics/rack.py`. */ +/** Ball identities, colours, and the opening rack. Mirrors `pocket/physics/rack.py`. */ import { BALL, makeBall } from "./physics.js"; @@ -70,7 +70,7 @@ export function rackOrder(seed = 7) { } export function trianglePositions(apex) { - // Racked balls touch; see `triangle_positions` in `cueai/physics/rack.py`. + // Racked balls touch; see `triangle_positions` in `pocket/physics/rack.py`. const gap = 2 * BALL.radius; const spots = []; for (let row = 0; row < 5; row++) { diff --git a/web/test/browser.mjs b/web/test/browser.mjs index 7046ab2..b8e7730 100644 --- a/web/test/browser.mjs +++ b/web/test/browser.mjs @@ -81,7 +81,7 @@ async function main() { throw new Error(`page returned ${response ? response.status() : "no response"}`); } - await page.waitForFunction(() => window.cueai !== undefined, { timeout: 10000 }); + await page.waitForFunction(() => window.pocket !== undefined, { timeout: 10000 }); // The canvas must actually have been painted, not merely created. const painted = await page.evaluate(() => { @@ -102,8 +102,25 @@ async function main() { } console.log(`page loaded and painted (${painted.width}x${painted.height} device pixels)`); + + // The README and the page nav send people to #play expecting to land on the + // table. That is one attribute away from silently scrolling nowhere. + const anchors = await page.evaluate(() => { + const local = [...document.querySelectorAll("a[href^='#']")].map((a) => a.getAttribute("href")); + return { + broken: local.filter((href) => !document.querySelector(href)), + playIsTheTable: document.getElementById("play")?.contains(document.getElementById("table")), + }; + }); + if (anchors.broken.length) { + throw new Error(`links to nothing: ${anchors.broken.join(", ")}`); + } + if (!anchors.playIsTheTable) { + throw new Error("#play does not contain the table, so the play link lands somewhere else"); + } + console.log("every in-page link resolves, and #play is the table"); for (let game = 0; game < games; game++) { - if (game > 0) await page.evaluate(() => window.cueai.newGame()); + if (game > 0) await page.evaluate(() => window.pocket.newGame()); const result = await playGame(page, shotLimit, verbose); console.log( `game ${game + 1}: ${result.shots} shots (${result.youShots} yours, ` + @@ -135,13 +152,13 @@ async function playGame(page, shotLimit, verbose = false) { let turns = 0; for (; turns < shotLimit; turns++) { - const mode = await page.evaluate(() => window.cueai.mode); + const mode = await page.evaluate(() => window.pocket.mode); if (mode === "over") break; if (mode === "placing") { await page.evaluate(() => { - const s = window.cueai.state; - window.cueai.place(s.table.length * (s.behindHeadString ? 0.18 : 0.5), s.table.width * 0.5); + const s = window.pocket.state; + window.pocket.place(s.table.length * (s.behindHeadString ? 0.18 : 0.5), s.table.width * 0.5); }); continue; } @@ -151,7 +168,7 @@ async function playGame(page, shotLimit, verbose = false) { // eight is only chosen once the group is genuinely cleared, so a random // walk does not end every game by potting it early. await page.evaluate(() => { - const s = window.cueai.state; + const s = window.pocket.state; const cue = s.balls.find((b) => b.number === 0); const suit = (n) => (n === 8 ? "eight" : n <= 7 ? "solid" : "stripe"); const mine = s.groups.you; @@ -162,9 +179,9 @@ async function playGame(page, shotLimit, verbose = false) { if (targets.length === 0) targets = onTable.filter((b) => b.number === 8); if (targets.length === 0) targets = onTable; const t = targets[Math.floor(Math.random() * targets.length)]; - window.cueai.aim(Math.atan2(t.y - cue.y, t.x - cue.x)); - window.cueai.setPower(0.3 + Math.random() * 0.35); - window.cueai.shoot(); + window.pocket.aim(Math.atan2(t.y - cue.y, t.x - cue.x)); + window.pocket.setPower(0.3 + Math.random() * 0.35); + window.pocket.shoot(); }); await settle(page); continue; @@ -181,9 +198,9 @@ async function playGame(page, shotLimit, verbose = false) { // The page records every resolved shot, which is the only reliable way to // see the bot's turns: they begin and end inside a single wait. const final = await page.evaluate(() => ({ - phase: window.cueai.state.phase, - winner: window.cueai.state.winner, - history: window.cueai.history, + phase: window.pocket.state.phase, + winner: window.pocket.state.winner, + history: window.pocket.history, })); const history = final.history; @@ -213,19 +230,19 @@ async function playGame(page, shotLimit, verbose = false) { /** Wait until the table is at rest and the page is ready for input again. */ async function settle(page) { try { - await page.waitForFunction(() => ["aim", "placing", "over"].includes(window.cueai.mode), { + await page.waitForFunction(() => ["aim", "placing", "over"].includes(window.pocket.mode), { timeout: 60000, polling: 120, }); } catch { // A stuck turn is the failure this test exists to catch, so say what stuck. const snapshot = await page.evaluate(() => { - const s = window.cueai.state; + const s = window.pocket.state; const moving = s.balls .filter((b) => !b.pocketed && Math.hypot(b.vx, b.vy) > 1e-4) .map((b) => `${b.number}@${Math.hypot(b.vx, b.vy).toFixed(4)}m/s`); return { - mode: window.cueai.mode, + mode: window.pocket.mode, turn: s.turn, phase: s.phase, ballInHand: s.ballInHand, diff --git a/web/test/capture.mjs b/web/test/capture.mjs index 6d98b97..d1c42ed 100644 --- a/web/test/capture.mjs +++ b/web/test/capture.mjs @@ -53,7 +53,7 @@ function skip(reason) { /** Aim at the ghost ball for the straightest clear pot, the way a player would. */ const AIM_AT_BEST_POT = () => { - const s = window.cueai.state; + const s = window.pocket.state; const cue = s.balls.find((b) => b.number === 0); const R = 0.028575; const suit = (n) => (n === 8 ? "eight" : n <= 7 ? "solid" : "stripe"); @@ -96,8 +96,8 @@ const AIM_AT_BEST_POT = () => { } } if (!best) return false; - window.cueai.aim(best.angle); - window.cueai.setPower(0.34); + window.pocket.aim(best.angle); + window.pocket.setPower(0.34); return true; }; @@ -108,7 +108,7 @@ const AIM_AT_BEST_POT = () => { * both common and early, and the clip becomes a break and one shot. */ const AIM_AT_ANY_LEGAL = () => { - const s = window.cueai.state; + const s = window.pocket.state; const cue = s.balls.find((b) => b.number === 0); const suit = (n) => (n === 8 ? "eight" : n <= 7 ? "solid" : "stripe"); const mine = s.groups.you; @@ -119,8 +119,8 @@ const AIM_AT_ANY_LEGAL = () => { const near = targets.reduce((a, b) => Math.hypot(b.x - cue.x, b.y - cue.y) < Math.hypot(a.x - cue.x, a.y - cue.y) ? b : a ); - window.cueai.aim(Math.atan2(near.y - cue.y, near.x - cue.x)); - window.cueai.setPower(0.3); + window.pocket.aim(Math.atan2(near.y - cue.y, near.x - cue.x)); + window.pocket.setPower(0.3); return true; }; @@ -133,7 +133,7 @@ const AIM_AT_ANY_LEGAL = () => { * a shot chosen to be one. */ const AIM_INTO_OPEN_SPACE = () => { - const s = window.cueai.state; + const s = window.pocket.state; const cue = s.balls.find((b) => b.number === 0); const R = 0.028575; let best = null; @@ -170,19 +170,19 @@ const AIM_INTO_OPEN_SPACE = () => { if (!best || clear > best.clear) best = { clear, angle }; } if (!best) return 0; - window.cueai.aim(best.angle); + window.pocket.aim(best.angle); // A ball struck at v slides 12v²/(49 μ g) before it rolls, so the stroke is // sized to finish that inside the clear line with room to spare. Too hard and // it reaches a rail mid-slide, which withdraws the prediction just as surely // as hitting a ball does. const slideRoom = 0.55 * best.clear; const v = Math.sqrt((slideRoom * 49 * 0.2 * 9.81) / 12); - window.cueai.setPower(Math.max(0.12, Math.min(0.6, v / 7.5))); + window.pocket.setPower(Math.max(0.12, Math.min(0.6, v / 7.5))); return best.clear; }; async function settle(page, timeout = 45000) { - await page.waitForFunction(() => ["aim", "placing", "over"].includes(window.cueai.mode), { + await page.waitForFunction(() => ["aim", "placing", "over"].includes(window.pocket.mode), { timeout, polling: 100, }); @@ -190,7 +190,7 @@ async function settle(page, timeout = 45000) { /** Wait for the balls to stop, which is earlier than waiting for the turn. */ async function settleShot(page, timeout = 45000) { - await page.waitForFunction(() => !["rolling", "stroking"].includes(window.cueai.mode), { + await page.waitForFunction(() => !["rolling", "stroking"].includes(window.pocket.mode), { timeout, polling: 50, }); @@ -206,27 +206,33 @@ async function settleShot(page, timeout = 45000) { * taking the picture — so the legend is read back before the shutter, and a * shot that touched something first buys another turn rather than a caption * explaining why the interesting line is missing. + * + * A cue ball that drops after rebounding satisfies the legend and still makes a + * poor picture: the trace stops mid-roll and the panel is captioned with a + * foul. The heading is chosen to miss the pockets, but only along the line to + * the first rail, so the outcome is checked as well as the aim. */ async function captureInspector(page, panel, file) { const showsPrediction = () => document.getElementById("trace-legend").textContent.includes("5/7·v₀ ="); + const cueStillOnTable = () => !window.pocket.state.balls.find((b) => b.number === 0).pocketed; for (let attempt = 0; attempt < 5; attempt++) { await settle(page); - if (await page.evaluate(() => window.cueai.mode === "over")) break; + if (await page.evaluate(() => window.pocket.mode === "over")) break; await page.evaluate(() => { - if (window.cueai.mode === "placing") { - const s = window.cueai.state; - window.cueai.place(s.table.length * 0.45, s.table.width * 0.5); + if (window.pocket.mode === "placing") { + const s = window.pocket.state; + window.pocket.place(s.table.length * 0.45, s.table.width * 0.5); } }); // With no clean line available, play a pot instead: it rearranges the // table, which is what the next attempt needs. const clear = await page.evaluate(AIM_INTO_OPEN_SPACE); if (!clear && !(await page.evaluate(AIM_AT_BEST_POT))) break; - await page.evaluate(() => window.cueai.shoot()); + await page.evaluate(() => window.pocket.shoot()); await settleShot(page); - if (await page.evaluate(showsPrediction)) { + if (await page.evaluate(showsPrediction) && (await page.evaluate(cueStillOnTable))) { await panel.screenshot({ path: file }); return true; } @@ -246,7 +252,7 @@ async function captureInspector(page, panel, file) { */ async function reportBreak(page, before) { const spread = await page.evaluate((prior) => { - const live = window.cueai.state.balls.filter((b) => !b.pocketed && b.number !== 0); + const live = window.pocket.state.balls.filter((b) => !b.pocketed && b.number !== 0); const cx = live.reduce((a, b) => a + b.x, 0) / live.length; const cy = live.reduce((a, b) => a + b.y, 0) / live.length; const moved = live.filter((b, i) => Math.hypot(b.x - prior[i][0], b.y - prior[i][1]) > 0.05); @@ -296,7 +302,7 @@ async function main() { const page = await browser.newPage(); await page.setViewport({ width: 1420, height: 940, deviceScaleFactor: 2 }); await page.goto(url, { waitUntil: "networkidle0", timeout: 30000 }); - await page.waitForFunction(() => window.cueai !== undefined, { timeout: 10000 }); + await page.waitForFunction(() => window.pocket !== undefined, { timeout: 10000 }); await page.select("#difficulty", "sharp"); // "Quick". The last seconds of a pool shot are balls creeping to a halt, // which is honest physics and dull footage; the page offers the speed, so @@ -309,40 +315,40 @@ async function main() { // leaves the rack standing, which made the clip an advertisement for a // problem the simulator does not have. await page.evaluate(() => { - const s = window.cueai.state; - window.cueai.place(s.table.length * 0.2, s.table.width * 0.52); + const s = window.pocket.state; + window.pocket.place(s.table.length * 0.2, s.table.width * 0.52); const cue = s.balls.find((b) => b.number === 0); const apex = s.balls .filter((b) => !b.pocketed && b.number !== 0) .reduce((a, b) => (b.x < a.x ? b : a)); // The same fraction off square the bot uses: dead centre sends the // energy back down the table instead of into the corners. - window.cueai.aim(Math.atan2(apex.y - cue.y, apex.x - cue.x) + 0.004); - window.cueai.setPower(0.95); + window.pocket.aim(Math.atan2(apex.y - cue.y, apex.x - cue.x) + 0.004); + window.pocket.setPower(0.95); }); const before = await page.evaluate(() => - window.cueai.state.balls.filter((b) => b.number !== 0).map((b) => [b.x, b.y]) + window.pocket.state.balls.filter((b) => b.number !== 0).map((b) => [b.x, b.y]) ); const frames = noVideo || !haveFfmpeg ? null : await startScreencast(page); - await page.evaluate(() => window.cueai.shoot()); + await page.evaluate(() => window.pocket.shoot()); await settle(page); await reportBreak(page, before); // Real shots, so the trace and the bot panel have content. for (let i = 0; i < 30; i++) { - const mode = await page.evaluate(() => window.cueai.mode); + const mode = await page.evaluate(() => window.pocket.mode); if (mode === "over") break; if (mode === "placing") { await page.evaluate(() => { - const s = window.cueai.state; - window.cueai.place(s.table.length * (s.behindHeadString ? 0.2 : 0.45), s.table.width * 0.5); + const s = window.pocket.state; + window.pocket.place(s.table.length * (s.behindHeadString ? 0.2 : 0.45), s.table.width * 0.5); }); } const aimed = (await page.evaluate(AIM_AT_BEST_POT)) || (await page.evaluate(AIM_AT_ANY_LEGAL)); if (!aimed) break; - await page.evaluate(() => window.cueai.shoot()); + await page.evaluate(() => window.pocket.shoot()); await settle(page); if (frames && frames.elapsed() > seconds) break; } @@ -354,9 +360,9 @@ async function main() { // Line up a shot for the stills, then hold it. await page.evaluate(() => { - if (window.cueai.mode === "placing") { - const s = window.cueai.state; - window.cueai.place(s.table.length * 0.45, s.table.width * 0.5); + if (window.pocket.mode === "placing") { + const s = window.pocket.state; + window.pocket.place(s.table.length * 0.45, s.table.width * 0.5); } }); await page.evaluate(AIM_AT_BEST_POT); @@ -397,7 +403,7 @@ async function startScreencast(page) { await page.evaluate(() => { window.__modeMarks = []; window.__modeTimer = setInterval(() => { - window.__modeMarks.push([performance.timeOrigin + performance.now(), window.cueai.mode]); + window.__modeMarks.push([performance.timeOrigin + performance.now(), window.pocket.mode]); }, 60); }); await client.send("Page.startScreencast", { diff --git a/web/test/input.mjs b/web/test/input.mjs index b86c8f4..d301410 100644 --- a/web/test/input.mjs +++ b/web/test/input.mjs @@ -1,7 +1,7 @@ /** * Drive the game with a real cursor and real keys. * - * `browser.mjs` plays through `window.cueai`, which proves the modules wire + * `browser.mjs` plays through `window.pocket`, which proves the modules wire * together but skips the layer a person actually touches: pointer capture, * drag thresholds, which element owns the spacebar. Those are where an * interactive page goes wrong, and they cannot be tested by calling the @@ -56,7 +56,7 @@ function check(name, ok, detail = "") { const wait = (ms) => new Promise((r) => setTimeout(r, ms)); async function settle(page, timeout = 60000) { - await page.waitForFunction(() => ["aim", "placing", "over"].includes(window.cueai.mode), { + await page.waitForFunction(() => ["aim", "placing", "over"].includes(window.pocket.mode), { timeout, polling: 100, }); @@ -64,24 +64,24 @@ async function settle(page, timeout = 60000) { /** Put the game in a known state: cue ball placed, our turn, ready to aim. */ async function resetToAim(page) { - await page.evaluate(() => window.cueai.newGame()); + await page.evaluate(() => window.pocket.newGame()); const spot = await page.evaluate(() => { - const s = window.cueai.state; - return window.cueai.toClient(s.table.length * 0.18, s.table.width * 0.5); + const s = window.pocket.state; + return window.pocket.toClient(s.table.length * 0.18, s.table.width * 0.5); }); await page.mouse.click(spot.x, spot.y); - const mode = await page.evaluate(() => window.cueai.mode); + const mode = await page.evaluate(() => window.pocket.mode); if (mode !== "aim") throw new Error(`placing a cue ball by clicking left mode "${mode}"`); } /** Viewport pixels for a point in table metres. */ function at(page, x, y) { - return page.evaluate(([tx, ty]) => window.cueai.toClient(tx, ty), [x, y]); + return page.evaluate(([tx, ty]) => window.pocket.toClient(tx, ty), [x, y]); } async function cueBall(page) { return page.evaluate(() => { - const b = window.cueai.state.balls.find((ball) => ball.number === 0); + const b = window.pocket.state.balls.find((ball) => ball.number === 0); return { x: b.x, y: b.y }; }); } @@ -116,7 +116,7 @@ async function main() { page.on("pageerror", (error) => problems.push(`uncaught: ${error.message}`)); await page.goto(url, { waitUntil: "networkidle0", timeout: 30000 }); - await page.waitForFunction(() => window.cueai !== undefined, { timeout: 10000 }); + await page.waitForFunction(() => window.pocket !== undefined, { timeout: 10000 }); await page.select("#playback", "3"); // the shots here are means, not ends await placingPutsTheBallWhereYouClick(page); @@ -147,16 +147,16 @@ async function main() { // ---------- the checks ---------- async function placingPutsTheBallWhereYouClick(page) { - await page.evaluate(() => window.cueai.newGame()); - const before = await page.evaluate(() => window.cueai.mode); + await page.evaluate(() => window.pocket.newGame()); + const before = await page.evaluate(() => window.pocket.mode); const target = await page.evaluate(() => { - const s = window.cueai.state; + const s = window.pocket.state; return { x: s.table.length * 0.18, y: s.table.width * 0.36 }; }); const spot = await at(page, target.x, target.y); await page.mouse.click(spot.x, spot.y); const cue = await cueBall(page); - const mode = await page.evaluate(() => window.cueai.mode); + const mode = await page.evaluate(() => window.pocket.mode); const off = Math.hypot(cue.x - target.x, cue.y - target.y); check( "clicking behind the head string places the cue ball there", @@ -166,15 +166,15 @@ async function placingPutsTheBallWhereYouClick(page) { // In front of the head string is illegal on the break, and the page snaps // back to the nearest legal spot rather than silently ignoring the click. - await page.evaluate(() => window.cueai.newGame()); + await page.evaluate(() => window.pocket.newGame()); const illegal = await page.evaluate(() => { - const s = window.cueai.state; + const s = window.pocket.state; return { x: s.table.length * 0.8, y: s.table.width * 0.5 }; }); const far = await at(page, illegal.x, illegal.y); await page.mouse.click(far.x, far.y); const after = await cueBall(page); - const headString = await page.evaluate(() => window.cueai.state.table.length * 0.25); + const headString = await page.evaluate(() => window.pocket.state.table.length * 0.25); check( "an illegal placement snaps behind the head string", after.x <= headString + 1e-6, @@ -185,7 +185,7 @@ async function placingPutsTheBallWhereYouClick(page) { async function theCueFollowsTheCursor(page) { await resetToAim(page); const cue = await cueBall(page); - const table = await page.evaluate(() => window.cueai.state.table); + const table = await page.evaluate(() => window.pocket.state.table); const targets = [ [cue.x + 0.5, cue.y], @@ -196,7 +196,7 @@ async function theCueFollowsTheCursor(page) { for (const [tx, ty] of targets) { const p = await at(page, Math.min(tx, table.length - 0.05), Math.max(0.05, ty)); await page.mouse.move(p.x, p.y); - const angle = await page.evaluate(() => window.cueai.aimAngle); + const angle = await page.evaluate(() => window.pocket.aimAngle); const want = Math.atan2(Math.max(0.05, ty) - cue.y, Math.min(tx, table.length - 0.05) - cue.x); let delta = Math.abs(angle - want); if (delta > Math.PI) delta = 2 * Math.PI - delta; @@ -214,7 +214,7 @@ async function shiftAimsSlowly(page) { const cue = await cueBall(page); const start = await at(page, cue.x + 0.5, cue.y); await page.mouse.move(start.x, start.y); - const before = await page.evaluate(() => window.cueai.aimAngle); + const before = await page.evaluate(() => window.pocket.aimAngle); // One shift-move a long way round: the aim should ease toward the cursor // rather than snap to it, which is what makes a quarter-degree cut reachable. @@ -222,7 +222,7 @@ async function shiftAimsSlowly(page) { await page.keyboard.down("Shift"); await page.mouse.move(away.x, away.y); await page.keyboard.up("Shift"); - const after = await page.evaluate(() => window.cueai.aimAngle); + const after = await page.evaluate(() => window.pocket.aimAngle); const demanded = Math.atan2(cue.y + 0.35 - cue.y, cue.x + 0.35 - cue.x) - before; const moved = after - before; @@ -238,13 +238,13 @@ async function theArrowKeysMoveByAHair(page) { await resetToAim(page); await page.evaluate(() => document.body.focus()); - const a0 = await page.evaluate(() => window.cueai.aimAngle); + const a0 = await page.evaluate(() => window.pocket.aimAngle); await page.keyboard.press("ArrowRight"); - const a1 = await page.evaluate(() => window.cueai.aimAngle); + const a1 = await page.evaluate(() => window.pocket.aimAngle); await page.keyboard.down("Shift"); await page.keyboard.press("ArrowRight"); await page.keyboard.up("Shift"); - const a2 = await page.evaluate(() => window.cueai.aimAngle); + const a2 = await page.evaluate(() => window.pocket.aimAngle); const coarse = a1 - a0; const fine = a2 - a1; @@ -254,9 +254,9 @@ async function theArrowKeysMoveByAHair(page) { `${((coarse * 180) / Math.PI).toFixed(3)}° plain, ${((fine * 180) / Math.PI).toFixed(3)}° with shift` ); - const p0 = await page.evaluate(() => window.cueai.power); + const p0 = await page.evaluate(() => window.pocket.power); await page.keyboard.press("ArrowUp"); - const p1 = await page.evaluate(() => window.cueai.power); + const p1 = await page.evaluate(() => window.pocket.power); const readout = await page.evaluate(() => document.getElementById("power-readout").textContent); check( "the up arrow raises the power, and the readout follows", @@ -275,8 +275,8 @@ async function aQuickClickDoesNotShoot(page) { await page.mouse.up(); await wait(120); - const mode = await page.evaluate(() => window.cueai.mode); - const shots = await page.evaluate(() => window.cueai.history.length); + const mode = await page.evaluate(() => window.pocket.mode); + const shots = await page.evaluate(() => window.pocket.history.length); check( "a quick click lines the shot up rather than playing it", mode === "aim" && shots === 0, @@ -295,7 +295,7 @@ async function holdingStillDoesShoot(page) { await page.mouse.up(); await wait(120); - const mode = await page.evaluate(() => window.cueai.mode); + const mode = await page.evaluate(() => window.pocket.mode); check( "a press held on the spot plays the shot", ["stroking", "rolling"].includes(mode), @@ -312,13 +312,13 @@ async function pullingTheCueBackSetsThePower(page) { // drawn, and the further it comes back the harder the shot. const ahead = await at(page, cue.x + 0.5, cue.y); await page.mouse.move(ahead.x, ahead.y); - const angle = await page.evaluate(() => window.cueai.aimAngle); - const before = await page.evaluate(() => window.cueai.power); + const angle = await page.evaluate(() => window.pocket.aimAngle); + const before = await page.evaluate(() => window.pocket.power); await page.mouse.down(); const short = await at(page, cue.x + 0.5 - DRAG_DEADZONE * 0.4, cue.y); await page.mouse.move(short.x, short.y); - const nudged = await page.evaluate(() => window.cueai.power); + const nudged = await page.evaluate(() => window.pocket.power); check( "a twitch inside the dead zone does not change the power", Math.abs(nudged - before) < 1e-9, @@ -327,8 +327,8 @@ async function pullingTheCueBackSetsThePower(page) { const drawn = await at(page, cue.x + 0.5 - 0.25, cue.y); await page.mouse.move(drawn.x, drawn.y, { steps: 6 }); - const pulled = await page.evaluate(() => window.cueai.power); - const aimHeld = await page.evaluate(() => window.cueai.aimAngle); + const pulled = await page.evaluate(() => window.pocket.power); + const aimHeld = await page.evaluate(() => window.pocket.aimAngle); check( "drawing the cue back raises the power without disturbing the aim", pulled > before + 0.1 && Math.abs(aimHeld - angle) < 1e-9, @@ -337,7 +337,7 @@ async function pullingTheCueBackSetsThePower(page) { await page.mouse.up(); await wait(120); - const mode = await page.evaluate(() => window.cueai.mode); + const mode = await page.evaluate(() => window.pocket.mode); check( "releasing a drawn cue plays the shot", ["stroking", "rolling"].includes(mode), @@ -354,7 +354,7 @@ async function theControlsOwnTheirOwnKeys(page) { await page.focus("#difficulty"); await page.keyboard.press("Space"); await wait(120); - const afterSelect = await page.evaluate(() => window.cueai.mode); + const afterSelect = await page.evaluate(() => window.pocket.mode); check( "space in a dropdown does not play a shot", afterSelect === "aim", @@ -364,7 +364,7 @@ async function theControlsOwnTheirOwnKeys(page) { await page.evaluate(() => document.activeElement.blur()); await page.keyboard.press("Space"); await wait(120); - const afterTable = await page.evaluate(() => window.cueai.mode); + const afterTable = await page.evaluate(() => window.pocket.mode); check( "space with nothing focused plays the shot", ["stroking", "rolling"].includes(afterTable), @@ -376,15 +376,15 @@ async function theControlsOwnTheirOwnKeys(page) { async function theSpinWidgetMovesTheTip(page) { await resetToAim(page); const box = await (await page.$("#spin")).boundingBox(); - const before = await page.evaluate(() => window.cueai.spin); + const before = await page.evaluate(() => window.pocket.spin); // Bottom of the circle: draw. Then drag well outside it, which must clamp to // the miscue limit rather than let the tip leave the ball. await page.mouse.move(box.x + box.width / 2, box.y + box.height * 0.78); await page.mouse.down(); - const drawn = await page.evaluate(() => window.cueai.spin); + const drawn = await page.evaluate(() => window.pocket.spin); await page.mouse.move(box.x + box.width * 2, box.y + box.height / 2, { steps: 4 }); - const clamped = await page.evaluate(() => window.cueai.spin); + const clamped = await page.evaluate(() => window.pocket.spin); await page.mouse.up(); const magnitude = Math.hypot(clamped.x, clamped.y); @@ -400,7 +400,7 @@ async function theShootButtonWorks(page) { const disabledBefore = await page.evaluate(() => document.getElementById("shoot").disabled); await page.click("#shoot"); await wait(120); - const mode = await page.evaluate(() => window.cueai.mode); + const mode = await page.evaluate(() => window.pocket.mode); const disabledDuring = await page.evaluate(() => document.getElementById("shoot").disabled); check( "the shoot button plays a shot and locks while the balls roll", diff --git a/web/test/parity.mjs b/web/test/parity.mjs index 89837f9..33a6e28 100644 --- a/web/test/parity.mjs +++ b/web/test/parity.mjs @@ -2,7 +2,7 @@ * Check the browser physics against the Python reference simulator. * * `scripts/export_parity_cases.py` runs a spread of shots through - * `src/cueai/physics/` and records where every ball came to rest. This replays + * `src/pocket/physics/` and records where every ball came to rest. This replays * the same shots through the module the game actually uses and reports the * worst disagreement. A port that is not measured is a rumour. *