not-tonight/tools/gen/assets.py
m3ultra 74cbd71ad3 Materials, and the floor-cache bug that would have eaten the promotion
Two things, one of them a real bug.

THE BUG: bakeTiles baked to a single global 'floor:tiles' and early-returned if
that key existed. Textures outlive a scene, so the first venue you played baked
the floor for every venue after it — you would survive a week at The Royal, get
promoted, walk into Voltage and still be standing on the pub's carpet. It only
shows on promotion, which is exactly the moment the venue ladder is supposed to
pay off, and never in a dev route because those reload the page. The baked
texture is now keyed per venue; verified by switching venues inside one session
and watching the concrete arrive.

MATERIALS: FloorLayout.materials names a generated plate per tile kind and the
baker stamps it TINTED to the palette colour. The plate carries grain, the
palette keeps hue — an untinted photo-real plate blows straight past the
brightness band the whole room was tuned in, and darkness is the mechanic.
Plates are 16x16, the game's own tile, because at that size "carpet" is
structured noise, which is also what pub carpet looks like from head height.
Ten of them: carpet, concrete, deck, parquet, looTile, paving, plaster, brick,
timber, steel. No plate named, or art not landed, falls back to flat colour
exactly as before.

Wave 2 filler for the two spaces the review found genuinely empty — The Royal's
central carpet (four props across ~500 tiles) and ROOM's east hall (~23% of its
interior with no lights and three props) — plus the kit every real room has and
no game room does: fire extinguishers, exit signs, CCTV, bins, a notice board,
ceiling fans. Voltage gets the same pass; being the reference room is how it
quietly missed the last one.

The invariant caught a duplicate prop id I introduced while placing these, which
is what it is for.

Also relaxed one ROOM assertion that keyed on array order: it checked that the
FIRST sodium light stands over the loading dock, which broke the moment the east
hall legitimately burned the same colour. It now checks that one of them does.

Gate: lint clean, tsc clean, 821 tests passing.

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

287 lines
14 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"),
]
# ---- Material plates (docs/VENUES.md §1: `FloorLayout.materials`) -------------
# Stamped one-per-tile and TINTED to the venue palette, so these want to be
# grain and pattern, not colour — the hue is applied at bake time. 16x16 is the
# game's tile, and a plate that reads as "carpet" at 16px is coloured noise with
# structure, which is also what pub carpet looks like from head height.
TEXTURE_TAIL = (
"seamless repeating texture, flat lay, straight overhead, evenly lit, "
"no objects, no text, no watermark, tileable"
)
def T(kind: str, seed: int, subject: str) -> Asset:
return Asset(f"tex:{kind}", "flat", (16, 16), f"{subject}, {TEXTURE_TAIL}",
seed, gen=(1024, 1024), quantise=False, keep_bg=True)
TEXTURES = [
T("carpet", 4490, "gaudy patterned red and navy pub carpet, worn, swirling 1990s design"),
T("concrete", 4491, "raw grey polished concrete warehouse floor, cracks and stains"),
T("deck", 4492, "pale grey composite timber rooftop decking boards"),
T("parquet", 4493, "scuffed wooden parquet dance floor, herringbone blocks"),
T("looTile", 4494, "small square white ceramic bathroom floor tiles with grimy grout"),
T("paving", 4495, "grey concrete paver slabs, wet, outdoor courtyard"),
T("plaster", 4496, "flat painted plaster wall, scuffed and nicotine stained"),
T("brick", 4497, "dark painted brick wall, mortar lines"),
T("timber", 4498, "varnished dark timber bar counter top, grain visible"),
T("steel", 4499, "brushed stainless steel panel, faint scratches"),
]
# ---- 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),
]
# ---- Wave 2: filling the rooms out ------------------------------------------
# Reviewers flagged two genuinely empty spaces (The Royal's central carpet, ROOM's
# east hall) and the venues share almost no small dressing — the fire kit, signage
# and bins every real room is full of and no game room ever has.
FILLER = [
# The Royal's middle
M("pieWarmer", (32, 16), 4500, 60,
"a glass fronted heated pie warmer cabinet on a pub counter"),
M("binRound", (16, 16), 4501, 76,
"a round stainless steel swing top rubbish bin"),
M("sandwichBoard", (16, 16), 4502, 60,
"a wooden A-frame chalkboard sandwich board sign standing open"),
F("noticeBoard", (32, 16), 4503,
"a cork pub notice board covered in pinned paper flyers and raffle tickets"),
F("ceilingFan", (32, 32), 4504,
"a dusty four blade ceiling fan seen from directly below, dark"),
# ROOM's east hall and general industrial
M("flightCase", (32, 16), 4505, 60,
"a black road flight case with metal corners and latches"),
M("barrier", (32, 16), 4506, 76,
"a galvanised steel crowd control barrier fence section"),
M("distroBox", (16, 16), 4507, 76,
"a grey industrial electrical distribution box with thick cables"),
F("fireHose", (16, 16), 4508,
"a red fire hose reel mounted on a wall"),
# Elevate
M("firepit", (32, 16), 4509, 60,
"a modern rectangular gas fire pit table with glass surround, lit"),
M("topiary", (16, 16), 4510, 76,
"a neatly clipped ball topiary shrub in a tall planter"),
# Every venue: the kit a real room has and a game room never does
M("fireExt", (16, 16), 4511, 76,
"a red fire extinguisher on a wall bracket"),
F("exitSign", (16, 16), 4512,
"a glowing green illuminated fire exit sign, running man pictogram"),
F("cctv", (16, 16), 4513,
"a small white dome CCTV security camera on a ceiling mount"),
# More staff figures (set dressing, never patrons)
M("staffCleaner", (16, 32), 4514, 76,
"a single cleaner standing holding a mop, hi vis vest, full body"),
M("staffChef", (16, 32), 4515, 76,
"a single kitchen cook standing in whites and apron, arms at sides, full body"),
]
ALL: list[Asset] = [
*ROYAL, *ELEVATE, *ROOM, *TEXTURES, *WALLS, *STAFF, *FILLER, *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}")