The three newest catalogue screens had AI mockups but no staged capture, so compare and judge skipped them. capture_screenshots.gd now continues past the night set: - switches to The Joke Shop at 620 s (EVENING still has routine spawns), frames a guest, and retries the wand until it alerts; if nobody carries an alerting item it stages the documented keys false-alarm case. - applies a formal warning (arms the body camera) then blocking, jumps to 760 s for night lighting, and captures the HUD at L3. - closes the open incident and opens the body-camera timeline, which now shows the warning / body-camera / blocking rows. Wired the refs for wand_screening, escalation_hud and body_camera_timeline, and regenerated the escalation mockup as a third-person over-the-shoulder night shot so the reference matches the game camera. Added Hud.clear_log() to drop target-cycling noise from staged shots. README gallery lists the three captures. Verification: headless 182/182, Python tooling 53/53, compare 9 good / 0 bad, judge: wand match 0.90, timeline match 1.00, escalation review, 0 diff. Version 0.3.3.
1034 lines
45 KiB
Python
1034 lines
45 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate screen-design reference images for Step Line and compare captures.
|
|
|
|
This tool turns the screen descriptions that already live in `docs/`
|
|
(README screenshot captions, ADRs 0007-0010, PLAN.md, architecture.md, and the
|
|
HUD strings in `content/strings.en.json`) into reference "design" images, then
|
|
uses those images as the expected state to check the game's captured
|
|
screenshots against.
|
|
|
|
Two commands:
|
|
|
|
generate write one reference image per screen to docs/screens/designs/.
|
|
Two modes:
|
|
--ai realistic 3D game scenes and in-game screen designs via
|
|
the OpenRouter image API (default; requires
|
|
OPENROUTER_API_KEY, same as tools/asset_pipeline.py).
|
|
--draft deterministic wireframe sketches drawn with Pillow
|
|
(no network or API key; good for CI and layout checks).
|
|
|
|
judge send each capture together with its reference design to a vision
|
|
model and ask whether they depict the same screen. Pixel and
|
|
perceptual metrics cannot judge composition; this closes that gap.
|
|
Requires OPENROUTER_API_KEY (falls back to the repo .env).
|
|
--dry-run build and print the requests without calling the API.
|
|
|
|
compare compare docs/screenshots/*.png against the reference designs and
|
|
flag gross drift. Semantic composition is judged with coarse,
|
|
Pillow-only signals (aspect ratio, frame size, blank-frame
|
|
contrast, grayscale MAE, colour-histogram distance, edge density).
|
|
These catch "wrong screen / blank frame / wrong time of day"
|
|
regressions; they are NOT a pixel-exact or semantic match. A
|
|
vision-model pass is the documented next step (see PLAN.md).
|
|
|
|
list print the catalogue: name, kind, whether a reference design
|
|
exists, and which capture pairs with it.
|
|
|
|
Run from the repo root:
|
|
|
|
python3 tools/screen_designs.py list
|
|
python3 tools/screen_designs.py generate
|
|
python3 tools/screen_designs.py generate --draft
|
|
python3 tools/screen_designs.py compare
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps, ImageStat
|
|
|
|
import generate_textures
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
DESIGNS_DIR = ROOT / "docs" / "screens" / "designs"
|
|
SCREENSHOTS_DIR = ROOT / "docs" / "screenshots"
|
|
|
|
# Reference "design" dimensions — match the capture tool and the README gallery.
|
|
WIDTH, HEIGHT = 1280, 720
|
|
ASPECT = 16 / 9
|
|
|
|
# Default image model for screen mockups (validated role-aware model, per
|
|
# MEMORY.md; seedream rejects small sizes and ignores transparency, so we use
|
|
# the model asset_pipeline.py already trusts for non-alpha roles).
|
|
DEFAULT_MODEL = "openai/gpt-5-image-mini"
|
|
|
|
# Vision model used by `judge`. Cheap, multimodal, and good at "same screen?".
|
|
DEFAULT_JUDGE_MODEL = "google/gemini-2.5-flash-lite"
|
|
OPENROUTER_CHAT_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
|
|
# Shared art direction, appended to every prompt so the generated frames read
|
|
# as one coherent game. "scene" entries are first-person 3D world views;
|
|
# "ui" entries are in-game panels overlaid on a softened scene.
|
|
SCENE_STYLE = (
|
|
"rendered in a modern 3D game engine, first-person perspective, cinematic "
|
|
"lighting, physically based materials, soft shadows, ambient occlusion, "
|
|
"subtle depth of field, photorealistic detail, no watermark or UI clutter"
|
|
)
|
|
UI_STYLE = (
|
|
"clean modern in-game user interface, dark translucent panels, readable "
|
|
"sans-serif labels, consistent game UI colour theme, crisp vector text"
|
|
)
|
|
|
|
# Landscape source ratio requested from the API before cropping to 16:9.
|
|
# 3:2 is the validated non-square ratio for the default model (see MEMORY.md).
|
|
SOURCE_ASPECT = "3:2"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Screen catalogue. Each entry is grounded in a doc; `prompt` feeds --ai and
|
|
# `regions` feed --draft (normalised rects + a short label). `ref` names the
|
|
# captured screenshot, if one exists, so `compare` can pair them up.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SCREENS = [
|
|
{
|
|
"name": "venue_overview",
|
|
"kind": "scene",
|
|
"title": "Venue overview",
|
|
"source": "README screenshot 01",
|
|
"ref": "01-venue-overview.png",
|
|
"description": "First-person view from the guard post at the top of the "
|
|
"steps: Hollywood Boulevard entrance, wide concrete steps, "
|
|
"sidewalk, street lamps, planters, crosswalk, and building "
|
|
"signage/marquee under early-morning light.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: a security guard's view from "
|
|
"the top of wide concrete steps outside a grand Hollywood Boulevard "
|
|
"building entrance, early-morning golden light, sidewalk with street "
|
|
"lamps, planters, a crosswalk and distant pedestrians, detailed brick "
|
|
"facade with a striped awning and glowing marquee sign"),
|
|
"regions": [
|
|
(0.00, 0.55, 1.00, 1.00, "steps + sidewalk"),
|
|
(0.20, 0.08, 0.80, 0.60, "building facade / marquee"),
|
|
(0.05, 0.60, 0.20, 0.75, "street lamp"),
|
|
(0.78, 0.60, 0.95, 0.75, "street lamp"),
|
|
],
|
|
},
|
|
{
|
|
"name": "targeting_npc",
|
|
"kind": "scene",
|
|
"title": "Targeting an NPC",
|
|
"source": "README screenshot 02 · ADR-0008 cone focus + focus circle",
|
|
"ref": "02-targeting-npc.png",
|
|
"description": "An approaching NPC is in the focus cone: a blue "
|
|
"semi-transparent circle in front of their face, a display "
|
|
"name under the circle, and the HUD shows a target line "
|
|
"with the interaction hint.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: a friendly pedestrian "
|
|
"standing on a city sidewalk, a blue semi-transparent circle in front "
|
|
"of their face as a game selection marker, a small name label under "
|
|
"the circle, minimal HUD target text at the bottom, daytime"),
|
|
"regions": [
|
|
(0.35, 0.15, 0.65, 0.60, "NPC in focus"),
|
|
(0.44, 0.10, 0.56, 0.16, "face circle"),
|
|
(0.05, 0.85, 0.95, 0.95, "HUD: name + hint"),
|
|
],
|
|
},
|
|
{
|
|
"name": "access_inspection",
|
|
"kind": "ui",
|
|
"title": "Access inspection",
|
|
"source": "README screenshot 03 · ADR-0003 access verification",
|
|
"ref": "03-access-inspection.png",
|
|
"description": "An inspection panel shows the access artifact (wristband / "
|
|
"ID / ticket / credential / guest list) with its colour, "
|
|
"pattern, date, and event marking, plus allow and deny "
|
|
"prompts.",
|
|
"prompt": (
|
|
"Realistic 3D game screenshot with an in-game access inspection panel "
|
|
"overlaid: a centered card showing a wristband or ID credential with a "
|
|
"photo placeholder, colour band, date and event marking, and two "
|
|
"buttons labelled Allow and Deny, dark translucent panel over a "
|
|
"softly blurred city street background"),
|
|
"regions": [
|
|
(0.25, 0.10, 0.75, 0.90, "inspection panel"),
|
|
(0.30, 0.18, 0.70, 0.60, "artifact card"),
|
|
(0.30, 0.65, 0.48, 0.72, "allow"),
|
|
(0.52, 0.65, 0.70, 0.72, "deny"),
|
|
],
|
|
},
|
|
{
|
|
"name": "radio_report",
|
|
"kind": "ui",
|
|
"title": "Radio report",
|
|
"source": "README screenshot 04 · PLAN level 4 radio assistance",
|
|
"ref": "04-radio-report.png",
|
|
"description": "A radio dispatch panel with fields for location, "
|
|
"description, behaviour, whether weapons were seen, and "
|
|
"whether anyone is in immediate danger, plus a send action.",
|
|
"prompt": (
|
|
"Realistic 3D game screenshot with an in-game radio dispatch panel "
|
|
"overlaid: form fields for location, description, behaviour, a threat "
|
|
"assessment and whether anyone is in danger, and a Send button, dark "
|
|
"translucent panel over a softly blurred city street background"),
|
|
"regions": [
|
|
(0.30, 0.08, 0.70, 0.92, "radio panel"),
|
|
(0.34, 0.16, 0.66, 0.30, "location / description"),
|
|
(0.34, 0.34, 0.66, 0.56, "behaviour / weapons / danger"),
|
|
(0.42, 0.60, 0.58, 0.66, "send"),
|
|
],
|
|
},
|
|
{
|
|
"name": "late_night_avenue",
|
|
"kind": "scene",
|
|
"title": "Late night avenue",
|
|
"source": "README screenshot 05",
|
|
"ref": "05-late-night-avenue.png",
|
|
"description": "Night-time view down Hollywood Boulevard: street lamps "
|
|
"lit, marquee glowing, dark blue street, no flashlight.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene at night: a dark Hollywood "
|
|
"Boulevard avenue receding into the distance, lit street lamps, a "
|
|
"glowing neon marquee sign, deep blue night sky, wet reflective "
|
|
"pavement, no flashlight"),
|
|
"regions": [
|
|
(0.00, 0.55, 1.00, 1.00, "dark avenue"),
|
|
(0.15, 0.25, 0.85, 0.55, "glowing marquee"),
|
|
(0.10, 0.45, 0.30, 0.70, "lit street lamp"),
|
|
(0.70, 0.45, 0.90, 0.70, "lit street lamp"),
|
|
],
|
|
},
|
|
{
|
|
"name": "night_flashlight",
|
|
"kind": "scene",
|
|
"title": "Night flashlight",
|
|
"source": "README screenshot 06",
|
|
"ref": "06-night-flashlight.png",
|
|
"description": "Night shift: the guard's flashlight beam cuts through the "
|
|
"dark toward the entrance, illuminating the steps and door.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene at night: a flashlight beam "
|
|
"cutting through darkness toward a building entrance and steps, a "
|
|
"bright spotlight pool illuminating the door, dark surroundings and "
|
|
"deep shadows"),
|
|
"regions": [
|
|
(0.35, 0.30, 0.65, 0.80, "flashlight beam pool"),
|
|
(0.40, 0.20, 0.60, 0.35, "lit door"),
|
|
(0.00, 0.00, 1.00, 0.25, "dark sky"),
|
|
],
|
|
},
|
|
{
|
|
"name": "radial_menu",
|
|
"kind": "ui",
|
|
"title": "Radial action menu",
|
|
"source": "ADR-0009 · content/strings.en.json",
|
|
"ref": None,
|
|
"description": "A round doughnut menu centred on screen, 12 slices in "
|
|
"de-escalation order: Stop, Look, Point, No, Ask, Inspect, "
|
|
"Wand, Warn, Block, Reason, Recognize, Radio. The focused "
|
|
"person's display name sits in the centre.",
|
|
"prompt": (
|
|
"Realistic 3D game screenshot with an in-game radial action menu "
|
|
"overlaid: a round doughnut wheel centered on screen with twelve "
|
|
"labelled slices reading Stop, Look, Point, No, Ask, Inspect, Wand, "
|
|
"Warn, Block, Reason, Recognize, Radio, one slice highlighted, a name "
|
|
"in the center of the ring, over a softly blurred city street scene"),
|
|
"regions": [
|
|
(0.38, 0.12, 0.62, 0.88, "radial wheel (12 slices)"),
|
|
(0.46, 0.44, 0.54, 0.56, "name"),
|
|
(0.62, 0.12, 0.70, 0.20, "highlight"),
|
|
],
|
|
},
|
|
{
|
|
"name": "shift_review",
|
|
"kind": "ui",
|
|
"title": "Shift review",
|
|
"source": "PLAN reputation / shift review · architecture.md ShiftReview",
|
|
"ref": None,
|
|
"description": "End-of-shift performance review: access decisions, "
|
|
"escalation choices, incident list, battery and crowd "
|
|
"outcomes, and reputation/score feedback.",
|
|
"prompt": (
|
|
"Realistic 3D game end-of-shift review screen: a full-screen "
|
|
"performance summary panel listing incident decisions, escalation "
|
|
"choices, a reputation score, crowd reaction and battery use, with a "
|
|
"Continue button, dark themed game UI"),
|
|
"regions": [
|
|
(0.30, 0.06, 0.70, 0.94, "review panel"),
|
|
(0.36, 0.14, 0.64, 0.24, "score / reputation"),
|
|
(0.36, 0.28, 0.64, 0.70, "incident list"),
|
|
(0.42, 0.76, 0.58, 0.84, "continue"),
|
|
],
|
|
},
|
|
{
|
|
"name": "start_menu",
|
|
"kind": "ui",
|
|
"title": "Start menu",
|
|
"source": "MEMORY session shell (StartMenu, layer 20)",
|
|
"ref": None,
|
|
"description": "The boot screen: a venue-selection menu (Hollywood "
|
|
"Boulevard, The Joke Shop, TV Studio) before a shift.",
|
|
"prompt": (
|
|
"Realistic 3D game start menu screen: a game title and three venue "
|
|
"selection buttons labelled Hollywood Boulevard, The Joke Shop and TV "
|
|
"Studio, dark themed menu over a softly blurred city street backdrop"),
|
|
"regions": [
|
|
(0.25, 0.10, 0.75, 0.26, "title"),
|
|
(0.36, 0.36, 0.64, 0.44, "Hollywood Boulevard"),
|
|
(0.36, 0.48, 0.64, 0.56, "The Joke Shop"),
|
|
(0.36, 0.60, 0.64, 0.68, "TV Studio"),
|
|
],
|
|
},
|
|
{
|
|
"name": "pause_overlay",
|
|
"kind": "ui",
|
|
"title": "Pause overlay",
|
|
"source": "MEMORY session shell (PauseOverlay, layer 18)",
|
|
"ref": None,
|
|
"description": "Pause menu: resume, restart, settings, and quit-to-menu.",
|
|
"prompt": (
|
|
"Realistic 3D game pause menu overlay: buttons labelled Resume, "
|
|
"Restart, Settings and Quit to Menu, dark translucent overlay over a "
|
|
"paused, softly blurred city street scene"),
|
|
"regions": [
|
|
(0.38, 0.26, 0.62, 0.74, "pause menu"),
|
|
(0.42, 0.32, 0.58, 0.38, "Resume"),
|
|
(0.42, 0.42, 0.58, 0.48, "Restart"),
|
|
(0.42, 0.52, 0.58, 0.58, "Settings"),
|
|
(0.42, 0.62, 0.58, 0.68, "Quit to Menu"),
|
|
],
|
|
},
|
|
{
|
|
"name": "settings_panel",
|
|
"kind": "ui",
|
|
"title": "Settings",
|
|
"source": "MEMORY session shell (SettingsPanel, layer 22)",
|
|
"ref": None,
|
|
"description": "Settings: look sensitivity and HUD font scale controls.",
|
|
"prompt": (
|
|
"Realistic 3D game settings screen: sliders labelled look sensitivity "
|
|
"and HUD font scale, dark themed settings panel"),
|
|
"regions": [
|
|
(0.30, 0.16, 0.70, 0.84, "settings panel"),
|
|
(0.36, 0.30, 0.64, 0.40, "look sensitivity"),
|
|
(0.36, 0.46, 0.64, 0.56, "HUD font scale"),
|
|
],
|
|
},
|
|
{
|
|
"name": "emergency_radio",
|
|
"kind": "ui",
|
|
"title": "Emergency radio",
|
|
"source": "PLAN level 5 · strings radio.emergency_dispatched",
|
|
"ref": None,
|
|
"description": "Hold-to-confirm emergency radio panel with a confirmation "
|
|
"prompt and a cooldown indicator.",
|
|
"prompt": (
|
|
"Realistic 3D game emergency radio hold-to-confirm panel: a large "
|
|
"warning prompt with a hold progress bar and a cooldown label, red "
|
|
"accent, dark translucent overlay over a softly blurred city street "
|
|
"scene"),
|
|
"regions": [
|
|
(0.30, 0.28, 0.70, 0.72, "emergency panel"),
|
|
(0.36, 0.36, 0.64, 0.46, "confirm prompt"),
|
|
(0.38, 0.52, 0.62, 0.60, "hold progress"),
|
|
(0.36, 0.64, 0.64, 0.68, "cooldown"),
|
|
],
|
|
},
|
|
{
|
|
"name": "wand_screening",
|
|
"kind": "ui",
|
|
"title": "Wand screening",
|
|
"source": "README screenshot gallery · PLAN The Joke Shop · src/core/wand_screener.gd",
|
|
"ref": "07-wand-screening.png",
|
|
"description": "Handheld wand screening panel for The Joke Shop: the alert "
|
|
"result, the items the guest is carrying, and allow/deny "
|
|
"buttons. A phone, keys or a belt buckle can raise a false "
|
|
"alarm, so the alert alone never proves a weapon.",
|
|
"prompt": (
|
|
"Realistic 3D game screenshot with an in-game security wand screening "
|
|
"panel overlaid: a handheld metal-detector alert indicator, a short "
|
|
"list of carried items such as keys, phone and belt buckle, a warning "
|
|
"note that an alert can be a false alarm, and two buttons labelled "
|
|
"Allow and Deny, dark translucent panel over a softly blurred comedy "
|
|
"club entrance at night"),
|
|
"regions": [
|
|
(0.26, 0.12, 0.74, 0.88, "wand panel"),
|
|
(0.32, 0.20, 0.68, 0.32, "alert result"),
|
|
(0.32, 0.38, 0.68, 0.56, "carried items"),
|
|
(0.30, 0.70, 0.48, 0.78, "allow"),
|
|
(0.52, 0.70, 0.70, 0.78, "deny"),
|
|
],
|
|
},
|
|
{
|
|
"name": "joke_shop_queue",
|
|
"kind": "scene",
|
|
"title": "Joke Shop door queue",
|
|
"source": "PLAN.md The Joke Shop — long line, ID and ticket checks",
|
|
"ref": None,
|
|
"description": "Evening view of a small comedy-club entrance: a short queue "
|
|
"on the sidewalk, a door person checking an ID and a ticket "
|
|
"under a warm bulb, a red awning, and the show board.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: a narrow comedy club entrance at "
|
|
"dusk with a short queue of guests on the sidewalk, a door person "
|
|
"checking an ID and a paper ticket under a warm lamp, red fabric awning, "
|
|
"a hand-lettered show board, wet pavement reflections, warm interior "
|
|
"light spilling through the door"),
|
|
"regions": [
|
|
(0.33, 0.30, 0.67, 0.86, "door + door person"),
|
|
(0.00, 0.55, 0.30, 0.95, "queue"),
|
|
(0.24, 0.16, 0.76, 0.36, "awning + show board"),
|
|
(0.72, 0.55, 1.00, 0.95, "sidewalk"),
|
|
],
|
|
},
|
|
{
|
|
"name": "studio_arrival",
|
|
"kind": "scene",
|
|
"title": "TV Studio celebrity arrival",
|
|
"source": "PLAN.md Late Night TV Studio — crowd gathers for an arrival",
|
|
"ref": None,
|
|
"description": "A celebrity arrival at the studio entrance: tourists and "
|
|
"influencers pressing toward the door, cameras raised, the "
|
|
"guard holding a line on the steps, studio marquee behind.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: a busy late-night television "
|
|
"studio entrance with a small crowd of tourists and influencers raising "
|
|
"cameras toward a black car at the curb, a security guard holding a line "
|
|
"on the steps, glowing studio marquee and lit canopy, night time"),
|
|
"regions": [
|
|
(0.30, 0.30, 0.70, 0.80, "crowd + cameras"),
|
|
(0.40, 0.10, 0.60, 0.34, "studio marquee"),
|
|
(0.00, 0.60, 0.25, 0.95, "car at the curb"),
|
|
(0.78, 0.55, 1.00, 0.90, "guard line"),
|
|
],
|
|
},
|
|
{
|
|
"name": "crowd_panic",
|
|
"kind": "scene",
|
|
"title": "Crowd panic",
|
|
"source": "PLAN.md Public Crowd Reaction · CrowdSystem.reaction_for_event",
|
|
"ref": None,
|
|
"description": "After a panic event the sidewalk empties away from the "
|
|
"door: people running toward the street, one person still "
|
|
"near the steps, the guard between them and the entrance.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: bystanders scattering away from a "
|
|
"building entrance across a wide sidewalk, motion blur on the fleeing "
|
|
"figures, one person left near the steps, a security guard standing "
|
|
"between them and the door, tense evening light, long shadows"),
|
|
"regions": [
|
|
(0.00, 0.45, 0.42, 0.90, "fleeing crowd"),
|
|
(0.55, 0.40, 0.78, 0.85, "guard between crowd and door"),
|
|
(0.38, 0.10, 0.80, 0.45, "building entrance"),
|
|
(0.72, 0.50, 1.00, 0.95, "empty sidewalk"),
|
|
],
|
|
},
|
|
{
|
|
"name": "responder_arrival",
|
|
"kind": "scene",
|
|
"title": "Responder arrival",
|
|
"source": "PLAN.md Level 5 Emergency Response · radio_system.gd responder dispatch",
|
|
"ref": None,
|
|
"description": "A dispatched responder walks up the steps toward a locked "
|
|
"door while the entrance is being cleared; blue light washes "
|
|
"the facade and the lock indicator reads red.",
|
|
"prompt": (
|
|
"Realistic 3D first-person game scene: an emergency responder in dark "
|
|
"uniform walking up wide concrete steps toward a locked building door, "
|
|
"blue light washing the brick facade, a red lock indicator beside the "
|
|
"door, people clearing from the entrance, night"),
|
|
"regions": [
|
|
(0.40, 0.28, 0.60, 0.85, "responder on the steps"),
|
|
(0.42, 0.14, 0.58, 0.32, "locked door + indicator"),
|
|
(0.00, 0.00, 1.00, 0.30, "facade + blue light"),
|
|
(0.00, 0.70, 0.28, 0.98, "people clearing"),
|
|
],
|
|
},
|
|
{
|
|
"name": "body_camera_timeline",
|
|
"kind": "ui",
|
|
"title": "Body-camera timeline",
|
|
"source": "README screenshot gallery · ADR-0005 · src/game/ui/event_timeline.gd",
|
|
"ref": "09-body-camera-timeline.png",
|
|
"description": "The shift review's body-camera timeline: a text list of every "
|
|
"recorded incident event stamped mm:ss with the person and the "
|
|
"action or outcome, one row highlighted, and previous/next "
|
|
"buttons.",
|
|
"prompt": (
|
|
"Realistic 3D game screenshot with an in-game body-camera event timeline "
|
|
"overlaid: a tall dark panel listing time-stamped rows such as "
|
|
"02:14 formal warning, 02:31 body camera on and 03:02 access denied, one "
|
|
"row highlighted, Previous and Next buttons at the bottom, over a "
|
|
"softly blurred night street background"),
|
|
"regions": [
|
|
(0.28, 0.10, 0.72, 0.90, "timeline panel"),
|
|
(0.32, 0.16, 0.68, 0.26, "timeline header"),
|
|
(0.32, 0.28, 0.68, 0.72, "timestamped rows"),
|
|
(0.32, 0.78, 0.68, 0.85, "previous / next"),
|
|
],
|
|
},
|
|
{
|
|
"name": "escalation_hud",
|
|
"kind": "ui",
|
|
"title": "Escalation HUD",
|
|
"source": "README accessibility note · Hud.escalation_meter (shape meter · level)",
|
|
"ref": "08-escalation-hud.png",
|
|
"description": "The in-shift HUD: a shape meter plus numeric escalation level "
|
|
"(`Escalation: Blocking [###--] L3/5`), the targeted person's "
|
|
"name, flashlight / door / body-camera indicators, shift clock, "
|
|
"and the reaction log.",
|
|
"prompt": (
|
|
"Third-person over-the-shoulder 3D game screenshot at night: a security "
|
|
"guard in the foreground facing a guest in front of a venue entrance, "
|
|
"with a minimal in-game HUD overlaid. Bottom left: an escalation meter "
|
|
"drawn as a shape bar reading Escalation: Blocking [###--] L3/5. Centre: "
|
|
"a small target name label above the guest. Top right: a shift clock, "
|
|
"reputation and crowd percentage. Left: a short stacked reaction log. "
|
|
"Bottom centre: a control hint bar. Realistic lighting, muted colours"),
|
|
"regions": [
|
|
(0.02, 0.80, 0.34, 0.95, "escalation shape meter"),
|
|
(0.35, 0.28, 0.75, 0.40, "target name label"),
|
|
(0.78, 0.02, 0.98, 0.16, "shift clock + crowd"),
|
|
(0.02, 0.20, 0.36, 0.65, "reaction log"),
|
|
(0.02, 0.94, 0.98, 0.99, "control hints"),
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
def resolve_path(value):
|
|
path = Path(value).expanduser()
|
|
return path if path.is_absolute() else ROOT / path
|
|
|
|
|
|
def list_screens(args):
|
|
"""Print the screen catalogue and what state each entry is in."""
|
|
for screen in SCREENS:
|
|
design = _design_path(screen["name"])
|
|
state = "design" if design.is_file() else "- "
|
|
capture = screen["ref"] or "-"
|
|
print(f"{screen['name']:22} {screen['kind']:5} {state} "
|
|
f"capture={capture:24} {screen['title']}")
|
|
scenes = sum(1 for screen in SCREENS if screen["kind"] == "scene")
|
|
print(f"Total: {len(SCREENS)} screens "
|
|
f"({scenes} scene, {len(SCREENS) - scenes} ui)")
|
|
return 0
|
|
|
|
|
|
def _design_path(name):
|
|
return DESIGNS_DIR / f"{name}.png"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Draft (wireframe) rendering — deterministic, offline.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _draw_wireframe(screen, path):
|
|
image = Image.new("RGB", (WIDTH, HEIGHT), (24, 26, 38))
|
|
draw = ImageDraw.Draw(image)
|
|
title_font = generate_textures.font(52)
|
|
body_font = generate_textures.font(24)
|
|
label_font = generate_textures.font(26)
|
|
|
|
draw.text((40, 28), screen["title"], font=title_font, fill=(240, 240, 245))
|
|
draw.text((40, 98), screen["source"], font=body_font, fill=(150, 155, 175))
|
|
# wrap the description into a small caption block
|
|
words = screen["description"].split()
|
|
lines, line = [], ""
|
|
for word in words:
|
|
if draw.textlength(line + " " + word, font=body_font) < WIDTH - 80:
|
|
line = (line + " " + word).strip()
|
|
else:
|
|
lines.append(line)
|
|
line = word
|
|
lines.append(line)
|
|
for index, text in enumerate(lines[:4]):
|
|
draw.text((40, 132 + index * 30), text,
|
|
font=body_font, fill=(120, 125, 145))
|
|
|
|
for region in screen["regions"]:
|
|
x0, y0, x1, y1, label = region
|
|
box = (int(x0 * WIDTH), int(y0 * HEIGHT),
|
|
int(x1 * WIDTH), int(y1 * HEIGHT))
|
|
draw.rounded_rectangle(box, radius=10,
|
|
outline=(120, 130, 170), width=2)
|
|
# Anchor the label to the top-left corner: nested regions (a panel plus
|
|
# the cards inside it) would otherwise stack their centred labels on top
|
|
# of each other.
|
|
draw.text((box[0] + 10, box[1] + 6), label,
|
|
font=label_font, fill=(170, 180, 210))
|
|
|
|
image.save(path)
|
|
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
|
|
print(f"wrote {shown} {image.size}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AI generation (reuses the OpenRouter backend from generate_textures).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _compose_prompt(screen):
|
|
"""Append the shared art direction to a screen's content prompt."""
|
|
style = UI_STYLE if screen.get("kind") == "ui" else SCENE_STYLE
|
|
return f"{screen['prompt']}. {style}. 16:9 widescreen composition."
|
|
|
|
|
|
def _generate_ai(screen, model, request_size):
|
|
prompt = _compose_prompt(screen)
|
|
# Request a landscape source and crop to the 16:9 capture dimensions rather
|
|
# than a square that loses framing when centre-cropped.
|
|
image = generate_textures.generate_image(
|
|
prompt, model, request_size, transparent=False,
|
|
aspect_ratio=SOURCE_ASPECT)
|
|
image = ImageOps.fit(image, (WIDTH, HEIGHT),
|
|
method=Image.Resampling.LANCZOS)
|
|
if image.mode in ("RGBA", "LA", "P"):
|
|
image = image.convert("RGB")
|
|
path = _design_path(screen["name"])
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
image.save(path)
|
|
shown = path.relative_to(ROOT) if path.is_relative_to(ROOT) else path
|
|
print(f"wrote {shown} {image.size}")
|
|
|
|
|
|
def generate(args):
|
|
_load_env_file()
|
|
DESIGNS_DIR.mkdir(parents=True, exist_ok=True)
|
|
failed = []
|
|
selected = [screen for screen in SCREENS
|
|
if not args.only or args.only in screen["name"]]
|
|
if not selected:
|
|
print(f"no screen matches --only {args.only!r}", file=sys.stderr)
|
|
return 2
|
|
for screen in selected:
|
|
path = _design_path(screen["name"])
|
|
# Draft mode is the offline fallback; do not clobber existing AI-crafted
|
|
# references unless the caller explicitly asks for it.
|
|
if path.is_file() and not args.force:
|
|
print(
|
|
f"skip {screen['name']} (reference exists; --force to replace)")
|
|
continue
|
|
print(f"generating {screen['name']} ...")
|
|
try:
|
|
if args.draft:
|
|
_draw_wireframe(screen, path)
|
|
else:
|
|
_generate_ai(screen, args.model, args.request_size)
|
|
except SystemExit as exc:
|
|
failed.append(screen["name"])
|
|
print(f" FAILED {screen['name']}: {exc}", file=sys.stderr)
|
|
print("done")
|
|
if failed:
|
|
print(f"failed: {', '.join(failed)}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Comparison metrics (Pillow only; no numpy/scikit-image dependency).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_rgb(path):
|
|
with Image.open(path) as image:
|
|
image.load()
|
|
return image.convert("RGB")
|
|
|
|
|
|
def _grayscale(image):
|
|
return ImageOps.grayscale(image)
|
|
|
|
|
|
def _mae(left, right):
|
|
"""Mean absolute error between two same-size grayscale images, 0..255."""
|
|
left_gray = _grayscale(left).resize((256, 144))
|
|
right_gray = _grayscale(right).resize((256, 144))
|
|
diff = ImageChops.difference(left_gray, right_gray)
|
|
return ImageStat.Stat(diff).mean[0]
|
|
|
|
|
|
def _histogram_distance(left, right):
|
|
"""Normalised L1 distance between 3x256-bin colour histograms, 0..1."""
|
|
left_hist = left.resize((256, 144)).histogram()
|
|
right_hist = right.resize((256, 144)).histogram()
|
|
total = sum(left_hist)
|
|
if total == 0:
|
|
return 1.0
|
|
distance = sum(abs(a - b) for a, b in zip(left_hist, right_hist))
|
|
return distance / (2 * total)
|
|
|
|
|
|
def _edge_density(image):
|
|
"""Mean brightness of an edge-detected frame; higher = more detail."""
|
|
edges = _grayscale(image).filter(ImageFilter.FIND_EDGES)
|
|
return ImageStat.Stat(edges).mean[0]
|
|
|
|
|
|
def _contrast(image):
|
|
"""Standard deviation of grayscale brightness; low = near-blank frame."""
|
|
return ImageStat.Stat(_grayscale(image)).stddev[0]
|
|
|
|
|
|
def _aspect_ok(image, tolerance=0.12):
|
|
width, height = image.size
|
|
actual = width / height
|
|
return abs(actual - ASPECT) / ASPECT <= tolerance
|
|
|
|
|
|
def compare_screenshot(reference, capture):
|
|
"""Compare one capture against its reference design; return a dict report."""
|
|
ref = _load_rgb(reference)
|
|
cap = _load_rgb(capture)
|
|
width, height = cap.size
|
|
report = {
|
|
"capture": str(capture),
|
|
"reference": str(reference),
|
|
"size": [width, height],
|
|
}
|
|
|
|
errors, warnings = [], []
|
|
|
|
if width < WIDTH or height < HEIGHT:
|
|
errors.append(f"too small: {width}x{height}; minimum {WIDTH}x{HEIGHT}")
|
|
if not _aspect_ok(cap):
|
|
errors.append(f"wrong aspect ratio: {width / height:.3f}; "
|
|
f"expected about {ASPECT:.3f}")
|
|
|
|
contrast = _contrast(cap)
|
|
report["contrast"] = round(contrast, 1)
|
|
if contrast < 5.0:
|
|
errors.append(f"near-blank frame: contrast {contrast:.1f} below 5.0")
|
|
|
|
mae = _mae(ref, cap)
|
|
hist = _histogram_distance(ref, cap)
|
|
edge_ref = _edge_density(ref)
|
|
edge_cap = _edge_density(cap)
|
|
report["mae"] = round(mae, 1)
|
|
report["hist_distance"] = round(hist, 3)
|
|
report["edge_density_ref"] = round(edge_ref, 1)
|
|
report["edge_density_cap"] = round(edge_cap, 1)
|
|
|
|
# Coarse drift warnings (not hard failures: an AI mockup will never match a
|
|
# real render pixel-for-pixel, but a wildly different frame will still
|
|
# stand out on these signals).
|
|
if mae > 90:
|
|
warnings.append(
|
|
f"grayscale MAE {mae:.1f} is high (very different layout)")
|
|
if hist > 0.75:
|
|
warnings.append(f"colour histogram distance {hist:.3f} is high "
|
|
"(very different palette / time of day)")
|
|
edge_delta = abs(edge_cap - edge_ref)
|
|
report["edge_delta"] = round(edge_delta, 1)
|
|
if edge_delta > 18:
|
|
warnings.append(f"edge density differs by {edge_delta:.1f} "
|
|
"(very different amount of detail)")
|
|
|
|
report["quality"] = "bad" if errors else "good"
|
|
report["errors"] = errors
|
|
report["warnings"] = warnings
|
|
return report
|
|
|
|
|
|
def compare(args):
|
|
if not SCREENSHOTS_DIR.is_dir():
|
|
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}; "
|
|
"run src/test/capture_screenshots.gd first")
|
|
captures = sorted(SCREENSHOTS_DIR.glob("*.png"))
|
|
if not captures:
|
|
sys.exit(f"error: no .png screenshots found in {SCREENSHOTS_DIR}")
|
|
|
|
refs = {screen["name"]: screen["ref"]
|
|
for screen in SCREENS if screen["ref"]}
|
|
results = []
|
|
for capture in captures:
|
|
name = capture.name
|
|
# find the screen whose ref matches this capture filename
|
|
design_name = next((n for n, ref in refs.items() if ref == name), None)
|
|
if design_name is None:
|
|
results.append({"capture": str(capture), "reference": None,
|
|
"errors": [f"no design reference for {name}"],
|
|
"warnings": [], "quality": "bad"})
|
|
continue
|
|
reference = _design_path(design_name)
|
|
if not reference.is_file():
|
|
results.append({"capture": str(capture),
|
|
"reference": str(reference),
|
|
"errors": [f"missing reference design "
|
|
f"({reference.name}); run generate first"],
|
|
"warnings": [], "quality": "bad"})
|
|
continue
|
|
results.append(compare_screenshot(reference, capture))
|
|
|
|
if args.json:
|
|
import json
|
|
print(json.dumps(results, indent=2))
|
|
else:
|
|
good_count = sum(r["quality"] == "good" for r in results)
|
|
for result in results:
|
|
ref = (Path(result["reference"]).name
|
|
if result.get("reference") else "-")
|
|
size = ("x".join(map(str, result["size"]))
|
|
if result.get("size") else "unreadable")
|
|
print(f"{result['quality'].upper():4} {result['capture']}")
|
|
print(f" ref={ref} size={size} "
|
|
f"mae={result.get('mae', '-')} "
|
|
f"hist={result.get('hist_distance', '-')}")
|
|
for message in result["errors"] + result["warnings"]:
|
|
print(f" - {message}")
|
|
print(f"Summary: {good_count} good, {len(results) - good_count} bad")
|
|
|
|
return 1 if any(r["quality"] == "bad" for r in results) else 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Vision judge — semantic "same screen?" check against the reference designs.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
JUDGE_INSTRUCTIONS = (
|
|
"You are reviewing screenshots of a low-poly 3D security-guard game. "
|
|
"IMAGE 1 is a CONCEPT MOCKUP of the intended screen (it may be painted in a "
|
|
"different art style and show different actors); IMAGE 2 is the ACTUAL "
|
|
"capture from the game. Different art style, fidelity, fonts, and character "
|
|
"models are EXPECTED and must be ignored.\n\n"
|
|
"Judge only whether IMAGE 2 realises the same SCREEN and LAYOUT INTENT as "
|
|
"IMAGE 1: is it the same kind of screen (world view, HUD overlay, modal "
|
|
"panel), are the main interface regions present in roughly the same places "
|
|
"(target line, inspection card, report form, on-screen buttons), and is the "
|
|
"subject of the shot comparable (entrance, street, person)?\n\n"
|
|
"Answer with JSON only: {\"match\": true|false, \"confidence\": 0..1, "
|
|
"\"differences\": [\"short point\", ...]}. Set match=true when the capture "
|
|
"realises the mockup's screen even if it looks much simpler."
|
|
)
|
|
|
|
|
|
def _load_env_file(path=None):
|
|
"""Populate os.environ from a .env file without overriding real variables."""
|
|
path = Path(path) if path else ROOT / ".env"
|
|
if not path.is_file():
|
|
return
|
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
os.environ.setdefault(key.strip(), value.strip().strip("\"'"))
|
|
|
|
|
|
def _data_url(path):
|
|
import base64
|
|
payload = Path(path).read_bytes()
|
|
return "data:image/png;base64," + base64.b64encode(payload).decode("ascii")
|
|
|
|
|
|
def build_judge_request(model, screen, capture_path, design_path):
|
|
"""Build the OpenRouter chat payload for one capture/design pair."""
|
|
prompt = (f"{JUDGE_INSTRUCTIONS}\n\nScreen: {screen['title']} "
|
|
f"({screen['source']}). Design notes: {screen['description']}")
|
|
return {
|
|
"model": model,
|
|
"messages": [{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": prompt},
|
|
{"type": "image_url", "image_url": {
|
|
"url": _data_url(design_path)}},
|
|
{"type": "image_url", "image_url": {
|
|
"url": _data_url(capture_path)}},
|
|
],
|
|
}],
|
|
"temperature": 0,
|
|
"response_format": {"type": "json_object"},
|
|
}
|
|
|
|
|
|
def parse_judge_response(payload):
|
|
"""Extract a verdict dict from an OpenRouter chat response."""
|
|
try:
|
|
text = payload["choices"][0]["message"]["content"]
|
|
except (KeyError, IndexError, TypeError):
|
|
return {"match": None, "confidence": 0.0,
|
|
"differences": ["unreadable model response"]}
|
|
if isinstance(text, list): # some providers return content parts
|
|
text = "".join(part.get("text", "") for part in text
|
|
if isinstance(part, dict))
|
|
text = str(text).strip()
|
|
start, end = text.find("{"), text.rfind("}")
|
|
if start >= 0 and end > start:
|
|
text = text[start:end + 1]
|
|
import json
|
|
try:
|
|
data = json.loads(text)
|
|
except ValueError:
|
|
return {"match": None, "confidence": 0.0,
|
|
"differences": [f"non-JSON response: {text[:120]}"]}
|
|
differences = data.get("differences") or []
|
|
if not isinstance(differences, list):
|
|
differences = [str(differences)]
|
|
return {
|
|
"match": data.get("match") if isinstance(data.get("match"), bool) else None,
|
|
"confidence": float(data.get("confidence", 0.0) or 0.0),
|
|
"differences": [str(item) for item in differences],
|
|
}
|
|
|
|
|
|
def classify(verdict, threshold=0.6):
|
|
"""Turn a model verdict into a QA class.
|
|
|
|
The reference images are concept mockups, so a low-confidence disagreement is
|
|
a review note, not a failure. Only a confident mismatch fails the check.
|
|
"""
|
|
if verdict.get("match") is True:
|
|
return "match"
|
|
if verdict.get("match") is False:
|
|
return "bad" if float(verdict.get("confidence", 0.0)) >= threshold else "review"
|
|
return "unknown"
|
|
|
|
|
|
def _call_openrouter(body, timeout=180):
|
|
import urllib.error
|
|
import urllib.request
|
|
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
|
if not key:
|
|
raise ValueError("OPENROUTER_API_KEY is not set (repo .env is read "
|
|
"automatically); use --dry-run to inspect requests")
|
|
req = urllib.request.Request(
|
|
OPENROUTER_CHAT_URL,
|
|
data=json.dumps(body).encode("utf-8"),
|
|
headers={
|
|
"Authorization": f"Bearer {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=timeout) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", "replace")
|
|
raise ValueError(f"OpenRouter HTTP {exc.code}: {detail[:300]}")
|
|
|
|
|
|
def judge(args):
|
|
_load_env_file()
|
|
if not SCREENSHOTS_DIR.is_dir():
|
|
sys.exit(f"error: no screenshots directory at {SCREENSHOTS_DIR}")
|
|
refs = {screen["name"]: screen["ref"]
|
|
for screen in SCREENS if screen["ref"]}
|
|
by_name = {screen["name"]: screen for screen in SCREENS}
|
|
results = []
|
|
for capture in sorted(SCREENSHOTS_DIR.glob("*.png")):
|
|
if args.only and args.only not in capture.name:
|
|
continue
|
|
design_name = next((n for n, ref in refs.items()
|
|
if ref == capture.name), None)
|
|
if design_name is None:
|
|
results.append({"capture": capture.name, "screen": None,
|
|
"match": None, "confidence": 0.0,
|
|
"differences": ["no design reference for this capture"]})
|
|
continue
|
|
reference = _design_path(design_name)
|
|
if not reference.is_file():
|
|
results.append({"capture": capture.name, "screen": design_name,
|
|
"match": None, "confidence": 0.0,
|
|
"differences": ["missing reference design; run generate"]})
|
|
continue
|
|
body = build_judge_request(args.model, by_name[design_name],
|
|
capture, reference)
|
|
if args.dry_run:
|
|
results.append({
|
|
"capture": capture.name, "screen": design_name, "match": None,
|
|
"confidence": 0.0, "model": body["model"], "class": "unknown",
|
|
"differences": [f"dry run: {len(body['messages'][0]['content'])} parts"],
|
|
})
|
|
continue
|
|
verdict = parse_judge_response(_call_openrouter(body))
|
|
verdict.update({"capture": capture.name, "screen": design_name,
|
|
"class": classify(verdict, args.fail_confidence)})
|
|
results.append(verdict)
|
|
|
|
if args.json:
|
|
print(json.dumps(results, indent=2))
|
|
else:
|
|
labels = {"match": "MATCH", "review": "REVIEW", "bad": "DIFF",
|
|
"unknown": "UNKNOWN"}
|
|
for result in results:
|
|
print(f"{labels[result['class']]:7} {result['capture']} "
|
|
f"(design: {result['screen']}) "
|
|
f"confidence={result['confidence']:.2f}")
|
|
for difference in result["differences"]:
|
|
print(f" - {difference}")
|
|
counts = {key: sum(1 for r in results if r["class"] == key)
|
|
for key in labels}
|
|
print(f"Summary: {counts['match']}/{len(results)} match, "
|
|
f"{counts['review']} review, {counts['bad']} diff "
|
|
f"(fail threshold confidence >= {args.fail_confidence:.2f})")
|
|
|
|
if args.dry_run:
|
|
return 0
|
|
return 1 if any(r["class"] == "bad" for r in results) else 0
|
|
|
|
|
|
def build_parser():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate and compare Step Line screen-design references.")
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
|
|
gen = commands.add_parser(
|
|
"generate", help="write reference design images to docs/screens/designs/")
|
|
mode = gen.add_mutually_exclusive_group()
|
|
mode.add_argument("--draft", action="store_true",
|
|
help="draw deterministic wireframes (no API/network)")
|
|
mode.add_argument("--ai", action="store_true",
|
|
help="generate mockups via OpenRouter (default)")
|
|
gen.add_argument("--model", default=DEFAULT_MODEL)
|
|
gen.add_argument("--request-size", type=int,
|
|
default=generate_textures.GEN_SIZE)
|
|
gen.add_argument("--only", default="",
|
|
help="only generate screens whose name contains this text")
|
|
gen.add_argument("--force", action="store_true",
|
|
help="replace reference designs that already exist")
|
|
gen.set_defaults(handler=generate, draft=False)
|
|
|
|
judge_cmd = commands.add_parser(
|
|
"judge", help="ask a vision model whether captures match the designs")
|
|
judge_cmd.add_argument("--model", default=DEFAULT_JUDGE_MODEL)
|
|
judge_cmd.add_argument("--json", action="store_true")
|
|
judge_cmd.add_argument("--dry-run", action="store_true",
|
|
help="print requests instead of calling the API")
|
|
judge_cmd.add_argument("--only", default="",
|
|
help="only judge captures whose name contains this text")
|
|
judge_cmd.add_argument("--fail-confidence", type=float, default=0.6,
|
|
help="a mismatch at or above this confidence fails")
|
|
judge_cmd.set_defaults(handler=judge)
|
|
|
|
cmp = commands.add_parser(
|
|
"compare", help="compare docs/screenshots/*.png against the designs")
|
|
cmp.add_argument("--json", action="store_true")
|
|
cmp.set_defaults(handler=compare)
|
|
|
|
listing = commands.add_parser(
|
|
"list", help="print the screen catalogue and its state")
|
|
listing.set_defaults(handler=list_screens)
|
|
|
|
return parser
|
|
|
|
|
|
def main(argv=None):
|
|
args = build_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())
|