返回 Skills
majidmanzarpour/threejs-game-skills· MIT 内容可用

threejs-qa-release

Verify and release Three.js browser games. Combines playtest QA, automated bot playtests, mobile/responsive checks, production builds, preview verification, static-hosting base paths, debug gating, bundle review, screenshots, visual test harness decisions, packaged canvas-pixel inspection with measured metrics, console checks, and release risk reports.

安装

与 skills.sh 相同的 Command / Prompt 安装方式


name: threejs-qa-release description: "Verify and release Three.js browser games. Combines playtest QA, automated bot playtests, mobile/responsive checks, production builds, preview verification, static-hosting base paths, debug gating, bundle review, screenshots, visual test harness decisions, packaged canvas-pixel inspection with measured metrics, console checks, and release risk reports."

Three.js QA Release

Purpose

Prove the game works as a player encounters it, then prepare a shippable browser build with known risks.

QA Workflow

Load references/qa-release-checklists.md as the first action before broad QA, mobile verification, bug reporting, production preview, static-hosting checks, or release preparation. Track it in a reference ledger with yes/no, path, and failure reason. Do not mark QA/release complete while this reference is skipped for QA or release work.

Load references/checklists/visual-verification.md for screenshot/canvas verification, references/checklists/playtest-qa.md for player-loop QA, and references/checklists/release.md for production release checks. Load references/prompt-templates.md only when the user asks for reusable QA/release prompts or a task template.

Load references/visual-test-harness.md and references/checklists/visual-test-harness.md when the game warrants screenshot baselines, visual regression testing, release-ready visual evidence, UI/generated-asset regression protection, or premium visual QA. If a harness is not warranted, report the skip reason.

Load references/playtest-bot.md and references/checklists/bot-playtest.md for release-ready gameplay claims, difficulty/fairness verification, or when the playable loop has never been driven by scripted input. Report the bot playtest decision as added/extended/skipped with reason.

  1. Install dependencies if needed.
  2. Run build/typecheck.
  3. Start dev or preview server.
  4. Open browser target.
  5. Capture console/page/network errors.
  6. Verify nonblank canvas pixels.
  7. Capture desktop and mobile screenshots.
  8. Trigger main input, objective progression, fail/retry, and recent risky paths.
  9. Check HUD text fit, safe areas, touch targets, responsive layout.
  10. Decide whether to add or extend a visual test harness. For premium/release UI or generated-asset work, prefer a harness unless determinism is a real blocker. 10b. Decide whether to run the bot playtest (tests/bot-playtest.template.ts in scaffold games). For release-ready gameplay claims, run it and report the metrics JSON.
  11. If audio changed, verify user-gesture unlock, SFX triggers, ambience loop start/stop, pause/restart cleanup, mute/volume behavior, and decode/load errors.
  12. Record artifacts and issues.

Packaged Canvas Inspector

Use the bundled inspector when the target project does not already include one:

node <this-skill-dir>/scripts/inspect-threejs-canvas.mjs --url http://127.0.0.1:5188

For mobile emulation, add --mobile. Add --state <name> (and optionally --seed <n>) to drive the game's __THREE_GAME_TEST_HOOKS__ before capture, so every named state (active-play, fail, stress) can be measured deterministically without live play — outputs are suffixed per state. Generated games from the packaged scaffold also include their own scripts/inspect-threejs-canvas.mjs and npm run inspect:canvas.

The inspector JSON includes a metrics block (color entropy, edge density, luminance contrast, dominant-color share) and a renderBudget comparison against starting-point tier budgets. Cite these as the Measured Evidence in the visual scorecard (threejs-aaa-graphics-builder/references/visual-scorecard.md); over-budget rows need a documented tradeoff, and blank-canvas or error conditions still exit nonzero.

Release Workflow

  1. Inspect package scripts, Vite config, base path, public/assets.
  2. Gate debug UI/logging/test helpers.
  3. Run production build and preview/static server.
  4. Verify built output desktop/mobile.
  5. Review bundle and large assets.
  6. Document deploy command, host assumptions, and residual risks.

Final Response

Lead with pass/fail. Include the reference ledger, QA matrix/checklist result, commands, URL, controls, screenshots/artifacts, issues found/fixed, deployment notes, and risks. When visual baselines are in scope, include the harness decision, states covered, update/compare commands, artifact paths, thresholds/masks, and flake risks. When the bot playtest ran, include its metrics JSON (frames, score progression, distance, softlock windows, seed) and the added/extended/skipped decision.

附带文件

