From c451a50349968c90fcfc5c001f94878bbd8b6c87 Mon Sep 17 00:00:00 2001 From: Samuel Bouchet Date: Fri, 17 Apr 2026 16:57:56 +0200 Subject: [PATCH] Headless Linux dev container: Godot + .NET + Xvfb for autonomous testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code running inside the project's dev container can now build the game, launch a real Godot instance under Xvfb, and drive the automation harness end-to-end — no Windows dependency. Dockerfile adds (as root, before USER node): - X11 / Mesa software GL / audio runtime deps + python3 - .NET SDK 9.0 via upstream dot.net install script -> /usr/local/dotnet - Godot 4.6.2-stable mono Linux x86_64 -> /opt/godot/godot - /usr/local/bin/godot-xvfb wrapper: auto-wraps invocations in xvfb-run -a --server-args="-screen 0 1280x720x24 ..." harness.py picks GODOT_BIN from env, defaults to /opt/godot/godot on Linux, and auto-wraps the subprocess in xvfb-run when DISPLAY is unset. Windows code path unchanged. init-firewall.sh adds api.nuget.org to the allowlist so dotnet restore works post-boot. Godot + .NET SDK are fetched at image build time, before the firewall exists. New docs: - autonomous_plan.md: design rationale, alternatives considered - README.md: launch instructions for Windows terminal / Docker Desktop / VS Code Dev Containers / WSL2 natif - CLAUDE.md already documents the harness (done in previous commit) Validation: docker build succeeds; inside the container, dotnet --version =9.0.313, godot --version=4.6.2.stable.mono, dotnet test=102/102, python3 tools/automation/smoke.py passes end-to-end with 14 non-black 1280x720 PNGs. Mission 1 screenshot is visually identical to the Windows build, and Xvfb determinism is a bonus (det_a.png ≡ det_b.png bytewise). --- .devcontainer/Dockerfile | 72 ++++++++++++++ .devcontainer/godot-xvfb.sh | 15 +++ .devcontainer/init-firewall.sh | 3 +- README.md | 174 +++++++++++++++++++++++++++++++++ autonomous_plan.md | 149 ++++++++++++++++++++++++++++ tools/automation/harness.py | 33 ++++++- 6 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 .devcontainer/godot-xvfb.sh create mode 100644 README.md create mode 100644 autonomous_plan.md diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8b48f6a..08c7b00 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -25,8 +25,80 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ jq \ nano \ vim \ + ca-certificates \ + curl \ + wget \ && apt-get clean && rm -rf /var/lib/apt/lists/* +# ----------------------------------------------------------------------------- +# Chessistics: headless Godot + .NET SDK +# ----------------------------------------------------------------------------- + +# 1. Xvfb + Mesa software GL + X/audio runtime deps for Godot's GL-compatibility renderer +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb \ + xauth \ + x11-utils \ + libx11-6 \ + libxcursor1 \ + libxinerama1 \ + libxrandr2 \ + libxi6 \ + libxext6 \ + libxrender1 \ + libxfixes3 \ + libxss1 \ + libxkbcommon0 \ + libxkbcommon-x11-0 \ + libgl1 \ + libglx-mesa0 \ + libgl1-mesa-dri \ + libglu1-mesa \ + libegl1 \ + libgles2 \ + libasound2 \ + libpulse0 \ + libfontconfig1 \ + libfreetype6 \ + libdbus-1-3 \ + libudev1 \ + fonts-dejavu-core \ + python3 \ + python3-pip \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# 2. .NET SDK 9.0 via the upstream install script (arch-agnostic, no apt repo needed) +RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ + && bash /tmp/dotnet-install.sh --channel 9.0 --install-dir /usr/local/dotnet \ + && ln -s /usr/local/dotnet/dotnet /usr/local/bin/dotnet \ + && rm /tmp/dotnet-install.sh +ENV DOTNET_ROOT=/usr/local/dotnet +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 +ENV DOTNET_NOLOGO=1 + +# 3. Godot 4.6.2-stable Mono for Linux x86_64 +# The zip contains a directory like "Godot_v..._mono_linux_x86_64/" with +# an executable whose exact filename has varied across releases +# ("Godot_v..._mono_linux.x86_64" on 4.x). Locate it dynamically. +ARG GODOT_VERSION=4.6.2-stable +RUN mkdir -p /opt/godot \ + && cd /tmp \ + && wget -q "https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip" \ + && unzip -q "Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip" -d /opt/godot \ + && GODOT_EXE="$(find /opt/godot -maxdepth 3 -type f \( -name 'Godot_v*mono_linux*x86_64' -o -name 'Godot_v*mono_linux*x86_64' \) | head -1)" \ + && if [ -z "$GODOT_EXE" ]; then echo "Godot executable not found in zip" && ls -R /opt/godot && exit 1; fi \ + && chmod +x "$GODOT_EXE" \ + && ln -sf "$GODOT_EXE" /opt/godot/godot \ + && rm "Godot_v${GODOT_VERSION}_mono_linux_x86_64.zip" +ENV GODOT_BIN=/opt/godot/godot +ENV PATH=$PATH:/opt/godot:/usr/local/dotnet + +# 4. xvfb wrapper — any Godot invocation gets its own virtual 1280x720x24 display. +# Usage: `godot-xvfb --path /workspace ...` or let tools/automation/harness.py +# invoke it automatically on Linux. +COPY godot-xvfb.sh /usr/local/bin/godot-xvfb +RUN chmod +x /usr/local/bin/godot-xvfb + # Ensure default node user has access to /usr/local/share RUN mkdir -p /usr/local/share/npm-global && \ chown -R node:node /usr/local/share diff --git a/.devcontainer/godot-xvfb.sh b/.devcontainer/godot-xvfb.sh new file mode 100644 index 0000000..32a2d5c --- /dev/null +++ b/.devcontainer/godot-xvfb.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Wrap a Godot invocation in a fresh Xvfb 1280x720x24 display so the GL +# renderer has something to draw into. If DISPLAY is already set (real +# display / nested X server), skip xvfb-run and exec Godot directly. +set -euo pipefail + +: "${GODOT_BIN:=/opt/godot/godot}" + +if [[ -n "${DISPLAY:-}" ]]; then + exec "$GODOT_BIN" "$@" +fi + +exec xvfb-run -a \ + --server-args="-screen 0 1280x720x24 -ac +extension GLX +render -noreset" \ + "$GODOT_BIN" "$@" diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh index ad4e589..49b2726 100644 --- a/.devcontainer/init-firewall.sh +++ b/.devcontainer/init-firewall.sh @@ -69,7 +69,8 @@ for domain in \ "api.anthropic.com" \ "sentry.io" \ "statsig.anthropic.com" \ - "statsig.com"; do + "statsig.com" \ + "api.nuget.org"; do echo "Resolving $domain..." ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}') if [ -z "$ips" ]; then diff --git a/README.md b/README.md new file mode 100644 index 0000000..3670609 --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# Chessistics + +Jeu de logistique sur échiquier en Godot 4 / C#. Le joueur place des pièces +d'échecs sur un plateau ; elles se déplacent automatiquement et transportent +des ressources entre des productions et des demandes. + +Voir [`CLAUDE.md`](CLAUDE.md) pour l'architecture (black-box simulation) et +les conventions internes. + +## Arborescence + +``` +Chessistics/ +├─ Scripts/ # Code Godot (présentation) +│ ├─ Automation/ # Harness autonome (CLI --automation=) +│ ├─ Board/ Input/ UI/ Pieces/ Presentation/ +│ └─ Main.cs +├─ chessistics-engine/ # Moteur pur .NET, sans Godot +├─ chessistics-tests/ # Tests unitaires xUnit (sans Godot) +├─ Data/ +│ ├─ campaigns/ # campaign_01.json (7 missions) +│ └─ levels/ # niveaux legacy +├─ tools/automation/ # Harness Python stdlib pour piloter Godot +├─ .devcontainer/ # Image Docker + firewall + Xvfb + Godot Linux +├─ Scenes/ icon.svg project.godot … +``` + +## Lancer le jeu (poste Windows direct) + +Prérequis : Godot 4.6 mono + .NET 9 SDK installés localement. + +```powershell +dotnet build Chessistics.csproj +"C:\Apps\godot\Godot_v4.6.2-stable_mono_win64.exe" --path . +``` + +Tests headless du moteur : + +```powershell +dotnet test chessistics-tests/ +``` + +Smoke test du harness d'automatisation : + +```powershell +python tools/automation/smoke.py +``` + +## Dev container autonome (recommandé pour Claude Code) + +Le dossier `.devcontainer/` contient une image Docker qui embarque : + +- **.NET SDK 9.0** (build + tests) +- **Godot 4.6.2-stable mono Linux x86_64** sous `/opt/godot/godot` +- **Xvfb** + Mesa software GL pour un framebuffer virtuel 1280×720 +- **Python 3** pour le harness d'automatisation +- **Claude Code** installé automatiquement (`@anthropic-ai/claude-code`) +- Un firewall `iptables` qui restreint les sorties réseau à une allow-list + (GitHub, npm, Anthropic, NuGet, Sentry, Statsig) + +Claude Code, lancé à l'intérieur, peut donc compiler le projet, exécuter +les tests, **démarrer une vraie instance de Godot en headless** et lire les +captures PNG produites — sans dépendance Windows. + +Voir [`autonomous_plan.md`](autonomous_plan.md) pour le design détaillé. + +### Option 1 — Docker Desktop + terminal Windows (le plus simple) + +1. Installer [Docker Desktop pour Windows](https://www.docker.com/products/docker-desktop/). + Docker Desktop utilise WSL2 en coulisses ; aucune configuration WSL + manuelle n'est nécessaire. +2. Installer la CLI devcontainers : + + ```powershell + npm install -g @devcontainers/cli + ``` + +3. Depuis PowerShell ou Terminal Windows, à la racine du repo : + + ```powershell + cd C:\Projets\Chessistics + devcontainer up --workspace-folder . + devcontainer exec --workspace-folder . zsh + ``` + + La première commande construit l'image (long le premier coup : Godot + + .NET à télécharger). La seconde ouvre un shell interactif dans le + container. + +4. Dans le container : + + ```bash + dotnet build Chessistics.csproj + python3 tools/automation/smoke.py + claude # lance Claude Code inside the container + ``` + +### Option 2 — VS Code + extension Dev Containers + +1. Installer Docker Desktop + [VS Code](https://code.visualstudio.com/) + + l'extension **Dev Containers**. +2. Ouvrir le dossier du repo dans VS Code. +3. Palette de commandes → `Dev Containers: Reopen in Container`. +4. Le terminal intégré est déjà dans le container. Lancer `claude` pour + démarrer Claude Code. + +### Option 3 — WSL2 natif (plus performant si Docker Desktop rame) + +Les bind-mounts Docker Desktop sur `C:\` sont plus lents qu'un fichier +stocké directement dans le système de fichiers WSL2. Pour un gros projet +c'est négligeable, mais si la lenteur devient visible : + +1. Installer WSL2 + Ubuntu depuis le Microsoft Store. +2. Cloner le repo **à l'intérieur** de WSL2 (pas sur `/mnt/c/`) : + + ```bash + cd ~ && git clone chessistics && cd chessistics + ``` + +3. Installer Docker dans WSL2 (via `docker.io` apt ou Docker Desktop avec + intégration WSL2 activée). +4. Installer la CLI devcontainers : + + ```bash + npm install -g @devcontainers/cli + devcontainer up --workspace-folder . + devcontainer exec --workspace-folder . zsh + ``` + +Avantage : I/O disque natif Linux. Inconvénient : pas d'accès Explorer +direct aux fichiers (`\\wsl$\Ubuntu\home\…`). + +### Vérifier que tout fonctionne dans le container + +```bash +dotnet --version # -> 9.0.x +godot --version # -> 4.6.2.stable.mono.official.* +dotnet test chessistics-tests/ # 102/102 +python3 tools/automation/smoke.py # Godot boots, PNG screenshots written +ls .automation_runs/smoke/screens/ # 01_loaded.png, 02_placed.png, ... +``` + +Le harness Python détecte automatiquement Linux et enveloppe Godot dans +`xvfb-run`. Aucune variable d'environnement à positionner. + +### Personnaliser la version de Godot + +L'image par défaut pose Godot 4.6.2-stable. Pour changer, modifier +l'argument de build : + +```jsonc +// .devcontainer/devcontainer.json +"build": { + "args": { + "GODOT_VERSION": "4.7.0-stable" + } +} +``` + +Puis `devcontainer up --workspace-folder . --build-no-cache`. + +## Dépannage + +| Symptôme | Cause probable | Fix | +|----------|----------------|-----| +| `godot --version` donne *no such file* | PATH non mis à jour | `source /etc/profile` ou relancer le shell | +| Screenshot tout noir | Aucun DISPLAY + pas d'xvfb | Vérifier `which xvfb-run` ; utiliser `godot-xvfb` au lieu de `godot` directement | +| `dotnet restore` bloque | Firewall bloque `api.nuget.org` | Vérifier que `init-firewall.sh` s'est bien exécuté avec les changements récents | +| Build Docker échoue au download de Godot | Réseau restreint côté hôte | Retry, ou installer Godot manuellement et commenter les lignes correspondantes | +| Claude Code demande `--dangerously-skip-permissions` | Comportement normal en container sandbox | Accepter si tu es conscient du modèle de confiance | + +## Licence + +Voir les fichiers du repo. diff --git a/autonomous_plan.md b/autonomous_plan.md new file mode 100644 index 0000000..ee214a7 --- /dev/null +++ b/autonomous_plan.md @@ -0,0 +1,149 @@ +# Headless Linux dev container for autonomous Chessistics testing + +## Why + +Today the automation harness only runs on Windows because the Godot binary is +hardcoded to `C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe`. When +Claude Code runs inside the project's dev container (Linux / `node:20`), it +can read source code and run `dotnet test`, but it **cannot launch the actual +game** — there's no Godot binary, no .NET SDK, and no display server for the +renderer. + +The goal: make the dev container a self-contained environment where Claude +Code can build the project, launch a real Godot instance in headless Linux +mode, drive it via the automation harness, and read back 1280×720 PNG +screenshots — all without any Windows dependency. + +## Design + +### Pieces required + +1. **Godot 4.6.2-stable Mono for Linux** — matches the Windows editor the + project already uses. Installed once at image build time to `/opt/godot/` + with a symlink `/opt/godot/godot`. +2. **.NET SDK 9.0** — the project targets `net9.0`. Installed via the + upstream `dot.net` install script to `/usr/local/dotnet/`. +3. **Xvfb + Mesa software GL** — a virtual framebuffer at `:99` so Godot's + GL-compatibility renderer has somewhere to draw. `xvfb-run` wraps any + command transparently. +4. **Python 3** — the automation harness is stdlib-only Python. +5. **Minimal X / audio runtime deps** — `libx11`, `libxcursor`, `libxrandr`, + `libxi`, `libgl1`, `libgles2`, `libasound2`, `libxkbcommon0`, etc. + Without these, Godot exits on startup with `libXext not found`-style errors. + +### How Godot reaches the framebuffer + +Two options considered: + +- **(A)** Run `Xvfb :99 -screen 0 1280x720x24 &` as a background process, + export `DISPLAY=:99`, launch Godot normally. Persistent display, shared by + many Godot runs. +- **(B)** Use `xvfb-run -a --server-args="-screen 0 1280x720x24"` as a prefix + on every Godot invocation. A fresh display per launch; cleans up + automatically on exit. + +**Chosen: (B)**, because the automation harness already spawns Godot once per +`Harness.launch()` and cleans up on context exit — matches the per-launch +lifecycle naturally, no daemon to keep alive, no race on the display number. + +A tiny wrapper `/usr/local/bin/godot-xvfb` wraps `xvfb-run … $GODOT_BIN +"$@"`, so the harness (or a human) only has to invoke one path. + +### Integration with the existing harness + +`tools/automation/harness.py` currently hardcodes the Windows Godot path. We +teach it two things: + +1. Read `GODOT_BIN` from the environment first; fall back to the platform + default (Windows path on Windows, `/opt/godot/godot` on Linux). +2. On Linux, auto-prepend `["xvfb-run", "-a", "--server-args=-screen 0 + 1280x720x24"]` to the Godot launch command unless `DISPLAY` is already + set (someone has a real display, skip the wrap). + +With those two tweaks, every existing Python helper (`smoke.py`, +`run_game.py`, `solve_*.py`) works unchanged inside the container. + +### Firewall considerations + +The container's `init-firewall.sh` runs at `postStartCommand`, **after** the +image is built, and drops all outbound traffic except to a small allowlist +(GitHub, npmjs, Anthropic, Sentry, Statsig). Impact on our pieces: + +- **Godot binary + .NET SDK**: downloaded during `docker build`, which runs + _before_ the firewall exists → works unconditionally. +- **`dotnet restore`** (runtime, e.g. after a `git pull`): needs + `api.nuget.org`. Added to the allowlist. +- **Godot runtime**: no outbound traffic required — the engine runs fully + offline once installed. + +### Build sequence inside the Dockerfile + +As `root`, before the existing `USER node` switch: + +``` +# 1. X/GL/audio runtime + python + xvfb +apt-get install xvfb xauth libx11-6 libxcursor1 libxinerama1 libxrandr2 \ + libxi6 libxext6 libgl1 libglx-mesa0 libgl1-mesa-dri libglu1-mesa \ + libasound2 libxkbcommon0 libxkbcommon-x11-0 libfontconfig1 libdbus-1-3 \ + python3 python3-pip + +# 2. .NET SDK 9.0 via upstream install script +curl -sSL https://dot.net/v1/dotnet-install.sh | bash -s -- \ + --channel 9.0 --install-dir /usr/local/dotnet +ln -s /usr/local/dotnet/dotnet /usr/local/bin/dotnet + +# 3. Godot 4.6.2-stable mono, Linux x86_64 +wget https://github.com/godotengine/godot/releases/download/${VERSION}/\ +Godot_v${VERSION}_mono_linux_x86_64.zip +unzip … -d /opt/godot +ln -s /opt/godot/Godot_v…/Godot_v…_mono_linux.x86_64 /opt/godot/godot +``` + +Then: +- `ENV GODOT_BIN=/opt/godot/godot` +- `ENV PATH=$PATH:/opt/godot:/usr/local/dotnet` +- Drop in `/usr/local/bin/godot-xvfb` wrapper + +## File-by-file change list + +| File | Change | +|------|--------| +| `.devcontainer/Dockerfile` | Add Godot / dotnet / xvfb installs before `USER node` | +| `.devcontainer/init-firewall.sh` | Append `api.nuget.org` to the domain allowlist | +| `.devcontainer/godot-xvfb.sh` *(new)* | `exec xvfb-run -a … "$GODOT_BIN" "$@"` | +| `tools/automation/harness.py` | Env-aware Godot path + Linux xvfb auto-wrap | +| `README.md` *(new)* | Windows / WSL2 launch instructions for the dev container | + +Nothing inside `Scripts/` or `chessistics-engine/` changes. The harness +contract (inbox/outbox/screens) is platform-agnostic already. + +## Verification + +After rebuild: + +1. `docker build .devcontainer -t chessistics-dev` succeeds. +2. `devcontainer up --workspace-folder .` starts the container and + post-start firewall passes. +3. Inside: `dotnet --version` → `9.0.x`, `godot --version` → + `4.6.2.stable.mono.official.*`. +4. `dotnet build Chessistics.csproj` → green. +5. `dotnet test chessistics-tests/` → 102 / 102. +6. `python3 tools/automation/smoke.py` → loads mission 1, takes PNG + screenshots that are non-black, quits cleanly. +7. `Read` one of the PNGs — it should show the same mission 1 UI as the + Windows run (title bar, board, objectives, stock panel). + +## Out of scope (explicit non-goals) + +- **GPU acceleration**: we use Mesa software rendering. Xvfb + llvmpipe is + enough for 1280×720 at a few FPS, which is what the harness needs. +- **Real display forwarding** (X11 forwarding, VNC, noVNC): doable but + unnecessary — Claude reads PNGs, not a live video feed. +- **Multi-arch images**: we ship x86_64 only. ARM (Apple Silicon via + Docker Desktop emulation) would need `Godot_v…_mono_linux_arm64.zip` — + straightforward to add if needed, not done here. +- **Shrinking the image**: Godot + .NET SDK adds ~500 MB. Worth it; + multi-stage builds could trim later. +- **Keeping Xvfb warm across launches**: the single-launch pattern is clean + enough. If someone ever scripts dozens of rapid Godot starts and the + xvfb-run startup cost shows up, revisit approach (A). diff --git a/tools/automation/harness.py b/tools/automation/harness.py index 59edd4e..636466b 100644 --- a/tools/automation/harness.py +++ b/tools/automation/harness.py @@ -36,10 +36,37 @@ from typing import Any # Resolve defaults relative to the repo root (parent of tools/). _REPO_ROOT = Path(__file__).resolve().parents[2] -_DEFAULT_GODOT = Path(r"C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe") _DEFAULT_RUNS = _REPO_ROOT / ".automation_runs" +def _default_godot_exe() -> Path: + """Locate a Godot binary. Env var wins; otherwise platform defaults.""" + env = os.environ.get("GODOT_BIN") + if env: + return Path(env) + if sys.platform.startswith("linux"): + return Path("/opt/godot/godot") + return Path(r"C:\Apps\godot\Godot_v4.6.2-stable_mono_win64_console.exe") + + +_DEFAULT_GODOT = _default_godot_exe() + + +def _wrap_with_xvfb(argv: list[str]) -> list[str]: + """On Linux without an existing DISPLAY, wrap Godot in xvfb-run so the + GL-compatibility renderer has a framebuffer to target. + """ + if not sys.platform.startswith("linux"): + return argv + if os.environ.get("DISPLAY"): + return argv + return [ + "xvfb-run", "-a", + "--server-args=-screen 0 1280x720x24 -ac +extension GLX +render -noreset", + *argv, + ] + + class HarnessError(RuntimeError): """Raised when a command fails or times out.""" @@ -98,11 +125,11 @@ class Harness: if not self.project_path.exists(): raise HarnessError(f"Project path not found: {self.project_path}") - args = [ + args = _wrap_with_xvfb([ str(self.godot_exe), "--path", str(self.project_path), f"--automation={self.root}", - ] + ]) print(f"[harness] launching: {' '.join(args)}", file=sys.stderr) # Inherit stdout/stderr so GD.Print output is visible. self._proc = subprocess.Popen(args)