Test harness: declarative Playwright scenarios + wasm state bindings

Mirrors the cucucaracha (lacucarachanews) toolkit pattern adapted for
the voxel game:

bridges.rs adds TestCommand + Telemetry plumbing:
  - thread_local TEST_COMMANDS queue + TELEMETRY snapshot.
  - drain_test_commands() called by App::tick at frame start.
  - publish_telemetry(t) called at frame end.
  - wasm_api exports: set_scene_time, teleport, look_at, plus
    getters get_scene_time / get_position / get_camera_angles.

app.rs:
  - drain_test_commands() applies SetSceneTime / Teleport / LookAt
    before physics integrates. Teleport zeroes velocity and syncs the
    camera to feet+EYE_HEIGHT.
  - publish_telemetry() at end of tick exposes scene state to JS.

test/:
  launch.py     Open Chromium with persistent profile + CDP:9222.
                Navigates to https://voxel.mxvs.art by default;
                --url for local dev.
  peek.py       Attach via CDP, screenshot canvas, dump telemetry
                (scene_time, position, camera angles, hp). Read-only.
  run.py        Execute a YAML scenario:
                  wait_for, wait, eval, key, mouse_move, mouse,
                  screenshot, assert
                Key allowlist prevents stray scenarios from sending
                arbitrary input.
  requirements.txt   playwright + PyYAML.
  README.md          Setup, grammar, available bindings, why this
                     exists.
  scenarios/
    lighting-times-of-day.yaml   Screenshots at noon / afternoon /
                                  sunset / civil twilight / midnight
                                  / sunrise. Verifies the Round A
                                  sunset fixes by visual diff.
    god-rays-look-at-sun.yaml    Pointed at the sun at four altitudes
                                  to inspect the Round B shafts.
    voxel-construction-darkness.yaml  Visual baseline for the sky_vis
                                  bake from Round D.
  .gitignore                     Excludes the browser profile +
                                  screenshots directory.

Visual regression workflow:
  1. python3 launch.py
  2. (separate terminal) python3 run.py scenarios/lighting-times-of-day.yaml
  3. Compare screenshots/lighting-times-of-day_*.png against baseline.

Tests still 63 passing. Native + wasm release clean.
This commit is contained in:
Maximus Gorog 2026-05-24 10:51:17 -06:00
parent dccb06dddf
commit f1e007dd63
11 changed files with 780 additions and 1 deletions

View file