agents/openai.yaml
interface:
  display_name: "Three.js QA Release"
  short_description: "Verify and release Three.js games"
  default_prompt: "Load threejs-qa-release/SKILL.md (try ~/.codex/skills/threejs-qa-release/SKILL.md, then ~/.claude/skills, ~/.agents/skills, or repo skills/) and follow it exactly. Non-negotiables: browser QA with screenshots and canvas-pixel evidence on desktop and mobile; visual test harness and bot playtest decisions reported as added/extended/skipped; report the reference ledger, pass/fail QA matrix, artifacts, and risks."
references/checklists/bot-playtest.md
# Bot Playtest Checklist

Use with `threejs-qa-release/references/playtest-bot.md`.

- Diagnostics publish frame, score/objective, complete/fail state, and player position.
- Test hooks expose `seed()` and `setState()`; the run seed is reported.
- `INPUT_SCRIPT` expresses the game's core verb, not just arbitrary key mashing.
- The bot run completed with zero console and page errors.
- Player moved in response to scripted input (distance above threshold).
- The objective progressed during the run (score, waves, distance, or completion).
- Softlock windows are within threshold, or each one is explained.
- Games with fail states: a reckless run triggers the fail state and the retry path restores play.
- Difficulty claims are backed by two-skill-level bot runs, not intuition.
- Bot playtest decision reported as added/extended/skipped with reason.
references/checklists/playtest-qa.md
# Playtest QA Checklist

- Start from a clean load.
- Play through the main loop for at least 2 minutes.
- Verify controls, camera, objective feedback, failure/retry, and progression.
- Try rapid input changes and edge movement against arena boundaries.
- Trigger collisions from multiple angles.
- Pause, restart, resize, and refocus the tab if supported.
- Check audio unlock and volume behavior after the first gesture.
- Watch for unreadable moments, camera occlusion, jitter, and missed feedback.
- Capture screenshots for desktop and mobile.
- Record bugs as reproduction steps with expected and actual behavior.
references/checklists/release.md
# Release Checklist

- `npm run build` passes.
- `npm run preview` or equivalent static server runs the built files.
- Asset URLs work with the intended Vite `base` path.
- No local-only files, debug panels, or test overlays are visible unless intentionally gated.
- Console is clean in production preview.
- Desktop and mobile visual checks pass.
- Main interaction works in production preview.
- Bundle size and large assets are reviewed.
- License/source notes for third-party assets are present.
- Final report includes commands, screenshots/artifacts, known risks, and deployment notes.
references/checklists/visual-test-harness.md
# Visual Test Harness Checklist

Use when visual regression testing is added, extended, or intentionally skipped for a meaningful Three.js game change.

- Harness decision is explicit: added / extended / skipped.
- Decision is justified by milestone risk: premium/release/UI/generated assets/visual style vs prototype/non-deterministic smoke only.
- Active-play desktop state is covered or a reason is reported.
- Active-play mobile state is covered when mobile is in scope or a reason is reported.
- Important menu/HUD/fail/retry/generated-asset states are covered when those surfaces changed.
- Randomness, camera shake, particles, time, debug UI, and dynamic overlays are seeded, paused, hidden, or intentionally excluded.
- Fonts, textures, GLTFs, and first rendered frames are awaited before screenshots.
- `toHaveScreenshot()` thresholds are narrow enough to catch real regressions.
- Masks are used only for dynamic areas that are not acceptance criteria.
- Canvas pixel/nonblank smoke checks still run; screenshot baselines do not replace interaction checks.
- Baseline update command and comparison command are reported.
- Artifacts/snapshot paths are reported.
- Flake risks and unsupported states are listed.
references/checklists/visual-verification.md
# Visual Verification Checklist

