diff --git a/AGENTS.md b/AGENTS.md index afd29b9..7397909 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ them. In short: ```sh python3 build.py # build dist/index.html from src/ (takes a second) -python3 -m http.server -d dist # play at http://localhost:8000 (or open dist/index.html) +python3 tools/serve.py # build, serve at http://localhost:8000, rebuild on src/ changes python3 test/run_all.py # all headless test suites, in parallel (about a minute) python3 test/run_all.py pipes # only suites whose name contains "pipes" python3 test/run_all.py --browser # also the browser tests (rebuilds first; about 3 more minutes) diff --git a/README.md b/README.md index 49b4236..0c8d503 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ machines, until the land claim is paid off and beyond. ```sh python3 build.py -python3 -m http.server -d dist # then open http://localhost:8000 +python3 tools/serve.py # builds, serves at http://localhost:8000 and rebuilds when src/ changes ``` Opening `dist/index.html` directly works too. The game is one self-contained page with no diff --git a/build.py b/build.py index 0209af9..76ecbf1 100644 --- a/build.py +++ b/build.py @@ -6,7 +6,7 @@ code) into one classic script, so they share one global scope and 'use strict' a src/sim/base.js applies to all of it. No bundler, no dependencies. Writes: - dist/index.html the full page: open it, or serve dist/ with python -m http.server + dist/index.html the full page: open it, or serve with python3 tools/serve.py dist/deepgrain-foundry.html the same without //, for hosts (like a claude.ai artifact) that wrap the page themselves dist/bundle-lines.json where each source file starts in dist/index.html, so a line number diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 09fef4c..0101c07 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -20,9 +20,10 @@ src/ui/ the browser shell: input, HUD, build menu, notebook, setti audio, the frame loop test/ headless suites (t_*.js) and their harness; browser tests (play.py, t_smooth.py); run_all.py -tools/ shot.py (screenshots), where.py, map.py, perf.py, playthrough.py, - use_venv.py (switches browser scripts to .venv), scenes/ (screenshot - setups), experiments/ (measurements and reports) +tools/ serve.py (build, serve, rebuild on change), shot.py (screenshots), + where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser + scripts to .venv), scenes/ (screenshot setups), experiments/ + (measurements and reports) docs/ this map, DESIGN.md, TESTING.md, systems/ (notes per system), history/ (the owner's requests), agent-log.md dist/, shots/ build output and screenshots (not committed) @@ -456,6 +457,7 @@ The frame loop and boot. - `tools/map.py`: Regenerate the file and symbol index at the end of docs/PROJECT_MAP.md. - `tools/perf.py`: Times the renderer and the simulation in a headless browser: light, drawWorld (with and without light and glow), the pixel upload and blit, and one simulation tick, in ms. Rebuild first. - `tools/playthrough.py`: Headless browser run: load the page, start a world, play a little, take screenshots. +- `tools/serve.py`: Build, serve and rebuild when sources change: python3 tools/serve.py [--port 8000]. - `tools/shot.py`: Screenshot the game in a headless browser, after setting up a scene with JavaScript. - `tools/use_venv.py`: Let browser scripts run with plain python3 by switching to the repo's .venv. - `tools/where.py`: Turn a line number in dist/index.html into the source file and line it came from. diff --git a/docs/agent-log.md b/docs/agent-log.md index a002796..d901521 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,15 @@ What agents have done, newest first. Add an entry for every piece of work: the d note or request it was for, what changed, the files touched, the tests run, and anything left open or not verified. Keep each entry short; the git history has the details. +## 2026-10-24: t9 Script to run the local server for testing (Claude Code, Codex) + +- New `tools/serve.py`: builds, serves `dist/` on 127.0.0.1 (port 8000, or the next free one up + to 8010), sends `Cache-Control: no-store`, and rebuilds when `src/` or `build.py` changes; a + failed build is printed and the old page keeps being served. +- `README.md`, `AGENTS.md`, `build.py` docstring, `docs/PROJECT_MAP.md`: point at it. +- Checked by hand: page served with no-store; touching a source file rebuilt it; with the owner's + old server on 8000 it moved to 8001; Ctrl+C stops it. Headless suites and map check pass. + ## 2026-10-24: t11 Welcome and first milestone shown again on an older world (Claude Code, Codex) - Cause: nothing cleared the discovery cards (`#cards`) when a world was entered, so a new diff --git a/tasks/t9_script_run_local.md b/tasks/t9_script_run_local.md index 6d1892c..5263c57 100644 --- a/tasks/t9_script_run_local.md +++ b/tasks/t9_script_run_local.md @@ -1,11 +1,23 @@ --- id: t9 title: Script to run the local server for testing -status: Todo +status: Review created: 2026-10-24 -updated: 2026-10-24T04:41:46Z +updated: 2026-10-24T05:06:19Z +order: 3 +report: | + (agent) New: python3 tools/serve.py builds the game, serves it at http://localhost:8000 and + rebuilds by itself when anything in src/ changes. Pages aren't cached, so a normal refresh + shows the new build. If 8000 is taken (an old server still running), it uses 8001 and says so. + To check: stop your old server (Ctrl+C), run python3 tools/serve.py, open the address it + prints, change something in src/, refresh. Ctrl+C stops it. --- ## Context - i need a script to run the local server for testing. + +## Subtasks + +- [x] Add the server and update the command references and map. +- [x] Check serving, rebuilds, port fallback and shutdown; run the headless suites. diff --git a/tools/serve.py b/tools/serve.py new file mode 100644 index 0000000..d84b376 --- /dev/null +++ b/tools/serve.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Build, serve and rebuild when sources change: python3 tools/serve.py [--port 8000].""" +import argparse, errno, http.server, pathlib, subprocess, sys, threading, time + +ROOT = pathlib.Path(__file__).resolve().parent.parent +DIST = ROOT / 'dist' + + +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(DIST), **kwargs) + + def end_headers(self): + self.send_header('Cache-Control', 'no-store') + super().end_headers() + + def log_message(self, format, *args): + pass + + +def build(first=False): + result = subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + if result.returncode: + print('build failed:\n' + result.stdout, flush=True) + elif first: + print(result.stdout, end='', flush=True) + else: + print(time.strftime('rebuilt %H:%M:%S'), flush=True) + + +def mtimes(): + files = [ROOT / 'build.py', *(ROOT / 'src').rglob('*')] + stamps = {} + for path in files: + try: + if path.is_file(): stamps[path] = path.stat().st_mtime_ns + except FileNotFoundError: + pass + return stamps + + +def watch(stopped, previous): + while not stopped.wait(0.5): + current = mtimes() + if current != previous: + previous = current + build() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--port', type=int, default=8000) + args = parser.parse_args() + if not 1 <= args.port <= 65525: + parser.error('--port must be between 1 and 65525 (leaves room for ten retries)') + previous = mtimes() + build(first=True) + if not (DIST / 'index.html').is_file(): + sys.exit('Cannot serve: dist/index.html does not exist. Fix the build and try again.') + for port in range(args.port, args.port + 11): + try: + server = http.server.ThreadingHTTPServer(('127.0.0.1', port), Handler) + break + except OSError as error: + if error.errno != errno.EADDRINUSE: + sys.exit(f'Cannot serve on port {port}: {error}') + else: + sys.exit(f'Cannot serve: ports {args.port}-{args.port + 10} are all busy.') + if port != args.port: + print(f'Port {args.port} was busy; using {port}.', flush=True) + print(f'Serving dist/ at http://localhost:{port} (Ctrl+C to stop)', flush=True) + stopped = threading.Event() + threading.Thread(target=watch, args=(stopped, previous), daemon=True).start() + try: + with server: + server.serve_forever() + finally: + stopped.set() + + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + print('\nServer stopped.', flush=True)