@ -17,7 +17,7 @@
//! 11. Block interaction (raycast → break/place) → maybe broadcast edit
//! 12. Apply damage; periodic state broadcast
//! 13. Render frame
use crate::bridges::{self, RemotePlayer, Settings};
use crate::bridges::{self, RemotePlayer, Settings, Telemetry, TestCommand};
use crate::camera::{Camera, InputState, KbHeld};
use crate::net::{parse_inbox, NetEvent};
use crate::proto::{ClientMsg, EditRec};
@ -398,8 +398,59 @@ impl App {
}
}
/// Apply queued declarative-test commands. Called at the very top
/// of tick so a scenario's "set time / teleport / look at" land
/// before physics integrates anything this frame.
fn drain_test_commands(&mut self) {
for cmd in bridges::drain_test_commands() {
match cmd {
TestCommand::SetSceneTime(t) => {
self.shader_time = t;
}
TestCommand::Teleport(p) => {
self.body.feet = p;
self.body.velocity = Vec3::ZERO;
self.body.on_ground = false;
self.body.max_y_since_ground = p.y;
if let Some(cam) = self.camera.borrow_mut().as_mut() {
cam.position = Vec3::new(p.x, p.y + EYE_HEIGHT, p.z);
}
}
TestCommand::LookAt(yaw, pitch) => {
if let Some(cam) = self.camera.borrow_mut().as_mut() {
cam.yaw = yaw;
cam.pitch = pitch;
}
}
}
}
}
/// Publish telemetry for the test harness to read back.
fn publish_telemetry(&self) {
let (yaw, pitch) = self
.camera
.borrow()
.as_ref()
.map(|c| (c.yaw, c.pitch))
.unwrap_or((0.0, 0.0));
bridges::publish_telemetry(Telemetry {
scene_time: self.shader_time,
pos_x: self.body.feet.x,
pos_y: self.body.feet.y,
pos_z: self.body.feet.z,
yaw,
pitch,
hp: self.body.hp,
alive: self.body.alive,
});
}
/// One frame. See module doc-comment for the pipeline shape.
fn tick(&mut self) {
// Apply any test-harness commands before integrating physics.
self.drain_test_commands();
let dt = match self.last_frame.as_ref() {
Some(c) => c.elapsed().as_secs_f32().min(0.1),
None => 0.016,
@ -600,6 +651,7 @@ impl App {
drop(world_borrow);
drop(camera_borrow);
self.render_frame(settings, outline);
self.publish_telemetry();
let _ = WORLD_RADIUS;
}

View file

@ -12,6 +12,38 @@ use glam::Vec3;
use std::cell::RefCell;
use std::collections::HashMap;
// ---------------- Test harness ----------------
//
// Lightweight declarative-test surface: scenarios poke commands in
// from JS (set scene time, teleport, look at angle) and read back
// telemetry (current time, position, yaw/pitch) to verify what the
// game thinks is true. App::tick drains the command queue at the
// start of each frame and publishes telemetry at the end.
#[derive(Clone, Copy, Debug)]
pub enum TestCommand {
/// Override the shader_time directly (in seconds). Useful for
/// stepping the day/night cycle to a specific phase for visual
/// regression screenshots.
SetSceneTime(f32),
/// Teleport the player feet to this world position.
Teleport(Vec3),
/// Set camera yaw + pitch (radians).
LookAt(f32, f32),
}
#[derive(Default, Clone, Copy, Debug)]
pub struct Telemetry {
pub scene_time: f32,
pub pos_x: f32,
pub pos_y: f32,
pub pos_z: f32,
pub yaw: f32,
pub pitch: f32,
pub hp: u8,
pub alive: bool,
}
// ---------------- Data types stored in the bridges ----------------
/// User-tunable settings, mirrored from the JS settings menu.
@ -79,6 +111,9 @@ thread_local! {
});
static NET_BRIDGE: RefCell<NetBridge> = RefCell::new(NetBridge::default());
static SETTINGS: RefCell<Settings> = RefCell::new(Settings::default());
// Test-harness storage.
static TEST_COMMANDS: RefCell<Vec<TestCommand>> = RefCell::new(Vec::new());
static TELEMETRY: RefCell<Telemetry> = RefCell::new(Telemetry::default());
}
// ---------------- Public typed accessors ----------------
@ -183,6 +218,16 @@ pub fn snapshot_remote_players() -> Vec<RemotePlayer> {
NET_BRIDGE.with(|n| n.borrow().remote_players.values().cloned().collect())
}
/// Drain queued test commands (called by App::tick at frame start).
pub fn drain_test_commands() -> Vec<TestCommand> {
TEST_COMMANDS.with(|q| std::mem::take(&mut *q.borrow_mut()))
}
/// Publish current frame telemetry (called by App::tick at frame end).
pub fn publish_telemetry(t: Telemetry) {
TELEMETRY.with(|x| *x.borrow_mut() = t);
}
// ---------------- wasm-bindgen JS interface ----------------
#[cfg(target_arch = "wasm32")]
@ -295,4 +340,44 @@ mod wasm_api {
pub fn reset_input() {
super::clear_touch_inputs();
}
// ----- Test harness exports -----
#[wasm_bindgen]
pub fn set_scene_time(t: f32) {
super::TEST_COMMANDS.with(|q| {
q.borrow_mut().push(super::TestCommand::SetSceneTime(t));
});
}
#[wasm_bindgen]
pub fn teleport(x: f32, y: f32, z: f32) {
super::TEST_COMMANDS.with(|q| {
q.borrow_mut()
.push(super::TestCommand::Teleport(super::Vec3::new(x, y, z)));
});
}
#[wasm_bindgen]
pub fn look_at(yaw: f32, pitch: f32) {
super::TEST_COMMANDS.with(|q| {
q.borrow_mut().push(super::TestCommand::LookAt(yaw, pitch));
});
}
#[wasm_bindgen]
pub fn get_scene_time() -> f32 {
super::TELEMETRY.with(|x| x.borrow().scene_time)
}
#[wasm_bindgen]
pub fn get_position() -> Vec<f32> {
super::TELEMETRY.with(|x| {
let t = x.borrow();
vec![t.pos_x, t.pos_y, t.pos_z]
})
}
#[wasm_bindgen]
pub fn get_camera_angles() -> Vec<f32> {
super::TELEMETRY.with(|x| {
let t = x.borrow();
vec![t.yaw, t.pitch]
})
}
}