- Open the local dev or preview URL in a browser.
- Check browser console errors and page errors.
- Confirm the canvas has nonzero display size and drawing-buffer size.
- Capture a screenshot.
- Sample canvas pixels for nonblank output and color variance.
- Test at desktop, laptop, and mobile viewport sizes.
- Confirm camera aspect updates after resize.
- Confirm UI/HUD text does not overlap or clip.
- Interact for at least one core action and observe visible state change.
- If using snapshots, make dynamic effects deterministic or mask them.
- If the game is premium, release-ready, UI-heavy, or generated-asset-heavy, also use `threejs-qa-release/references/visual-test-harness.md` and `threejs-qa-release/references/checklists/visual-test-harness.md`, or record why a baseline harness is not warranted.
- If HUD/menu layout changed, also use `threejs-game-ui-designer/references/checklists/game-ui-quality.md`, `threejs-game-ui-designer/references/checklists/hud-readability.md`, and `threejs-game-ui-designer/references/checklists/responsive-ui-fit.md`.
- If procedural models/materials changed, also use `threejs-aaa-graphics-builder/references/checklists/procedural-model-quality.md`, `threejs-aaa-graphics-builder/references/checklists/material-lighting-quality.md`, and `threejs-aaa-graphics-builder/references/checklists/performance-safe-visual-detail.md`.
- If the target is premium, AAA, complete, release-ready, or showcase quality, also use `threejs-aaa-graphics-builder/references/checklists/aaa-game-quality-gate.md`.
- If screenshots still look basic, also use `threejs-aaa-graphics-builder/references/checklists/aaa-visual-scorecard.md`.
- If the game is an endless runner, also use `threejs-gameplay-systems/references/checklists/endless-runner-premium-quality.md`.
references/playtest-bot.md
# Bot Playtest

Automated playtests drive the game through scripted real input and measure whether it actually plays: objective progression, player responsiveness, softlock windows, and error-free runtime. A game that renders beautifully but cannot be progressed by a scripted sweep is not release-ready. Use this for release-ready gameplay claims and difficulty/fairness verification; the canvas inspector proves the game renders, the bot proves it plays.

## Prerequisites

- `window.__THREE_GAME_DIAGNOSTICS__` publishing frame, score/objective, complete/fail state, and player position every update.
- `window.__THREE_GAME_TEST_HOOKS__` with at least `seed()` and `setState()` so runs are reproducible (scaffold games ship both).
- All gameplay randomness routed through the seeded RNG — otherwise bot metrics are noise.

## Setup

Copy the packaged template and adapt it:

```bash
cp tests/bot-playtest.template.ts tests/bot-playtest.spec.ts
npx playwright test tests/bot-playtest.spec.ts
```

Adapt `INPUT_SCRIPT` to the game's controls and level layout: an endless runner bot holds forward and switches lanes on a cadence; an arena game sweeps the play space; a tower defense bot places affordable towers via test hooks and starts waves. Game-specific hooks (e.g. `forceWave()`, `buildFirstOpenPad()`) are encouraged for genres where raw keyboard input cannot express the core verb.

## Metrics And What They Mean

- `framesAdvanced` — the loop survived the whole run; a stall here is a crash or frozen loop.
- `distanceTravelled` — input responsiveness; near-zero under held keys means broken input mapping.
- `scoreAfter - scoreBefore` and `stepOfFirstScore` — objective progression and how quickly a naive player finds it. If a scripted sweep never scores, the objective is unreachable, unreadable, or broken.
- `softlockWindows` — sampling windows where frames advanced but held input produced neither motion nor progress. Repeated windows indicate stuck-on-geometry, dead input states, or unrecovered fail states.
- Time-to-first-fail (games with fail states) — add a scripted "reckless" run that seeks hazards and assert the fail state triggers and the retry path restores play; a game that cannot be failed has no pressure, and a fail state that cannot be retried is a release blocker.
- Console/page errors — must be empty for the full run.

## Headless WebGL Caveats

- Always launch Chromium with `channel: 'chromium'` (the scaffold config and `inspect-threejs-canvas.mjs` do). Playwright's default headless is `chromium_headless_shell`, which ships no GPU backend and silently falls back to SwiftShader (CPU). This is a launch-config bug, not a headless limitation: on the same 1024x1024 scene the shell reports `ANGLE (Google, ... SwiftShader driver)` and renders at 32 fps, while `channel: 'chromium'` reports `ANGLE (Apple, ANGLE Metal Renderer: Apple M3 Pro)` and renders at 127 fps — ~4x, from one line of config.
- Verify the GPU before reporting any FPS; never assume it. `inspect-threejs-canvas.mjs` records a `gpu` block (`renderer`, `vendor`, `softwareRendered`) in its JSON report — check it. If `softwareRendered` is true, the run fell back to CPU and its FPS/frame-time numbers are not performance evidence; pixel, budget, and functional checks are still valid. Fix the fallback with `npx playwright install chromium` rather than caveating the number.
- Run Playwright suites with `workers: 1` for WebGL games (the scaffold config does). Parallel contexts still contend for the GPU, and the frame-time collapse makes game time drift from wall time, flaking timed phases and screenshot baselines.
- Headless FPS on a verified real GPU is still not a phone. Treat it as a desktop-GPU signal and validate mobile targets on real hardware.

