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>
This commit is contained in:
m3ultra 2026-07-28 21:56:20 +10:00
parent 7225eed349
commit 74cbd71ad3
19 changed files with 292 additions and 10 deletions

View File

@ -30,6 +30,19 @@ bouncer actually learns about a room.
sealed tile freezes the player for the night — this has shipped twice);
4. every `PROBES` entry has a walkable tile within reach;
5. no prop hangs off the grid.
- **Materials are grain, the palette is hue.** `FloorLayout.materials` names a
generated plate per tile kind (`tex_carpet`, `tex_concrete`, …) and the baker
stamps it **tinted to the palette colour**. That is deliberate: an untinted
photo-real plate blows straight past the brightness band everything else was
tuned in, and the room stops being dark. Plates are 16×16 — the game's tile —
because at that size "carpet" is structured noise, which is also what pub
carpet looks like from head height. No plate named, or art not landed: flat
palette colour, exactly as before.
- **The baked floor is keyed per venue** (`floor:tiles:<id>`). Textures outlive a
scene, so a single global key meant the first venue you played baked the floor
for every venue after it — you would survive a week at The Royal, get promoted,
and walk into Voltage still standing on the pub's carpet. It only shows on
promotion, which is precisely when the ladder is supposed to pay off.
- **Light signatures survive** (SPACES.md §1): bar = amber, dance = pulsing
pink on the real beat, DJ = deep blue, toilets = flickering green, smoking =
cool blue, entry = venue pink. A venue may add ONE signature colour of its

View File

@ -79,6 +79,16 @@
"subBass",
"tabBoard",
"taps",
"tex_brick",
"tex_carpet",
"tex_concrete",
"tex_deck",
"tex_looTile",
"tex_parquet",
"tex_paving",
"tex_plaster",
"tex_steel",
"tex_timber",
"till",
"trough",
"truss",

BIN
public/props/tex_brick.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 B

BIN
public/props/tex_carpet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 755 B

BIN
public/props/tex_deck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 B

BIN
public/props/tex_paving.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

BIN
public/props/tex_steel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

BIN
public/props/tex_timber.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 B

View File

