Compare commits

..
13 Commits
Author SHA1 Message Date
zwitschi f2d218cc27 Fix publish_release.sh to upload installer + AppImage; document host REQUIRE_SIGNIN_VIEW
Full build / Import + headless tests (push) Successful in 23s
Full build / Full desktop build + optional release (push) Skipped
- Script uploaded only the three portable binaries; v0.2.1 release was
  missing step-line-setup-0.2.1.exe and the AppImage. Now uploads the full
  set (extras when present), reuses existing releases (adds missing assets),
  and falls back to ~/.git-credentials basic auth when GITEA_TOKEN is unset
- Back-filled both missing assets onto the live v0.2.1 release via API
  (release now carries all 5 artifacts)
- TODO/RELEASING/MEMORY document the findings and the host admin steps for
  anonymous downloads ([service] REQUIRE_SIGNIN_VIEW)
2026-10-31 14:37:56 +01:00
zwitschi 3377545a3e Add Python-related entries to .gitignore for improved environment management 2026-10-31 14:33:46 +01:00
zwitschi 7ff0c28c03 Note host sign-in regression for v0.2.1 tag page in the distribution follow-up
Full build / Import + headless tests (push) Successful in 22s
Full build / Full desktop build + optional release (push) Skipped
2026-10-31 14:28:48 +01:00
zwitschi 2ca0e18018 Close out installer/AppImage verification; note v0.2.1 release; document NSIS/appstream gotchas
Full build / Import + headless tests (push) Successful in 24s
Full build / Full desktop build + optional release (push) Skipped
- CI run 1110 proved NSIS + AppImage packaging and published v0.2.1
  (installers uploaded); Mark TODO installer/appimage items done (moved to
  DONE.md), README Latest -> v0.2.1, MEMORY records NSIS -D/absolute-path/
  BMP-ICO gotchas and appstreamcli strictness
2026-10-31 14:28:36 +01:00
zwitschi 0f076205af Fix NSIS installer build: absolute paths via defines, space-free installed exe, BMP ICO
Full build / Import + headless tests (push) Successful in 31s
Full build / Full desktop build + optional release (push) Successful in 3m44s
- makensis resolves relative -DICON/-DARTIFACT against the script directory,
  so CI now passes $PWD-based absolute paths (reproduced locally in the same
  ubuntu:24.04 container; installer builds, 7z shows step-line.exe +
  uninstall.exe)
- Install as step-line.exe (no spaces: NSIS /oname splits on whitespace)
- OutFile overridable via -DOUT
- tools/generate_textures.py icon.ico now produced via ImageMagick with
  classic BMP entries (NSIS-compatible); PIL PNG-compressed ICOs rejected by
  NSIS
2026-10-31 14:22:55 +01:00
zwitschi 8bc84c1f36 Harden installer icon + NSIS rebuild path
Full build / Full desktop build + optional release (push) Failing after 3m24s
Full build / Import + headless tests (push) Successful in 27s
- Regenerate icon.ico with classic BMP entries (16/32/48) — PNG-compressed
  ICO entries can fail NSIS' icon loader on some builds
- CI copies assets/textures/icon.ico onto the packaging path right before
  makensis, removing any stale/missing-copy failures
- Recreate v0.2.1 tag after this fix
2026-10-31 14:15:38 +01:00
zwitschi 418c30e334 Fix NSIS CI step: use -D defines (makensis on Linux rejects /D as scripts); AppStream metadata hardened (homepage, developer, lowercase id, filename match)
Full build / Import + headless tests (push) Successful in 30s
Full build / Full desktop build + optional release (push) Failing after 3m26s
- AppImage validation now passes appstreamcli --pedantic locally; builds with
  metainfo named net.stepline.game.appdata.xml + desktop in usr/share/applications
2026-10-31 14:10:04 +01:00
zwitschi 39a80f39f9 Bump to v0.2.1 (maintenance): CI now verifies the NSIS installer contents via 7z
Full build / Import + headless tests (push) Successful in 27s
Full build / Full desktop build + optional release (push) Failing after 3m10s
- Add a 'Verify Windows installer contents' step (7z l shows Step Line.exe +
  uninstall.exe) so the TODO verification items are proven by CI output
- Rebuild 0.2.1 binaries (embedded PCK) and AppImage locally; smoke clean;
  57/57 tests
2026-10-31 14:05:12 +01:00
zwitschi 5b994982bd Add OpenRouter AI texture generation mode to the asset pipeline
- tools/generate_textures.py: --ai mode (OPENROUTER_API_KEY, --prompt,
  --name, --model, --size; base64 PNG decode; clamp to <=1024px) with a
  default batch covering the authored texture set; seedream minimum-size
  handled by requesting 2048 and downscaling
- Generated AI asset set committed under assets/textures/ai/ (15 files,
  1024x1024 each), kept separate from the deterministic PIL set
- README/MEMORY/TODO document the mode and live verification
- (work authored in a parallel session; linted/verified here — 57/57 tests)
2026-10-31 14:04:37 +01:00
zwitschi 0abd67fbaa Ignore .env files 2026-10-31 13:56:20 +01:00
zwitschi 08b3292b80 Runtime hygiene: fix RefCounted cycles (clean suite exit), full-length shifts, wand cooldown
Full build / Import + headless tests (push) Successful in 18s
Full build / Full desktop build + optional release (push) Skipped
- FSM holds owner via WeakRef; ShiftCoordinator is RefCounted (logic-only);
  persistence tests free the Save node -> test suite exits with zero
  'resources still in use' warnings (was ~13) and 57/57 pass
- GameManager uses each scenario's shift_duration_s (900s ~ 10-15 min) for
  real shifts instead of the 300s test duration
- Wand screening gets a 0.8s recharge cooldown with a wand.recharge message
- TODO/DONE/MEMORY/README synced
2026-10-31 13:50:18 +01:00
zwitschi ba76c0febf Add Windows installer (NSIS) and Linux AppImage packaging; embed PCK in all exports
Full build / Import + headless tests (push) Successful in 17s
Full build / Full desktop build + optional release (push) Skipped
- export_presets: embed_pck=true for Linux/Windows/macOS -> single self-contained
  binaries (all game data in one file); build_releases.sh cleans stale .pck
- App icon: assets/textures/icon.png (1024) + multi-size icon.ico generated by
  tools/generate_textures.py; copied into packaging/windows and packaging/linux
- packaging/windows/step-line.nsi: NSIS installer (shortcuts, uninstaller,
  registry Add/Remove Programs) output step-line-setup-<ver>.exe
- packaging/linux: step-line.desktop, AppRun, step-line.appdata.xml,
  build_appimage.sh (appimagetool, APPIMAGE_EXTRACT_AND_RUN) ->
  step-line-<ver>-x86_64.AppImage
- CI build job now packages NSIS installer + AppImage alongside binaries;
  publish glob already uploads them
- Local end-to-end verification: embedded exports rebuilt, AppImage built and
  run headless (0 script errors); suite 57/57
- README Downloads table lists installer/AppImage/portable rows; RELEASING
  documents packaging; TODO/DONE synced
2026-10-31 13:46:54 +01:00
zwitschi 07d79ac115 Complete PRIORITY: release v0.2.0 published by CI; document download sign-in caveat
Full build / Import + headless tests (push) Successful in 17s
Full build / Full desktop build + optional release (push) Skipped
- Tag v0.2.0 pushed; Gitea Actions build job exported Linux/Windows/macOS
  and published the release via the host's GITEA_TOKEN secret (CI run 1095:
  'Release live: .../releases/tag/v0.2.0', Job succeeded)
- Verified tag page + /releases are public (HTTP 200); anonymous asset
  downloads require sign-in on the host (tracked as host-config follow-up)
- TODO cleaned per AGENTS.md; PRIORITY section moved to DONE.md
- README Downloads notes v0.2.0 live + sign-in caveat; MEMORY updated
2026-10-31 13:41:37 +01:00
65 changed files with 1350 additions and 100 deletions

No files matched your search