## Difficulty And Fairness Signals

For games with fail states, run the bot at two skill levels (e.g. reaction delay 0ms vs 300ms between script steps) and compare survival time and score. If the delayed bot survives as long as the fast one, difficulty pressure is decorative; if even the fast script cannot survive the first threat, the opening is unfair. Report both runs when difficulty tuning is in scope.

## Reporting

Include in the QA evidence: the JSON report attachment (steps, frames, score progression, distance, softlock windows, errors), the seed used, and pass/fail per assertion. Report the bot playtest decision like the visual harness decision: added / extended / skipped with reason.
references/prompt-templates.md
# Three.js QA/Release Prompt Templates

Reusable prompt templates packaged with this skill. Use only templates relevant to the current request, and adapt placeholders to the game/project context.

---

# Release Pass Prompt

Use `threejs-qa-release` to prepare this Three.js game for release.

Release target:
- static host, GitHub Pages, Netlify, Vercel, itch.io, or other:

Requirements:
- Run production build and preview.
- Verify asset paths under the intended base path.
- Check bundle size and large assets.
- Run desktop and mobile visual QA.
- Confirm no debug-only UI leaks unless intentionally enabled.
- Produce final report with commands, artifacts, screenshots, and residual risks.

---

# Visual Test Harness Prompt

Use `threejs-qa-release` to add or evaluate a visual test harness for this Three.js game.

Context:
- Game/milestone:
- Visual surfaces to protect:
- Dynamic/random systems:
- Target viewports:

Requirements:
- Load `references/visual-test-harness.md`.
- Decide whether to add, extend, or skip screenshot baselines.
- Cover active-play desktop and mobile when warranted.
- Add deterministic hooks or setup for seed, state, pause, reduced motion, camera shake, debug UI, and asset loading where practical.
- Keep canvas-pixel smoke and interaction checks.
- Report baseline update command, compare command, artifact paths, thresholds/masks, and flake risks.
references/qa-release-checklists.md
# QA And Release Checklists

Use this before calling a Three.js browser game complete, premium, release-ready, or fixed.

## Browser QA Matrix

Minimum meaningful QA:

- Dependencies installed or known.
- Build/typecheck passes.
- Dev or preview server opened at the correct URL.
- Console/page/network errors captured.
- Canvas nonblank and visually varied through pixel sampling.
- Desktop active-play screenshot.
- Mobile active-play screenshot when mobile is in scope.
- Main input path changes game state.
- Objective/progress path works.
- Fail/retry or pause/resume path works when relevant.
- Recent or risky code paths triggered.
- Physics-heavy games: engine choice, fixed timestep, body/collider count, collision/trigger path, high-speed tunneling check, and restart body cleanup verified.
- HUD text fit, overlap, safe areas, and touch targets checked when UI changed.
- Renderer diagnostics captured when graphics complexity changed.
- Imported/generated asset paths, file sizes, and runtime load behavior checked when external assets changed.
- Audio unlock, decode/load, loop cleanup, mute/volume, and main SFX triggers checked when audio changed.
- Visual test harness decision recorded when work is premium, release-ready, UI-heavy, generated-asset-heavy, or likely to regress visually.

## Interaction QA

Test what a player actually does:

- Start or resume.
- Move/aim/steer/jump/attack/boost as appropriate.
- Collect or score.
- Avoid or hit a hazard.
- Trigger a state change: combo, wave, checkpoint, damage, shield, fail, win.
- Pause and resume.
- Restart after fail.
- For physics games, verify bodies reset cleanly after restart and no stale bodies keep simulating.
- For audio, verify user-gesture unlock, main SFX triggers, ambience loop start/stop, pause/resume, restart cleanup, and mute/volume controls.
- Resize or rotate when responsive/mobile is in scope.

Do not rely only on screenshots for gameplay changes.

## Visual QA

For premium/AAA/showcase or "less basic" requests:

- Capture active-play screenshot before and after when possible.
- Use the visual scorecard.
- Check for automatic failures:
  - primitive-dominant active screenshot
  - flat plane/box skyline world
  - generic stat-card HUD
  - one repeated obstacle/reward silhouette
  - fog/glow/darkness hiding missing geometry
  - no renderer diagnostics
- Confirm UI and VFX do not obscure threats, rewards, player, or next decision.
- Confirm desktop and mobile framing both show the playable path.
- For generated 3D assets, confirm imported models have correct scale, orientation, material readability, collision proxies, and animation clips in active gameplay.
- Decide whether to add/extend visual regression baselines. If skipped, record why the scene is not deterministic, not valuable enough yet, or covered by smoke checks only.