@ -3,7 +3,7 @@ import { renderDoll } from '../../patrons/doll';
import { dollPlan } from '../../patrons/dollPlan';
import { conePolygon, type ConeSpec } from './cone';
import { FIGHT_FUSE_MS } from './fight';
import { TILE, tileAt, tileColour, type FloorLayout, type PropDef, type PropKind, type VenueMap, type ZoneLight } from './venueMap';
import { TILE, tileAt, tileColour, type FloorLayout, type PropDef, type PropKind, type TileKind, type VenueMap, type ZoneLight } from './venueMap';
import type { Agent } from './crowdSim';
// Pure drawing for the floor. Darkness is the mechanic: the venue is painted
@ -69,6 +69,9 @@ export class FloorView {
private readonly posts: FloorLayout['posts'];
private readonly lights: readonly ZoneLight[];
private readonly palette: FloorLayout['palette'];
private readonly materials: FloorLayout['materials'];
/** Per-venue, so one venue's floor cannot be reused for the next. */
private readonly tilesKey: string;
private readonly tileImg: Phaser.GameObjects.Image;
private readonly neon: Phaser.GameObjects.Image[] = [];
@ -118,6 +121,8 @@ export class FloorView {
this.posts = layout.posts;
this.lights = layout.lights;
this.palette = layout.palette;
this.materials = layout.materials;
this.tilesKey = `${TILES_TEX}:${layout.id}`;
const worldW = map.width * TILE;
const worldH = map.height * TILE;
@ -126,7 +131,7 @@ export class FloorView {
this.bakeMarker();
this.bakeTiles(worldW, worldH);
this.tileImg = scene.add.image(0, 0, TILES_TEX).setOrigin(0, 0).setDepth(D_TILES);
this.tileImg = scene.add.image(0, 0, this.tilesKey).setOrigin(0, 0).setDepth(D_TILES);
this.dark = scene.add
.rectangle(0, 0, worldW, worldH, 0x000000, 0.5)
@ -630,8 +635,36 @@ export class FloorView {
g.uvDot.destroy();
}
/**
* Paint the whole room into one texture.
*
* Keyed PER VENUE. It used to bake to a single global 'floor:tiles' and
* early-return if that existed correct while there was one room, and a
* silent disaster with four: 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, and walk into Voltage still standing on the pub's
* carpet. It only shows on promotion, which is exactly the moment the whole
* venue ladder is supposed to pay off.
*
* Materials: where the layout names one for a tile kind and its art has
* loaded, the tile is STAMPED with that plate tinted to the palette colour
* the plate carries the grain, the palette keeps the venue's hue and the
* brightness band the darkness sheet was tuned against. No plate, or art not
* landed: flat colour exactly as before.
*/
private bakeTiles(worldW: number, worldH: number): void {
if (this.scene.textures.exists(TILES_TEX)) return;
if (this.scene.textures.exists(this.tilesKey)) return;
const materials = this.materials ?? {};
const plateFor = (kind: TileKind): string | null => {
const name = materials[kind];
if (!name) return null;
const key = `gen:prop:${name}`;
return this.scene.textures.exists(key) ? key : null;
};
// Flat colour underneath everything — it is the fallback AND the base the
// stamped plates sit on, so a plate with any alpha still reads in-venue.
const g = this.scene.make.graphics({}, false);
for (let ty = 0; ty < this.map.height; ty++) {
for (let tx = 0; tx < this.map.width; tx++) {
@ -639,8 +672,26 @@ export class FloorView {
g.fillRect(tx * TILE, ty * TILE, TILE, TILE);
}
}
g.generateTexture(TILES_TEX, worldW, worldH);
if (Object.keys(materials).length === 0) {
g.generateTexture(this.tilesKey, worldW, worldH);
g.destroy();
return;
}
const rt = this.scene.make.renderTexture({ width: worldW, height: worldH }, false);
rt.draw(g);
g.destroy();
for (let ty = 0; ty < this.map.height; ty++) {
for (let tx = 0; tx < this.map.width; tx++) {
const kind = tileAt(this.map, tx, ty);
const plate = plateFor(kind);
if (!plate) continue;
rt.draw(plate, tx * TILE, ty * TILE, 1, tileColour(kind, this.palette));
}
}
rt.saveTexture(this.tilesKey);
rt.destroy();
}
private bakeGlow(): void {
@ -716,6 +767,12 @@ const PROP_SIZES: Record<PropKind, [number, number]> = {
staffBartender: [16, 32], staffDj: [16, 32], staffGlassie: [16, 32], staffSecurity: [16, 32],
// Wall surfaces
mirror: [32, 16], graffiti: [32, 16],
// Wave 2 filler + venue dressing
pieWarmer: [32, 16], binRound: [16, 16], sandwichBoard: [16, 16],
noticeBoard: [32, 16], ceilingFan: [32, 32], flightCase: [32, 16],
barrier: [32, 16], distroBox: [16, 16], fireHose: [16, 16], firepit: [32, 16],
topiary: [16, 16], fireExt: [16, 16], exitSign: [16, 16], cctv: [16, 16],
staffCleaner: [16, 32], staffChef: [16, 32],
};
function bakePropPlaceholder(scene: Phaser.Scene, kind: PropKind): string {

View File

@ -265,6 +265,16 @@ const PROPS: readonly PropDef[] = Object.freeze([
P('loungeC', 'loungeChair', 41, 32, 2, 1),
P('terraceArt', 'poster2', 15, 14, 1, 2),
P('glassie', 'staffGlassie', 44, 20, 1, 1),
// Wave 2: a rooftop bar's actual furniture, and the safety kit every venue has.
P('firepit', 'firepit', 40, 31, 2, 1),
P('topiaryA', 'topiary', 17, 12, 1, 1),
P('topiaryB', 'topiary', 44, 12, 1, 1),
P('topiaryC', 'topiary', 44, 34, 1, 1),
P('extRoof', 'fireExt', 15, 30, 1, 1),
P('exitLobby', 'exitSign', 2, 20, 1, 1),
P('camLobby', 'cctv', 12, 20, 1, 1),
P('runner', 'staffGlassie', 24, 20, 1, 2),
]);
const L = (
@ -359,4 +369,15 @@ export const ELEVATE: FloorLayout = Object.freeze({
posts: POSTS,
probes: PROBES,
palette: PALETTE,
// Materials: the roof is decking, the two roofed pockets are poured concrete,
// and the bar is marble-ish steel. Tinted, so the terrace stays cool and dark.
materials: Object.freeze({
yard: 'tex_deck',
floor: 'tex_concrete',
wall: 'tex_plaster',
bar: 'tex_steel',
dance: 'tex_parquet',
sink: 'tex_looTile',
stall: 'tex_looTile',
}),
});

View File

@ -244,6 +244,22 @@ const ROOM_PROPS: readonly PropDef[] = Object.freeze([
P('bin', 'wheelieBin', 47, 40, 1, 2),
P('butts', 'buttBin', 36, 41, 1, 1),
P('dockCrate', 'crate', 43, 38, 1, 1),
// Wave 2. The review found the east hall — the route from the floor to the
// dunnies, ~300 walkable tiles — carrying zero lights and three props. It is
// where a warehouse party actually stores its gear, so that is what is in it.
P('caseA', 'flightCase', 60, 18, 2, 1),
P('caseB', 'flightCase', 66, 24, 2, 1),
P('caseC', 'flightCase', 61, 32, 2, 1),
P('barrierA', 'barrier', 58, 22, 2, 1),
P('barrierB', 'barrier', 68, 28, 2, 1),
P('distroA', 'distroBox', 66, 17, 1, 1),
P('distroB', 'distroBox', 59, 27, 1, 1),
P('hose', 'fireHose', 71, 21, 1, 1),
P('extHall', 'fireExt', 71, 30, 1, 1),
P('extBar', 'fireExt', 9, 22, 1, 1),
P('exitHall', 'exitSign', 9, 18, 1, 1),
P('cleaner', 'staffCleaner', 64, 33, 1, 2),
]);
const L = (
@ -256,6 +272,12 @@ const L = (
// dunnies and the way out by colour — but between those pools there is nothing,
// and that is the point of the venue.
const ROOM_LIGHTS: readonly ZoneLight[] = Object.freeze([
// Two low sodium spills down the east hall. Deliberately dimmer and smaller
// than anything on the main floor — this is the corridor to the dunnies, not a
// room, and ROOM is meant to stay hard to navigate without the torch. The
// review's point was that the hall had NO light at all, not that it was dark.
L('hallN', 63, 19, 0xe08420, 26),
L('hallS', 63, 30, 0xe08420, 26),
// Two work lamps clipped to the scaffolding. Amber, because a bar is amber.
L('bar0', 10, 26, 0xd8a020, 26),
L('bar1', 10, 31, 0xd8a020, 26),
@ -347,4 +369,15 @@ export const ROOM: FloorLayout = Object.freeze({
posts: ROOM_POSTS,
probes: ROOM_PROBES,
palette: ROOM_PALETTE,
// Materials: concrete, concrete, concrete. The bar is scaffold plank, the dock
// is wet paving, and the dunnies are the only tiled surface in the building.
materials: Object.freeze({
floor: 'tex_concrete',
dance: 'tex_concrete',
wall: 'tex_brick',
bar: 'tex_timber',
yard: 'tex_paving',
sink: 'tex_looTile',
stall: 'tex_looTile',
}),
});

View File

@ -272,6 +272,22 @@ const PROPS: readonly PropDef[] = Object.freeze([
P('poster1', 'poster1', 3, 13, 2, 1),
P('poster2', 'poster2', 6, 13, 2, 1),
P('poster3', 'poster3', 9, 13, 2, 1),
// Wave 2. The review counted four props across ~500 tiles of central carpet —
// "more genuinely empty carpet than any shipped room". A pub that size has
// bins, signage and a fan every few metres; now so does this one.
P('aFrame', 'sandwichBoard', 34, 19, 1, 1),
P('binMidW', 'binRound', 36, 26, 1, 1),
P('binMidE', 'binRound', 50, 30, 1, 1),
P('fanMidW', 'ceilingFan', 41, 21, 2, 2),
P('fanMidE', 'ceilingFan', 52, 27, 2, 2),
P('cleaner', 'staffCleaner', 46, 18, 1, 2),
P('notices', 'noticeBoard', 28, 12, 2, 1),
P('pies', 'pieWarmer', 52, 2, 2, 1),
P('extMain', 'fireExt', 32, 13, 1, 1),
P('extGarden', 'fireExt', 45, 33, 1, 1),
P('exitMain', 'exitSign', 12, 13, 1, 1),
P('camMain', 'cctv', 40, 16, 1, 1),
]);
const L = (
@ -374,4 +390,16 @@ export const THE_ROYAL: FloorLayout = Object.freeze({
posts: POSTS,
probes: PROBES,
palette: PALETTE,
// Materials (docs/VENUES.md §1). Stamped tinted, so these carry grain and the
// palette above still decides hue — pub carpet, nicotine plaster, a varnished
// counter, and the 2004 parquet nobody has re-laid.
materials: Object.freeze({
floor: 'tex_carpet',
wall: 'tex_plaster',
bar: 'tex_timber',
dance: 'tex_parquet',
yard: 'tex_paving',
sink: 'tex_looTile',
stall: 'tex_looTile',
}),
});

View File

@ -230,7 +230,13 @@ export type PropKind =
// Wall surfaces. Added after review: Elevate was standing BOTH its mirrors up
// as `poster3`, so the lift lobby and the marble bathroom would have rendered
// the same torn gig poster and called it a mirror.
| 'mirror' | 'graffiti';
| 'mirror' | 'graffiti'
// Wave 2: the small dressing every real room is full of and no game room has —
// the fire kit, the signage, the bins — plus filler for the two spaces the
// review found genuinely empty (The Royal's middle, ROOM's east hall).
| 'pieWarmer' | 'binRound' | 'sandwichBoard' | 'noticeBoard' | 'ceilingFan'
| 'flightCase' | 'barrier' | 'distroBox' | 'fireHose' | 'firepit' | 'topiary'
| 'fireExt' | 'exitSign' | 'cctv' | 'staffCleaner' | 'staffChef';
export interface PropEmissive {
colour: number;
@ -333,6 +339,18 @@ export const PROPS: readonly PropDef[] = Object.freeze([
P('heater', 'patioHeater', 21, 39, 1, 2, { colour: 0xe07030, radius: 34, pulse: 'flicker' }),
P('trussN', 'truss', 34, 15, 3, 1),
P('trussS', 'truss', 34, 30, 3, 1),
// Wave 2 (2026-07-28). Voltage is the reference room, which made it the one
// venue that quietly missed the dressing pass the other three got — the fire
// kit, the signage and the bins every real room is full of.
P('extCorridor', 'fireExt', 15, 21, 1, 1),
P('extFloor', 'fireExt', 55, 35, 1, 1),
P('exitCorridor', 'exitSign', 2, 20, 1, 1),
P('camFloor', 'cctv', 30, 10, 1, 1),
P('fanFloor', 'ceilingFan', 33, 12, 2, 2),
P('binFloor', 'binRound', 24, 33, 1, 1),
P('cleaner', 'staffCleaner', 58, 34, 1, 2),
P('barkeep', 'staffBartender', 30, 3, 1, 1),
]);
const L = (
@ -417,6 +435,15 @@ export interface FloorLayout {
probes: Readonly<Record<string, TilePoint>>;
/** Per-venue tile colours, merged over COLOURS. */
palette?: Readonly<Partial<Record<TileKind, number>>>;
/**
* Generated material plates per tile kind, by manifest name (e.g. 'tex_carpet'
* for public/props/tex_carpet.png). The plate supplies grain; `palette`
* supplies hue, because the plate is stamped TINTED that keeps a venue
* inside the brightness band the darkness sheet was tuned against instead of
* letting a bright texture wash the room out. Unlisted kinds, or art that has
* not landed, fall back to flat palette colour.
*/
materials?: Readonly<Partial<Record<TileKind, string>>>;
}
export function tileAt(map: VenueMap, tx: number, ty: number): TileKind {
@ -458,4 +485,15 @@ export const VOLTAGE: FloorLayout = Object.freeze({
lights: LIGHTS,
posts: POSTS,
probes: PROBES,
// Voltage's colours are the tuned baseline and do not move; the plates only
// add grain under them (materials are stamped TINTED to these same colours).
materials: Object.freeze({
floor: 'tex_concrete',
wall: 'tex_plaster',
bar: 'tex_timber',
dance: 'tex_parquet',
yard: 'tex_paving',
sink: 'tex_looTile',
stall: 'tex_looTile',
}),
});

View File

@ -111,10 +111,17 @@ describe('ROOM layout', () => {
const dance = ROOM.lights.filter((l) => l.colour === 0xd03470 && l.pulse === 'beat');
expect(dance.length).toBeGreaterThanOrEqual(4);
for (const l of dance) expect(l.radius).toBeLessThan(26);
// The one signature colour ROOM is allowed, and it belongs to the dock.
const sodium = ROOM.lights.find((l) => l.colour === 0xe08420);
expect(sodium).toBeDefined();
expect(tileAt(ROOM.map, sodium!.tx, sodium!.ty)).toBe('yard');
// The one signature colour ROOM is allowed, and the dock is what it is FOR.
// Asserted as "at least one sodium light stands over the dock" rather than
// "the first one does": the east hall legitimately burns the same colour
// (it is the same building's lighting), and keying on array order made this
// fail the moment another sodium light was added anywhere earlier.
const sodium = ROOM.lights.filter((l) => l.colour === 0xe08420);
expect(sodium.length).toBeGreaterThan(0);
expect(
sodium.some((l) => tileAt(ROOM.map, l.tx, l.ty) === 'yard'),
'no sodium light stands over the loading dock',
).toBe(true);
});
it('runs the rig on the floor, taped down', () => {

View File

@ -164,6 +164,35 @@ ROOM = [
"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,
@ -200,7 +229,53 @@ STREETS = [
gen=(2048, 576), quantise=False, keep_bg=True),
]
ALL: list[Asset] = [*ROYAL, *ELEVATE, *ROOM, *WALLS, *STAFF, *STREETS]
# ---- 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}