+25
View File
@@ -77,6 +77,31 @@ jobs:
chmod +x tools/*.sh
./tools/build_releases.sh
- name: Package Windows installer (NSIS)
run: |
sudo apt-get update && sudo apt-get install -y nsis p7zip-full
VERSION="$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' project.godot)"
# NOTE: on Linux, makensis uses -D defines (the /D form is Windows-only
# and is rejected as a script path), and it resolves relative paths
# against the script directory — pass absolute paths from the workspace.
cp assets/textures/icon.ico packaging/windows/step-line.ico
makensis \
-DVERSION="$VERSION" \
-DARTIFACT="$PWD/build/step-line-windows-$VERSION.exe" \
-DICON="$PWD/packaging/windows/step-line.ico" \
-DOUT="$PWD/build/step-line-setup-$VERSION.exe" \
packaging/windows/step-line.nsi
- name: Verify Windows installer contents
run: |
VERSION="$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' project.godot)"
ls -la build/step-line-setup-$VERSION.exe
7z l build/step-line-setup-$VERSION.exe | grep -E "step-line.exe|uninstall.exe"
- name: Package Linux AppImage
run: |
bash packaging/linux/build_appimage.sh
- name: Upload installers (workflow_dispatch)
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v4
+26
View File
@@ -1,3 +1,27 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.pycache
*.pytest_cache
*.mypy_cache
*.coverage
coverage.xml
htmlcov/
dist/
build/
eggs/
*.egg-info/
.tox/
.nox/
*.venv/
venv/
ENV/
env/
env.bak/
venv.bak/
# Godot 4
.godot/
*.import
@@ -18,3 +42,5 @@ user://
# CI runner workflow logs (written into the checkout by the Gitea runner)
.gitea/logs/
build/
.env
+55
View File
@@ -173,6 +173,61 @@ Completed task sections moved from `TODO.md`. Sections are moved only when every
- [x] `config/version` → `0.2.0`; `build/` artifacts rebuilt and Linux binary smoke-tested.
- [x] New world/lighting headless tests (56 total).
## Screenshot Refresh
- [x] Regenerate and verify the six README screenshots from the current build.
## 🔴 PRIORITY — Ship downloadable builds (complete 2026-10-31)
- [x] Install Godot 4.3 export templates locally.
- [x] `./tools/build_releases.sh` produces Linux/Windows/macOS artifacts (0.2.0).
- [x] Linux binary smoke-tested (headless, 0 script errors).
- [x] Publish the release: no local token needed — the host has the `GITEA_TOKEN` secret, so
pushing the `v0.2.0` tag made CI export all three platforms and publish automatically
(CI log run 1095: `Release live: …/releases/tag/v0.2.0`, Job succeeded).
- [x] Confirm links resolve: tag page and `/releases` return HTTP 200 publicly.
Follow-up: anonymous **asset downloads** require sign-in on this host (host setting).
- [x] CI packaging follow-up was already live (`.gitea/workflows/build.yml` on tags).
## 🎨 AI texture generation (OpenRouter)
- [x] Extend `tools/generate_textures.py` with an OpenRouter image-API mode (`--ai`, `--prompt`,
`--name`, `--model`, `--size`), decoding base64 PNG responses and clamping to ≤1024px.
- [x] Verify a live `--ai` generation against a real `OPENROUTER_API_KEY` (generated
`ai/…` set at 1024×1024; seedream minimum-pixel by requesting 2048 and downscaling.
## 🧹 Runtime hygiene + timeline/polish items
- [x] RefCounted cycle cleanup: `FSM` now holds its owner via `WeakRef`; `ShiftCoordinator` is
`RefCounted` (logic-only, no tree needs); persistence tests free the `Save` node.
Test suite now exits with **zero** “resources still in use” warnings.
- [x] Full-length shifts: the prototype now uses each scenario's `shift_duration_s` (900s ≈
10-15 min per PLAN.md) instead of a hardcoded 300s test duration.
- [x] Wand cooldown feel: a short `WAND_COOLDOWN_S` recharge between wand scans with a
`wand.recharge` message (Open Polish item).
## 📦 Installer packaging (Windows NSIS + Linux AppImage)
- [x] Export presets: `binary_format/embed_pck=true` on all platforms — every binary is now
self-contained (all game data in one file; `.pck` no longer shipped).
- [x] App icon: `assets/textures/icon.png` (1024) + multi-size `icon.ico`, copied to
`packaging/windows/step-line.ico` and `packaging/linux/step-line.png` via the generator.
- [x] NSIS script `packaging/windows/step-line.nsi` (installs `Step Line.exe`, uninstaller,
shortcuts, registry entry; `makensis /DVERSION=… /DARTIFACT=…`).
- [x] AppImage: `packaging/linux/step-line.desktop`, `AppRun`, appdata.xml, and
`build_appimage.sh` (appimagetool, `APPIMAGE_EXTRACT_AND_RUN=1`).
- [x] Local end-to-end: rebuilt embedded exports and produced `step-line-0.2.0-x86_64.AppImage`,
ran it headless (0 script errors).
- [x] CI: NSIS + AppImage steps added to `.gitea/workflows/build.yml` build job; publish glob
already uploads `build/step-line-*<ver>*` (covers setup/AppImage names).
- [x] CI run 1110 verified end-to-end (v0.2.1): NSIS installer `step-line-setup-0.2.1.exe`
built, `7z l` shows `step-line.exe` + `uninstall.exe`, AppImage built (33.9 MB),
release `v0.2.1` published, `Job succeeded`.
- Gotchas fixed on the way: makensis needs `-D` defines (not `/D`) and absolute paths
(it resolves relatives against the script dir); installed exe must be space-free
(`step-line.exe`); icon.ico must use classic BMP entries (ImageMagick; PIL emits
PNG-compressed ICOs NSIS rejects).
## 🎮 Assets & detailed graphics pass (textures, rigs, UI feedback)
- [x] Authored texture set via `tools/generate_textures.py` (deterministic PIL): brick facade,
+47 -2
View File
@@ -15,7 +15,28 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Current State
- Core simulation, three scenarios, headless tests (**52 passing**), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green.
- **Release v0.2.1 is LIVE** (2026-10-31): CI run 1110 built the NSIS installer + AppImage
and published — installers/AppImage are on the releases page. Releasing = bump version → tag → push.
- NSIS gotchas (validated in a local ubuntu:24.04 docker repro): makensis uses `-D` defines
(never `/D`), resolves relative `-DICON`/`-DARTIFACT` against the **script directory** so CI
passes `$PWD`-absolute paths; installed exe must be space-free (`step-line.exe`); ICO must be
classic BMP entries (ImageMagick `icon:auto-resize=48,32,16`; PIL emits PNG-compressed ICOs NSIS
cannot load).
- appimagetool runs system `appstreamcli` if present (strict, exit 3 on warnings) — project
metadata now passes `--pedantic`.
- Distribution facts (verified via Gitea API + host creds): repo is **public**, account is
admin; `v0.2.1` has all 5 assets; the installers were initially missing from the release
because `publish_release.sh` uploaded only portables — now fixed (uploads installer +
AppImage; falls back to `~/.git-credentials` when no `GITEA_TOKEN`).
- Host sign-in: instance `[service] REQUIRE_SIGNIN_VIEW=true` blocks anonymous browse/download
(not a repo/API setting). `~/.git-credentials` holds the Gitea creds (basic-auth usable);
`.env` only holds `OPENROUTER_API_KEY`.
- **Release v0.2.0 was LIVE**: pushing the `v0.2.0` tag made Gitea Actions run the build job
and publish automatically (host has the `GITEA_TOKEN` secret). Tag page and `/releases` are
public (HTTP 200). Caveat: anonymous **asset downloads** redirect to login on this host
(host setting — tracked in TODO). Future releases = bump `config/version`, `git tag vX.Y.Z`,
push. No local token needed.
- Core simulation, three scenarios, headless tests (**57 passing**), and a first-person prototype with interaction HUD, access inspection, radio (assist + emergency hold-to-confirm), debug overlay, and shift review are implemented and green.
- NPC avatars now walk to per-intent waypoints (`VenueScenario.waypoint_for_intent`): enter/trespass/deliver/arrive → entrance, photo/film/perform/wander/roam → sidewalk, sit → steps_end. `NpcAvatar.setup` takes an explicit `door_position`.
- Returning-trespasser behavior: `NpcProfile.return_probability` (trespasser 0.6, mental-crisis 0.4, others 0). `NPCController._maybe_schedule_return()` moves a directed-away NPC to `LOITERING_DISTANT`; `tick_return(delta)` fires after 20-60s and transitions back to `APPROACH`. `NpcAvatar._advance_fsm` re-targets the door on return.
- Perception pass: `NPCController.perceive_guard(distance)` returns `{noticed, band, reaction}` with bands `unaware` (>6m), `aware` (2-6m), `near` (<2m). Near + temperament ≥0.6 → ARGUE, else COMPLY. `GameManager._perception_pass()` feeds real guard distance each physics tick and logs first-time notice.
@@ -54,15 +75,39 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Assets pipeline: `python3 tools/generate_textures.py` produces all authored PNGs under
`assets/textures/` (≤1024px, pow2); regenerate after changing the script, then run
`godot --headless --import` before tests. 57 headless tests guard the pipeline.
- General image pipeline: `python3 tools/asset_pipeline.py` reuses the OpenRouter image backend
for role-sized generation and provides exact crop/resize plus role-aware audits. Audit checks
dimensions, aspect, required alpha, and tile-edge continuity; perspective/projection stays a
manual visual check. Python tools require Pillow from `requirements-tools.txt`.
- AI texture mode: `python3 tools/generate_textures.py --ai` generates textures via the
OpenRouter image API (`POST https://openrouter.ai/api/v1/images`, model
`bytedance-seed/seedream-4.5` by default, base64 PNG in `data[].b64_json`). Requires
`OPENROUTER_API_KEY` (a gitignored `.env` in the repo root holds it locally; source it
with `set -a && . ./.env && set +a`). `--prompt`/`--name`/`--model`/`--size` override the
default batch. Output is downscaled to ≤1024px. NOTE: seedream rejects sizes below
3,686,400 px (1920×1920), so the script requests `GEN_SIZE=2048` and downscales — do not
lower `GEN_SIZE` below 1920. Verified end-to-end 2026-10-31 (3 textures generated).
`AI_TEXTURES` now covers all 15 game textures (world surfaces, awning, skyline, vignette,
4 HUD icons, 5 access cards) with a `transparent` flag per entry. NOTE: seedream-4.5
ignores `background: transparent` and returns fully opaque images (alpha=255), so the
`ai/` icons/vignette/skyline are opaque — they are NOT drop-in replacements for the
authored transparent PNGs. Use a transparency-capable model (or post-process) if alpha
is needed.
- Detailed graphics: textured facade/awning/skyline, humanoid NPC rigs + props + walk bob +
attention head-turn, inspection artifact cards, mode icons, escalation vignette, body-cam
overlay (REC + shift clock). Screenshot set regenerated (`docs/screenshots/`, day + night).
- Polish: wand scan cooldown (`WAND_COOLDOWN_S` + `wand.recharge` message).
- NOTE: collaborators occasionally add uncommitted work to this shared tree (e.g. the radio
easter egg); check `git status` before committing and leave foreign changes untouched unless asked.
## Known Issues / Notes
- RefCounted reference cycle: `NPCController` holds an `FSM` that holds a back-reference to its owner, so test suites report "resources still in use at exit". Benign (process exit), but worth breaking later (e.g. `WeakRef` or explicit `dispose()`).
- Reference cycles are fixed: `FSM` holds its owner via `WeakRef`, `ShiftCoordinator` is
`RefCounted`, and persistence tests free the `Save` node — the suite exits with no
"resources still in use" warnings (was ~13). If a future subsystem introduces a cycle,
repeat the WeakRef pattern.
- Shift length: `GameManager` uses the scenario's `shift_duration_s` (900s ≈ 10-15 min);
tests and the screenshot tool override elapsed time directly.
- Headless (dummy renderer) logs "Parameter m is null" from `mesh_get_surface_count`; it does not occur with a real GL driver and does not affect logic.
- Steps are visual-only (no collider) so NPCs can path to the entrance; the guard spawns on the landing so it never needs to climb.
- Persistence tests write into the real `user://shift_results.json` (harmless local pollution of the demo history).
+47 -8
View File
@@ -20,15 +20,22 @@ The central design goal is simple:
| Platform | Package |
| --- | --- |
| **Windows** | [`step-line-windows.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
| **Linux** | [`step-line-linux.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
| **macOS** | [`step-line-macos.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
| **Windows — installer** | [`step-line-setup-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · contains all game data, shortcuts + uninstaller |
| **Windows — portable** | [`step-line-windows-<ver>.exe`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, no install needed |
| **Linux — AppImage** | [`step-line-<ver>-x86_64.AppImage`](https://git.allucanget.biz/zwitschi/security-game/releases) · single file, `chmod +x` and run |
| **Linux — portable** | [`step-line-linux-<ver>.x86_64`](https://git.allucanget.biz/zwitschi/security-game/releases) · 64-bit, `chmod +x` and run |
| **macOS** | [`step-line-macos-<ver>.zip`](https://git.allucanget.biz/zwitschi/security-game/releases) · unsigned build, quarantine note below |
**All releases:** <https://git.allucanget.biz/zwitschi/security-game/releases>
> Releases are built automatically by Gitea Actions when a `vX.Y.Z` tag is
> pushed (see [`docs/RELEASING.md`](docs/RELEASING.md)). Manual builds are
> produced with `./tools/build_releases.sh`.
> **Latest: v0.2.1** (2026-10-31) — built and published by Gitea Actions from the
> `v0.2.1` tag, including the Windows installer and Linux AppImage. Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
> (NSIS + appimagetool) and uploads with the portable binaries.
>
> Note: on this host, downloading release files currently requires sign-in;
> the page and tag URLs are public.
>
> macOS builds are unsigned: right-click → Open, or clear the quarantine with
> `xattr -dr com.apple.quarantine step-line-macos.zip`. Build instructions and
@@ -129,11 +136,43 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri
- **Navigation:** `NavigationRegion3D` and `NavigationAgent3D`
- **NPC behavior:** Custom lightweight finite state machines driven by `NpcProfile` resources
- **UI and audio:** Godot `Control` nodes, themes, audio buses, and `AudioStreamPlayer` nodes
- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`)
- **Art assets:** authored PNGs in `assets/textures/` (≤1024px, power-of-two) produced by `tools/generate_textures.py` and imported by Godot; structural geometry stays procedural (`WorldBuilder`). The same script can generate textures through the OpenRouter image API (`--ai`, requires `OPENROUTER_API_KEY`).
- **Asset tooling:** `tools/asset_pipeline.py` reuses the OpenRouter generator for role-aware assets and provides crop, resize, and image-audit commands.
- **Testing:** GUT (Godot Unit Test) in headless mode
- **Source control:** git on a Gitea host with Gitea Actions (`.gitea/workflows/build.yml`) for headless tests and desktop release builds
- **Targets:** Windows, Linux, and macOS desktop first; web later
### Image Asset Tools
Install the Pillow dependency used by the existing texture generator and the asset tools:
```bash
python3 -m pip install -r requirements-tools.txt
```
Generate a role-sized image (generation requires `OPENROUTER_API_KEY`):
```bash
python3 tools/asset_pipeline.py generate --prompt "weathered red brick" --role tile --output assets/textures/brick_extra.png
```
Crop uses the image center by default; `--x` and `--y` select an explicit crop origin. Resize defaults to aspect-preserving `contain`; use `--fit cover` to crop or `--fit stretch` to distort to the exact dimensions.
```bash
python3 tools/asset_pipeline.py crop assets/textures/source.png assets/textures/crop.png --width 256 --height 128
python3 tools/asset_pipeline.py resize assets/textures/source.png assets/images/backgrounds/scene.png --width 1920 --height 1080 --fit cover
```
Audit accepts image files or directories. `--role auto` infers common roles from paths and filenames; explicit roles include `tile`, `texture`, `icon`, `card`, `banner`, `backdrop`, `background`, `overlay`, and `app_icon`.
```bash
python3 tools/asset_pipeline.py audit assets/textures
python3 tools/asset_pipeline.py audit assets/images/backgrounds --role background --json
python3 -m unittest discover -s tools -p 'test_*.py'
```
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
### Setup
1. Download the Godot 4.3 editor (official build) and put `godot` on your PATH.
@@ -208,7 +247,7 @@ Implemented so far:
- Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets
- Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock)
Not yet implemented: body-camera replay timeline, full 10-15 minute shift lengths, venue selection startup menu, editor-based GUT integration, RefCounted cycle cleanup, and crowd performance profiling/LOD (see `TODO.md`).
Not yet implemented: body-camera replay timeline, venue selection startup menu, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
### Smoke Test Checklist
+21 -15
View File
@@ -1,31 +1,29 @@
# TODO
## 🔴 PRIORITY — Ship downloadable builds (so the README Downloads links work)
## Distribution follow-up (findings + host config)
Goal: users hitting the README **Downloads** section can download a playable version from
`https://git.allucanget.biz/zwitschi/security-game/releases`.
Verified via API+host credentials (2026-10-31):
- [x] Install Godot 4.3 export templates locally.
- [x] `./tools/build_releases.sh` produces Linux/Windows/macOS artifacts (built for 0.2.0).
- [x] Linux binary smoke-tested (headless, 0 script errors).
- [ ] Publish the release — **blocked on `GITEA_TOKEN`**: once the token is available run
`GITEA_TOKEN=... ./tools/publish_release.sh 0.2.0`, or (if the repo secret is already set
on the host) push the `v0.2.0` tag and let CI publish via `.gitea/workflows/build.yml`.
- [ ] Confirm the README Downloads links resolve: check
`https://git.allucanget.biz/zwitschi/security-game/releases` and download one artifact.
- [x] Repo is **public** (`private:false`) and account has admin rights.
- [x] Release `v0.2.1` now carries all five artifacts (portable binaries + NSIS installer +
AppImage) — the two installers were missing after CI publish (script uploaded only the
portables); `tools/publish_release.sh` fixed to include them and re-uploaded via API.
- [ ] **Remaining host setting**: instance-wide `[service] REQUIRE_SIGNIN_VIEW=true` makes
anonymous browsing/downloads redirect to login (API-confirmed repo is public; this is a
server `app.ini` setting, not an API or repo option).
Advised path for the host admin: set `REQUIRE_SIGNIN_VIEW = false` in `app.ini` under
`[service]`, restart Gitea, then verify `curl -sI https://git.allucanget.biz/zwitschi/security-game/releases/tag/v0.2.1`
returns 200 (not a login redirect) and an anonymous asset download succeeds. Alternative:
mirror downloads (Itch.io / GitHub Releases) for anonymous users.
## Future Systems (design-level backlog)
- [ ] Body-camera replay timeline in the shift review.
- [ ] Long (10-15 minute) shift lengths (phase lighting already implemented).
- [ ] Venue selection startup menu (keyboard cycling with `V` is implemented).
- [ ] IDE/editor-based GUT integration when a Godot editor workflow is introduced.
- [ ] Reduce RefCounted reference cycles (NPCController <-> FSM) noted in `MEMORY.md`.
## Open Polish
- [ ] Add a body/wand cooldown feel: wand screening currently has no reuse penalty; consider a
short per-guest scan animation.
- [ ] Keep `%` placeholders stable in `Strings.fmt` when adding keys (positional formatting).
## Detailed Graphics — remaining performance items
@@ -36,3 +34,11 @@ performance hardening only.
- [ ] Profile the crowd: ensure textured materials and props hold target frame rate around a
busy entrance.
- [ ] Add LOD or culling for distant NPCs/props if the crowd grows.
## Generalized Game Asset Tooling
- [ ] Reuse the OpenRouter image backend for role-aware arbitrary asset generation.
- [ ] Add exact-dimension image crop and aspect-preserving resize commands.
- [ ] Audit existing assets for role-specific dimensions, aspect, alpha, and tile seams;
report projection/perspective checks for visual review.
- [ ] Add automated tests and document asset-pipeline usage.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 801 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+2 -1
View File
@@ -40,5 +40,6 @@
"door.thwarted": "%s tries the locked door and fails.",
"flashlight.on": "Flashlight on.",
"flashlight.off": "Flashlight off.",
"hud.venue_switched": "Venue: %s"
"hud.venue_switched": "Venue: %s",
"wand.recharge": "Wand scanner is recharging…"
}
+23 -6
View File
@@ -57,12 +57,22 @@ godot --headless --path . --quit-after 120 # boot smoke t
./tools/build_releases.sh
```
Artifacts are written to `build/`:
Artifacts are written to `build/` (all exports embed the PCK — single files):
- `build/step-line-linux-<version>.x86_64`
- `build/step-line-windows-<version>.exe`
- `build/step-line-macos-<version>.zip`
When run in CI (tags/dispatch), the build job also packages:
- `build/step-line-setup-<version>.exe` — Windows installer (NSIS, incl. shortcuts +
uninstaller; apt `nsis` on the runner)
- `build/step-line-<version>-x86_64.AppImage` — Linux AppImage (`packaging/linux/build_appimage.sh`,
appimagetool via `APPIMAGE_EXTRACT_AND_RUN`)
Locally you can build the AppImage with `bash packaging/linux/build_appimage.sh <version>`;
the NSIS installer requires `makensis` (covered by CI).
> macOS builds are unsigned and will need `xattr -dr com.apple.quarantine`
> guidance or notarization for production releases.
@@ -70,15 +80,22 @@ Artifacts are written to `build/`:
The repository lives at `https://git.allucanget.biz/zwitschi/security-game`.
Preferred path — use the helper script with a token:
Preferred path — use the helper script. It needs either a token or the same
credentials used by `git push` (it falls back to `~/.git-credentials`):
```bash
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.1.0
GITEA_TOKEN=your_api_token ./tools/publish_release.sh 0.2.1
# or simply: ./tools/publish_release.sh 0.2.1 (uses ~/.git-credentials)
```
The script creates the release from `project.godot`'s `config/version`, uploads
all three `build/` artifacts, and prints the release URL. The token needs only
`write:repository` scope on the Gitea host.
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
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
> browse and download when the instance sets `[service] REQUIRE_SIGNIN_VIEW=false`
> in `app.ini` (the repo itself is public). See the TODO for the exact steps.
Manual alternative:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 237 KiB

+3 -3
View File
@@ -23,7 +23,7 @@ script_export_mode=2
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
binary_format/architecture="x86_64"
@@ -54,7 +54,7 @@ script_export_mode=2
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
binary_format/embed_pck=false
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
codesign/enable=false
@@ -90,7 +90,7 @@ script_export_mode=2
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=0
binary_format/embed_pck=false
binary_format/embed_pck=true
texture_format/s3tc_bptc=true
texture_format/etc2_astc=true
codesign/enable=false
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# AppImage entry point: launch the bundled game binary.
SELF="$(readlink -f "$0")"
HERE="$(dirname "$SELF")"
exec "${HERE}/usr/bin/step-line" "$@"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Build a self-contained Step Line AppImage (all game data included).
#
# Prereqs: exported Linux binary in build/ (embed_pck=true), curl.
# Usage: bash packaging/linux/build_appimage.sh [version]
# Output: build/step-line-<version>-x86_64.AppImage
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
VERSION="${1:-$(sed -n 's/.*config\/version="\([^"]*\)".*/\1/p' "$ROOT/project.godot")}"
SRC_BIN="$ROOT/build/step-line-linux-${VERSION}.x86_64"
if [[ ! -f "$SRC_BIN" ]]; then
echo "error: missing $SRC_BIN — run ./tools/build_releases.sh first" >&2
exit 1
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
APPDIR="$WORK/StepLine.AppDir"
mkdir -p "$APPDIR/usr/bin"
cp "$SRC_BIN" "$APPDIR/usr/bin/step-line"
# The export embeds the PCK (embed_pck=true), so no extra data files are needed.
cp "$HERE/step-line.desktop" "$APPDIR/step-line.desktop"
cp "$HERE/AppRun" "$APPDIR/AppRun"
chmod +x "$APPDIR/AppRun"
cp "$HERE/step-line.png" "$APPDIR/step-line.png"
# Desktop entry + metainfo in standard locations (AppStream validation requires
# the metainfo filename to match the component id, and the .desktop discoverable).
mkdir -p "$APPDIR/usr/share/applications" "$APPDIR/usr/share/metainfo"
cp "$HERE/step-line.desktop" "$APPDIR/usr/share/applications/step-line.desktop"
cp "$HERE/step-line.appdata.xml" "$APPDIR/usr/share/metainfo/net.stepline.game.appdata.xml"
TOOL="${APPIMAGE_TOOL:-/tmp/appimagetool-x86_64.AppImage}"
if [[ ! -f "$TOOL" ]]; then
echo "==> Downloading appimagetool"
curl -sL -o "$TOOL" \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x "$TOOL"
fi
OUT="$ROOT/build/step-line-${VERSION}-x86_64.AppImage"
echo "==> Building AppImage -> $OUT"
# Extract-and-run avoids a FUSE dependency on the build host.
APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" "$APPDIR" "$OUT"
echo "AppImage ready: $(ls -la "$OUT" | awk '{print $5}') bytes"
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>net.stepline.game</id>
<name>Step Line</name>
<summary>Workplace security simulation on Hollywood Boulevard</summary>
<url type="homepage">https://git.allucanget.biz/zwitschi/security-game</url>
<developer id="net.stepline"><name>Step Line Team</name></developer>
<description>
<p>
Step Line is a workplace security simulation about observation, communication,
access verification, and proportionate escalation. Guard the steps and entrance
of venues around Hollywood Boulevard: direct tourists, verify wristbands and IDs,
screen guests, and escalate only when you have to.
</p>
</description>
<launchable type="desktop-id">step-line.desktop</launchable>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<content_rating type="oars-1.1" />
<releases>
<release version="0.2.0" date="2026-10-31"/>
</releases>
</component>
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Step Line
Comment=Workplace security simulation on Hollywood Boulevard
Exec=step-line
Icon=step-line
Terminal=false
Categories=Game;Simulation;
Keywords=security;simulation;hollywood;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+74
View File
@@ -0,0 +1,74 @@
; Step Line Windows installer (NSIS 3).
; Build from the repo root with the exported self-contained exe:
; makensis /DVERSION=0.2.0 /DARTIFACT=build/step-line-windows-0.2.0.exe \
; /DICON=packaging/windows/step-line.ico packaging/windows/step-line.nsi
; Outputs: build/step-line-setup-<VERSION>.exe
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!ifndef ARTIFACT
!error "ARTIFACT define required (path to exported windows exe)"
!endif
!ifndef ICON
!define ICON "packaging\windows\step-line.ico"
!endif
; The installed executable uses this clean name (no spaces — NSIS /oname tokens
; on whitespace, so a spaced name breaks the File directive).
!define INSTALLED_EXE "step-line.exe"
!ifndef OUT
!define OUT "build\step-line-setup-${VERSION}.exe"
!endif
!define COMPANY "Step Line Project"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Step Line"
Unicode true
ManifestDPIAware true
Icon "${ICON}"
UninstallIcon "${ICON}"
Name "Step Line ${VERSION}"
OutFile "${OUT}"
InstallDir "$PROGRAMFILES64\Step Line"
RequestExecutionLevel admin
Page directory
Page instfiles
UninstPage uninstConfirm
UninstPage instfiles
Section "Install"
SetOutPath "$INSTDIR"
; The self-contained executable contains ALL game data (embedded PCK).
File "/oname=${INSTALLED_EXE}" "${ARTIFACT}"
WriteUninstaller "$INSTDIR\uninstall.exe"
; Shortcuts
CreateDirectory "$SMPROGRAMS\Step Line"
CreateShortCut "$SMPROGRAMS\Step Line\Step Line.lnk" "$INSTDIR\${INSTALLED_EXE}"
CreateShortCut "$SMPROGRAMS\Step Line\Uninstall.lnk" "$INSTDIR\uninstall.exe"
CreateShortCut "$DESKTOP\Step Line.lnk" "$INSTDIR\${INSTALLED_EXE}"
; Registry (Add/Remove Programs)
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "Step Line"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${COMPANY}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${INSTALLED_EXE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegDWORD HKLM "${UNINST_KEY}" "NoModify" 1
WriteRegDWORD HKLM "${UNINST_KEY}" "NoRepair" 1
SectionEnd
Section "Uninstall"
Delete "$INSTDIR\${INSTALLED_EXE}"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\Step Line\Step Line.lnk"
Delete "$SMPROGRAMS\Step Line\Uninstall.lnk"
RMDir "$SMPROGRAMS\Step Line"
Delete "$DESKTOP\Step Line.lnk"
DeleteRegKey HKLM "${UNINST_KEY}"
SectionEnd
+1 -1
View File
@@ -8,7 +8,7 @@ config_version=5
config/name="Step Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.2.0"
config/version="0.2.1"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility")
+1
View File
@@ -0,0 +1 @@
Pillow>=9.2
+1
View File
@@ -0,0 +1 @@
appimagetool.png
+10
View File
@@ -0,0 +1,10 @@
#! /bin/sh
set -e
this_dir="$(dirname -- "$(readlink -f -- "$0")")"
# make appimagetool prefer the bundled mksquashfs
export PATH="$this_dir"/usr/bin:"$PATH"
exec "$this_dir"/usr/bin/appimagetool "$@"
+1
View File
@@ -0,0 +1 @@
usr/share/applications/appimagetool.desktop
+1
View File
@@ -0,0 +1 @@
usr/share/icons/hicolor/128x128/apps/appimagetool.png
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=appimagetool
Exec=appimagetool
Comment=Tool to generate AppImages from AppDirs
Icon=appimagetool
Categories=Development;
Terminal=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop">
<id>appimagetool.desktop</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>appimagetool</name>
<summary>Create AppImage files</summary>
<description>
<p>
appimagetool is a tool that lets you generate AppImage files. An AppImage
is a self-running bundle that contains an application and everything
it needs to run that cannot reasonably expected to be part of each target system.
</p>
<p>
This can include libraries that are not commonly available on target systems,
resources such as translations, icons, fonts, and other auxiliary files.
appimagetool makes it easy to take an AppDir (e.g., generated by linuxdeployqt)
and turn it into an AppImage.
</p>
<p>
You can also embed update information so that your users can update the AppImage
using binary delta updates with the AppImageUpdate tool.
You can also sign the AppImage with GPG.
</p>
</description>
<url type="homepage">https://github.com/AppImage/AppImageKit</url>
<project_group>AppImage</project_group>
<provides>
<binary>appimagetool</binary>
</provides>
</component>
+17 -6
View File
@@ -2,15 +2,26 @@ class_name FSM
extends RefCounted
## Minimal generic finite state machine used by NPCController and ShiftCoordinator.
## Deterministic and headless-testable.
##
## The owner is held through a WeakRef so long-lived owners do not create a
## reference cycle (FSM <-> owner), which previously leaked RefCounted objects
## at process exit (see MEMORY.md).
signal state_changed(node: Object, from_state: String, to_state: String)
var current_state: String = ""
var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}]
var _owner_node: Object = null
var _owner_ref: WeakRef = null
func _init(owner_node: Object = null) -> void:
_owner_node = owner_node
if owner_node != null:
_owner_ref = weakref(owner_node)
## The live owner object, or null if it was freed.
func _owner() -> Object:
if _owner_ref == null:
return null
return _owner_ref.get_ref() as Object
## Register a transition. guard is an optional Callable(node) -> bool.
func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void:
@@ -20,7 +31,7 @@ func add_transition(from_state: String, to_state: String, guard: Callable = Call
func start(initial_state: String) -> void:
current_state = initial_state
state_changed.emit(_owner_node, "", initial_state)
state_changed.emit(_owner(), "", initial_state)
## Attempt to move to `to_state`. Returns true when executed.
func transition_to(to_state: String) -> bool:
@@ -31,10 +42,10 @@ func transition_to(to_state: String) -> bool:
if t["to"] != to_state:
continue
var guard: Callable = t["guard"]
if not guard.is_valid() or guard.call(_owner_node):
if not guard.is_valid() or guard.call(_owner()):
var from := current_state
current_state = to_state
state_changed.emit(_owner_node, from, to_state)
state_changed.emit(_owner(), from, to_state)
return true
return false
@@ -42,6 +53,6 @@ func can_transition_to(to_state: String) -> bool:
for t in _transitions.get(current_state, []):
if t["to"] == to_state:
var guard: Callable = t["guard"]
if not guard.is_valid() or guard.call(_owner_node):
if not guard.is_valid() or guard.call(_owner()):
return true
return false
+1 -1
View File
@@ -1,5 +1,5 @@
class_name ShiftCoordinator
extends Node
extends RefCounted
const Rules := preload("res://src/core/rules.gd")
const EventBus := preload("res://src/core/event_bus.gd")
+14 -1
View File
@@ -64,7 +64,11 @@ var verifier := AccessVerifier.new()
var wand_screener := WandScreener.new()
var active_scenario_id := "hollywood_building"
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing
## Shift length mirrors the scenario default (900s / 10-15 min per PLAN.md) unless
## overridden for a test or debug build.
var scenario_duration_s: float = 900.0
var _wand_cooldown := 0.0
const WAND_COOLDOWN_S := 0.8
var scene_root: Node3D = null
var npcs: Array = []
var npc_counter := 0
@@ -103,6 +107,7 @@ func _ready() -> void:
guard.cleared_target.connect(_on_guard_clear)
selected_scenario = _build_scenario(active_scenario_id)
scenario_duration_s = selected_scenario.shift_duration_s
coordinator = ShiftCoordinator.new()
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.npc_spawn_requested.connect(_spawn_npc)
@@ -169,6 +174,7 @@ func restart_shift() -> void:
inspector_current_events = []
current_incident_active = false
current_incident_npc_id = ""
scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
coordinator.start()
_last_phase = -1
@@ -552,6 +558,9 @@ func _toggle_inspection() -> void:
## Handheld wand inspection (The Joke Shop). Only available when the venue
## carries a wand. The wand reports an alert; the player interprets it.
func _toggle_wand_screening() -> void:
if _wand_cooldown > 0.0:
hud.log(Strings.lookup("wand.recharge"))
return
if wand_open:
hud.hide_wand()
hud.set_wand_icon(false)
@@ -615,6 +624,8 @@ func decide_wand(allowed: bool) -> void:
controller.screened = true
hud.hide_wand()
wand_open = false
# Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel).
_wand_cooldown = WAND_COOLDOWN_S
func decide_access(allowed: bool) -> void:
var controller: Object = _targeted_controller()
@@ -714,6 +725,8 @@ func _close_incident(outcome: String) -> void:
current_incident_npc_id = ""
func _physics_process(delta: float) -> void:
if _wand_cooldown > 0.0:
_wand_cooldown = maxf(0.0, _wand_cooldown - delta)
if coordinator == null or not coordinator.running:
return
coordinator.tick(delta)
+15 -7
View File
@@ -140,7 +140,7 @@ func _init() -> void:
# --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
var cam_box := VBoxContainer.new()
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
cam_box.position = Vector2(-180, 12)
cam_box.position = Vector2(-180, 50)
cam_box.alignment = BoxContainer.ALIGNMENT_END
add_child(cam_box)
var rec_row := HBoxContainer.new()
@@ -180,9 +180,12 @@ func _init() -> void:
icon_row.add_child(wand_icon)
# --- inspect panel (popup center) ---
var inspect_center := CenterContainer.new()
inspect_center.set_anchors_preset(Control.PRESET_FULL_RECT)
inspect_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(inspect_center)
inspect_panel = PanelContainer.new()
inspect_panel.visible = false
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
inspect_panel.modulate.a = 1.0
var ivbox := VBoxContainer.new()
inspect_panel.add_child(ivbox)
@@ -197,12 +200,15 @@ func _init() -> void:
insp_hint.text = "A = allow entry D = deny entry"
insp_hint.add_theme_font_size_override("font_size", 12)
ivbox.add_child(insp_hint)
add_child(inspect_panel)
inspect_center.add_child(inspect_panel)
# --- wand screening panel (popup center) ---
var wand_center := CenterContainer.new()
wand_center.set_anchors_preset(Control.PRESET_FULL_RECT)
wand_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(wand_center)
wand_panel = PanelContainer.new()
wand_panel.visible = false
wand_panel.set_anchors_preset(Control.PRESET_CENTER)
var wvbox := VBoxContainer.new()
wand_panel.add_child(wvbox)
wand_text = Label.new()
@@ -216,7 +222,7 @@ func _init() -> void:
wand_hint.text = "A = admit D = refuse entry"
wand_hint.add_theme_font_size_override("font_size", 12)
wvbox.add_child(wand_hint)
add_child(wand_panel)
wand_center.add_child(wand_panel)
# --- debug overlay ---
debug_label = Label.new()
@@ -374,6 +380,7 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
continue
var card := TextureRect.new()
card.texture = tex
card.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
card.custom_minimum_size = Vector2(92, 58)
card.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
card.tooltip_text = artifact.summary()
@@ -382,7 +389,8 @@ func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -
card_row.add_child(card)
added_card = true
if added_card:
inspect_panel.add_child(card_row)
var content := inspect_panel.get_child(0) as VBoxContainer
content.add_child(card_row)
var lines: Array[String] = []
for artifact in artifacts:
lines.append(" • " + artifact.summary())
@@ -403,7 +411,7 @@ func _clear_card_row() -> void:
return
var row := inspect_panel.find_child("INSPECT_CARD_ROW", true, false) as Control
if row:
inspect_panel.remove_child(row)
row.get_parent().remove_child(row)
row.queue_free()
func show_wand(controller: Object, result: Dictionary) -> void:
+5 -2
View File
@@ -34,9 +34,12 @@ func _init() -> void:
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(dim)
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(center)
panel = PanelContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
add_child(panel)
center.add_child(panel)
var vbox := VBoxContainer.new()
panel.add_child(vbox)
+6 -1
View File
@@ -12,9 +12,10 @@ var gm: Node = null
var state := 0
var state_frames := 0
var targeted := false
var camera_staged := false
func _initialize() -> void:
root.size = Vector2i(1600, 900)
root.size = Vector2i(1280, 720)
var packed: PackedScene = load("res://scenes/main.tscn")
var inst: Node = packed.instantiate()
root.add_child(inst)
@@ -29,6 +30,10 @@ func _process(_delta: float) -> bool:
push_error("GameManager not found")
quit(1)
return true
if gm != null and not camera_staged:
gm.guard.global_position = Vector3(0, 1.6, 5.0)
gm.guard.head.rotation.x = deg_to_rad(-8.0)
camera_staged = true
if frames > MAX_FRAMES:
print("timeout reached")
quit(0)
+4 -1
View File
@@ -20,10 +20,13 @@ func test_save_round_trip() -> bool:
var last: Dictionary = results[results.size() - 1]
var c := TestUtils.check_eq(last.get("scenario_id", ""), "hollywood_building", "round-trips scenario id")
var d := TestUtils.check_eq(float(last.get("reputation", {}).get("rating", 0.0)), 82.5, "round-trips rating")
save.free()
return a and b and c and d
func test_load_returns_array_when_empty_or_missing() -> bool:
# The tester home may already contain results; this only asserts shape safety.
var save := _save_instance()
var results: Array = save.load_shift_results()
return TestUtils.check(results is Array, "load_shift_results always returns Array")
var ok := TestUtils.check(results is Array, "load_shift_results always returns Array")
save.free()
return ok
+1 -1
View File
@@ -30,7 +30,7 @@ func test_strings_cover_ui_keys() -> bool:
"hud.aim_first", "hud.no_one_nearby", "hud.targeting", "hud.no_wand",
"hud.screen_first", "access.granted", "access.denied", "access.policy_satisfied",
"access.wrong_grant", "access.wrong_deny", "wand.admitted_prohibited",
"wand.denied_false_alarm", "wand.cleared", "wand.refused", "radio.address_first",
"wand.denied_false_alarm", "wand.cleared", "wand.refused", "wand.recharge", "radio.address_first",
"radio.blocked", "radio.inaccurate", "radio.received", "radio.emergency_dispatched",
"responder.arrived", "react.comply", "react.leave", "react.argue", "react.record",
"react.ignore", "react.threaten", "react.force_entry", "react.return_later",
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env python3
"""Generate, crop, resize, and quality-check game image assets."""
import argparse
import json
import sys
from pathlib import Path
from PIL import Image, ImageChops, ImageOps, ImageStat
import generate_textures
ROOT = Path(__file__).resolve().parent.parent
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}
PROFILES = {
"texture": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
"aspect": 1.0, "alpha": None, "view": "flat/orthographic"},
"tile": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
"aspect": 1.0, "alpha": None, "view": "flat/orthographic",
"power_of_two": True, "seamless": True},
"icon": {"target": (64, 64), "min": (32, 32), "max": (512, 512),
"aspect": 1.0, "alpha": True, "view": "flat, centered glyph"},
"app_icon": {"target": (1024, 1024), "min": (512, 512), "max": (1024, 1024),
"aspect": 1.0, "alpha": None, "view": "square, centered composition"},
"overlay": {"target": (512, 512), "min": (256, 256), "max": (1024, 1024),
"aspect": 1.0, "alpha": None, "view": "screen-space overlay"},
"card": {"target": (256, 128), "min": (128, 64), "max": (1024, 512),
"aspect": 2.0, "alpha": None, "view": "flat, front-facing card"},
"banner": {"target": (1024, 256), "min": (512, 128), "max": (2048, 512),
"aspect": 4.0, "alpha": None, "view": "flat, front-facing banner"},
"backdrop": {"target": (1024, 256), "min": (1024, 256), "max": (8192, 2048),
"aspect": 4.0, "alpha": None, "view": "straight-on wide backdrop"},
"background": {"target": (1920, 1080), "min": (1280, 720), "max": (8192, 4320),
"aspect": 16 / 9, "alpha": None, "view": "intended game camera and horizon"},
}
ROLE_GUIDANCE = {
"texture": "flat-lit game surface texture, straight-on orthographic view, no perspective",
"tile": "seamless tileable surface texture, flat even lighting, orthographic, no perspective or shadows",
"icon": "single centered flat UI glyph, clean silhouette, transparent background, no mockup or perspective",
"app_icon": "square game application icon, centered composition, no perspective",
"overlay": "screen-space overlay texture with transparent edges, no perspective",
"card": "single front-facing flat game UI card, no perspective, no surrounding mockup",
"banner": "wide front-facing game banner texture, no perspective, no surrounding scene",
"backdrop": "wide seamless-looking game environment backdrop, straight-on view, no foreground objects",
"background": "wide 16:9 game background environment with a coherent horizon and straight camera view",
}
def resolve_path(value):
path = Path(value).expanduser()
return path if path.is_absolute() else ROOT / path
def has_alpha(image):
return image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info)
def has_transparency(image):
if not has_alpha(image):
return False
return image.convert("RGBA").getchannel("A").getextrema()[0] < 255
def parse_color(value):
value = value.strip().lstrip("#")
if len(value) not in (6, 8):
raise argparse.ArgumentTypeError("color must be #RRGGBB or #RRGGBBAA")
try:
channels = tuple(int(value[index:index + 2], 16)
for index in range(0, len(value), 2))
except ValueError as exc:
raise argparse.ArgumentTypeError(
"color must use hexadecimal digits") from exc
return channels + ((255,) if len(channels) == 3 else ())
def save_image(image, path):
path.parent.mkdir(parents=True, exist_ok=True)
suffix = path.suffix.lower()
if suffix in (".jpg", ".jpeg") and image.mode in ("RGBA", "LA"):
background = Image.new("RGB", image.size, (0, 0, 0))
background.paste(image.convert("RGBA"),
mask=image.convert("RGBA").getchannel("A"))
image = background
image.save(path)
print(
f"wrote {path.relative_to(ROOT) if path.is_relative_to(ROOT) else path} {image.size}")
def crop_image(source, output, width, height, x=None, y=None):
with Image.open(source) as image:
image.load()
source_width, source_height = image.size
if width <= 0 or height <= 0 or width > source_width or height > source_height:
raise ValueError(
f"crop {width}x{height} must fit inside source {source_width}x{source_height}")
if (x is None) != (y is None):
raise ValueError("--x and --y must be supplied together")
left = (source_width - width) // 2 if x is None else x
top = (source_height - height) // 2 if y is None else y
if left < 0 or top < 0 or left + width > source_width or top + height > source_height:
raise ValueError(
"crop origin and dimensions extend beyond the source image")
cropped = image.crop((left, top, left + width, top + height))
save_image(cropped, output)
def resize_image(source, output, width, height, fit="contain", background=(0, 0, 0, 0)):
if width <= 0 or height <= 0:
raise ValueError("resize dimensions must be positive")
with Image.open(source) as image:
image.load()
if fit == "stretch":
result = image.resize((width, height), Image.Resampling.LANCZOS)
elif fit == "cover":
result = ImageOps.fit(image, (width, height),
method=Image.Resampling.LANCZOS)
else:
contained = ImageOps.contain(
image, (width, height), method=Image.Resampling.LANCZOS)
use_alpha = has_alpha(image) or background[3] < 255
mode = "RGBA" if use_alpha else "RGB"
fill = background if use_alpha else background[:3]
result = Image.new(mode, (width, height), fill)
contained = contained.convert(mode)
offset = ((width - contained.width) // 2,
(height - contained.height) // 2)
if use_alpha:
result.alpha_composite(contained, offset)
else:
result.paste(contained, offset)
save_image(result, output)
def infer_role(path):
parts = [part.lower() for part in path.parts]
stem = path.stem.lower()
if "icons" in parts:
return "icon"
if "cards" in parts:
return "card"
if stem == "icon":
return "app_icon"
if "vignette" in stem or "overlay" in stem:
return "overlay"
if "skyline" in stem or "backdrop" in parts:
return "backdrop"
if "awning" in stem or "banner" in parts:
return "banner"
if stem in {"brick_facade", "concrete", "asphalt"} or "tiles" in parts:
return "tile"
if "backgrounds" in parts or "background" in parts:
return "background"
return "texture"
def _mean_edge_difference(image):
rgb = image.convert("RGB")
width, height = rgb.size
horizontal = ImageStat.Stat(ImageChops.difference(
rgb.crop((0, 0, 1, height)), rgb.crop((width - 1, 0, width, height))))
vertical = ImageStat.Stat(ImageChops.difference(
rgb.crop((0, 0, width, 1)), rgb.crop((0, height - 1, width, height))))
return max(sum(horizontal.mean) / len(horizontal.mean),
sum(vertical.mean) / len(vertical.mean))
def audit_image(path, role="auto", seam_threshold=18.0):
path = Path(path)
chosen_role = infer_role(path) if role == "auto" else role
profile = PROFILES[chosen_role]
errors = []
warnings = []
try:
with Image.open(path) as image:
image.load()
width, height = image.size
alpha = has_transparency(image)
minimum = profile["min"]
maximum = profile["max"]
if width < minimum[0] or height < minimum[1]:
errors.append(
f"too small: {width}x{height}; minimum is {minimum[0]}x{minimum[1]}")
if width > maximum[0] or height > maximum[1]:
errors.append(
f"too large: {width}x{height}; maximum is {maximum[0]}x{maximum[1]}")
aspect = profile.get("aspect")
actual_aspect = width / height
if aspect and abs(actual_aspect - aspect) / aspect > 0.12:
errors.append(
f"wrong aspect ratio: {actual_aspect:.3f}; expected about {aspect:.3f}")
if profile.get("alpha") is True and not alpha:
errors.append("transparency required but image is opaque")
elif profile.get("alpha") is False and alpha:
warnings.append(
"image has an alpha channel although this role is normally opaque")
if profile.get("power_of_two") and (width & (width - 1) or height & (height - 1)):
warnings.append("dimensions are not powers of two")
edge_difference = None
if profile.get("seamless"):
edge_difference = _mean_edge_difference(image)
if edge_difference > seam_threshold:
errors.append(
f"tile edges differ by {edge_difference:.1f} (limit {seam_threshold:.1f})")
manual_review = [
f"Confirm {profile.get('view', 'intended')} perspective/projection visually; "
"automated checks cannot infer it reliably."
]
return {
"path": str(path), "role": chosen_role, "size": [width, height],
"alpha": alpha, "edge_difference": edge_difference,
"quality": "bad" if errors else "good",
"errors": errors, "warnings": warnings, "manual_review": manual_review,
}
except (OSError, ValueError) as exc:
return {
"path": str(path), "role": chosen_role, "size": None, "alpha": None,
"edge_difference": None, "quality": "bad", "errors": [f"cannot read image: {exc}"],
"warnings": [], "manual_review": [],
}
def image_paths(values):
found = []
for value in values:
path = resolve_path(value)
if path.is_dir():
found.extend(item for item in path.rglob(
"*") if item.is_file() and item.suffix.lower() in IMAGE_EXTENSIONS)
elif path.suffix.lower() in IMAGE_EXTENSIONS:
found.append(path)
else:
raise ValueError(f"not an image file or directory: {path}")
return sorted(set(found))
def _generate(args):
profile = PROFILES[args.role]
if (args.width is None) != (args.height is None):
raise ValueError("--width and --height must be supplied together")
width, height = (
args.width, args.height) if args.width else profile["target"]
transparent = args.transparent if args.transparent is not None else profile.get(
"alpha") is True
prompt = f"{args.prompt.strip()}. {ROLE_GUIDANCE[args.role]}."
image = generate_textures.generate_image(
prompt, args.model, args.request_size, transparent)
if args.role == "icon" and has_alpha(image):
image = resize_image_from_image(
image, width, height, "contain", (0, 0, 0, 0))
else:
image = ImageOps.fit(image, (width, height),
method=Image.Resampling.LANCZOS)
save_image(image, resolve_path(args.output))
def resize_image_from_image(image, width, height, fit, background):
if fit == "cover":
return ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS)
contained = ImageOps.contain(
image, (width, height), method=Image.Resampling.LANCZOS)
mode = "RGBA" if has_alpha(image) or background[3] < 255 else "RGB"
result = Image.new(mode, (width, height),
background if mode == "RGBA" else background[:3])
contained = contained.convert(mode)
offset = ((width - contained.width) // 2, (height - contained.height) // 2)
if mode == "RGBA":
result.alpha_composite(contained, offset)
else:
result.paste(contained, offset)
return result
def build_parser():
parser = argparse.ArgumentParser(
description="Create and validate game image assets.")
commands = parser.add_subparsers(dest="command", required=True)
generate = commands.add_parser(
"generate", help="generate an image using the existing OpenRouter backend")
generate.add_argument("--prompt", required=True)
generate.add_argument("--output", required=True,
help="output path, relative to the project root or absolute")
generate.add_argument("--role", choices=PROFILES, required=True)
generate.add_argument(
"--model", default=generate_textures.DEFAULT_IMAGE_MODEL)
generate.add_argument("--request-size", type=int,
default=generate_textures.GEN_SIZE)
generate.add_argument("--width", type=int)
generate.add_argument("--height", type=int)
alpha = generate.add_mutually_exclusive_group()
alpha.add_argument("--transparent", dest="transparent",
action="store_true")
alpha.add_argument("--opaque", dest="transparent", action="store_false")
generate.set_defaults(transparent=None)
generate.set_defaults(handler=_generate)
crop = commands.add_parser(
"crop", help="crop an exact-size region; defaults to the image center")
crop.add_argument("input")
crop.add_argument("output")
crop.add_argument("--width", type=int, required=True)
crop.add_argument("--height", type=int, required=True)
crop.add_argument("--x", type=int)
crop.add_argument("--y", type=int)
crop.set_defaults(handler=lambda args: crop_image(resolve_path(args.input), resolve_path(args.output),
args.width, args.height, args.x, args.y))
resize = commands.add_parser(
"resize", help="resize to exact dimensions while preserving aspect by default")
resize.add_argument("input")
resize.add_argument("output")
resize.add_argument("--width", type=int, required=True)
resize.add_argument("--height", type=int, required=True)
resize.add_argument("--fit", choices=("contain",
"cover", "stretch"), default="contain")
resize.add_argument("--background", type=parse_color, default=(0, 0, 0, 0))
resize.set_defaults(handler=lambda args: resize_image(resolve_path(args.input), resolve_path(args.output),
args.width, args.height, args.fit, args.background))
audit = commands.add_parser(
"audit", help="classify images as good/bad using their game-asset role")
audit.add_argument("paths", nargs="+", help="image files or directories")
audit.add_argument("--role", choices=("auto", *PROFILES), default="auto")
audit.add_argument("--seam-threshold", type=float, default=18.0)
audit.add_argument("--json", action="store_true",
help="emit machine-readable JSON")
audit.set_defaults(handler=_audit)
return parser
def _audit(args):
results = [audit_image(path, args.role, args.seam_threshold)
for path in image_paths(args.paths)]
if args.json:
print(json.dumps(results, indent=2))
else:
good_count = sum(result["quality"] == "good" for result in results)
for result in results:
size = "unreadable" if result["size"] is None else "x".join(
map(str, result["size"]))
print(
f"{result['quality'].upper():4} {result['role']:10} {size:12} {result['path']}")
for message in result["errors"] + result["warnings"]:
print(f" - {message}")
for message in result["manual_review"]:
print(f" - review: {message}")
print(f"Summary: {good_count} good, {len(results) - good_count} bad")
return 1 if any(result["quality"] == "bad" for result in results) else 0
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
try:
result = args.handler(args)
return result if isinstance(result, int) else 0
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -16,6 +16,8 @@ set -euo pipefail
GODOT="${GODOT:-godot}"
VERSION="$(grep -oP 'config/version="\K[^"]+' project.godot)"
mkdir -p build
# Presets now embed the PCK into a single binary; drop stale .pck leftovers.
rm -f build/*.pck
echo "==> Exporting Linux (x86_64)"
"$GODOT" --headless --export-release "Linux" "build/step-line-linux-${VERSION}.x86_64"
+299 -21
View File
@@ -3,17 +3,93 @@
All outputs are deterministic (fixed seeds), <=1024px, power-of-two where
possible. Run from the repo root: python3 tools/generate_textures.py
Optional AI mode generates textures through the OpenRouter image API
(https://openrouter.ai/docs/api/api-reference/images/generate-an-image.md).
Requires an API key in the OPENROUTER_API_KEY environment variable:
python3 tools/generate_textures.py --ai
python3 tools/generate_textures.py --ai --prompt "..." --name foo.png
"""
import argparse
import base64
import io
import json
import math
import os
import random
import stat
import sys
import urllib.error
import urllib.request
from PIL import Image, ImageDraw, ImageFilter, ImageFont
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
ASSETS = os.path.join(ROOT, "assets", "textures")
# OpenRouter image generation endpoint and default model (from the API docs).
OPENROUTER_IMAGES_URL = "https://openrouter.ai/api/v1/images"
DEFAULT_IMAGE_MODEL = "bytedance-seed/seedream-4.5"
MAX_DIM = 1024 # hard cap on the longest side of any generated texture
# px requested from the API; some models (e.g. seedream) need >= 1920
GEN_SIZE = 2048
# Default batch for `--ai`: one entry per game texture (see test_world.gd).
# Each entry is (output name, prompt, transparent_background).
AI_TEXTURES = [
# --- world surfaces (seamless, flat-lit) ---
("ai/brick_facade.png",
"seamless tileable red brick wall texture, weathered urban facade, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/concrete.png",
"seamless tileable grey concrete wall texture, subtle cracks and stains, "
"flat even lighting, frontal view, photorealistic game texture", False),
("ai/asphalt.png",
"seamless tileable dark asphalt road texture, fine grain, "
"flat even lighting, frontal view, photorealistic game texture", False),
# --- storefront dressing ---
("ai/awning.png",
"red and white striped storefront awning with scalloped bottom edge, "
"frontal view, flat even lighting, game texture", False),
("ai/skyline.png",
"night city skyline silhouette, dark building shapes with a few lit "
"windows, flat graphic style, isolated on transparent background", True),
# --- screen-space overlay ---
("ai/vignette.png",
"radial vignette overlay, black edges fading to fully transparent center, "
"grayscale, soft gradient", True),
# --- HUD icons (white glyphs on transparent) ---
("ai/icons/flashlight_on.png",
"simple flat flashlight icon, white glyph on transparent background, "
"minimal UI icon", True),
("ai/icons/door_locked.png",
"simple flat locked door icon with keyhole, white outline glyph on "
"transparent background, minimal UI icon", True),
("ai/icons/wand.png",
"simple flat handheld security wand icon, white glyph on transparent "
"background, minimal UI icon", True),
("ai/icons/rec_dot.png",
"record button icon, red filled dot inside a white circle outline, on "
"transparent background, minimal UI icon", True),
# --- access artifact cards ---
("ai/cards/id_card.png",
"driver license ID card, blue plastic card with photo placeholder and "
"text lines, flat frontal view, game UI asset", False),
("ai/cards/wristband.png",
"red event access wristband with strap, flat frontal view, game UI asset",
False),
("ai/cards/ticket.png",
"green admission ticket stub with perforated edge, flat frontal view, "
"game UI asset", False),
("ai/cards/credential.png",
"dark grey staff credential pass with lanyard clip, flat frontal view, "
"game UI asset", False),
("ai/cards/guest_list.png",
"beige guest list document with ruled lines and checkboxes, flat frontal "
"view, game UI asset", False),
]
FONT_CANDIDATES = [
"/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
@@ -21,12 +97,14 @@ FONT_CANDIDATES = [
"/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Bold.ttf",
]
def font(size: int):
for path in FONT_CANDIDATES:
if os.path.exists(path):
return ImageFont.truetype(path, size)
return ImageFont.load_default()
def save(img: Image.Image, name: str) -> None:
os.makedirs(ASSETS, exist_ok=True)
path = os.path.join(ASSETS, name)
@@ -34,6 +112,7 @@ def save(img: Image.Image, name: str) -> None:
img.save(path, optimize=True)
print(f"wrote {os.path.relpath(path, ROOT)} {img.size}")
def grain(size, base, spread, seed, blur=1.0):
rnd = random.Random(seed)
img = Image.new("L", (size, size), base)
@@ -44,6 +123,7 @@ def grain(size, base, spread, seed, blur=1.0):
px[x, y] = max(0, min(255, v))
return img.filter(ImageFilter.GaussianBlur(blur))
def brick_facade():
size = 512
mortar = (118, 110, 104)
@@ -68,13 +148,14 @@ def brick_facade():
bx = rnd.randint(0, size - 1)
by = rnd.randint(0, size - 1)
d.point((bx, by), fill=(max(0, base[0] - 18 + rnd.randint(0, 30)),
max(0, base[1] - 10 + rnd.randint(0, 22)),
max(0, base[2] - 8 + rnd.randint(0, 18))))
max(0, base[1] - 10 + rnd.randint(0, 22)),
max(0, base[2] - 8 + rnd.randint(0, 18))))
# horizontal mortar lines on top of bricks (wraps vertically by drawing at 0 and size)
for y in [-mortar_t, size - mortar_t]:
d.rectangle([0, y, size, y + mortar_t], fill=mortar)
save(img, "brick_facade.png")
def seamless_concrete():
size = 512
img = grain(size, 150, 26, 7, blur=2.5).convert("RGBA")
@@ -83,7 +164,8 @@ def seamless_concrete():
for _ in range(90):
x, y = rnd.randint(0, size), rnd.randint(0, size)
r = rnd.choice([2, 3, 4])
d.ellipse([x - r, y - r, x + r, y + r], fill=(0, 0, 0, rnd.randint(8, 22)))
d.ellipse([x - r, y - r, x + r, y + r],
fill=(0, 0, 0, rnd.randint(8, 22)))
for _ in range(8): # subtle crack segments
x, y = rnd.randint(0, size), rnd.randint(0, size)
pts = [(x, y)]
@@ -94,6 +176,7 @@ def seamless_concrete():
d.line(pts, fill=(70, 70, 70, 170), width=1)
save(img, "concrete.png")
def asphalt():
size = 512
img = grain(size, 52, 14, 23, blur=1.2).convert("RGBA")
@@ -101,9 +184,11 @@ def asphalt():
rnd = random.Random(31)
for _ in range(600):
x, y = rnd.randint(0, size), rnd.randint(0, size)
d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),) * 3 + (rnd.randint(40, 110),))
d.point((x, y), fill=(max(0, 40 - rnd.randint(0, 18)),)
* 3 + (rnd.randint(40, 110),))
save(img, "asphalt.png")
def awning():
w, h = 1024, 256
img = Image.new("RGB", (w, h), (140, 40, 45))
@@ -111,7 +196,8 @@ def awning():
stripe = 64
for i in range(w // stripe):
if i % 2 == 1:
d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h], fill=(230, 225, 220))
d.rectangle([i * stripe, 0, (i + 1) * stripe - 2, h],
fill=(230, 225, 220))
# scalloped bottom edge
for x in range(0, w, 32):
d.arc([x, h - 24, x + 32, h + 40], 0, 180, fill=(80, 20, 24), width=6)
@@ -125,6 +211,7 @@ def awning():
img = Image.alpha_composite(img.convert("RGBA"), shade)
save(img, "awning.png")
def skyline():
w, h = 1024, 256
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
@@ -141,11 +228,13 @@ def skyline():
wx = x + rnd.randint(4, max(4, bw - 8))
wy = h - rnd.randint(4, bh - 4)
if rnd.random() < 0.5:
d.rectangle([wx, wy, wx + 6, wy + 8], fill=(255, 200, 120, 220))
d.rectangle([wx, wy, wx + 6, wy + 8],
fill=(255, 200, 120, 220))
x += bw + rnd.randint(10, 46)
d.rectangle([0, h - 6, w, h], fill=(12, 13, 22, 255))
save(img, "skyline.png")
def vignette():
size = 512
img = Image.new("L", (size, size), 0)
@@ -159,52 +248,115 @@ def vignette():
px[x, y] = int(255 * (t * t))
save(img, "vignette.png")
def icon(name, draw_fn):
size = 64
def icon(name, draw_fn, size=64):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
draw_fn(d, size)
save(img, name)
def icons():
def flashlight_on(d, s):
d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62], radius=4, fill=(255,255,255,255))
d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s*0.74), (s*0.62, s*0.62)], fill=(255,214,120,255))
d.rounded_rectangle([s*0.30, s*0.30, s*0.62, s*0.62],
radius=4, fill=(255, 255, 255, 255))
d.polygon([(s*0.62, s*0.30), (s*0.82, s*0.18), (s*0.82, s *
0.74), (s*0.62, s*0.62)], fill=(255, 214, 120, 255))
def door_locked(d, s):
d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82], outline=(255,255,255,255), width=5)
d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66], radius=3, fill=(255,255,255,255))
d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180, 360, fill=(255,255,255,255), width=5)
d.rectangle([s*0.18, s*0.18, s*0.82, s*0.82],
outline=(255, 255, 255, 255), width=5)
d.rounded_rectangle([s*0.36, s*0.34, s*0.64, s*0.66],
radius=3, fill=(255, 255, 255, 255))
d.arc([s*0.40, s*0.28, s*0.60, s*0.50], 180,
360, fill=(255, 255, 255, 255), width=5)
def wand(d, s):
d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90], radius=3, fill=(255,255,255,255))
d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120,220,160,255))
d.rounded_rectangle([s*0.30, s*0.10, s*0.44, s*0.90],
radius=3, fill=(255, 255, 255, 255))
d.ellipse([s*0.24, s*0.06, s*0.50, s*0.32], fill=(120, 220, 160, 255))
def rec(d, s):
d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86], outline=(255,255,255,255), width=5)
d.ellipse([s*0.14, s*0.14, s*0.86, s*0.86],
outline=(255, 255, 255, 255), width=5)
d.ellipse([s*0.30, s*0.30, s*0.70, s*0.70], fill=(230, 60, 60, 255))
icon("icons/flashlight_on.png", flashlight_on)
icon("icons/door_locked.png", door_locked)
icon("icons/wand.png", wand)
icon("icons/rec_dot.png", rec)
def app_icon():
"""Game app icon: dark badge, gold star, white 'S' — used by desktop/AppImage
(PNG) and the Windows installer (ICO)."""
def badge(d, s):
d.ellipse([2, 2, s-2, s-2], fill=(24, 26, 38, 255))
d.ellipse([int(s*0.10), int(s*0.10), int(s*0.90), int(s*0.90)],
outline=(120, 130, 170, 255), width=max(2, s//64))
# gold star
cx, cy, r = s*0.5, s*0.44, s*0.30
pts = []
for i in range(10):
ang = -90 + i * 36
rad = r if i % 2 == 0 else r * 0.42
import math
pts.append((cx + rad * math.cos(math.radians(ang)),
cy + rad * math.sin(math.radians(ang))))
d.polygon(pts, fill=(255, 205, 80, 255))
icon("icon.png", badge, size=1024)
icon("icon.ico", badge, size=256)
# ICO for Windows. Must be BMP-entry based: PIL's ICO serializer emits
# PNG-compressed entries which NSIS' icon loader rejects. ImageMagick
# writes classic BMP entries; fall back to PIL only if ImageMagick is gone
# (flag it so a human notices).
import subprocess
import sys
ico_path = os.path.join(ASSETS, "icon.ico")
src_png = os.path.join(ASSETS, "icon.png")
ran = False
for cmd in (
["magick", src_png, "-background", "none", "-define",
"icon:auto-resize=48,32,16", ico_path],
["convert", src_png, "-background", "none",
"-define", "icon:auto-resize=48,32,16", ico_path],
):
try:
subprocess.run(cmd, check=True, capture_output=True)
ran = True
break
except (FileNotFoundError, subprocess.CalledProcessError):
continue
if ran:
print("wrote assets/textures/icon.ico (BMP 16/32/48 via ImageMagick)")
else:
print("WARNING: ImageMagick not found; writing PIL ICO (may be PNG-compressed)")
Image.open(src_png).convert("RGBA").save(
ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)])
def cards():
CW, CH = 256, 128
def base(card_name, bg, label, band=(0,0,0,0)):
def base(card_name, bg, label, band=(0, 0, 0, 0)):
img = Image.new("RGBA", (CW, CH), bg + (255,))
d = ImageDraw.Draw(img)
d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10, outline=(0,0,0,90), width=2)
d.rounded_rectangle([6, 6, CW-6, CH-6], radius=10,
outline=(0, 0, 0, 90), width=2)
if band and band[3] == 255:
d.rectangle([0, CH-34, CW, CH], fill=band)
f = font(26)
d.text((14, 10), label, font=f, fill=(255,255,255,235))
d.text((14, 10), label, font=f, fill=(255, 255, 255, 235))
# body text lines
f2 = font(15)
y = 52
for _ in range(4):
w = 150 - (24 if _ == 3 else 0)
d.rectangle([16, y, 16 + w, y + 8], fill=(255,255,255,110))
d.rectangle([16, y, 16 + w, y + 8], fill=(255, 255, 255, 110))
y += 24
# photo square for id-like cards
if "photo" in card_name:
d.rectangle([CW-84, 14, CW-18, 96], fill=(180,190,205,255))
d.rectangle([CW-84, 14, CW-18, 96], fill=(180, 190, 205, 255))
save(img, f"cards/{card_name}.png")
base("id_card", (46, 92, 168), "CA DRIVER LICENSE", band=(20, 36, 70, 255), )
base("wristband", (196, 62, 78), "EVENT ACCESS", band=(150, 35, 50, 255))
@@ -212,7 +364,132 @@ def cards():
base("credential", (58, 62, 74), "STAFF PASS", band=(20, 22, 28, 255))
base("guest_list", (200, 190, 168), "GUEST LIST", band=(120, 90, 60, 255))
def _api_key() -> str:
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if not key:
sys.exit("error: OPENROUTER_API_KEY is not set; export it before using --ai")
return key
def generate_image(prompt: str, model: str, size: int,
transparent: bool = False) -> Image.Image:
"""Call the OpenRouter image API and return the decoded PIL image.
Requests a square image at `size` px and decodes the base64 PNG/JPEG/WebP
bytes returned in `data[].b64_json`. The requested size is clamped to at
least GEN_SIZE because some models (e.g. seedream) reject small outputs;
callers downscale the result to MAX_DIM afterwards. When `transparent` is
true, the API is asked for a transparent background (PNG alpha).
"""
size = max(GEN_SIZE, int(size))
body = {
"model": model,
"prompt": prompt,
"size": f"{size}x{size}",
"output_format": "png",
"n": 1,
}
if transparent:
body["background"] = "transparent"
req = urllib.request.Request(
OPENROUTER_IMAGES_URL,
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {_api_key()}",
"Content-Type": "application/json",
"HTTP-Referer": "https://git.allucanget.biz/zwitschi/security-game",
"X-Title": "Step Line",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=300) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")
sys.exit(f"error: OpenRouter returned HTTP {exc.code}: {detail}")
except urllib.error.URLError as exc:
sys.exit(f"error: could not reach OpenRouter: {exc.reason}")
data = payload.get("data") or []
if not data or not data[0].get("b64_json"):
sys.exit(f"error: OpenRouter returned no image data: {payload}")
raw = base64.b64decode(data[0]["b64_json"])
try:
img = Image.open(io.BytesIO(raw))
img.load()
except Exception as exc: # noqa: BLE001 - surface any decode failure clearly
sys.exit(f"error: could not decode returned image: {exc}")
return img
def _fit(img: Image.Image, max_dim: int = MAX_DIM) -> Image.Image:
"""Downscale to fit within max_dim on the longest side, preserving aspect."""
w, h = img.size
longest = max(w, h)
if longest <= max_dim:
return img
scale = max_dim / longest
return img.resize((max(1, round(w * scale)), max(1, round(h * scale))),
Image.Resampling.LANCZOS)
def generate_ai_textures(prompt: str, name: str, model: str, size: int,
transparent: bool = False) -> None:
"""Generate one texture via OpenRouter and save it under assets/textures/."""
img = generate_image(prompt, model, size, transparent)
img = _fit(img)
if transparent:
img = img.convert("RGBA")
elif img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img = img.convert("RGBA")
else:
img = img.convert("RGB")
save(img, name)
def _run_ai(args: argparse.Namespace) -> None:
if args.prompt:
if not args.name:
sys.exit("error: --name is required when --prompt is given")
generate_ai_textures(args.prompt, args.name, args.model, args.size)
return
for name, prompt, transparent in AI_TEXTURES:
print(f"generating {name} ...")
generate_ai_textures(prompt, name, args.model, args.size, transparent)
def _build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate Step Line textures (authored or via OpenRouter).")
parser.add_argument(
"--ai", action="store_true",
help="generate textures through the OpenRouter image API "
"(requires OPENROUTER_API_KEY)")
parser.add_argument(
"--prompt", default="",
help="single prompt for --ai (overrides the default batch)")
parser.add_argument(
"--name", default="",
help="output filename under assets/textures/ for a single --prompt")
parser.add_argument(
"--model", default=DEFAULT_IMAGE_MODEL,
help=f"image model slug (default: {DEFAULT_IMAGE_MODEL})")
parser.add_argument(
"--size", type=int, default=GEN_SIZE,
help=f"square size requested from the API in px (min {GEN_SIZE}); "
f"output is downscaled to <= {MAX_DIM} (default: {GEN_SIZE})")
return parser
if __name__ == "__main__":
args = _build_arg_parser().parse_args()
if args.ai:
_run_ai(args)
print("done")
sys.exit(0)
brick_facade()
seamless_concrete()
asphalt()
@@ -220,5 +497,6 @@ if __name__ == "__main__":
skyline()
vignette()
icons()
app_icon()
cards()
print("done")
+48 -23
View File
@@ -2,8 +2,9 @@
# Publish a release to the Gitea/Forgejo host, uploading the built artifacts.
#
# Requires:
# - GITEA_TOKEN (a token with write access to the repo)
# - build artifacts from tools/build_releases.sh (or already exported)
# - GITEA_TOKEN (token with write access) OR credentials usable for basic auth
# (e.g. the same ones stored in ~/.git-credentials by `git push`)
# - build artifacts from tools/build_releases.sh (+ optional installer/AppImage)
#
# Usage:
# GITEA_TOKEN=xxx ./tools/publish_release.sh [version]
@@ -12,45 +13,69 @@ set -euo pipefail
REPO="${REPO:-zwitschi/security-game}"
HOST="${HOST:-https://git.allucanget.biz}"
API="$HOST/api/v1/repos/$REPO/releases"
VERSION="${1:-$(grep -oP 'config/version="\K[^"]+' project.godot)}"
TAG="v$VERSION"
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo "error: GITEA_TOKEN is not set (see docs/RELEASING.md)" >&2
if [[ -n "${GITEA_TOKEN:-}" ]]; then
AUTH=("Authorization: token $GITEA_TOKEN")
elif [[ -f "$HOME/.git-credentials" ]]; then
CRED="$(python3 -c "
import os
for l in open(os.path.expanduser('~/.git-credentials')):
if '$HOST'.split('//')[1] in l:
print(l.split('://',1)[1].split('@')[0]); break")"
if [[ -z "$CRED" ]]; then
echo "error: no credentials for $HOST in ~/.git-credentials" >&2
exit 1
fi
else
echo "error: set GITEA_TOKEN or provide ~/.git-credentials" >&2
exit 1
fi
# Artifacts must already exist (run tools/build_releases.sh first).
printf -v BS 'build/step-line-linux-%s.x86_64' "$VERSION"
printf -v BS_WIN 'build/step-line-windows-%s.exe' "$VERSION"
printf -v BS_MAC 'build/step-line-macos-%s.zip' "$VERSION"
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
if [[ ! -f "$f" ]]; then
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2
exit 1
# Artifacts that must exist (run tools/build_releases.sh first).
portables=(
"build/step-line-linux-$VERSION.x86_64"
"build/step-line-windows-$VERSION.exe"
"build/step-line-macos-$VERSION.zip"
)
extras=(
"build/step-line-setup-$VERSION.exe"
"build/step-line-$VERSION-x86_64.AppImage"
)
all_artifacts=("${portables[@]}")
for f in "${extras[@]}"; do
if [[ -f "$f" ]]; then
all_artifacts+=("$f")
else
echo "note: optional artifact missing (skipping): $f"
fi
done
API="$HOST/api/v1/repos/$REPO/releases"
echo "==> Creating release $TAG on $HOST/$REPO"
RELEASE_JSON="$(curl -s -X POST "$API" \
-H "Authorization: token $GITEA_TOKEN" \
RELEASE_ID="$(curl -s -X POST "$API" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Step Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}")"
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Step Line $VERSION — playable prototype. See README for controls.\",\"draft\":false,\"prerelease\":false}" \
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
RELEASE_ID="$(echo "$RELEASE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
# If the release already exists (tag already pushed), reuse it.
if [[ -z "$RELEASE_ID" ]]; then
echo "error: could not create release; response:" >&2
echo "$RELEASE_JSON" >&2
RELEASE_ID="$(curl -s "${AUTH[@]}" "$API/tags/$TAG" | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)"
fi
if [[ -z "$RELEASE_ID" ]]; then
echo "error: could not create or find release $TAG" >&2
exit 1
fi
echo "Release id: $RELEASE_ID"
for f in "$BS" "$BS_WIN" "$BS_MAC"; do
for f in "${all_artifacts[@]}"; do
if [[ ! -f "$f" ]]; then
echo "error: missing artifact $f — run ./tools/build_releases.sh first" >&2
exit 1
fi
echo "==> Uploading $(basename "$f")"
curl -s -X POST "$API/$RELEASE_ID/assets" \
-H "Authorization: token $GITEA_TOKEN" \
-F "attachment=@$f" >/dev/null
curl -s -X POST "$API/$RELEASE_ID/assets" "${AUTH[@]}" -F "attachment=@$f" >/dev/null
done
echo ""
+109
View File
@@ -0,0 +1,109 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image
import asset_pipeline
class AssetPipelineTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.temp_dir.name)
def tearDown(self):
self.temp_dir.cleanup()
def test_crop_uses_center_and_exact_dimensions(self):
source = self.root / "source.png"
output = self.root / "crop.png"
Image.new("RGB", (80, 60), (20, 40, 60)).save(source)
asset_pipeline.crop_image(source, output, 32, 24)
with Image.open(output) as image:
self.assertEqual(image.size, (32, 24))
def test_resize_contain_preserves_aspect_and_exact_canvas(self):
source = self.root / "source.png"
output = self.root / "resized.png"
Image.new("RGB", (80, 40), (20, 40, 60)).save(source)
asset_pipeline.resize_image(source, output, 64, 64, "contain")
with Image.open(output) as image:
self.assertEqual(image.size, (64, 64))
self.assertEqual(image.getpixel((32, 0)), (0, 0, 0, 0))
def test_texture_audit_accepts_valid_tile_and_rejects_bad_seams(self):
good = self.root / "good.png"
bad = self.root / "bad.png"
Image.new("RGB", (512, 512), (80, 80, 80)).save(good)
mismatch = Image.new("RGB", (512, 512), (80, 80, 80))
for y in range(512):
mismatch.putpixel((0, y), (255, 0, 0))
mismatch.save(bad)
self.assertEqual(asset_pipeline.audit_image(
good, "tile")["quality"], "good")
self.assertEqual(asset_pipeline.audit_image(
bad, "tile")["quality"], "bad")
def test_background_audit_rejects_small_image(self):
image_path = self.root / "small.png"
Image.new("RGB", (640, 360)).save(image_path)
result = asset_pipeline.audit_image(image_path, "background")
self.assertEqual(result["quality"], "bad")
self.assertTrue(
any("too small" in error for error in result["errors"]))
def test_grayscale_vignette_is_valid_without_alpha(self):
image_path = self.root / "vignette.png"
Image.new("L", (512, 512), 0).save(image_path)
self.assertEqual(asset_pipeline.audit_image(
image_path, "overlay")["quality"], "good")
def test_icon_audit_rejects_opaque_rgba_images(self):
image_path = self.root / "opaque_icon.png"
Image.new("RGBA", (64, 64), (255, 255, 255, 255)).save(image_path)
result = asset_pipeline.audit_image(image_path, "icon")
self.assertEqual(result["quality"], "bad")
self.assertTrue(any("transparency required" in error
for error in result["errors"]))
def test_generation_reuses_existing_backend_and_targets_role_dimensions(self):
output = self.root / "background.png"
args = type("Args", (), {
"prompt": "Hollywood at night", "role": "background", "output": str(output),
"width": 1920, "height": 1080, "transparent": None,
"model": "test-model", "request_size": 2048,
})()
generated = Image.new("RGB", (256, 256), (20, 40, 60))
with patch.object(asset_pipeline.generate_textures, "generate_image",
return_value=generated) as backend:
asset_pipeline._generate(args)
self.assertIn("Hollywood at night", backend.call_args.args[0])
self.assertIn("wide 16:9", backend.call_args.args[0])
with Image.open(output) as image:
self.assertEqual(image.size, (1920, 1080))
def test_role_inference_uses_game_asset_paths(self):
self.assertEqual(asset_pipeline.infer_role(
Path("assets/textures/icons/wand.png")), "icon")
self.assertEqual(asset_pipeline.infer_role(
Path("assets/textures/cards/ticket.png")), "card")
self.assertEqual(asset_pipeline.infer_role(
Path("assets/textures/skyline.png")), "backdrop")
if __name__ == "__main__":
unittest.main()