## Visual Test Harness QA

When a visual harness is warranted:

- Add deterministic hooks or test setup for random seed, camera shake, particles, time, debug UI, and active state.
- Cover active desktop and active mobile screenshots when mobile is in scope.
- Cover changed HUD/menu/fail/generated-asset states.
- Use Playwright screenshot comparisons with deliberate thresholds.
- Keep canvas-pixel smoke and interaction tests; visual baselines are additional evidence.
- Report baseline update command, compare command, snapshot paths, masks, thresholds, and flake risks.

## Mobile QA

- Touch controls emit game intents.
- Pointer release/cancel/blur cannot leave controls stuck.
- Safe areas respected.
- Touch targets reachable and separated.
- Page scroll does not steal gameplay input.
- Orientation/resize preserves canvas and HUD.
- DPR/performance acceptable.
- Desktop input still works unless intentionally removed.
- UI remains readable on narrow screens.

## Performance QA

When draw calls, asset counts, shaders, shadows, or post-processing changed:

- Record renderer calls, triangles, geometries, textures.
- Record FPS/frame time if available.
- Record physics engine, timestep, body count, collider count, active sensors, CCD bodies, and known expensive colliders when physics changed.
- Note DPR cap and post/shadow settings.
- Check active gameplay, not only idle view.
- Compare before/after if performance work was requested.
- Report any unmeasured risk honestly.

## Release Checks

Before release-ready:

- Production build passes.
- Production preview/static server tested.
- Vite `base` and asset URLs match target host.
- Debug GUI, diagnostics overlays, verbose logs, and test shortcuts are gated or removed from player-facing release.
- Bundle and large assets reviewed.
- API keys are not present in client-side code, checked-in files, built assets, or browser-visible environment.
- Public assets load under static hosting assumptions.
- Browser support assumptions documented.
- Deployment command or static artifact location reported.
- Residual risks listed.

## Evidence Format

```text
QA result: pass/fail
Commands:
URL:
Controls tested:
Screenshots/artifacts:
Console/page/network errors:
Canvas pixel check:
Desktop/mobile viewports:
Renderer/performance diagnostics:
Visual test harness:
Physics diagnostics:
External asset evidence:
Audio evidence:
Issues found/fixed:
Residual risks:
```

## Bug Report Format

```text
Title:
Severity:
Reproduction steps:
Expected:
Actual:
Browser/viewport/device:
Console/page errors:
Screenshot/artifact:
Likely owner:
Suggested fix:
```

## Common Release Failures

- Testing dev server but shipping untested production build.
- Static host base path breaks assets.
- Debug UI visible to players.
- Mobile UI passes screenshot but controls do not work.
- Canvas is nonblank but wrong app is running on the port.
- Physics gameplay looks right visually but collision proxies, sensors, or restart cleanup were not tested.
- Screenshots are title/idle views instead of active play.
- Premium claim has no visual scorecard or renderer diagnostics.
- 3D/image/audio generation API key or generated temporary URLs accidentally exposed in client code.
references/visual-test-harness.md
# Visual Test Harness

Use this reference when a Three.js game warrants visual regression testing, baseline screenshots, repeated release checks, generated asset verification, or UI overlap/text-fit regression protection.

Do not add screenshot baselines for every prototype. Use a harness when the visual state is valuable enough to protect and deterministic enough to compare.

Research basis: Playwright supports `expect(page).toHaveScreenshot()` for visual comparisons, device emulation for desktop/mobile projects, screenshot thresholds such as max diff pixels/ratio, and test artifacts/traces. Three.js exposes renderer diagnostics through `WebGLRenderer.info`. Existing canvas pixel checks are good smoke tests, but they do not replace screenshot baselines for polished screens.

## When To Add A Visual Harness

Add or extend a visual harness when:

- The user asks for premium, AAA, showcase, release-ready, or "less basic" quality.
- HUD/menu layout or responsive text fit has regressed before.
- Imported/generated assets must be proven visible in-game.
- A visual style, level, vehicle, table, arena, or boss scene is important enough to protect.
- You need desktop/mobile active-play evidence on every release.
- The game has deterministic states or can expose test hooks to freeze randomness, camera, time, and particles.

Skip or defer baseline screenshots when:

- The game is still an exploratory prototype.
- The scene is intentionally random and cannot be seeded quickly.
- Particles/camera/noise dominate the image and masking would hide the useful assertion.
- The only need is "is the canvas nonblank"; use the canvas inspector instead.