5
test/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.browser_profile/
screenshots/
.venv/
__pycache__/
*.pyc

102
test/README.md Normal file
View file

@ -0,0 +1,102 @@
# Test harness — declarative visual + behavioral scenarios
Mirrors the cucucaracha (lacucarachanews) toolkit pattern: `launch.py`
opens a Chromium with persistent profile + CDP on port 9222; small
attach-only tools drive the *same* session via Playwright.
What's different: the game exposes a small set of wasm bindings so
scenarios can declaratively set scene state and read back telemetry,
not just click DOM elements. See `src/bridges.rs` `wasm_api` for the
exports.
## Setup (once)
```sh
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
playwright install chromium
```
## Open the game in a controllable browser
```sh
python3 launch.py # opens https://voxel.mxvs.art
python3 launch.py --url http://localhost:8080 # local dev server
```
The browser stays open; profile lives at `./.browser_profile/`. CDP
listens on `localhost:9222` so the other tools can attach.
## One-shot inspection / screenshot
```sh
python3 peek.py # screenshot + dump game telemetry
python3 peek.py --json # machine-readable
```
Writes a PNG to `screenshots/<ts>_peek.png`.
## Run a scenario
```sh
python3 run.py scenarios/lighting-times-of-day.yaml
```
Scenarios are YAML lists of `steps`. Each step is one of:
| step | meaning |
|-----------------------------|---------|
| `wait_for: <js_expr>` | block until `js_expr` evaluates truthy |
| `wait: <ms>` | sleep that many ms |
| `eval: <js>` | run JS in the page (state setters, etc.) |
| `key: <key> [hold: ms]` | press a key (optionally hold) |
| `mouse_move: [dx, dy]` | relative mouse motion |
| `mouse: <down\|up\|click>` | mouse button events |
| `screenshot: <name>.png` | save canvas screenshot to `screenshots/` |
| `assert: <js_expr>` | fail scenario if `js_expr` is falsy |
## Available game-state JS bindings
All exported by the wasm module (see `src/bridges.rs::wasm_api`).
After `wait_for: "window.voxel_game !== undefined"`, call as
`window.voxel_game.<fn>(...)`.
| Setter | Effect |
|--------|--------|
| `set_scene_time(t: f32)` | jump shader time to `t` seconds |
| `set_time_scale(s: f32)` | freeze (0) / fast-forward time |
| `teleport(x, y, z)` | move player feet to (x,y,z) |
| `look_at(yaw, pitch)` | set camera angles (radians) |
| `set_paused(b)` | pause input + physics |
| `set_fov(deg)` / `set_mouse_sens(s)` / `set_render_distance(blocks)` | settings |
| `respawn()` | one-shot respawn request |
| Getter | Returns |
|--------|---------|
| `get_scene_time()` | `f32` |
| `get_position()` | `[x, y, z]` |
| `get_camera_angles()` | `[yaw, pitch]` |
| `get_hp()` / `is_alive()` | from death/respawn state |
## Why this exists
When something looks wrong (e.g. "tops of blocks don't react to
sunset"), the dev loop without this is: deploy, open browser, fiddle
the time slider, compare to baseline by memory. With this, the loop
is: write a scenario that screenshots the same view at noon / sunset /
midnight, run it, diff the screenshots against a baseline. Bugs become
visible in one command.
For non-visual behaviors (e.g. "is the joystick releasing correctly?")
the same harness sends keyboard events and reads back position
telemetry to assert "after release, player stops moving within N ms".
## Sanity guarantees
- Persistent profile means cookies, settings, and game state survive
across `launch.py` runs. The first launch is "fresh"; subsequent
ones resume where you left off.
- `launch.py` never touches the page beyond the initial navigation.
Scenarios drive the session; the launcher just hosts the browser.
- All tools attach via CDP — closing them doesn't close the browser.

97
test/launch.py Normal file
View file

@ -0,0 +1,97 @@
"""
Launch Chromium pointed at the voxel game with CDP attached.
Mirrors the cucucaracha launch pattern:
- Persistent profile dir (./.browser_profile/) so settings + cookies
survive between runs.
- Remote debugging on port 9222 so peek.py / run.py attach to the
same session.
- Disables saved-password autofill (irrelevant here but harmless).
- Navigates ONCE to the target URL and sits idle.
The browser stays open until you Ctrl-C this terminal or close the
window. Scenarios are driven from a second terminal via run.py.
Usage:
python3 launch.py
python3 launch.py --url http://localhost:8080
python3 launch.py --url http://localhost:8080 --headed/--headless
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
HERE = Path(__file__).resolve().parent
USER_DATA_DIR = HERE / ".browser_profile"
DEFAULT_URL = "https://voxel.mxvs.art/"
CDP_PORT = 9222
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--url", default=DEFAULT_URL, help="game URL to open")
ap.add_argument(
"--headless",
action="store_true",
help="run headless (no visible window). Default is headed.",
)
args = ap.parse_args()
USER_DATA_DIR.mkdir(exist_ok=True)
chrome_args = [
f"--remote-debugging-port={CDP_PORT}",
"--disable-blink-features=AutomationControlled",
# WebGPU benefits from these on Linux — voxel game prefers
# WebGPU but will fall back to WebGL2 if disabled.
"--enable-unsafe-webgpu",
# Smaller window default
"--window-size=1280,800",
]
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir=str(USER_DATA_DIR),
headless=args.headless,
viewport={"width": 1280, "height": 800},
args=chrome_args,
ignore_default_args=["--enable-automation"],
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
try:
page.goto(args.url, wait_until="domcontentloaded", timeout=45000)
except Exception as e: # noqa: BLE001
print(
f"[!] navigation error (browser stays open): {e}",
file=sys.stderr,
flush=True,
)
print(
f"[i] Browser open at {args.url}\n"
f" CDP listening on http://localhost:{CDP_PORT}\n"
f" Profile: {USER_DATA_DIR}\n"
" Use peek.py / run.py from another terminal.\n"
" Stop with Ctrl-C or close the browser window.",
file=sys.stderr,
flush=True,
)
try:
while True:
time.sleep(3600)
except KeyboardInterrupt:
print("[i] Ctrl-C received; exiting.", file=sys.stderr, flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())

114
test/peek.py Normal file
View file

@ -0,0 +1,114 @@
"""
One-shot inspection: attach to the running launch.py browser via CDP,
screenshot the canvas, and dump current game telemetry (scene time,
player position, camera angles, hp).
Read-only. Cannot send inputs, change state, or close the browser.
Usage:
python3 peek.py
python3 peek.py --json
python3 peek.py --name baseline # custom screenshot filename
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from playwright.sync_api import sync_playwright
HERE = Path(__file__).resolve().parent
SCREENSHOT_DIR = HERE / "screenshots"
CDP_URL = "http://localhost:9222"
# JS evaluated in the page to gather game state. Pure reads only.
TELEMETRY_JS = r"""
() => {
const g = window.voxel_game;
if (!g) return { ready: false };
return {
ready: true,
scene_time: g.get_scene_time?.() ?? null,
position: g.get_position?.() ?? null,
camera_angles: g.get_camera_angles?.() ?? null,
hp: g.get_hp?.() ?? null,
alive: g.is_alive?.() ?? null,
canvas_size: (() => {
const c = document.getElementById('game-canvas');
return c ? { w: c.width, h: c.height } : null;
})(),
};
}
"""
def attach_page():
p = sync_playwright().start()
try:
browser = p.chromium.connect_over_cdp(CDP_URL)
except Exception as e: # noqa: BLE001
p.stop()
sys.exit(f"could not attach to launch.py via {CDP_URL}: {e}")
pages = [pg for c in browser.contexts for pg in c.pages]
if not pages:
browser.close()
p.stop()
sys.exit("no open pages")
return p, browser, pages[-1]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--name", default="peek", help="screenshot name prefix")
ap.add_argument("--json", action="store_true", help="JSON output only")
args = ap.parse_args()
SCREENSHOT_DIR.mkdir(exist_ok=True)
p, browser, page = attach_page()
try:
try:
telemetry = page.evaluate(TELEMETRY_JS)
except Exception as e: # noqa: BLE001
telemetry = {"ready": False, "error": str(e)}
shot = SCREENSHOT_DIR / f"{int(time.time())}_{args.name}.png"
try:
page.screenshot(path=str(shot), full_page=False)
except Exception as e: # noqa: BLE001
shot = None
print(f"[!] screenshot failed: {e}", file=sys.stderr)
result = {
"url": page.url,
"screenshot": str(shot) if shot else None,
**telemetry,
}
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"URL: {result['url']}")
print(f"SHOT: {result.get('screenshot')}")
print(f"READY: {result.get('ready')}")
if result.get("ready"):
print(f"TIME: {result.get('scene_time'):.2f}s")
pos = result.get("position") or []
print(f"POS: ({', '.join(f'{v:.2f}' for v in pos)})")
ang = result.get("camera_angles") or []
print(f"YAW/PITCH: ({', '.join(f'{v:.3f}' for v in ang)})")
print(f"HP: {result.get('hp')}")
print(f"ALIVE: {result.get('alive')}")
finally:
browser.close()
p.stop()
return 0
if __name__ == "__main__":
sys.exit(main())

