not-tonight/tools/gen/assets.py
m3ultra 22bd43857c Review pass: the room-specific bugs a shared FloorView was hiding
Three independent reviewers checked each new venue against its brief. No
blockers, all three rooms genuinely distinct — but two of the findings were
mine, in the file the layout authors were told not to touch.

FloorView was still drawing two things at Voltage's coordinates:
- The scan beams pivoted on a hardcoded (38,23) — Voltage's mirror ball. In The
  Royal that swept two beams across the empty middle of the pub, twenty-five
  tiles from that venue's dance floor. Now derived from the layout's own
  discoball, and a venue without one gets no beams, because the beams ARE the
  ball's light.
- The resident DJ stood at a hardcoded (55.5, 22.5) — Voltage's booth, which in
  The Royal is the middle of the bistro. Now placed from the venue's own gear
  and stepped clear of `posts.decks`, so a player on a DJ shift is never
  standing inside him. The beat-bob had the same constant baked into it.

Elevate's staffDj prop is dropped as a consequence: with a resident DJ derived
per venue it would have made two DJs on the plinth, and three once the player
took the shift.

New invariant: a staff prop may not stand on a post tile. Elevate's bartender
was on the exact tile the bar shift teleports the player to, so the shift would
have been played from inside her. Caught by the rule, then fixed.

`mirror` and `graffiti` kinds added. Elevate was standing BOTH its mirrors up as
`poster3` (the author raised it as a contract request and stubbed it honestly),
which would have hung the same torn gig poster in a marble bathroom and called
it a mirror. ROOM's toilets get the graffiti its brief always asked for.

The Royal: TAB screens moved onto the wall they belong on rather than floating
mid-carpet, the trough given its own fluoro (the one fixture that room is known
for was rendering unlit), the beer garden's dark middle band lit — 288 tiles on
two lights left the gate everyone walks through in the dark — the out-of-order
sign moved off the tile the queue stands on, and two comments corrected to
describe what the code actually does.

Also hardened the farm client: `_req` raised SystemExit, which is a
BaseException, so `except Exception` in the batch's worker threads did not catch
it and one stray 401 during a poll killed a 43-asset run after a single asset.
It now raises a normal error and retries transient 401/429/5xx with backoff.

Gate: lint clean, tsc clean, floor suite 271 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:01:40 +10:00

212 lines
10 KiB
Python