Even when skipped, report the skip reason.

## Harness States

Prefer 2-5 high-value states:

- `active-play-desktop`: player, objective, threat, reward, HUD visible.
- `active-play-mobile`: same as above under mobile viewport/touch controls.
- `pause-or-settings`: menu layout, safe areas, text fit.
- `fail-or-retry`: failure feedback and restart affordance.
- `hero-asset-or-generated-asset`: imported/generated asset in real lighting and camera distance.

Avoid title-only screenshots unless title/menu work is the actual change.

## Determinism Requirements

Scaffold-generated games ship a working implementation of these hooks (`src/game/Game.ts` `installTestHooks`, typed in `src/vite-env.d.ts`) plus a seeded RNG in `src/utils/random.ts`. Keep the hooks real as the game evolves — the template fails loudly if the hooks object is missing, because silent no-op hooks capture live animating scenes and every rerun diffs. For non-scaffold games, implement the same contract:

```ts
window.__THREE_GAME_TEST_HOOKS__ = {
  seed(value: number) {},
  setState(name: string) {},
  setPausedForScreenshot(paused: boolean) {},
  setReducedMotion(enabled: boolean) {},
  hideDebugUi(hidden: boolean) {},
};
```

Before taking baselines:

- Seed random generation.
- Pause or stabilize particle/noise systems.
- Freeze camera shake, hit stop, and time-dependent post effects.
- Hide debug overlays and FPS meters unless the test covers diagnostics.
- Wait for fonts, GLTFs, textures, audio decode blockers, and first frames.
- Use fixed viewport/device profiles.
- Mask known dynamic UI only if the masked area is not part of the acceptance criteria.

## Playwright Pattern

Use the project's existing Playwright setup. Generated games include `tests/visual-regression.template.ts` as an optional starting point. Copy it to `tests/visual-regression.spec.ts` when the project is ready for baselines.

Suggested commands after copying:

```bash
npx playwright test tests/visual-regression.spec.ts --update-snapshots
npx playwright test tests/visual-regression.spec.ts
```

Use thresholds carefully:

- Prefer low `maxDiffPixelRatio` for stable UI/menu states.
- Allow slightly higher thresholds for WebGL antialiasing/post-processing differences.
- Do not set thresholds so high that real layout or asset failures pass.

## Asset Visibility Checks

For generated/imported assets:

- Assert the asset path is loaded or listed in diagnostics.
- Capture screenshot with the asset in active gameplay, not isolated in a showroom.
- Check scale, orientation, bounds, material readability, and collision proxy through diagnostics or visible state.
- Keep temporary provider URLs and API keys out of baseline paths and client code.

## Report Requirements

Report:

- Visual harness decision: added / extended / skipped.
- States covered.
- Determinism hooks used.
- Desktop/mobile projects covered.
- Screenshot update command and compare command.
- Baseline artifact paths.
- Thresholds/masks and why they are safe.
- Remaining flake risks.
scripts/inspect-threejs-canvas.mjs
#!/usr/bin/env node
import { chromium, devices } from '@playwright/test';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { PNG } from 'pngjs';

// Starting-point render budgets (see threejs-aaa-graphics-builder
// references/technical-art.md). Over-budget rows are reported, not fatal.
const RENDER_BUDGETS = {
  desktop: { calls: 300, triangles: 750_000, geometries: 300, textures: 60 },
  mobile: { calls: 150, triangles: 300_000, geometries: 200, textures: 40 },
};

function parseArgs(argv) {
  const args = {
    url: 'http://127.0.0.1:5188',
    out: 'artifacts/canvas-inspection',
    mobile: false,
    wait: 750,
    state: null,
    seed: undefined,
  };

  for (let i = 0; i < argv.length; i += 1) {
    const value = argv[i];
    if (value === '--url') args.url = argv[++i];
    else if (value === '--out') args.out = argv[++i];
    else if (value === '--mobile') args.mobile = true;
    else if (value === '--wait') args.wait = Number(argv[++i]);
    else if (value === '--state') args.state = argv[++i];
    else if (value === '--seed') args.seed = Number(argv[++i]);
    else if (value === '-h' || value === '--help') {
      console.log(
        'Usage: inspect-threejs-canvas.mjs [--url URL] [--out DIR] [--mobile] [--wait MS] [--state NAME] [--seed N]\n' +
          '  --state/--seed drive window.__THREE_GAME_TEST_HOOKS__ (setState/seed) before capture\n' +
          '  so specific game states can be measured deterministically.',
      );
      process.exit(0);
    } else {
      throw new Error(`Unknown argument: ${value}`);
    }
  }

  return args;
}