2
test/requirements.txt Normal file
View file

@ -0,0 +1,2 @@
playwright>=1.40
PyYAML>=6.0

196
test/run.py Normal file
View file

@ -0,0 +1,196 @@
"""
Run a declarative scenario YAML against the running launch.py browser.
A scenario is a YAML mapping with a `name`, `description`, and a list
of `steps`. Each step is one mapping like `{eval: "..."}`. See
test/README.md for the full grammar.
Usage:
python3 run.py scenarios/lighting-times-of-day.yaml
python3 run.py scenarios/sunset.yaml --halt-on-fail
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
import yaml
from playwright.sync_api import sync_playwright
HERE = Path(__file__).resolve().parent
SCREENSHOT_DIR = HERE / "screenshots"
CDP_URL = "http://localhost:9222"
# All keys we'll let scenarios send via page.keyboard.press. Mirrors
# cucucaracha's whitelist + the keys our game listens for.
KEY_ALLOWLIST = {
"Enter", "Escape", "Tab", "Space",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"KeyW", "KeyA", "KeyS", "KeyD",
"ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight",
"Digit0", "Digit1", "Digit2", "Digit3", "Digit4",
"Digit5", "Digit6", "Digit7", "Digit8", "Digit9",
}
def attach_page():
p = sync_playwright().start()
try:
browser = p.chromium.connect_over_cdp(CDP_URL)
except Exception as e: # noqa: BLE001
p.stop()
sys.exit(f"could not attach to launch.py via {CDP_URL}: {e}")
pages = [pg for c in browser.contexts for pg in c.pages]
if not pages:
browser.close()
p.stop()
sys.exit("no open pages")
return p, browser, pages[-1]
def step_wait_for(page, expr: str, timeout_ms: int = 30_000):
"""Block until `expr` evaluates truthy in the page."""
start = time.time()
while True:
try:
ok = page.evaluate(f"() => Boolean({expr})")
except Exception:
ok = False
if ok:
return
if (time.time() - start) * 1000 > timeout_ms:
raise TimeoutError(f"wait_for {expr!r} timed out after {timeout_ms} ms")
time.sleep(0.1)
def run_step(page, step: dict, scenario_name: str, halt_on_fail: bool) -> bool:
"""Execute one step. Returns False on assertion failure."""
if "wait_for" in step:
print(f" wait_for: {step['wait_for']}")
step_wait_for(page, step["wait_for"], step.get("timeout_ms", 30_000))
return True
if "wait" in step:
ms = int(step["wait"])
print(f" wait: {ms} ms")
time.sleep(ms / 1000.0)
return True
if "eval" in step:
print(f" eval: {step['eval']}")
# Wrap so a bare expression like `set_scene_time(0)` works too.
js = step["eval"]
page.evaluate(f"() => {{ {js}; return null; }}")
return True
if "key" in step:
key = step["key"]
if key not in KEY_ALLOWLIST:
raise ValueError(f"key {key!r} not in allowlist {sorted(KEY_ALLOWLIST)}")
hold = step.get("hold")
if hold:
print(f" key: {key} hold={hold}ms")
page.keyboard.down(key)
time.sleep(int(hold) / 1000.0)
page.keyboard.up(key)
else:
print(f" key: {key}")
page.keyboard.press(key)
return True
if "mouse_move" in step:
dx, dy = step["mouse_move"]
print(f" mouse_move: ({dx}, {dy})")
# Relative motion via DeviceEvent — Playwright doesn't expose
# raw relative deltas, so we move from center by (dx, dy).
# The game uses pointer lock which means we'd need a different
# path for real-relative; this still works for many scenarios.
page.mouse.move(640 + dx, 400 + dy)
return True
if "mouse" in step:
action = step["mouse"]
print(f" mouse: {action}")
if action == "down":
page.mouse.down()
elif action == "up":
page.mouse.up()
elif action == "click":
page.mouse.click(640, 400)
else:
raise ValueError(f"unknown mouse action: {action}")
return True
if "screenshot" in step:
SCREENSHOT_DIR.mkdir(exist_ok=True)
name = step["screenshot"]
out = SCREENSHOT_DIR / f"{scenario_name}_{name}"
page.screenshot(path=str(out), full_page=False)
print(f" screenshot → {out}")
return True
if "assert" in step:
expr = step["assert"]
ok = page.evaluate(f"() => Boolean({expr})")
symbol = "" if ok else ""
print(f" assert: {expr} {symbol}")
if not ok and halt_on_fail:
return False
return ok
raise ValueError(f"unknown step type in {step!r}")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("scenario", help="path to scenario YAML")
ap.add_argument(
"--halt-on-fail",
action="store_true",
help="stop on first failed assert (default: continue)",
)
args = ap.parse_args()
scenario_path = Path(args.scenario)
if not scenario_path.exists():
sys.exit(f"scenario not found: {scenario_path}")
scenario = yaml.safe_load(scenario_path.read_text())
name = scenario.get("name") or scenario_path.stem
print(f"# scenario: {name}")
if scenario.get("description"):
print(f"# {scenario['description']}")
print()
p, browser, page = attach_page()
failures = 0
try:
for i, step in enumerate(scenario.get("steps", [])):
print(f"step {i}:")
try:
ok = run_step(page, step, name, args.halt_on_fail)
if not ok:
failures += 1
if args.halt_on_fail:
break
except Exception as e: # noqa: BLE001
print(f" [!] step error: {e}", file=sys.stderr)
failures += 1
if args.halt_on_fail:
break
print()
if failures == 0:
print(f"# {name}: OK ({len(scenario.get('steps', []))} steps)")
else:
print(f"# {name}: FAILED ({failures} step(s))", file=sys.stderr)
finally:
browser.close()
p.stop()
return 0 if failures == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,40 @@
name: god-rays-look-at-sun
description: |
Verify the screen-space god-rays kick in when the camera is
pointed roughly toward the sun and there's geometry partially
occluding it. Screenshots at three sun altitudes for visual
inspection.
steps:
- wait_for: "window.voxel_game !== undefined && window.voxel_game.get_scene_time !== undefined"
- eval: "window.voxel_game.set_time_scale(0.0)"
# Stand on the surface and look in the sun's general direction.
# Sun rotates in the xy-plane with z-tilt; at t=60 the sun is
# toward the west-and-up. The look_at yaw puts the camera facing
# roughly that bearing.
- eval: "window.voxel_game.teleport(0, 40, 0)"
- eval: "window.voxel_game.look_at(0.0, 0.25)"
- wait: 200
# Mid-morning — sun well above horizon, full daylight, shafts subtle.
- eval: "window.voxel_game.set_scene_time(30.0)"
- wait: 300
- screenshot: 01-mid-morning.png
# Late afternoon — sun lower, warm light, shafts more pronounced.
- eval: "window.voxel_game.set_scene_time(60.0)"
- wait: 300
- screenshot: 02-late-afternoon.png
# Sunset — sun at horizon, maximum atmospheric scattering, shafts
# should be very prominent.
- eval: "window.voxel_game.set_scene_time(75.0)"
- wait: 300
- screenshot: 03-sunset-rays.png
# Night — no shafts at all.
- eval: "window.voxel_game.set_scene_time(150.0)"
- wait: 300
- screenshot: 04-night-no-rays.png

View file

@ -0,0 +1,54 @@
name: lighting-times-of-day
description: |
Screenshot the same view at noon, golden hour, sunset, civil
twilight, and midnight. Used to verify the sunset-family fixes
(zenith warm tint, fog warm tint, star fade, sun disc spread).
Position the player high above terrain looking toward the horizon
so all four faces of nearby blocks (top, two sides, bottom) are
visible in the frame.
steps:
- wait_for: "window.voxel_game !== undefined && window.voxel_game.get_scene_time !== undefined"
# Freeze time so set_scene_time isn't immediately overwritten.
- eval: "window.voxel_game.set_time_scale(0.0)"
# Move to a fixed vantage point and look out toward the horizon.
- eval: "window.voxel_game.teleport(0, 50, 0)"
- eval: "window.voxel_game.look_at(0.0, -0.15)"
# Wait a frame for the camera + body to settle.
- wait: 200
# ---- Sweep through the day/night cycle. DAY_PERIOD = 300s, so:
# t = 0 → noon (sun straight up)
# t = 75 → sunset (sun at the horizon, west)
# t = 150 → midnight (sun straight down)
# t = 225 → sunrise
- eval: "window.voxel_game.set_scene_time(0.0)"
- wait: 300
- screenshot: 01-noon.png
- eval: "window.voxel_game.set_scene_time(45.0)"
- wait: 300
- screenshot: 02-afternoon.png
- eval: "window.voxel_game.set_scene_time(75.0)"
- wait: 300
- screenshot: 03-sunset.png
- eval: "window.voxel_game.set_scene_time(90.0)"
- wait: 300
- screenshot: 04-civil-twilight.png
- eval: "window.voxel_game.set_scene_time(150.0)"
- wait: 300
- screenshot: 05-midnight.png
- eval: "window.voxel_game.set_scene_time(225.0)"
- wait: 300
- screenshot: 06-sunrise.png
# Telemetry sanity: after all that, time should match what we set.
- assert: "Math.abs(window.voxel_game.get_scene_time() - 225.0) < 2.0"

View file

@ -0,0 +1,32 @@
name: voxel-construction-darkness
description: |
Build a sealed shelter around the player at the surface, then
compare the lighting inside vs. an open-air view. The sky-vis
bake (sim::lighting::compute_ambience) should produce darker
interior even though it's noon — depends only on the surrounding
voxel construction.
steps:
- wait_for: "window.voxel_game !== undefined && window.voxel_game.get_scene_time !== undefined"
- eval: "window.voxel_game.set_time_scale(0.0)"
- eval: "window.voxel_game.set_scene_time(0.0)"
# Establish the open-air baseline at the surface.
- eval: "window.voxel_game.teleport(0, 35, 0)"
- eval: "window.voxel_game.look_at(0.0, 0.0)"
- wait: 300
- screenshot: 01-open-air-baseline.png
# Look straight up — should see bright blue sky (sky_vis = 1, full
# daylight) bordered by nearby block sides at lower sky_vis.
- eval: "window.voxel_game.look_at(0.0, 1.4)"
- wait: 300
- screenshot: 02-looking-up.png
# Now point at a side face of nearby terrain to see how block
# sides read at noon (the historical "dark sides" bug).
- eval: "window.voxel_game.look_at(0.0, -0.1)"
- wait: 300
- screenshot: 03-side-faces-at-noon.png
- assert: "Math.abs(window.voxel_game.get_scene_time() - 0.0) < 2.0"