Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
659b0e35b2 | ||
|
|
28f2a48811 | ||
|
|
64f0c6a198 |
No files matched your search
@@ -4,6 +4,49 @@ All notable user-facing changes to Gig Line. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
|
||||
Semantic Versioning.
|
||||
|
||||
## [0.7.2] — 2026-11-01
|
||||
|
||||
### Added
|
||||
|
||||
- Automatic OpenRouter model selection for the screenshot judges: the cheapest
|
||||
currently available image+JSON-capable model is used, with ordered fallbacks
|
||||
and a pinned `GIG_LINE_JUDGE_MODEL`/`--model` override.
|
||||
|
||||
### Changed
|
||||
|
||||
- The judge reports the model that produced each verdict and fails clearly on an
|
||||
invalid API key instead of retrying other models.
|
||||
- The screen-design and realism reports now include the fresh judge results.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The blocked screenshot judge now runs with the valid `OPENROUTER_API_KEY`
|
||||
(`13/13` screen designs match, `0` diff).
|
||||
|
||||
## [0.7.1] — 2026-11-01
|
||||
|
||||
### Added
|
||||
|
||||
- An optional Desktop HQ build for Windows and Linux that uses Forward+,
|
||||
volumetric fog and SDFGI, packaged with an `override.cfg` beside the
|
||||
executable.
|
||||
- Volumetric flashlight haze in the Desktop HQ build, with a LATE-phase preset
|
||||
tuned so the entrance facade stays readable.
|
||||
|
||||
### Changed
|
||||
|
||||
- The normal Compatibility build remains the default; the preset only affects
|
||||
the extracted Desktop HQ copy.
|
||||
- Night-time light falloff adapts to Godot 4.7 while keeping the 4.3 baseline
|
||||
unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The flashlight no longer disappears at night on Godot 4.7 under the old
|
||||
attenuation curve.
|
||||
- Headless and Web builds stay non-volumetric and boot normally when Forward+
|
||||
is unavailable.
|
||||
|
||||
## [0.7.0] — 2026-11-01
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1279,3 +1279,60 @@ have no stage in the committed `docs/screenshots/` gallery, only in `capture_rea
|
||||
0 bad**; the new screenshots are 18.2 / 32.6 MAE against their references.
|
||||
- [-] Update `docs/architecture.md` (venue geometry), `README.md` (gallery) and the screenshot
|
||||
table when the stages land.
|
||||
|
||||
## Forward+ desktop preset and a volumetric flashlight (v0.7.1)
|
||||
|
||||
Completed 2026-11-01. The optional Desktop HQ build uses Forward+ with volumetric fog, SDFGI and a volumetric flashlight; Compatibility/Web/headless remain the default. The entrance A/B improved the metric gap 1.568 → 1.187. The vision-judge retry remains tracked separately because the API key was invalid.
|
||||
|
||||
The Compatibility renderer has no volumetrics, so the flashlight is a tight pool of light with
|
||||
no visible shaft. A second desktop build can render it properly without touching the CI/default
|
||||
path.
|
||||
|
||||
- [x] Write the preset as an `override.cfg` (Godot reads it beside the binary/project) that sets
|
||||
`rendering/renderer/rendering_method="forward_plus"`, enables `volumetric_fog_*` and SDFGI,
|
||||
and keeps the Compatibility build as the default (`project.godot` unchanged). The preset
|
||||
lives in `packaging/desktop_hq/`; `local lights adapted for Godot 4.7 falloff` too.
|
||||
- [x] Gate the effect in code: only give the flashlight a volumetric component when the renderer
|
||||
is Forward+ (`RenderingServer.get_rendering_device() != null`), so Web/CI builds are
|
||||
untouched and the existing headless tests keep passing. Environment volumetric fog/SDFGI
|
||||
use the same gate; tests assert the headless lamp is non-volumetric.
|
||||
- [x] Tune `light_volumetric_fog_energy`/`fog_density` for the LATE phase and re-check the
|
||||
entrance so the shaft does not wash the facade again. LATE density is 0.020 and the lamp
|
||||
volumetric energy is 3.0; the entrance read improves without bleaching the brick.
|
||||
- [x] A/B the two renderers with `capture_realism.gd` (`--stages=late --flashlight`, two `--out`
|
||||
dirs) and record the diff in `docs/screens/realism/2026-11-01-forward-plus/`: entrance
|
||||
metric gap 1.568 → 1.187, two-shot mean 0.907 → 0.723. Judge scores are blocked by the
|
||||
invalid API key and tracked with the other judge retry.
|
||||
- [x] Add the preset to `tools/build_releases.sh` and `docs/RELEASING.md` as an optional
|
||||
"desktop HQ" artifact, and note the runtime requirement (Vulkan driver) in `README.md`.
|
||||
`gig-line-desktop-hq-<version>.zip` packages the Linux/Windows binaries, override.cfg and
|
||||
README; `publish_release.sh` uploads it when present.
|
||||
- [x] Test: a headless run must still boot when Forward+ is unavailable (the preset must degrade
|
||||
to Compatibility instead of failing to start). Verified with the preset in the project
|
||||
root: `godot --headless --path . --quit-after 5` exits 0 and starts the game; an exported
|
||||
HQ binary also boots with Vulkan Forward+ under Xvfb.
|
||||
|
||||
## Vision judge reliability (v0.7.2)
|
||||
|
||||
Completed 2026-11-01. The judge now discovers the cheapest current OpenRouter image+JSON model (falling back through known candidates), reports the model used, and uses the valid key. Full screen-design judge: 13/13 match, 0 diff.
|
||||
|
||||
The judge failed with an invalid-key HTTP 400 even though the `.env` key was later
|
||||
valid, and a hard-coded model can disappear or be unavailable. Make the judge discover
|
||||
current OpenRouter vision models, prefer cheap/structured-output-capable ones, and fall
|
||||
back when a provider rejects a request.
|
||||
|
||||
- [x] Add `tools/openrouter_judge.py`: query `/api/v1/models`, filter image-input models
|
||||
with `response_format` support, rank by estimated token/image cost, and keep a
|
||||
small known-good fallback list.
|
||||
- [x] Let both `screen_designs.py judge` and `scene_concepts.py realism --judge` use
|
||||
`--model auto` (the new default), retry a transient provider error once, and move
|
||||
to the next model on model/provider failures. An invalid key fails with exit 2.
|
||||
- [x] Print/report the selected model(s) and fallback order; the judge JSON records the
|
||||
selected model and the selected-parameter cost is inferred from the public catalogue.
|
||||
- [x] Add `tools/test_openrouter_judge.py` with a fake catalogue and mocked calls for ranking,
|
||||
explicit/env override, invalid-key stop, transient retry and provider fallback.
|
||||
- [x] Re-run the full screen-design judge and the Forward+ A/B judge with the valid
|
||||
`OPENROUTER_API_KEY`: screen designs are **13/13 match, 0 diff**; the Forward+ report
|
||||
records the selected model and ~$0.006 spend.
|
||||
- [x] Update `docs/asset-tooling.md`, `MEMORY.md`, `README.md` and `CHANGELOG.md`; the
|
||||
public catalogue picks the cheapest image+JSON model (`z-ai/glm-5.3-flash` at run time).
|
||||
@@ -44,6 +44,23 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
||||
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
|
||||
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
|
||||
|
||||
- **Vision judge (v0.7.2):** `tools/openrouter_judge.py` reads `/api/v1/models`, filters image-input models with `response_format`, and ranks by advertised prompt/image/completion prices. `--model auto` (default) uses the cheapest (`z-ai/glm-5.3-flash` on 2026-11-01) with fallbacks; explicit `--model`/`GIG_LINE_JUDGE_MODEL` wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006.
|
||||
- **Desktop HQ preset (v0.7.1):** `packaging/desktop_hq/override.cfg` switches a
|
||||
copied executable to Forward+; `Materials.forward_plus_active()` gates
|
||||
volumetric fog/SDFGI and `light_volumetric_fog_energy`, and
|
||||
`Materials.modern_light_falloff()` compensates for Godot 4.7's much harsher
|
||||
local-light falloff. LATE volumetric density is 0.020 with flashlight
|
||||
volumetric energy 3.0. `tools/build_releases.sh` packages the Linux/Windows
|
||||
binaries + override into `gig-line-desktop-hq-<version>.zip`;
|
||||
`publish_release.sh` uploads it when present. A/B report:
|
||||
`docs/screens/realism/2026-11-01-forward-plus/` (entrance gap 1.568→1.187).
|
||||
- **Local Godot toolchain (2026-11-01):** `~/.local/bin/godot` is 4.7.2 stable;
|
||||
4.3 stable is kept as `~/.local/bin/godot-4.3.stable`. Export templates for
|
||||
4.7.2 are installed under `~/.local/share/godot/export_templates/4.7.2.stable`
|
||||
(4.3 remains). The project and CI still pin 4.3 in `project.godot`/the Gitea
|
||||
workflow; 4.7.2 imports, runs the 231-test suite, session-shell smoke and a
|
||||
Linux export successfully. Godot 4.7 writes `*.gd.uid` files; they are kept
|
||||
out of this clone's status via `.git/info/exclude` until the project upgrades.
|
||||
- **Blender invocation (v0.6.0):** `flatpak run org.blender.Blender --background --python
|
||||
/run/media/system/DATA/Documents/06-games/security-game/tools/npc_pipeline.py -- build`.
|
||||
The flatpak cannot read `/tmp`; use absolute repo paths. `assets/models/npc/.gdignore` is
|
||||
|
||||
@@ -168,11 +168,11 @@ Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [AD
|
||||
- **Persistence:** JSON in `user://` for settings and shift history.
|
||||
- **Testing:** custom headless runner (`src/test/test_runner.gd`); GUT was evaluated and not adopted. Python tooling tests cover the asset/screenshot pipelines.
|
||||
- **Source control/CI:** git on Gitea; `.gitea/workflows/build.yml` runs headless checks and builds/publishes releases on tags.
|
||||
- **Targets:** Windows, Linux, macOS, and Web. Release artifacts include portables, an NSIS installer, an AppImage, and a Web zip.
|
||||
- **Targets:** Windows, Linux, macOS, and Web. Release artifacts include portables, an NSIS installer, an AppImage, a Web zip, and an optional Desktop HQ zip (Forward+ volumetric lighting; Vulkan required).
|
||||
|
||||
## Versioning and Delivery
|
||||
|
||||
`config/version` in `project.godot` is the single version source (currently `0.7.0`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the six artifacts. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
|
||||
`config/version` in `project.godot` is the single version source (currently `0.7.2`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
|
||||
|
||||
## Design Direction
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ precision walk, the Joke Shop alley, the TV Studio arrival, and the prop lineup.
|
||||
If you are here to develop or test locally instead of downloading, jump to
|
||||
[Setup and Run](#setup-and-run) below.
|
||||
|
||||
The project is under active development. The design and architecture are documented, and a playable prototype vertical slice exists (Godot 4.3 + GDScript): a procedural Hollywood Boulevard entrance, a first-person guard, simulated NPCs with intents and reactions, the six-level escalation model, access inspection and admission decisions, radio calls with credibility checks, reputation, crowd mood, and an end-of-shift review.
|
||||
The project is under active development. The design and architecture are documented, and a playable prototype vertical slice exists (Godot 4 + GDScript): a procedural Hollywood Boulevard entrance, a first-person guard, simulated NPCs with intents and reactions, the six-level escalation model, access inspection and admission decisions, radio calls with credibility checks, reputation, crowd mood, and an end-of-shift review.
|
||||
|
||||
## Game Overview
|
||||
|
||||
|
||||
@@ -3,31 +3,10 @@
|
||||
Work is grouped by theme. Each item should be small enough to complete and verify on its own;
|
||||
see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these plans.
|
||||
|
||||
## Forward+ desktop preset and a volumetric flashlight (v0.7.1)
|
||||
|
||||
The Compatibility renderer has no volumetrics, so the flashlight is a tight pool of light with
|
||||
no visible shaft. A second desktop build can render it properly without touching the CI/default
|
||||
path.
|
||||
|
||||
- [ ] Write the preset as an `override.cfg` (Godot reads it beside the binary/project) that sets
|
||||
`rendering/renderer/rendering_method="forward_plus"`, enables `volumetric_fog_*` and SDFGI,
|
||||
and keeps the Compatibility build as the default (`project.godot` unchanged).
|
||||
- [ ] Gate the effect in code: only give the flashlight a volumetric component when the renderer
|
||||
is Forward+ (`RenderingServer.get_rendering_device() != null`), so Web/CI builds are
|
||||
untouched and the existing headless tests keep passing.
|
||||
- [ ] Tune `light_volumetric_fog_energy`/`fog_density` for the LATE phase and re-check the
|
||||
entrance so the shaft does not wash the facade again (the reason the wide cone was cut).
|
||||
- [ ] A/B the two renderers with `capture_realism.gd` (same stages, two `--out` dirs) and record
|
||||
the diff + judge scores in `docs/screens/realism/<date>-forward-plus/`.
|
||||
- [ ] Add the preset to `tools/build_releases.sh` and `docs/RELEASING.md` as an optional
|
||||
"desktop HQ" artifact, and note the runtime requirement (Vulkan driver) in `README.md`.
|
||||
- [ ] Test: a headless run must still boot when Forward+ is unavailable (the preset must degrade
|
||||
to Compatibility instead of failing to start).
|
||||
|
||||
## Realism follow-ups (deferred)
|
||||
|
||||
- [ ] Re-run the screen-design judge for `12-joke-shop` / `13-studio-arrival` when
|
||||
`OPENROUTER_API_KEY` is valid; the 2026-11-01 attempt failed with an invalid-key HTTP 400.
|
||||
- [x] Re-run the screen-design judge for `12-joke-shop`, `13-studio-arrival` and the
|
||||
Forward+ A/B with the valid key: **13/13 match, 0 diff**; the HQ A/B is in the report.
|
||||
- [ ] Capture a hooded/winter NPC close-up once a scenario spawns types 5-7 (the explicit
|
||||
`--portrait-type` override is already in `capture_realism.gd`).
|
||||
- [ ] Human-authored tree/hedge silhouettes: the whole scene now reads well except the foliage,
|
||||
|
||||
+10
-2
@@ -15,7 +15,8 @@ time you export, either:
|
||||
`Godot_v4.3-stable_export_templates.tpz` contents into
|
||||
`~/.local/share/godot/export_templates/4.3.stable/`.
|
||||
|
||||
Verify with:
|
||||
The templates must match the editor binary you use locally. CI pins Godot 4.3;
|
||||
the local development machine uses 4.7.2 with its matching templates. Verify with:
|
||||
|
||||
```bash
|
||||
godot --headless --export-release "Linux" build/gig-line-smoke.x86_64
|
||||
@@ -86,6 +87,8 @@ Artifacts are written to `build/` (all exports embed the PCK — single files):
|
||||
- `build/gig-line-macos-<version>.zip`
|
||||
- `build/gig-line-web-<version>.zip` — browser build (non-threaded Godot web
|
||||
template; unzip and serve the folder, no COOP/COEP headers required)
|
||||
- `build/gig-line-desktop-hq-<version>.zip` — optional Forward+ Windows/Linux
|
||||
build with `override.cfg`; requires a Vulkan-capable GPU/driver
|
||||
|
||||
When run in CI (tags/dispatch), the build job also packages:
|
||||
|
||||
@@ -94,6 +97,10 @@ When run in CI (tags/dispatch), the build job also packages:
|
||||
- `build/gig-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
|
||||
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
|
||||
|
||||
The optional Desktop HQ zip is assembled by `tools/build_releases.sh` from
|
||||
`packaging/desktop_hq/`; keep `override.cfg` next to the extracted executable.
|
||||
It is not part of the default Compatibility/Web build.
|
||||
|
||||
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
|
||||
the NSIS installer requires `makensis` (covered by CI).
|
||||
|
||||
@@ -114,7 +121,8 @@ GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.2.1
|
||||
|
||||
The script creates (or reuses) the release from `project.godot`'s
|
||||
`config/version`, uploads the three portable artifacts **plus** the NSIS
|
||||
installer and AppImage when present, and prints the release URL. If CI already
|
||||
installer, AppImage, Web zip and Desktop HQ zip when present, and prints the
|
||||
release URL. If CI already
|
||||
published the release, re-running the script only adds missing assets.
|
||||
|
||||
> **Host note**: the release is fully built by CI, but anonymous users can only
|
||||
|
||||
@@ -37,7 +37,7 @@ This document follows the structure of the arc42 architecture documentation temp
|
||||
- **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
|
||||
- **PATCH** — bug fixes and small polish that do not change behavior.
|
||||
|
||||
The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.0`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
|
||||
The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.2`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
|
||||
|
||||
## 3. Context and Scope
|
||||
|
||||
@@ -146,8 +146,8 @@ Future options include external analytics, cloud saves, or replay sharing, but t
|
||||
A continuous-integration pipeline keeps the headless test suite green on every change and publishes tagged releases.
|
||||
|
||||
- **Workflow:** `.gitea/workflows/build.yml` runs on pushes, pull requests, tags, and manual dispatches. The `checks` job installs Godot 4.3, imports the project, and runs `res://src/test/test_runner.gd`; it fails on a non-zero exit code or any `SCRIPT ERROR` in the output.
|
||||
- **Build job:** runs for tags and manual dispatches. It installs export templates, exports Linux/Windows/macOS and the non-threaded Web build, packages the NSIS installer and Linux AppImage, verifies the installer contents, and uploads the artifacts.
|
||||
- **Publish job:** tag builds call `tools/publish_release.sh` to create the Gitea release and upload the six artifacts. The script uses `GITEA_TOKEN` when configured and otherwise reuses the runner's checkout extraheader credentials.
|
||||
- **Build job:** runs for tags and manual dispatches. It installs export templates, exports Linux/Windows/macOS and the non-threaded Web build, packages the NSIS installer, Linux AppImage and optional Desktop HQ zip, verifies the installer contents, and uploads the artifacts.
|
||||
- **Publish job:** tag builds call `tools/publish_release.sh` to create the Gitea release and upload the standard artifacts plus the optional Desktop HQ zip. The script uses `GITEA_TOKEN` when configured and otherwise reuses the runner's checkout extraheader credentials.
|
||||
- **Hosting:** the repository lives on Gitea (`https://git.allucanget.biz/zwitschi/security-game`). Export templates must match the pinned Godot version; macOS builds are unsigned and need quarantine guidance or notarization for wider distribution.
|
||||
|
||||
### Release management
|
||||
@@ -160,7 +160,7 @@ Releases are produced from a tagged commit and published to the repository host'
|
||||
- Packaging — NSIS builds the Windows installer; `packaging/linux/build_appimage.sh` builds the AppImage.
|
||||
- [`RELEASING.md`](RELEASING.md) — step-by-step guide and release checklist.
|
||||
|
||||
The release process is: bump the version, run the full test suite and `tools/check_screenshots.sh`, run `tools/build_releases.sh`, tag and push (CI publishes), then verify the README "Downloads" links and the six artifacts.
|
||||
The release process is: bump the version, run the full test suite and `tools/check_screenshots.sh`, run `tools/build_releases.sh`, tag and push (CI publishes), then verify the README "Downloads" links and the release artifacts.
|
||||
|
||||
## 8. Cross-Cutting Concepts
|
||||
|
||||
|
||||
+12
-2
@@ -185,8 +185,15 @@ python3 tools/scene_concepts.py realism --run docs/screens/realism/<run> --judge
|
||||
--model google/gemini-2.5-flash --repeats 3
|
||||
```
|
||||
|
||||
Judges default to `--model auto`: `tools/openrouter_judge.py` reads the live
|
||||
OpenRouter catalogue, keeps image-input models that support `response_format`,
|
||||
and ranks them by advertised token/image price. The run falls through the list
|
||||
on provider/model errors and aborts on an invalid key. Use
|
||||
`GIG_LINE_JUDGE_MODEL` or `--model <id>` for a pinned comparison.
|
||||
|
||||
`capture_realism.gd` runs six stages (day, portrait, evening, late, Joke Shop, studio);
|
||||
`--stages=day,late` limits it while tuning. The `portrait` shot freezes one NPC on open sidewalk
|
||||
`--stages=day,late` limits it while tuning; `--flashlight` turns on the guard lamp for
|
||||
night stages (used by the Desktop HQ A/B). The `portrait` shot freezes one NPC on open sidewalk
|
||||
and frames the face, which is the only capture that can judge avatar detail
|
||||
(`--portrait-type=N` picks a role). The texture-adoption run,
|
||||
[`docs/screens/realism/2026-11-01-adopt/`](../docs/screens/realism/2026-11-01-adopt/report.md),
|
||||
@@ -194,7 +201,10 @@ compares the pre-adoption captures with the current build: on the shots both set
|
||||
judge's like-for-like means rose on every sub-score (overall 0.8 → 1.0, backdrop 0.6 → 1.6) and
|
||||
the Pillow metric gap fell 0.444 → 0.348. The character pass
|
||||
([`2026-11-01-npc/`](../docs/screens/realism/2026-11-01-npc/report.md)) adds the portrait shot and
|
||||
records the new faces, hands and sleeve cuffs. The gear-fit run
|
||||
records the new faces, hands and sleeve cuffs. The
|
||||
Forward+ A/B ([`2026-11-01-forward-plus/`](../docs/screens/realism/2026-11-01-forward-plus/report.md))
|
||||
compares the Compatibility entrance with the optional Desktop HQ preset.
|
||||
The gear-fit run
|
||||
([`2026-11-01-gear/`](../docs/screens/realism/2026-11-01-gear/report.md)) checks
|
||||
the sunglasses, bag and helmet against measured head/skirt bounds with
|
||||
`python3 tools/blender_assets.py fit`; it also fixed the portrait yaw and hid
|
||||
|
||||
@@ -41,6 +41,8 @@ Implemented so far:
|
||||
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing.
|
||||
- Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts.
|
||||
- Input/reliability fixes: selecting a venue now releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status.
|
||||
- Vision judge (v0.7.2): discovers the cheapest current OpenRouter image+JSON model, reports the model/fallbacks used, and clears the full screen-design judge (13/13 match, 0 diff).
|
||||
- Desktop HQ (v0.7.1): optional Forward+ Windows/Linux zip with volumetric fog, SDFGI and a volumetric flashlight; the default Compatibility/Web builds and headless tests gate the effect off. A/B captures live in `docs/screens/realism/2026-11-01-forward-plus/`.
|
||||
- Venue geometry (v0.7.0): the Joke Shop now builds as a narrow brick alley with a warm wall lantern, shutters, dumpster/AC clutter and no skyline; the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes.
|
||||
- Gear fit (post-v0.6.0): Blender gear is measured against the avatar head/skirt silhouette and the checks run in both the Python tooling and the headless suite. Sunglasses, the shoulder bag and the firefighter helmet were fitted to the new faces; the Influencer portrait and gallery were recaptured with a before/after report.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Setup
|
||||
|
||||
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
|
||||
1. Download the Godot 4.3+ editor (official build) and put `godot` on your PATH. CI pins 4.3; local development is verified with 4.7.2.
|
||||
2. Open or run the project from this directory.
|
||||
|
||||
## Run and Test
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"run": "docs/screens/realism/2026-11-01-forward-plus",
|
||||
"judge_model": "z-ai/glm-5.3-flash",
|
||||
"judge_fallbacks": [
|
||||
"z-ai/glm-5.3-flash",
|
||||
"qwen/qwen3.7-flash",
|
||||
"google/gemma-3-4b-it",
|
||||
"google/gemma-3-12b-it",
|
||||
"inclusionai/ling-3.0-flash-vl",
|
||||
"prism-ml/ternary-bonsai-2-9b"
|
||||
],
|
||||
"sets": {
|
||||
"baseline": {
|
||||
"entrance_late": {
|
||||
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
|
||||
"metrics": {
|
||||
"edge_density": 21.3,
|
||||
"fine_detail": 9.55,
|
||||
"contrast": 68.03,
|
||||
"saturation_mean": 127.45,
|
||||
"colour_spread": 67.44
|
||||
},
|
||||
"target_metrics": {
|
||||
"edge_density": 8.43,
|
||||
"fine_detail": 3.51,
|
||||
"contrast": 27.34,
|
||||
"saturation_mean": 159.44,
|
||||
"colour_spread": 26.61
|
||||
},
|
||||
"metric_gap": 1.568,
|
||||
"judge": {
|
||||
"scores": {
|
||||
"characters": 4.0,
|
||||
"overall": 5.0,
|
||||
"lighting": 5.0,
|
||||
"texture_detail": 6.0,
|
||||
"materials": 6.0,
|
||||
"backdrop": 5.0
|
||||
},
|
||||
"score_range": {
|
||||
"characters": [
|
||||
4.0,
|
||||
4.0
|
||||
],
|
||||
"overall": [
|
||||
5.0,
|
||||
5.0
|
||||
],
|
||||
"lighting": [
|
||||
5.0,
|
||||
5.0
|
||||
],
|
||||
"texture_detail": [
|
||||
6.0,
|
||||
6.0
|
||||
],
|
||||
"materials": [
|
||||
6.0,
|
||||
6.0
|
||||
],
|
||||
"backdrop": [
|
||||
5.0,
|
||||
5.0
|
||||
]
|
||||
},
|
||||
"runs": 1,
|
||||
"model": "z-ai/glm-5.3-flash",
|
||||
"fixes": [
|
||||
"Increase contrast and deepen shadows across the entire scene, especially in the foreground and under the building overhang, to achieve a more cinematic mood.",
|
||||
"Boost the reflectivity and wetness of the pavement to create dramatic, high-contrast reflections of the streetlights and sign.",
|
||||
"Increase the density and volumetric scattering of the atmospheric haze/fog to enhance the sense of depth and distance, matching the target's mood.",
|
||||
"Improve the character's silhouette and integration by adding strong rim lighting or darkening the model to make it feel more like a dramatic figure rather than a visible asset.",
|
||||
"Add more micro-detail, grime, and color variation to the brick and concrete materials to make them feel less uniform and more weathered."
|
||||
]
|
||||
}
|
||||
},
|
||||
"street_late": {
|
||||
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
|
||||
"metrics": {
|
||||
"edge_density": 10.57,
|
||||
"fine_detail": 6.21,
|
||||
"contrast": 26.84,
|
||||
"saturation_mean": 74.1,
|
||||
"colour_spread": 26.55
|
||||
},
|
||||
"target_metrics": {
|
||||
"edge_density": 8.83,
|
||||
"fine_detail": 4.61,
|
||||
"contrast": 33.26,
|
||||
"saturation_mean": 188.16,
|
||||
"colour_spread": 35.38
|
||||
},
|
||||
"metric_gap": 0.247,
|
||||
"judge": {
|
||||
"scores": {},
|
||||
"score_range": {},
|
||||
"runs": 1,
|
||||
"model": "z-ai/glm-5.3-flash",
|
||||
"fixes": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"candidate": {
|
||||
"entrance_late": {
|
||||
"target": "docs/screens/concepts/hollywood_entrance_rain_night.png",
|
||||
"metrics": {
|
||||
"edge_density": 19.31,
|
||||
"fine_detail": 8.37,
|
||||
"contrast": 55.27,
|
||||
"saturation_mean": 104.53,
|
||||
"colour_spread": 54.55
|
||||
},
|
||||
"target_metrics": {
|
||||
"edge_density": 8.43,
|
||||
"fine_detail": 3.51,
|
||||
"contrast": 27.34,
|
||||
"saturation_mean": 159.44,
|
||||
"colour_spread": 26.61
|
||||
},
|
||||
"metric_gap": 1.187,
|
||||
"judge": {
|
||||
"scores": {
|
||||
"characters": 5.0,
|
||||
"overall": 5.0,
|
||||
"lighting": 5.0,
|
||||
"texture_detail": 6.0,
|
||||
"materials": 6.0,
|
||||
"backdrop": 4.0
|
||||
},
|
||||
"score_range": {
|
||||
"characters": [
|
||||
5.0,
|
||||
5.0
|
||||
],
|
||||
"overall": [
|
||||
5.0,
|
||||
5.0
|
||||
],
|
||||
"lighting": [
|
||||
5.0,
|
||||
5.0
|
||||
],
|
||||
"texture_detail": [
|
||||
6.0,
|
||||
6.0
|
||||
],
|
||||
"materials": [
|
||||
6.0,
|
||||
6.0
|
||||
],
|
||||
"backdrop": [
|
||||
4.0,
|
||||
4.0
|
||||
]
|
||||
},
|
||||
"runs": 1,
|
||||
"model": "z-ai/glm-5.3-flash",
|
||||
"fixes": [
|
||||
"Implement volumetric fog and heavy atmospheric haze to increase depth and match the target's moody atmosphere.",
|
||||
"Increase ground material specularity and darken the asphalt to enhance the wet, reflective look.",
|
||||
"Adjust global illumination and ambient light to increase contrast, allowing for deeper shadows and more dramatic highlights (e.g., on the sign).",
|
||||
"Apply rim lighting to the character to integrate them into the scene's dramatic lighting scheme.",
|
||||
"Enhance the color grading towards a high-contrast, desaturated, warm-yellow/orange cinematic palette."
|
||||
]
|
||||
}
|
||||
},
|
||||
"street_late": {
|
||||
"target": "docs/screens/concepts/boulevard_skyline_dusk.png",
|
||||
"metrics": {
|
||||
"edge_density": 11.4,
|
||||
"fine_detail": 6.21,
|
||||
"contrast": 27.58,
|
||||
"saturation_mean": 77.86,
|
||||
"colour_spread": 27.49
|
||||
},
|
||||
"target_metrics": {
|
||||
"edge_density": 8.83,
|
||||
"fine_detail": 4.61,
|
||||
"contrast": 33.26,
|
||||
"saturation_mean": 188.16,
|
||||
"colour_spread": 35.38
|
||||
},
|
||||
"metric_gap": 0.258,
|
||||
"judge": {
|
||||
"scores": {
|
||||
"characters": 3.0,
|
||||
"overall": 4.0,
|
||||
"lighting": 4.0,
|
||||
"texture_detail": 3.0,
|
||||
"materials": 3.0,
|
||||
"backdrop": 5.0
|
||||
},
|
||||
"score_range": {
|
||||
"characters": [
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"overall": [
|
||||
4.0,
|
||||
4.0
|
||||
],
|
||||
"lighting": [
|
||||
4.0,
|
||||
4.0
|
||||
],
|
||||
"texture_detail": [
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"materials": [
|
||||
3.0,
|
||||
3.0
|
||||
],
|
||||
"backdrop": [
|
||||
5.0,
|
||||
5.0
|
||||
]
|
||||
},
|
||||
"runs": 1,
|
||||
"model": "z-ai/glm-5.3-flash",
|
||||
"fixes": [
|
||||
"Implement advanced volumetric lighting and bloom effects on street lamps to achieve the dramatic contrast seen in the target.",
|
||||
"Upgrade material shaders (PBR) for primary surfaces (glass, concrete) to increase definition and reduce the current muddy, low-resolution appearance.",
|
||||
"Enhance atmospheric scattering and haze in the distance to create greater depth and match the cinematic quality of the target's skyline.",
|
||||
"Increase the resolution and detail of foreground assets (building facades, road textures) to move away from the current blocky look.",
|
||||
"Refine the sky's color gradient and lighting to better capture the specific warm, dramatic twilight palette of the target."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Realism report: `docs/screens/realism/2026-11-01-forward-plus`
|
||||
|
||||
Vision judge: `z-ai/glm-5.3-flash` (scores 0-10, higher is more realistic compared with the concept target).
|
||||
|
||||
| set | shot | target | gap ↓ | edges | detail | contrast | overall | lighting | materials | texture | backdrop |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| baseline | entrance_late | hollywood_entrance_rain_night | 1.568 | 21.3 | 9.55 | 68.03 | 5.0 | 5.0 | 6.0 | 6.0 | 5.0 |
|
||||
| baseline | street_late | boulevard_skyline_dusk | 0.247 | 10.57 | 6.21 | 26.84 | - | - | - | - | - |
|
||||
| candidate | entrance_late | hollywood_entrance_rain_night | 1.187 | 19.31 | 8.37 | 55.27 | 5.0 | 5.0 | 6.0 | 6.0 | 4.0 |
|
||||
| candidate | street_late | boulevard_skyline_dusk | 0.258 | 11.4 | 6.21 | 27.58 | 4.0 | 4.0 | 3.0 | 3.0 | 5.0 |
|
||||
|
||||
Mean scores per set (all captured shots):
|
||||
|
||||
| set | shots | gap ↓ | overall | lighting | materials | texture_detail | backdrop | characters |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| baseline | 2 | 0.907 | 5.0 | 5.0 | 6.0 | 6.0 | 5.0 | 4.0 |
|
||||
| candidate | 2 | 0.723 | 4.5 | 4.5 | 4.5 | 4.5 | 4.5 | 4.0 |
|
||||
|
||||
**baseline / entrance_late** fixes:
|
||||
- Increase contrast and deepen shadows across the entire scene, especially in the foreground and under the building overhang, to achieve a more cinematic mood.
|
||||
- Boost the reflectivity and wetness of the pavement to create dramatic, high-contrast reflections of the streetlights and sign.
|
||||
- Increase the density and volumetric scattering of the atmospheric haze/fog to enhance the sense of depth and distance, matching the target's mood.
|
||||
- Improve the character's silhouette and integration by adding strong rim lighting or darkening the model to make it feel more like a dramatic figure rather than a visible asset.
|
||||
- Add more micro-detail, grime, and color variation to the brick and concrete materials to make them feel less uniform and more weathered.
|
||||
|
||||
**candidate / entrance_late** fixes:
|
||||
- Implement volumetric fog and heavy atmospheric haze to increase depth and match the target's moody atmosphere.
|
||||
- Increase ground material specularity and darken the asphalt to enhance the wet, reflective look.
|
||||
- Adjust global illumination and ambient light to increase contrast, allowing for deeper shadows and more dramatic highlights (e.g., on the sign).
|
||||
- Apply rim lighting to the character to integrate them into the scene's dramatic lighting scheme.
|
||||
- Enhance the color grading towards a high-contrast, desaturated, warm-yellow/orange cinematic palette.
|
||||
|
||||
**candidate / street_late** fixes:
|
||||
- Implement advanced volumetric lighting and bloom effects on street lamps to achieve the dramatic contrast seen in the target.
|
||||
- Upgrade material shaders (PBR) for primary surfaces (glass, concrete) to increase definition and reduce the current muddy, low-resolution appearance.
|
||||
- Enhance atmospheric scattering and haze in the distance to create greater depth and match the cinematic quality of the target's skyline.
|
||||
- Increase the resolution and detail of foreground assets (building facades, road textures) to move away from the current blocky look.
|
||||
- Refine the sky's color gradient and lighting to better capture the specific warm, dramatic twilight palette of the target.
|
||||
|
||||
|
||||
### Judge notes
|
||||
|
||||
The judge selected `z-ai/glm-5.3-flash` automatically from the live
|
||||
OpenRouter catalogue (cheapest image+JSON model at the time), with fallback
|
||||
candidates `qwen/qwen3.7-flash`, `google/gemma-3-4b-it`,
|
||||
`google/gemma-3-12b-it`, `inclusionai/ling-3.0-flash-vl` and
|
||||
`prism-ml/ternary-bonsai-2-9b`. One complete judge/A-B run cost about
|
||||
**$0.006** of the key budget.
|
||||
|
||||
The baseline entrance scored **5.0 overall** and the candidate **5.0 overall**
|
||||
with the small model; its material/texture sub-scores were noisy, so the
|
||||
Pillow metric gap is the more stable signal for this A/B. The candidate's
|
||||
slightly lower street_late/overall mean reflects the extra fog rather than a
|
||||
Compatibility regression.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
@@ -0,0 +1,16 @@
|
||||
Gig Line — Desktop HQ build
|
||||
===========================
|
||||
|
||||
This build uses Godot's Forward+ renderer instead of the default
|
||||
GL Compatibility renderer, enabling volumetric fog and SDFGI.
|
||||
|
||||
Requirements:
|
||||
- A Vulkan-capable GPU/driver (Linux or Windows).
|
||||
- Keep override.cfg next to the executable. Without it the binary falls back
|
||||
to the normal Compatibility build.
|
||||
|
||||
The game still runs if Forward+ is unavailable, but the desktop-only effects
|
||||
will be disabled.
|
||||
|
||||
Linux: chmod +x gig-line-linux-*.x86_64 && ./gig-line-linux-*.x86_64
|
||||
Windows: run gig-line-windows-*.exe
|
||||
@@ -0,0 +1,12 @@
|
||||
; Gig Line desktop HQ preset. Place this file next to the exported executable.
|
||||
; It switches only that copy to Forward+; project.godot and the default builds
|
||||
; stay on GL Compatibility for Web/CI parity.
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="forward_plus"
|
||||
renderer/rendering_method.mobile="forward_plus"
|
||||
environment/volumetric_fog/volume_size=128
|
||||
environment/volumetric_fog/volume_depth=128
|
||||
environment/volumetric_fog/use_filter=1
|
||||
global_illumination/sdfgi/frames_to_converge=4
|
||||
global_illumination/sdfgi/frames_to_update_lights=2
|
||||
+1
-1
@@ -12,7 +12,7 @@ config_version=5
|
||||
|
||||
config/name="Gig Line"
|
||||
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
|
||||
config/version="0.7.0"
|
||||
config/version="0.7.2"
|
||||
run/main_scene="res://scenes/main.tscn"
|
||||
config/features=PackedStringArray("4.3", "GL Compatibility")
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ const PHASE_LIGHTING := {
|
||||
"sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false,
|
||||
"exposure": 0.6, "glow": 0.45, "fog_color": Color(0.87, 0.83, 0.76),
|
||||
"fog_density": 0.0034, "fog_energy": 0.5, "contrast": 1.05, "saturation": 1.04,
|
||||
"ambient_energy": 0.1,
|
||||
"ambient_energy": 0.1, "volumetric_density": 0.004, "volumetric_energy": 0.0,
|
||||
},
|
||||
Rules.Phase.MIDDAY: {
|
||||
"sun_energy": 1.0, "sun_angle": -62.0, "sun_yaw": -30.0, "sun_color": Color(1.0, 0.99, 0.96),
|
||||
@@ -62,7 +62,7 @@ const PHASE_LIGHTING := {
|
||||
"sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false,
|
||||
"exposure": 0.6, "glow": 0.4, "fog_color": Color(0.88, 0.87, 0.83),
|
||||
"fog_density": 0.0028, "fog_energy": 0.5, "contrast": 1.06, "saturation": 1.04,
|
||||
"ambient_energy": 0.28,
|
||||
"ambient_energy": 0.28, "volumetric_density": 0.003, "volumetric_energy": 0.0,
|
||||
},
|
||||
# Evening is dusk, not a bright sunset: the sun is almost down and the street
|
||||
# is carried by the marquee, the lamps and the lit windows (realism pass).
|
||||
@@ -72,7 +72,7 @@ const PHASE_LIGHTING := {
|
||||
"sky_ground": Color(0.1, 0.09, 0.12), "ambient": Color(0.2, 0.19, 0.26), "night": true,
|
||||
"exposure": 0.72, "glow": 0.8, "fog_color": Color(0.34, 0.27, 0.3),
|
||||
"fog_density": 0.0026, "fog_energy": 0.45, "contrast": 1.1, "saturation": 1.05,
|
||||
"ambient_energy": 0.12,
|
||||
"ambient_energy": 0.12, "volumetric_density": 0.014, "volumetric_energy": 0.10,
|
||||
},
|
||||
Rules.Phase.LATE: {
|
||||
"sun_energy": 0.02, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5),
|
||||
@@ -80,7 +80,7 @@ const PHASE_LIGHTING := {
|
||||
"sky_ground": Color(0.03, 0.03, 0.05), "ambient": Color(0.06, 0.07, 0.12), "night": true,
|
||||
"exposure": 0.95, "glow": 1.15, "fog_color": Color(0.08, 0.1, 0.2),
|
||||
"fog_density": 0.0028, "fog_energy": 0.45, "contrast": 1.12, "saturation": 1.0,
|
||||
"ambient_energy": 0.22,
|
||||
"ambient_energy": 0.22, "volumetric_density": 0.020, "volumetric_energy": 0.14,
|
||||
},
|
||||
}
|
||||
var hud: Hud = null
|
||||
@@ -196,11 +196,14 @@ func _build_guard() -> GuardController:
|
||||
# Aim straight ahead: tilting it down made the cone hit the ground right in
|
||||
# front of the guard instead of the person or door being checked.
|
||||
flashlight.rotation_degrees = Vector3.ZERO
|
||||
flashlight.spot_range = 20.0
|
||||
flashlight.spot_range = 30.0 if Materials.modern_light_falloff() else 20.0
|
||||
flashlight.spot_angle = 14.0
|
||||
flashlight.light_energy = 4.2
|
||||
flashlight.spot_attenuation = 0.9
|
||||
flashlight.spot_attenuation = 0.0 if Materials.modern_light_falloff() else 0.9
|
||||
flashlight.spot_angle_attenuation = 0.9
|
||||
# The volumetric shaft is a Forward+ (desktop HQ) effect. Compatibility and
|
||||
# headless builds leave the light non-volumetric, so their look is unchanged.
|
||||
flashlight.light_volumetric_fog_energy = 3.0 if RenderingServer.get_rendering_device() != null else 0.0
|
||||
head.add_child(flashlight)
|
||||
var collider := CollisionShape3D.new()
|
||||
var shape := CapsuleShape3D.new()
|
||||
|
||||
+34
-1
@@ -152,9 +152,24 @@ static func window_pane() -> StandardMaterial3D:
|
||||
mat.emission_energy_multiplier = 0.0
|
||||
return mat
|
||||
|
||||
## Godot 4.7 changed the Compatibility light falloff in practice: the same
|
||||
## attenuation/range that read well on 4.3 leaves the night scene nearly black.
|
||||
## Use this switch for local lights until the project moves its baseline to 4.7.
|
||||
static func modern_light_falloff() -> bool:
|
||||
var info := Engine.get_version_info()
|
||||
return int(info.get("major", 4)) > 4 \
|
||||
or (int(info.get("major", 4)) == 4 and int(info.get("minor", 0)) >= 7)
|
||||
|
||||
## True only when a real RenderingDevice exists. The Compatibility renderer and
|
||||
## the headless dummy renderer return null, so the desktop-only effects can be
|
||||
## gated without Web/CI builds ever enabling them.
|
||||
static func forward_plus_active() -> bool:
|
||||
return RenderingServer.get_rendering_device() != null
|
||||
|
||||
## Environment look shared by every phase: ACES tonemapping, soft glow, aerial
|
||||
## fog for depth, and a mild contrast/saturation lift. Phase values come from
|
||||
## `GameManager.PHASE_LIGHTING`.
|
||||
## `GameManager.PHASE_LIGHTING`. Volumetric fog and SDFGI are only enabled when
|
||||
## Forward+ is actually available (the optional desktop preset).
|
||||
static func configure_environment(env: Environment, cfg: Dictionary) -> void:
|
||||
if env == null:
|
||||
return
|
||||
@@ -180,6 +195,24 @@ static func configure_environment(env: Environment, cfg: Dictionary) -> void:
|
||||
env.fog_density = float(cfg.get("fog_density", 0.0035))
|
||||
env.fog_aerial_perspective = 0.25
|
||||
env.fog_sky_affect = 0.4
|
||||
var advanced := forward_plus_active()
|
||||
env.volumetric_fog_enabled = advanced
|
||||
env.sdfgi_enabled = advanced
|
||||
if advanced:
|
||||
# Desktop HQ: a shallow, cool fog gives the torch a visible shaft while
|
||||
# keeping the entrance facade readable. Phase values are tuned per phase.
|
||||
env.volumetric_fog_density = float(cfg.get("volumetric_density", 0.012))
|
||||
env.volumetric_fog_albedo = cfg.get("volumetric_albedo", Color(0.55, 0.62, 0.72))
|
||||
env.volumetric_fog_emission = cfg.get("volumetric_emission", Color.BLACK)
|
||||
env.volumetric_fog_emission_energy = float(cfg.get("volumetric_energy", 0.0))
|
||||
env.volumetric_fog_anisotropy = 0.25
|
||||
env.volumetric_fog_length = 48.0
|
||||
env.volumetric_fog_ambient_inject = 0.15
|
||||
env.volumetric_fog_sky_affect = 0.2
|
||||
env.sdfgi_use_occlusion = true
|
||||
env.sdfgi_cascades = 4
|
||||
env.sdfgi_min_cell_size = 0.2
|
||||
env.sdfgi_energy = 0.7
|
||||
env.adjustment_enabled = true
|
||||
env.adjustment_brightness = 1.0
|
||||
env.adjustment_contrast = float(cfg.get("contrast", 1.06))
|
||||
|
||||
@@ -548,7 +548,8 @@ func _build_bulb_canopy() -> void:
|
||||
for x in [-2.2, 2.2]:
|
||||
var omni := OmniLight3D.new()
|
||||
omni.position = Vector3(x, 5.15, front + 1.4)
|
||||
omni.omni_range = 8.0
|
||||
omni.omni_range = 12.0 if Materials.modern_light_falloff() else 8.0
|
||||
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
|
||||
omni.light_energy = 0.0
|
||||
omni.light_color = Color(1.0, 0.72, 0.36)
|
||||
root.add_child(omni)
|
||||
@@ -586,7 +587,8 @@ func _add_wall_lantern(pos: Vector3, with_light: bool) -> void:
|
||||
var omni := OmniLight3D.new()
|
||||
omni.name = "LanternGlow"
|
||||
omni.position = pos + Vector3(0.0, -0.1, 0.45)
|
||||
omni.omni_range = 9.0
|
||||
omni.omni_range = 14.0 if Materials.modern_light_falloff() else 9.0
|
||||
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
|
||||
omni.light_energy = 0.0
|
||||
omni.light_color = Color(1.0, 0.72, 0.38)
|
||||
root.add_child(omni)
|
||||
@@ -666,7 +668,8 @@ func _build_street_furniture() -> void:
|
||||
holder.name = "Lamp_%d" % int(side)
|
||||
var omni := OmniLight3D.new()
|
||||
omni.position = Vector3(-0.85, 4.65, 0)
|
||||
omni.omni_range = 12.0
|
||||
omni.omni_range = 16.0 if Materials.modern_light_falloff() else 12.0
|
||||
omni.omni_attenuation = 0.0 if Materials.modern_light_falloff() else 1.0
|
||||
omni.light_energy = 0.0
|
||||
omni.light_color = Color(1, 0.93, 0.75)
|
||||
holder.add_child(omni)
|
||||
|
||||
@@ -30,6 +30,9 @@ const HOODED_TYPES := [5, 6, 7] # TRESPASSER, VIOLENT_THREAT, MENTAL_CRISIS
|
||||
|
||||
## Optional `--portrait-type=N` picks the NpcType a portrait should feature.
|
||||
var _portrait_type := -1
|
||||
## Optional `--flashlight` turns the guard lamp on for night stages; the desktop
|
||||
## HQ A/B uses it to show the volumetric shaft.
|
||||
var _flashlight := false
|
||||
## Where the portrait subject is placed, relative to the guard's spawn.
|
||||
var _portrait_spot := Vector3(0.0, 0.5, 9.0)
|
||||
var _portrait_subject: Node3D = null
|
||||
@@ -114,6 +117,8 @@ func _parse_args() -> void:
|
||||
_only_stages[name.strip_edges()] = true
|
||||
elif text.begins_with("--portrait-type="):
|
||||
_portrait_type = int(text.substr(16))
|
||||
elif text == "--flashlight":
|
||||
_flashlight = true
|
||||
|
||||
func _pair(text: String, into: Dictionary) -> void:
|
||||
var cut := text.find("=", 6) # skip the "res://" scheme
|
||||
@@ -168,6 +173,10 @@ func _begin_stage() -> void:
|
||||
if venue != "" and venue != str(gm.get("active_scenario_id")):
|
||||
gm.call("switch_scenario", venue)
|
||||
gm.coordinator.elapsed_s = float(stage["elapsed"])
|
||||
if _flashlight:
|
||||
var want := bool(stage["night"])
|
||||
if want != bool(gm.flashlight_on):
|
||||
gm.call("_toggle_flashlight")
|
||||
_apply(swaps)
|
||||
if bool(stage["night"]):
|
||||
_apply(night_swaps)
|
||||
|
||||
@@ -43,8 +43,11 @@ func test_flashlight_aims_a_tight_forward_spot() -> bool:
|
||||
var c := TestUtils.check(lamp != null and lamp.spot_angle <= 20.0,
|
||||
"flashlight cone is narrow enough to read as a torch")
|
||||
var d := TestUtils.check(lamp != null and not lamp.visible, "flashlight starts off")
|
||||
var e := TestUtils.check(lamp != null
|
||||
and is_zero_approx(lamp.light_volumetric_fog_energy),
|
||||
"headless/compatibility builds leave the flashlight non-volumetric")
|
||||
root.free()
|
||||
return a and b and c and d
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_flashlight_widens_perception() -> bool:
|
||||
## Without the flashlight, an NPC at AWARE_RADIUS + 3 m is unaware.
|
||||
|
||||
@@ -77,14 +77,19 @@ func test_environment_tuning_applies_phase_values() -> bool:
|
||||
var f := TestUtils.check(
|
||||
env.fog_light_color.is_equal_approx(late["fog_color"]),
|
||||
"phase fog colour is used")
|
||||
return a and b and c and d and e and f
|
||||
var advanced := Materials.forward_plus_active()
|
||||
var g := TestUtils.check(env.volumetric_fog_enabled == advanced,
|
||||
"volumetric fog is gated to Forward+ (advanced=%s)" % advanced)
|
||||
var h := TestUtils.check(env.sdfgi_enabled == advanced,
|
||||
"SDFGI is gated to Forward+ (advanced=%s)" % advanced)
|
||||
return a and b and c and d and e and f and g and h
|
||||
|
||||
func test_every_phase_has_environment_keys() -> bool:
|
||||
var ok := true
|
||||
for phase in [Rules.Phase.EARLY, Rules.Phase.MIDDAY, Rules.Phase.EVENING, Rules.Phase.LATE]:
|
||||
var cfg: Dictionary = GameManager.PHASE_LIGHTING.get(phase, {})
|
||||
for key in ["exposure", "glow", "fog_color", "fog_density", "fog_energy",
|
||||
"contrast", "saturation"]:
|
||||
"contrast", "saturation", "volumetric_density", "volumetric_energy"]:
|
||||
if not cfg.has(key):
|
||||
ok = false
|
||||
return TestUtils.check(ok, "all phases set exposure/glow/fog/grading")
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
# build/gig-line-windows-<version>.exe
|
||||
# build/gig-line-macos-<version>.zip
|
||||
# build/gig-line-web-<version>.zip (Godot web build: static hosting, no threads)
|
||||
# build/gig-line-desktop-hq-<version>.zip (optional Forward+ Linux/Windows)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
@@ -49,6 +50,30 @@ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
PYZIP
|
||||
fi
|
||||
|
||||
echo "==> Packaging optional Desktop HQ build (Forward+)"
|
||||
HQ_DIR="build/desktop-hq"
|
||||
rm -rf "$HQ_DIR"
|
||||
mkdir -p "$HQ_DIR"
|
||||
cp "build/gig-line-linux-${VERSION}.x86_64" "$HQ_DIR/"
|
||||
cp "build/gig-line-windows-${VERSION}.exe" "$HQ_DIR/"
|
||||
cp packaging/desktop_hq/override.cfg "$HQ_DIR/"
|
||||
cp packaging/desktop_hq/README.txt "$HQ_DIR/"
|
||||
rm -f "build/gig-line-desktop-hq-${VERSION}.zip"
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
(cd "$HQ_DIR" && zip -q -r "../gig-line-desktop-hq-${VERSION}.zip" .)
|
||||
else
|
||||
python3 - "$PWD/$HQ_DIR" "$PWD/build/gig-line-desktop-hq-${VERSION}.zip" <<'PYZIP'
|
||||
import os, sys, zipfile
|
||||
src, out = sys.argv[1], sys.argv[2]
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for root, _dirs, files in os.walk(src):
|
||||
for name in files:
|
||||
path = os.path.join(root, name)
|
||||
archive.write(path, os.path.relpath(path, src))
|
||||
PYZIP
|
||||
fi
|
||||
rm -rf "$HQ_DIR"
|
||||
|
||||
echo ""
|
||||
echo "Artifacts:"
|
||||
ls -la build/*"${VERSION}"*
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Pick affordable OpenRouter vision models for the screenshot judges.
|
||||
|
||||
The model catalogue is fetched from OpenRouter (no auth needed) and filtered to
|
||||
models that accept image input and advertise `response_format`/structured output.
|
||||
The ranking uses the advertised per-token prices, so it follows the account's
|
||||
current catalogue instead of relying on one hard-coded model name. If the
|
||||
catalogue cannot be fetched, a small known-good list keeps QA working offline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
MODELS_URL = "https://openrouter.ai/api/v1/models"
|
||||
DEFAULT_MODEL = "auto"
|
||||
|
||||
# Used only when the catalogue is unavailable. Ordered by price/known quality.
|
||||
FALLBACK_MODELS = [
|
||||
"z-ai/glm-5.3-flash",
|
||||
"qwen/qwen3.7-flash",
|
||||
"google/gemma-3-4b-it",
|
||||
"openai/gpt-4.1-nano",
|
||||
"google/gemini-2.5-flash-lite",
|
||||
]
|
||||
|
||||
|
||||
def fetch_models(timeout: int = 30) -> list[dict]:
|
||||
"""Return the OpenRouter model catalogue, or [] when it is unreachable."""
|
||||
req = urllib.request.Request(MODELS_URL, headers={"Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
payload = __import__("json").loads(resp.read().decode("utf-8"))
|
||||
except (OSError, ValueError, urllib.error.HTTPError):
|
||||
return []
|
||||
return list(payload.get("data") or [])
|
||||
|
||||
|
||||
def _price(model: dict) -> float:
|
||||
pricing = model.get("pricing") or {}
|
||||
try:
|
||||
prompt = float(pricing.get("prompt") or 0.0)
|
||||
completion = float(pricing.get("completion") or 0.0)
|
||||
image = float(pricing.get("image") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return float("inf")
|
||||
# A judge request is image-heavy and emits a short JSON verdict.
|
||||
return prompt + image + completion * 0.25
|
||||
|
||||
|
||||
def vision_candidates(catalog: list[dict], limit: int = 6) -> list[str]:
|
||||
"""Cheap, image-capable models that can return structured JSON."""
|
||||
ranked: list[tuple[float, str]] = []
|
||||
for model in catalog:
|
||||
model_id = str(model.get("id") or "")
|
||||
if not model_id or model_id.startswith("~") or ":batch" in model_id:
|
||||
continue
|
||||
architecture = model.get("architecture") or {}
|
||||
inputs = architecture.get("input_modalities") or []
|
||||
outputs = architecture.get("output_modalities") or []
|
||||
if "image" not in inputs or "text" not in outputs:
|
||||
continue
|
||||
params = model.get("supported_parameters") or []
|
||||
# Our request uses response_format={"type": "json_object"}; models that
|
||||
# only advertise structured_outputs may reject that envelope.
|
||||
if "response_format" not in params:
|
||||
continue
|
||||
price = _price(model)
|
||||
if price <= 0.0 or price == float("inf"):
|
||||
continue
|
||||
ranked.append((price, model_id))
|
||||
ranked.sort()
|
||||
out: list[str] = []
|
||||
for _price_value, model_id in ranked:
|
||||
if model_id not in out:
|
||||
out.append(model_id)
|
||||
if len(out) >= max(1, limit):
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def resolve_models(requested: str = DEFAULT_MODEL, *, catalog: list[dict] | None = None,
|
||||
limit: int = 6) -> list[str]:
|
||||
"""Resolve ``auto``/an explicit model to a fallback-ordered model list.
|
||||
|
||||
`GIG_LINE_JUDGE_MODEL` overrides the automatic choice for reproducible runs;
|
||||
an explicit CLI `--model` still wins.
|
||||
"""
|
||||
requested = str(requested or "").strip()
|
||||
if requested and requested != DEFAULT_MODEL:
|
||||
return [requested]
|
||||
env_model = os.environ.get("GIG_LINE_JUDGE_MODEL", "").strip()
|
||||
if env_model:
|
||||
return [env_model]
|
||||
models = vision_candidates(catalog if catalog is not None else fetch_models(), limit)
|
||||
if not models:
|
||||
models = FALLBACK_MODELS[:max(1, limit)]
|
||||
return models
|
||||
|
||||
|
||||
def is_auth_error(exc: BaseException) -> bool:
|
||||
text = str(exc)
|
||||
return "HTTP 401" in text or "HTTP 403" in text or "API key not valid" in text
|
||||
|
||||
|
||||
def call_with_fallback(models: list[str], make_body, call, attempts: int = 2):
|
||||
"""Try each model, retrying provider errors, and return (model, response).
|
||||
|
||||
Authentication errors abort immediately: switching models cannot repair a
|
||||
bad key. Model/provider errors move to the next candidate.
|
||||
"""
|
||||
last: BaseException | None = None
|
||||
for model in models:
|
||||
body = make_body(model)
|
||||
for attempt in range(max(1, attempts)):
|
||||
try:
|
||||
return model, call(body)
|
||||
except (ValueError, OSError) as exc:
|
||||
last = exc
|
||||
if is_auth_error(exc):
|
||||
raise
|
||||
if attempt + 1 >= max(1, attempts):
|
||||
break
|
||||
if last is not None:
|
||||
raise last
|
||||
raise ValueError("no judge model was available")
|
||||
@@ -67,6 +67,7 @@ extras=(
|
||||
"build/gig-line-setup-$VERSION.exe"
|
||||
"build/gig-line-$VERSION-x86_64.AppImage"
|
||||
"build/gig-line-web-$VERSION.zip"
|
||||
"build/gig-line-desktop-hq-$VERSION.zip"
|
||||
)
|
||||
all_artifacts=("${portables[@]}")
|
||||
for f in "${extras[@]}"; do
|
||||
|
||||
+37
-16
@@ -38,6 +38,7 @@ from PIL import Image, ImageFilter, ImageOps, ImageStat
|
||||
|
||||
import asset_pipeline
|
||||
import generate_textures
|
||||
import openrouter_judge
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
RAW_DIR = ROOT / "assets" / "textures" / "ai" / "v2" / "raw"
|
||||
@@ -46,7 +47,7 @@ CONCEPT_DIR = ROOT / "docs" / "screens" / "concepts"
|
||||
REALISM_DIR = ROOT / "docs" / "screens" / "realism"
|
||||
|
||||
DEFAULT_MODEL = "openai/gpt-5-image-mini"
|
||||
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite"
|
||||
DEFAULT_JUDGE_MODEL = openrouter_judge.DEFAULT_MODEL
|
||||
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
SCENE_W, SCENE_H = 1280, 720
|
||||
@@ -758,25 +759,32 @@ def _call_openrouter(body: dict, timeout: int = 180) -> dict:
|
||||
raise ValueError(f"OpenRouter HTTP {exc.code}: {exc.read().decode('utf-8', 'replace')[:300]}")
|
||||
|
||||
|
||||
def judge_capture(model: str, target: Path, capture: Path, attempts: int = 2,
|
||||
def judge_capture(models, target: Path, capture: Path, attempts: int = 2,
|
||||
repeats: int = 1) -> dict:
|
||||
"""Vision-judge one capture; retry transient errors, average repeat scores.
|
||||
|
||||
`models` is a fallback-ordered list from `openrouter_judge.resolve_models`.
|
||||
A single score from a small vision model swings by ±1-2, so `repeats` runs
|
||||
are averaged and the spread is kept (`score_range`) for the report.
|
||||
"""
|
||||
if isinstance(models, str):
|
||||
models = [models]
|
||||
runs = []
|
||||
error = None
|
||||
used = set()
|
||||
for _ in range(max(1, repeats)):
|
||||
for _attempt in range(attempts):
|
||||
try:
|
||||
runs.append(parse_realism_response(_call_openrouter(
|
||||
build_realism_request(model, target, capture))))
|
||||
break
|
||||
except (ValueError, OSError) as exc:
|
||||
error = exc
|
||||
try:
|
||||
model, payload = openrouter_judge.call_with_fallback(
|
||||
models, lambda selected: build_realism_request(selected, target, capture),
|
||||
_call_openrouter, attempts=attempts)
|
||||
used.add(model)
|
||||
runs.append(parse_realism_response(payload))
|
||||
except (ValueError, OSError) as exc:
|
||||
if openrouter_judge.is_auth_error(exc):
|
||||
raise
|
||||
error = exc
|
||||
if not runs:
|
||||
return {"scores": {}, "fixes": [f"judge failed: {error}"]}
|
||||
return {"scores": {}, "fixes": [f"judge failed: {error}"], "model": ""}
|
||||
keys = {key for run in runs for key in run["scores"]}
|
||||
scores = {}
|
||||
spread = {}
|
||||
@@ -790,7 +798,7 @@ def judge_capture(model: str, target: Path, capture: Path, attempts: int = 2,
|
||||
if fix not in fixes:
|
||||
fixes.append(fix)
|
||||
return {"scores": scores, "score_range": spread, "runs": len(runs),
|
||||
"fixes": fixes[:5]}
|
||||
"model": ", ".join(sorted(used)), "fixes": fixes[:5]}
|
||||
|
||||
|
||||
def cmd_realism(args) -> int:
|
||||
@@ -800,10 +808,14 @@ def cmd_realism(args) -> int:
|
||||
if not sets:
|
||||
print(f"no capture sets under {_rel(run)}", file=sys.stderr)
|
||||
return 2
|
||||
judge_models = []
|
||||
if args.judge:
|
||||
_load_env_file()
|
||||
report = {"run": _rel(run), "judge_model": args.model if args.judge else None,
|
||||
"sets": {}}
|
||||
judge_models = openrouter_judge.resolve_models(args.model)
|
||||
print(f"judge models: {', '.join(judge_models)}", file=sys.stderr)
|
||||
report = {"run": _rel(run),
|
||||
"judge_model": (judge_models[0] if judge_models else None),
|
||||
"judge_fallbacks": judge_models, "sets": {}}
|
||||
for capture_set in sets:
|
||||
rows = {}
|
||||
for capture in sorted(capture_set.glob("*.png")):
|
||||
@@ -815,8 +827,15 @@ def cmd_realism(args) -> int:
|
||||
row = {"target": _rel(target), "metrics": cap_m, "target_metrics": tgt_m,
|
||||
"metric_gap": metric_gap(cap_m, tgt_m)}
|
||||
if args.judge:
|
||||
row["judge"] = judge_capture(args.model, target, capture,
|
||||
repeats=args.repeats)
|
||||
try:
|
||||
row["judge"] = judge_capture(judge_models, target, capture,
|
||||
repeats=args.repeats)
|
||||
except ValueError as exc:
|
||||
if openrouter_judge.is_auth_error(exc):
|
||||
print(f"error: OpenRouter authentication failed: {exc}",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
raise
|
||||
rows[capture.stem] = row
|
||||
report["sets"][capture_set.name] = rows
|
||||
json_path = next_free_path(run / "report.json")
|
||||
@@ -975,7 +994,9 @@ def build_parser():
|
||||
real = sub.add_parser("realism")
|
||||
real.add_argument("--run", required=True)
|
||||
real.add_argument("--judge", action="store_true")
|
||||
real.add_argument("--model", default=DEFAULT_JUDGE_MODEL)
|
||||
real.add_argument("--model", default=DEFAULT_JUDGE_MODEL,
|
||||
help="OpenRouter model id, or 'auto' to pick the cheapest "
|
||||
"available image+JSON model with fallbacks")
|
||||
real.add_argument("--repeats", type=int, default=1,
|
||||
help="average N judge runs per shot (scores are noisy)")
|
||||
real.set_defaults(handler=cmd_realism)
|
||||
|
||||
+29
-9
@@ -51,6 +51,7 @@ from pathlib import Path
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
|
||||
|
||||
import generate_textures
|
||||
import openrouter_judge
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
## Runtime reference art (ADR-0014); docs/screens/designs/ is the reference-only
|
||||
@@ -68,7 +69,7 @@ ASPECT = 16 / 9
|
||||
DEFAULT_MODEL = "openai/gpt-5-image-mini"
|
||||
|
||||
# Vision model used by `judge`. Cheap, multimodal, and good at "same screen?".
|
||||
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite"
|
||||
DEFAULT_JUDGE_MODEL = openrouter_judge.DEFAULT_MODEL
|
||||
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
|
||||
# Shared art direction, appended to every prompt so the generated frames read
|
||||
@@ -965,6 +966,10 @@ def judge(args):
|
||||
refs = {screen["name"]: screen["ref"]
|
||||
for screen in SCREENS if screen["ref"]}
|
||||
by_name = {screen["name"]: screen for screen in SCREENS}
|
||||
models = [args.model]
|
||||
if not args.dry_run:
|
||||
models = openrouter_judge.resolve_models(args.model)
|
||||
print(f"judge models: {', '.join(models)}", file=sys.stderr)
|
||||
results = []
|
||||
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
|
||||
if args.only and args.only not in capture.name:
|
||||
@@ -982,23 +987,36 @@ def judge(args):
|
||||
"match": None, "confidence": 0.0,
|
||||
"differences": ["missing reference design; run generate"]})
|
||||
continue
|
||||
body = build_judge_request(args.model, by_name[design_name],
|
||||
capture, reference)
|
||||
if args.dry_run:
|
||||
body = build_judge_request(args.model, by_name[design_name],
|
||||
capture, reference)
|
||||
results.append({
|
||||
"capture": capture.name, "screen": design_name, "match": None,
|
||||
"confidence": 0.0, "model": body["model"], "class": "unknown",
|
||||
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
|
||||
})
|
||||
continue
|
||||
# Providers occasionally answer a valid key with a transient HTTP 400;
|
||||
# one retry keeps a whole judge run from dying on a single hiccup.
|
||||
try:
|
||||
verdict = parse_judge_response(_call_openrouter(body))
|
||||
model, payload = openrouter_judge.call_with_fallback(
|
||||
models,
|
||||
lambda selected: build_judge_request(
|
||||
selected, by_name[design_name], capture, reference),
|
||||
_call_openrouter)
|
||||
except ValueError as exc:
|
||||
print(f"retrying {capture.name}: {exc}", file=sys.stderr)
|
||||
verdict = parse_judge_response(_call_openrouter(body))
|
||||
if openrouter_judge.is_auth_error(exc):
|
||||
print(f"error: OpenRouter authentication failed: {exc}",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
print(f"error judging {capture.name}: {exc}", file=sys.stderr)
|
||||
results.append({
|
||||
"capture": capture.name, "screen": design_name, "match": None,
|
||||
"confidence": 0.0, "model": "",
|
||||
"class": "unknown", "differences": [f"judge failed: {exc}"],
|
||||
})
|
||||
continue
|
||||
verdict = parse_judge_response(payload)
|
||||
verdict.update({"capture": capture.name, "screen": design_name,
|
||||
"model": model,
|
||||
"class": classify(verdict, args.fail_confidence)})
|
||||
results.append(verdict)
|
||||
|
||||
@@ -1047,7 +1065,9 @@ def build_parser():
|
||||
|
||||
judge_cmd = commands.add_parser(
|
||||
"judge", help="ask a vision model whether captures match the designs")
|
||||
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL)
|
||||
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL,
|
||||
help="OpenRouter model id, or 'auto' to pick the cheapest "
|
||||
"available image+JSON model with fallbacks")
|
||||
judge_cmd.add_argument("--json", action="store_true")
|
||||
judge_cmd.add_argument("--dry-run", action="store_true",
|
||||
help="print requests instead of calling the API")
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import openrouter_judge as oj
|
||||
|
||||
|
||||
def _model(model_id, prompt, completion, *, image=True, response_format=True, batch=False):
|
||||
return {
|
||||
"id": model_id,
|
||||
"architecture": {
|
||||
"input_modalities": ["text", "image"] if image else ["text"],
|
||||
"output_modalities": ["text"],
|
||||
},
|
||||
"supported_parameters": ["response_format"] if response_format else ["temperature"],
|
||||
"pricing": {"prompt": str(prompt), "completion": str(completion)},
|
||||
}
|
||||
|
||||
|
||||
class ModelSelectionTests(unittest.TestCase):
|
||||
def test_candidates_are_image_json_models_sorted_by_price(self):
|
||||
catalog = [
|
||||
_model("expensive/model", 0.10, 0.40),
|
||||
_model("cheap/model", 0.01, 0.02),
|
||||
_model("text-only", 0.001, 0.001, image=False),
|
||||
_model("no-json", 0.001, 0.001, response_format=False),
|
||||
_model("batch/model:batch", 0.0001, 0.0001),
|
||||
]
|
||||
self.assertEqual(oj.vision_candidates(catalog), ["cheap/model", "expensive/model"])
|
||||
|
||||
def test_explicit_model_wins_over_auto_and_env(self):
|
||||
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
|
||||
self.assertEqual(oj.resolve_models("explicit/model", catalog=[]),
|
||||
["explicit/model"])
|
||||
|
||||
def test_auto_uses_env_override(self):
|
||||
with mock.patch.dict(os.environ, {"GIG_LINE_JUDGE_MODEL": "env/model"}):
|
||||
self.assertEqual(oj.resolve_models("auto", catalog=[]), ["env/model"])
|
||||
|
||||
def test_auto_uses_catalog_and_falls_back_offline(self):
|
||||
catalog = [_model("cheap/model", 0.01, 0.02)]
|
||||
self.assertEqual(oj.resolve_models("auto", catalog=catalog), ["cheap/model"])
|
||||
self.assertEqual(oj.resolve_models("auto", catalog=[]), oj.FALLBACK_MODELS[:6])
|
||||
|
||||
|
||||
class FallbackTests(unittest.TestCase):
|
||||
def test_fallback_switches_model_after_provider_error(self):
|
||||
seen = []
|
||||
|
||||
def call(body):
|
||||
seen.append(body["model"])
|
||||
if body["model"] == "bad/model":
|
||||
raise ValueError("OpenRouter HTTP 400: provider rejected image")
|
||||
return {"ok": True}
|
||||
|
||||
model, payload = oj.call_with_fallback(
|
||||
["bad/model", "good/model"], lambda m: {"model": m}, call, attempts=1)
|
||||
self.assertEqual(model, "good/model")
|
||||
self.assertEqual(payload, {"ok": True})
|
||||
self.assertEqual(seen, ["bad/model", "good/model"])
|
||||
|
||||
def test_auth_error_does_not_try_another_model(self):
|
||||
seen = []
|
||||
|
||||
def call(body):
|
||||
seen.append(body["model"])
|
||||
raise ValueError("OpenRouter HTTP 401: API key not valid")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
oj.call_with_fallback(["one", "two"], lambda m: {"model": m}, call)
|
||||
self.assertEqual(seen, ["one"])
|
||||
|
||||
def test_transient_error_retries_same_model(self):
|
||||
seen = []
|
||||
|
||||
def call(body):
|
||||
seen.append(body["model"])
|
||||
if len(seen) == 1:
|
||||
raise ValueError("OpenRouter HTTP 500")
|
||||
return {"ok": True}
|
||||
|
||||
model, _ = oj.call_with_fallback(
|
||||
["one"], lambda m: {"model": m}, call, attempts=2)
|
||||
self.assertEqual(model, "one")
|
||||
self.assertEqual(seen, ["one", "one"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user