const round = (value, digits) => Number(value.toFixed(digits));

// Objective pixel statistics used as "Measured Evidence" in the visual
// scorecard. Computed on a coarse luminance grid so cost stays trivial.
function computePixelMetrics(png) {
  const stepX = Math.max(1, Math.floor(png.width / 160));
  const stepY = Math.max(1, Math.floor(png.height / 90));
  const cols = Math.floor(png.width / stepX);
  const rows = Math.floor(png.height / stepY);
  const luminance = new Float64Array(cols * rows);
  const bucketCounts = new Map();
  let samples = 0;

  for (let gy = 0; gy < rows; gy += 1) {
    for (let gx = 0; gx < cols; gx += 1) {
      const offset = ((gy * stepY) * png.width + gx * stepX) * 4;
      const r = png.data[offset];
      const g = png.data[offset + 1];
      const b = png.data[offset + 2];
      luminance[gy * cols + gx] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
      const key = `${r >> 4},${g >> 4},${b >> 4}`;
      bucketCounts.set(key, (bucketCounts.get(key) ?? 0) + 1);
      samples += 1;
    }
  }

  const sorted = Array.from(luminance).sort((a, b) => a - b);
  const mean = sorted.reduce((sum, v) => sum + v, 0) / sorted.length;
  const p5 = sorted[Math.floor(sorted.length * 0.05)];
  const p95 = sorted[Math.floor(sorted.length * 0.95)];

  let entropy = 0;
  let dominant = 0;
  for (const count of bucketCounts.values()) {
    const p = count / samples;
    entropy -= p * Math.log2(p);
    dominant = Math.max(dominant, count);
  }

  let edges = 0;
  let checked = 0;
  for (let gy = 0; gy < rows - 1; gy += 1) {
    for (let gx = 0; gx < cols - 1; gx += 1) {
      const i = gy * cols + gx;
      const dx = Math.abs(luminance[i] - luminance[i + 1]);
      const dy = Math.abs(luminance[i] - luminance[i + cols]);
      if (Math.max(dx, dy) > 12) edges += 1;
      checked += 1;
    }
  }

  return {
    colorBuckets: bucketCounts.size,
    colorEntropyBits: round(entropy, 2),
    edgeDensity: round(edges / checked, 3),
    luminance: {
      mean: round(mean, 1),
      p5: round(p5, 1),
      p95: round(p95, 1),
      contrast: round(p95 - p5, 1),
    },
    dominantColorShare: round(dominant / samples, 3),
    nonBackgroundShare: round(1 - dominant / samples, 3),
  };
}

// Playwright's default headless is chromium_headless_shell, which ships no GPU
// backend and silently falls back to SwiftShader (CPU). Every frame-time and FPS
// number measured that way is software-rendered fiction. channel:'chromium' runs
// the full Chromium build in new headless mode against the real GPU.
async function launchBrowser() {
  try {
    return await chromium.launch({ channel: 'chromium' });
  } catch {
    console.error(
      'warning: channel:"chromium" is unavailable, falling back to the bundled headless shell.\n' +
        '  Rendering will be software (SwiftShader) and any FPS/frame-time evidence is invalid.\n' +
        '  Fix with: npx playwright install chromium',
    );
    return chromium.launch();
  }
}

// Records which GPU actually rasterized the run, so a software fallback can never
// masquerade as performance evidence again. Reuses the game's own context when it
// is WebGL rather than allocating a second one.
async function readGpuInfo(page) {
  const info = await page.evaluate(() => {
    const canvas = document.querySelector('canvas');
    if (!canvas) return null;
    let gl = null;
    try {
      gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
    } catch {
      gl = null;
    }
    if (!gl) return null;
    const debug = gl.getExtension('WEBGL_debug_renderer_info');
    return {
      renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
      vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
    };
  });

  if (!info?.renderer) {
    return { renderer: null, vendor: null, softwareRendered: null };
  }

  return {
    ...info,
    softwareRendered: /swiftshader|llvmpipe|software|basic render/i.test(info.renderer),
  };
}

