diff --git a/MEMORY.md b/MEMORY.md index a2c9c02..77289fa 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -24,6 +24,13 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o 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 @@ -68,6 +75,10 @@ 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 diff --git a/README.md b/README.md index d89768d..4f85214 100644 --- a/README.md +++ b/README.md @@ -137,10 +137,42 @@ The architecture maps onto Godot 4 nodes, scenes, and custom resources as descri - **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`). 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. diff --git a/TODO.md b/TODO.md index d1a3626..855a5cd 100644 --- a/TODO.md +++ b/TODO.md @@ -1,11 +1,20 @@ # TODO -## Distribution follow-up (host config, no code work) +## Distribution follow-up (findings + host config) -- [ ] The host now requires sign-in broadly: `/releases/download/…` AND the `v0.2.1` tag page - redirect to login (the `v0.2.0` tag page was public earlier — host config changed). Decide - with the host admin: open repo visibility / `REQUIRE_SIGNIN_VIEW=false`, or accept that - anonymous users must sign in to browse/download. Repo/CI side is complete. +Verified via API+host credentials (2026-10-31): + +- [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) @@ -25,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. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index ef5f6e4..a0982fe 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -80,15 +80,22 @@ the NSIS installer requires `makensis` (covered by CI). 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: diff --git a/requirements-tools.txt b/requirements-tools.txt new file mode 100644 index 0000000..98de852 --- /dev/null +++ b/requirements-tools.txt @@ -0,0 +1 @@ +Pillow>=9.2 \ No newline at end of file diff --git a/tools/asset_pipeline.py b/tools/asset_pipeline.py new file mode 100644 index 0000000..4194b31 --- /dev/null +++ b/tools/asset_pipeline.py @@ -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()) diff --git a/tools/generate_textures.py b/tools/generate_textures.py index 791aac1..f5e53ce 100644 --- a/tools/generate_textures.py +++ b/tools/generate_textures.py @@ -316,8 +316,10 @@ def app_icon(): 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], + ["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) @@ -329,7 +331,8 @@ def app_icon(): 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)]) + Image.open(src_png).convert("RGBA").save( + ico_path, format="ICO", sizes=[(48, 48), (32, 32), (16, 16)]) def cards(): @@ -369,8 +372,8 @@ def _api_key() -> str: return key -def _generate_image(prompt: str, model: str, size: int, - transparent: bool = False) -> Image.Image: +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 @@ -435,7 +438,7 @@ def _fit(img: Image.Image, max_dim: int = MAX_DIM) -> Image.Image: 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 = generate_image(prompt, model, size, transparent) img = _fit(img) if transparent: img = img.convert("RGBA") diff --git a/tools/publish_release.sh b/tools/publish_release.sh index 9892c07..954abae 100755 --- a/tools/publish_release.sh +++ b/tools/publish_release.sh @@ -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 "" diff --git a/tools/test_asset_pipeline.py b/tools/test_asset_pipeline.py new file mode 100644 index 0000000..47a0538 --- /dev/null +++ b/tools/test_asset_pipeline.py @@ -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()