#!/usr/bin/env python3
"""assets.py — the not-tonight generation manifest (docs/VENUES.md §6).
One entry per sprite the three new venues need. `run_batch.py` walks this list;
nothing here executes anything itself, so it stays readable and diffable.
Two routes, per the A/B recorded in docs/SPACES.md:
flat — flux draws the finished sprite. For things with no volume: signage,
screens, boards, wall art, backdrops. Flux picking its own camera does
not matter when the object IS a flat plane.
mesh — flux draws a clean PRODUCT SHOT, TRELLIS.2 reconstructs it, Blender
renders it from the ONE fixed ortho camera every prop shares. For
anything with volume. The product shot is deliberately NOT a top-down:
TRELLIS reconstructs best from a clear view of the object, and the
game's camera angle is applied at render time by `rake`.
`rake` follows the house rule the hard way round (LANEHANDOVER, FABLE-SOLO-25):
**60° for low and wide, ~76° for tall.** Lowering the rake for a tall object to
"see more face" is wrong — a tall object is already all face, and at 55° it
renders as a featureless slab.
"""
from __future__ import annotations
from typing import NamedTuple
# Shared prompt tails. The mesh tail is tuned for TRELLIS: one object, no
# cropping, plain ground, even light, nothing for the reconstructor to mistake
# for geometry.
MESH_TAIL = (
"single object centred, full object visible, product photograph, "
"plain mid-grey seamless background, soft even studio lighting, sharp focus, "
"no people, no text, no lettering, no watermark"
)
FLAT_TAIL = (
"front-on orthographic view, flat to camera, no perspective, "
"dark background, grimy nightclub, video game texture art, "
# flux likes to sign its work in the bottom corner. Invisible on a 16px
# prop, very visible on a 640px street plate.
"no watermark, no logo, no signature"
)
class Asset(NamedTuple):
kind: str
route: str # 'flat' | 'mesh'
size: tuple[int, int] # ship size in px, must match PROP_SIZES/TARGETS
prompt: str
seed: int
rake: int = 60 # mesh route only
gen: tuple[int, int] = (1024, 1024) # generation resolution
quantise: bool = True
# True for PLATES (backdrops, skylines, a roller door filling its wall):
# they are meant to be opaque, and the importer's near-black-to-alpha pass
# would eat exactly the parts that make a night scene look like night.
# False for OBJECTS, which want cutting out of their background.
keep_bg: bool = False
def M(kind: str, size: tuple[int, int], seed: int, rake: int, subject: str) -> Asset:
return Asset(kind, "mesh", size, f"{subject}, {MESH_TAIL}", seed, rake=rake)
def F(kind: str, size: tuple[int, int], seed: int, subject: str,
gen: tuple[int, int] = (1024, 1024), quantise: bool = True,
keep_bg: bool = False) -> Asset:
return Asset(kind, "flat", size, f"{subject}, {FLAT_TAIL}", seed,
gen=gen, quantise=quantise, keep_bg=keep_bg)
# ---- The Royal — suburban pub -------------------------------------------------
ROYAL = [
M("pokie", (16, 32), 4400, 76,
"a single poker machine gaming cabinet, glowing screen, red and gold trim, worn"),
M("bistroTable", (16, 16), 4401, 60,
"a small square pub bistro table with two dining chairs, laminate top"),
M("picnicTable", (32, 16), 4402, 60,
"a weathered wooden picnic table with attached bench seats, beer garden furniture"),
M("trough", (48, 16), 4403, 70,
"a long stainless steel trough urinal, wall mounted, public bar toilet fixture"),
M("keg", (16, 16), 4404, 76,
"a stack of two stainless steel beer kegs, brewery barrels"),
M("jukebox", (16, 32), 4405, 76,
"a classic jukebox with illuminated arched top, chrome and coloured lights"),
M("raffleDrum", (16, 16), 4406, 60,
"a small wire mesh raffle barrel on a stand with a crank handle"),
M("bainMarie", (48, 16), 4407, 60,
"a stainless steel bain-marie hot food counter with sneeze guard, pub bistro"),
M("beerUmbrella", (32, 32), 4408, 80,
"a large outdoor patio umbrella open, canvas canopy seen from high above"),
M("ashtray", (16, 16), 4409, 60,
"a battered metal outdoor ashtray bin on a short pole"),
F("tabBoard", (48, 16), 4410,
"a bank of three wall mounted betting screens showing horse racing odds, glowing blue"),
F("dartboard", (16, 16), 4411,
"a dartboard on a scuffed wooden backboard with darts in it"),
F("tvSport", (32, 16), 4412,
"a wall mounted flat screen television showing a night rugby match, glowing"),
]
# ---- Elevate — rooftop --------------------------------------------------------
ELEVATE = [
M("cocktailBar", (96, 16), 4420, 60,
"a short modern cocktail bar counter with backlit bottle shelving, marble top"),
M("banquette", (32, 32), 4421, 60,
"a curved upholstered velvet banquette booth seat, luxury lounge furniture"),
M("ropePost", (16, 16), 4422, 76,
"a single polished brass rope stanchion post with red velvet rope"),
M("planter", (32, 16), 4423, 60,
"a long rectangular concrete planter box full of green foliage and grasses"),
M("balustrade", (64, 16), 4424, 76,
"a section of frameless glass balustrade railing with a steel handrail"),
M("djPlinth", (48, 32), 4425, 60,
"a raised white platform plinth stage with a DJ controller on top"),
M("marbleSink", (48, 16), 4426, 70,
"a luxury marble bathroom vanity with two vessel basins and brass taps"),
M("ashUrn", (16, 16), 4427, 76,
"a tall stainless steel standing ashtray urn, hotel lobby style"),
M("champBucket", (16, 16), 4428, 60,
"a silver champagne bucket on a stand full of ice and a bottle"),
M("loungeChair", (16, 16), 4429, 60,
"a low modern outdoor rattan lounge chair with a cushion"),
F("liftDoor", (32, 32), 4430,
"a pair of closed brushed steel lift elevator doors with a call panel"),
F("skylineCard", (128, 32), 4431,
"a distant night city skyline of lit office towers, warm gold windows, "
"seen across a dark horizon", gen=(2048, 512), quantise=False, keep_bg=True),
]
# ---- ROOM — concrete warehouse -----------------------------------------------
ROOM = [
M("stackF1", (32, 48), 4440, 76,
"a huge professional nightclub speaker stack tower, three stacked black "
"bass bins and horns, sound system rig"),
M("subBass", (32, 16), 4441, 60,
"a large black subwoofer bass bin cabinet lying wide, professional audio"),
M("plywoodBar", (128, 16), 4442, 60,
"a rough improvised bar counter made of plywood sheets on scaffolding poles"),
M("pallet", (32, 16), 4443, 60,
"a stack of two wooden shipping pallets"),
M("wheelieBin", (16, 16), 4444, 76,
"a large plastic wheelie rubbish bin with the lid closed, industrial"),
M("oldCouch", (48, 16), 4445, 60,
"a sagging ruined old fabric three seater couch, stained and torn"),
M("pillar", (16, 16), 4446, 76,
"a square raw concrete structural column pillar, industrial warehouse"),
M("strobe", (16, 16), 4447, 76,
"a black professional strobe light fixture on a short stand"),
M("smokeMachine", (16, 16), 4448, 60,
"a black fog smoke machine unit with a nozzle, stage equipment"),
M("cableSnake", (32, 16), 4449, 60,
"a coiled bundle of thick black stage power cables taped to the floor"),
M("brokenBasin", (32, 16), 4450, 70,
"a cracked dirty ceramic wall mounted bathroom basin with exposed pipes"),
M("canStack", (16, 16), 4451, 60,
"a stack of plain unbranded aluminium drink cans in a cardboard tray"),
M("cdj", (32, 32), 4452, 60,
"a professional CDJ media player deck, black with a large jog wheel"),
F("rollerDoor", (64, 32), 4453,
"a large corrugated steel roller shutter door, half open, loading dock",
keep_bg=True),
F("ruleBoard", (32, 32), 4454,
"a scuffed black letter board sign with white push-in plastic letters, "
"cryptic house rules, chipped frame"),
]
# ---- Wall surfaces (flat by nature — flux draws these directly) ---------------
WALLS = [
F("mirror", (32, 16), 4480,
"a large wall mirror in a plain frame reflecting dim room lights"),
F("graffiti", (32, 16), 4481,
"a concrete toilet wall completely covered in layered marker tags and scrawl"),
]
# ---- Staff figures (set dressing props, never patrons) ------------------------
STAFF = [
M("staffBartender", (16, 32), 4460, 76,
"a single bartender standing, black shirt, apron, arms at sides, full body"),
M("staffDj", (16, 32), 4461, 76,
"a single DJ standing wearing headphones around the neck, full body"),
M("staffGlassie", (16, 32), 4462, 76,
"a single hospitality worker standing holding a stack of empty pint glasses, full body"),
M("staffSecurity", (16, 32), 4463, 76,
"a single security guard standing, black bomber jacket, arms folded, full body"),
]
# ---- Door-scene street plates, one per venue ---------------------------------
STREETS = [
F("street:theRoyal", (640, 176), 4470,
"a wide suburban Australian pub frontage at night, painted brick, tiled dado, "
"a lit beer sign, bins, wet footpath, warm amber light",
gen=(2048, 576), quantise=False, keep_bg=True),
F("street:elevate", (640, 176), 4471,
"a wide polished lobby entrance of a rooftop bar at night, brushed steel lift doors, "
"marble, planters, cool white downlight, glass",
gen=(2048, 576), quantise=False, keep_bg=True),
F("street:room", (640, 176), 4472,
"a wide unmarked concrete warehouse wall at night, no signage, a single steel door, "
"graffiti, sodium orange streetlight, puddles, rain",
gen=(2048, 576), quantise=False, keep_bg=True),
]
ALL: list[Asset] = [*ROYAL, *ELEVATE, *ROOM, *WALLS, *STAFF, *STREETS]
BY_KIND = {a.kind: a for a in ALL}
if __name__ == "__main__":
flat = sum(1 for a in ALL if a.route == "flat")
print(f"{len(ALL)} assets — {len(ALL) - flat} mesh, {flat} flat")
for a in ALL:
print(f" {a.route:4} {a.kind:16} {a.size[0]:>4}x{a.size[1]:<3} rake={a.rake}")