function checkRenderBudget(renderer, mode) {
  if (!renderer) return null;
  const budget = RENDER_BUDGETS[mode];
  const rows = Object.entries(budget).map(([metric, limit]) => {
    const actual = renderer[metric];
    return {
      metric,
      actual: typeof actual === 'number' ? actual : null,
      limit,
      ok: typeof actual === 'number' ? actual <= limit : null,
    };
  });
  return {
    tier: mode,
    note: 'starting-point budget; adjust per game and document overrides',
    rows,
    withinBudget: rows.every((row) => row.ok !== false),
  };
}

async function sampleCanvas(page, mode) {
  const locator = page.locator('canvas').first();
  const rect = await locator.boundingBox();
  if (!rect || rect.width < 32 || rect.height < 32) {
    return { ok: false, reason: 'canvas-too-small', rect };
  }

  const buffer = await locator.screenshot();
  const png = PNG.sync.read(buffer);
  let min = 255;
  let max = 0;
  let alphaPixels = 0;
  const colors = new Set();
  const stride = Math.max(1, Math.floor((png.width * png.height) / 4096));

  for (let pixel = 0; pixel < png.width * png.height; pixel += stride) {
    const offset = pixel * 4;
    const r = png.data[offset];
    const g = png.data[offset + 1];
    const b = png.data[offset + 2];
    const a = png.data[offset + 3];
    min = Math.min(min, r, g, b);
    max = Math.max(max, r, g, b);
    if (a > 0) alphaPixels += 1;
    colors.add(`${r >> 4},${g >> 4},${b >> 4},${a >> 6}`);
  }

  const variance = max - min;
  const diagnostics = await page.evaluate(() => {
    const canvas = document.querySelector('canvas');
    return {
      drawingBuffer: canvas
        ? { width: canvas.width, height: canvas.height }
        : null,
      game: window.__THREE_GAME_DIAGNOSTICS__ ?? null,
    };
  });

  const ok = alphaPixels > 256 && (variance > 8 || colors.size > 3);
  return {
    ok,
    reason: ok ? 'nonblank' : 'low-variance',
    rect,
    drawingBuffer: diagnostics.drawingBuffer,
    alphaPixels,
    variance,
    colorBuckets: colors.size,
    metrics: computePixelMetrics(png),
    renderBudget: checkRenderBudget(diagnostics.game?.renderer ?? null, mode),
    diagnostics: diagnostics.game,
  };
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  await mkdir(args.out, { recursive: true });

  const browser = await launchBrowser();
  const context = await browser.newContext(args.mobile
    ? { ...devices['iPhone 13'], userAgent: undefined }
    : { viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
  const page = await context.newPage();
  const consoleErrors = [];
  const pageErrors = [];

  page.on('console', (message) => {
    if (message.type() === 'error') consoleErrors.push(message.text());
  });
  page.on('pageerror', (error) => pageErrors.push(error.message));

  await page.goto(args.url, { waitUntil: 'networkidle' });
  await page.waitForSelector('canvas', { state: 'visible', timeout: 10_000 });

  if (args.state || args.seed !== undefined) {
    const applied = await page.evaluate(({ seed, state }) => {
      const hooks = window.__THREE_GAME_TEST_HOOKS__;
      if (!hooks) return false;
      if (typeof seed === 'number') hooks.seed?.(seed);
      if (state) hooks.setState?.(state);
      return true;
    }, { seed: args.seed, state: args.state });
    if (!applied) {
      console.error(
        'warning: --state/--seed requested but __THREE_GAME_TEST_HOOKS__ is not defined; capturing the current state instead',
      );
    }
  }

  await page.waitForTimeout(args.wait);

  const mode = args.mobile ? 'mobile' : 'desktop';
  const baseName = args.state ? `${mode}-${args.state}` : mode;
  const gpu = await readGpuInfo(page);
  const result = await sampleCanvas(page, mode);
  const screenshotPath = path.join(args.out, `${baseName}.png`);
  await page.screenshot({ path: screenshotPath, fullPage: true });

  if (gpu.softwareRendered) {
    console.error(
      `warning: this run rasterized on ${gpu.renderer} (software). Pixel and budget ` +
        'checks remain valid; any FPS or frame-time reading from it does not.',
    );
  }

  const report = {
    url: args.url,
    mode,
    state: args.state,
    seed: args.seed ?? null,
    screenshotPath,
    gpu,
    result,
    consoleErrors,
    pageErrors,
  };

  await writeFile(path.join(args.out, `${baseName}.json`), `${JSON.stringify(report, null, 2)}\n`);
  await browser.close();

  console.log(JSON.stringify(report, null, 2));

  if (!result.ok || consoleErrors.length > 0 || pageErrors.length > 0) {
    process.exit(1);
  }
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});