Lane C: keeper stand pose on the counter contract (Lane D/F integration)
Answers Lane F §8 (the counter is kind:'counter' in `places`) and closes the
keeper-spawn gap: buildInterior now returns room.counter = { mesh, pose, stand }
and tags the counter interactable with userData.keeperStand. `stand` = {x,z,ry}
where a shopkeeper stands (service side, facing the customer), in room-local
space, rig-front = local -Z per keepers.js. Deterministic; purely additive.
Verified in-browser: 36-build type sweep + 50-room soak — stand in-bounds,
faces customer (dot>0 all), carried on `places` (deep-equal), deterministic;
no change to existing placement, path, leaks, or perf (avg 3.6ms / worst 7.7ms,
geo/tex leak 0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0e9d3fb0f2
commit
42350607a0
@ -1,6 +1,7 @@
|
||||
# LANE A — CITYGEN · progress (PROCITY-A)
|
||||
|
||||
*Status: **all deliverables landed & self-check all-green.** Awaiting Fable review.*
|
||||
*Status: **all deliverables landed; self-check all-green (1283/1283); round-2 cross-lane requests from
|
||||
B/E/F closed** (see bottom). Awaiting Fable review.*
|
||||
*Date: 2026-07-14 · owner: PROCITY-A (Opus 4.8)*
|
||||
|
||||
## TL;DR
|
||||
@ -124,3 +125,37 @@ required infra — now added to the table).
|
||||
facing/cross-block-overlap/edge-coverage/finiteness/fingerprint assertions). Seed 20261990 →
|
||||
"Boolarra Heads": 27 nodes, 22 edges, 35 blocks, 681 lots, 493 shops, 174 chunks. All 5 seed
|
||||
screenshots re-captured. Golden fingerprint `0xb5d5cc13`.
|
||||
|
||||
## Round 2 — cross-lane integration responses (2026-07-14)
|
||||
|
||||
Picked the lane back up after B–F integrated against it. Worked through the notes those lanes left
|
||||
for Lane A (`docs/LANES/LANE_F_NOTES.md §8/§3.5/§7`, `B-progress.md`, `E-progress.md`) and closed
|
||||
every request that lands in my files. **Self-check green at 1283/1283; golden fingerprint refreshed
|
||||
`0xb5d5cc13` → `0x098eec2b`; map.html re-verified in-browser (renders a town, chunk-grid overlay
|
||||
works, zero console errors).** No screenshot refresh needed — the changes don't alter the plan's 2D
|
||||
appearance (the map colours by type/use, not by facade or hours).
|
||||
|
||||
| # | request (who asked) | what I changed | files |
|
||||
|---|---|---|---|
|
||||
| R1 | **"Which field marks the open-late shop, and what's the `hours` encoding?"** (Lane F §8); "exactly one late-night shop per town" (F §3.5) | The old `sr()<0.06` per-shop dice yielded **0…many** late shops and — because its formula was `max(close,19)+0..2` — usually **none past 22:00**, so F's `hours[1]>=22` gate was flaky/false. Replaced it with **exactly one deterministic `openLate:true` landmark** per town, chosen after de-confliction from a naturally-late type (video/milk bar, never a stall), closing **22:00 or 23:00**. Encoding documented: `hours=[open,close]`, 24h ints, `0≤open<close≤23`. | `plan.js`, `CITY_SPEC.md` |
|
||||
| R2 | **Skin-name double-prefix trap** — registry stores full filenames, `skins.js` expected bare keys → `facade-facade-…jpg.jpg` 404s (Lane F §7); **"pick one and say so"** on field types (Lane B) | Added a **Field-type contract** to CITY_SPEC: all ids + id-refs are **integers**; `facadeSkin`/registry `facades` entries are **full filenames** under `web/assets/gen/`, loaded verbatim (extended to E's ground/awning/sky/wall/interior skins). Lane A is the source of truth; consumer fixtures conform to it. | `CITY_SPEC.md` |
|
||||
| R3 | **`chunkIndex` furniture-drop** — verge props at a perpendicular offset bucketed into a chunk that omitted their edge (Lane B) | Rewrote edge bucketing from a 3-rail supercover to a **gap-free corridor rasterisation**: each edge is split into ≤64m pieces and every cell of each piece's `±width/2` corridor-rect AABB is bucketed. Now *provably* covers the full road+verge band. Self-check samples 9 offsets across the band (incl. between-rail) to prove it. | `plan.js`, `selfcheck.js` |
|
||||
| R4 | **`stall` pool has only 1 facade** (Lane E validator wants ≥2) | Added `facade-corrugated.jpg` (market-shed backdrop) to the `stall` pool — matches Lane E's manifest facade→type map, which already tags corrugated for stall. Every type now has ≥2 facades. | `registry.js`, `CITY_SPEC.md` |
|
||||
|
||||
**New self-check assertions (regression guards):** exactly one `openLate` shop per seed; the late
|
||||
shop closes ≥22:00 and isn't a stall; `chunkIndex` covers the full road corridor sampled densely
|
||||
across the verge band (not just the centreline).
|
||||
|
||||
**Empirical result** (5 seeds): every town gets one late-night video store — e.g. seed 20261990 →
|
||||
"Yass VIDEO" open till 23:00, seed 42 → "MOVIES" till 23:00, seed 1 → "RENTALS" till 22:00. Both
|
||||
stall facades now appear in the market square.
|
||||
|
||||
### Notes for Fable / other lanes
|
||||
- **Lane B can drop its private furniture-rasterisation workaround** if it likes — `chunkIndex.edges`
|
||||
now covers the whole corridor at the source, so any consumer (incl. Lane D footpath NPCs) can trust it.
|
||||
- **Orientation:** CITY_SPEC (§Units/axes + Layer-1 schema) already specifies GLB front = **−Z** at
|
||||
`ry=0`, world facing `(−sin ry, −cos ry)` toward `frontEdge`; the self-check enforces it and B renders
|
||||
it correctly. Lane B's note prose says "+Z" informally — flagging only so the **−Z** spec stays the
|
||||
single reference for Lane C/E when modelling facades. No code change; it works.
|
||||
- **Still-open design questions from round 1** (retail density ~493/core vs Lane B's draw budget; true
|
||||
closed residential ring) remain parked for your call — both are one-constant tunes, not rework.
|
||||
|
||||
@ -71,20 +71,36 @@ Schema v1 (Lane A owns it; extend, don't break):
|
||||
// ry: Y-rotation (radians) so a GLB modelled facing −Z ends up with its facade's OUTWARD normal
|
||||
// pointing at frontEdge. World facing = (−sin ry, −cos ry). Lane B rotates the shell by ry.
|
||||
// frontEdge: id of the street edge this lot fronts (guaranteed to exist and be geometrically adjacent).
|
||||
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close] } ]
|
||||
// type: see SHOP TYPES below. hours: [open, close] with 0 ≤ open < close ≤ 23.
|
||||
shops: [ { id, lot, type, name, sign, seed, facadeSkin, storeys, hours: [open, close], openLate? } ]
|
||||
// type: see SHOP TYPES below. hours: [open, close], 24h integers, 0 ≤ open < close ≤ 23.
|
||||
// openLate: present and `true` on EXACTLY ONE shop per town — the designated late-night landmark
|
||||
// (closes ≥ 22:00; a naturally-late type, video rental or milk bar, never a market stall). Absent
|
||||
// on every other shop. Lane B lights it after dark; Lane F's night gate keys off this field
|
||||
// (`plan.shops.find(s => s.openLate)`), NOT a magic hours threshold.
|
||||
}
|
||||
```
|
||||
|
||||
> **Field-type contract** (Lane A is the source of truth; consumer *fixtures* must match this, not the
|
||||
> reverse). Every `id` and every reference to one — `lot.block`, `lot.frontEdge`, `edge.a`/`b`,
|
||||
> `block.district`, `shop.lot` — is an **integer**, not a string. `facadeSkin`, and every entry in a
|
||||
> registry `facades` pool, is a **full filename** under `web/assets/gen/` (e.g. `"facade-fibro-blue.jpg"`)
|
||||
> — load it verbatim; do **not** prepend `facade-` or append an extension (the shop-types table below
|
||||
> abbreviates skin names for readability only). The same rule holds for Lane E's ground/awning/sky/wall/
|
||||
> interior skins: store the file exactly as the manifest names it, never re-derive a key from it.
|
||||
|
||||
> **Layer-1 invariants Lane A guarantees** (enforced by `web/js/citygen/selfcheck.js`): fully
|
||||
> deterministic per `citySeed` (with a committed golden fingerprint guarding against drift); every
|
||||
> `frontEdge`/`block`/`district`/`lot` reference resolves; every lot faces its `frontEdge`; **no two
|
||||
> building lots (`shop`/`anchor`/`house`/`stall`) overlap, within OR across blocks**; `chunkIndex`
|
||||
> covers every lot and lists every edge in every chunk its road crosses; strictly JSON round-trippable
|
||||
> (all finite numbers, no stray keys).
|
||||
> building lots (`shop`/`anchor`/`house`/`stall`) overlap, within OR across blocks**; **exactly one shop
|
||||
> carries `openLate`** (the late-night landmark); `chunkIndex` covers every lot and lists every edge in
|
||||
> every chunk its road **corridor (road + verge, centreline ± width/2)** touches — so verge-placed street
|
||||
> furniture never resolves to a chunk that omits its edge; strictly JSON round-trippable (all finite
|
||||
> numbers, no stray keys).
|
||||
|
||||
Chunk key: `cx = floor(x/64)`, `cz = floor(z/64)`. **Chunk size 64m.** Lane A ships a
|
||||
`chunkIndex(plan)` helper: chunk key → { lots, shops, edges } touching it.
|
||||
`chunkIndex(plan)` helper: chunk key → { lots, shops, edges } touching it. Edges are bucketed across
|
||||
the full road **corridor** (centreline ± `width/2`), so anything placed in the verge (street furniture,
|
||||
footpath NPCs) resolves to a chunk whose `edges` list includes its edge.
|
||||
|
||||
### Layer 2 — Streetscape (Lane B)
|
||||
|
||||
@ -115,6 +131,14 @@ night in v1; interior-mapping shader is a stretch goal).
|
||||
> `toy`/`book`/`pawn` and rounded out the fittings mixes — a `counter` to shops that sell over one,
|
||||
> a `freezer` to milkbar, a `glass_case`+`counter` to dept — so this table matches the registry
|
||||
> lanes actually consume. Non-breaking; all facades exist in `web/assets/gen/`.)*
|
||||
>
|
||||
> *(Lane A amendment 2026-07-14 · round 2, responding to B/E/F integration notes: gave `stall` a 2nd
|
||||
> facade `facade-corrugated.jpg` (a market-shed backdrop, matching Lane E's manifest facade→type map,
|
||||
> so every type now has ≥ 2 facades per Lane E's validator). Replaced the old per-shop 6% "open late"
|
||||
> dice — which yielded 0…many late shops and often none past 22:00 — with **exactly one** deterministic
|
||||
> `openLate` landmark per town (see the schema note above). Widened `chunkIndex` edge coverage to the
|
||||
> full road corridor. All non-breaking; self-check green at 1283/1283; golden fingerprint refreshed to
|
||||
> `0x098eec2b`.)*
|
||||
|
||||
| type | facade pool | interior archetype | fittings mix |
|
||||
|---|---|---|---|
|
||||
@ -126,7 +150,7 @@ night in v1; interior-mapping shader is a stretch goal).
|
||||
| `pawn` | besser, grimy, djsim-pawn, corrugated | counter-forward | glass cabinets, wall hooks, barred window, counter |
|
||||
| `milkbar` | djsim-milkbar, brickveneer | counter + freezer | counter, fridge, magazine rack, freezer |
|
||||
| `dept` (anchor) | terrazzo, djsim-dept | grand hall | mixed sections, escalator prop, glass case, counter |
|
||||
| `stall` (market) | facade-market | open stall | trestle tables, crates |
|
||||
| `stall` (market) | market, corrugated | open stall | trestle tables, crates |
|
||||
|
||||
## NPC contract (Lane D)
|
||||
|
||||
|
||||
@ -78,8 +78,9 @@ export function generatePlan(citySeed) {
|
||||
let open = reg.hours.open, close = reg.hours.close;
|
||||
if (sr() < 0.3) open = clampHour(open + (sr() < 0.5 ? -1 : 1));
|
||||
if (sr() < 0.3) close = clampHour(close + (sr() < 0.5 ? -1 : 1));
|
||||
if (sr() < 0.06) close = Math.min(23, Math.max(close, 19) + ((sr() * 3) | 0)); // the weirdo open late
|
||||
if (close <= open) close = clampHour(open + 2);
|
||||
// (the town's one guaranteed late-night shop is chosen deterministically after the whole
|
||||
// shop set is final — see the `openLate` pass at the end of generatePlan.)
|
||||
const { name, sign } = shopName(seed, type);
|
||||
shops.push({ id, lot: lotId, type, name, sign, seed, facadeSkin, storeys, hours: [open, close] });
|
||||
return id;
|
||||
@ -351,6 +352,23 @@ export function generatePlan(citySeed) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── exactly one late-night landmark: the town's designated "open till late" shop ──────
|
||||
// The brief calls for one weirdo trading late. Make it a deterministic, explicitly-flagged
|
||||
// landmark (not a per-shop dice, which gave 0..many and often none past 22:00) so Lane B can
|
||||
// light it after dark and Lane F can gate on a known field instead of a magic hours threshold.
|
||||
// Prefer a naturally-late type (video rental / milk bar); market stalls (morning-only) are never it.
|
||||
// Runs AFTER corner de-confliction so the chosen shop can't then be spliced out.
|
||||
if (shops.length) {
|
||||
const lateCandidates = shops.filter(s => s.type === 'video' || s.type === 'milkbar');
|
||||
const pool = lateCandidates.length ? lateCandidates : shops.filter(s => s.type !== 'stall');
|
||||
if (pool.length) {
|
||||
const lr = rng(citySeed, 'openlate', 0);
|
||||
const chosen = pick(lr, pool);
|
||||
chosen.openLate = true;
|
||||
chosen.hours = [chosen.hours[0], 22 + ((lr() * 2) | 0)]; // closes 22:00 or 23:00
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
citySeed,
|
||||
@ -395,35 +413,28 @@ export function chunkIndex(plan, chunkSize = CHUNK) {
|
||||
for (const shop of plan.shops)
|
||||
for (const k of (lotKeys.get(shop.lot) || [])) bucket(k).shops.push(shop.id);
|
||||
|
||||
// edges → every chunk the ROAD covers. Grid-supercover (Amanatides–Woo) traversal visits every
|
||||
// cell a segment passes through with no gaps (point-sampling skipped diagonally-crossed cells —
|
||||
// notably the origin chunk under the spine). Run it on the centreline AND the two kerb rails
|
||||
// (centreline ± halfWidth), so a wide road registers in every chunk its surface touches.
|
||||
// edges → every chunk the road CORRIDOR touches (centreline ± width/2 — the road + verge band
|
||||
// where Lane B drops verandah posts, trees and street furniture). Each edge is split into
|
||||
// ≤chunkSize pieces and every cell of each piece's corridor-rectangle AABB is bucketed. This is
|
||||
// gap-free by construction: every point of the corridor lies inside some piece's AABB, so a prop
|
||||
// placed anywhere in the verge always resolves to a chunk that lists its edge — closing the
|
||||
// furniture-drop class Lane B hit against the old centreline-only sampling. (selfcheck asserts it.)
|
||||
const addEdgeCell = (id, cx, cz) => { const bk = bucket(chunkKey(cx, cz)); if (!bk.edges.includes(id)) bk.edges.push(id); };
|
||||
const traverse = (id, x0, z0, x1, z1) => {
|
||||
let cx = cell(x0), cz = cell(z0);
|
||||
const ecx = cell(x1), ecz = cell(z1), dx = x1 - x0, dz = z1 - z0;
|
||||
const stepX = dx > 0 ? 1 : dx < 0 ? -1 : 0, stepZ = dz > 0 ? 1 : dz < 0 ? -1 : 0;
|
||||
const tDeltaX = dx !== 0 ? Math.abs(chunkSize / dx) : Infinity;
|
||||
const tDeltaZ = dz !== 0 ? Math.abs(chunkSize / dz) : Infinity;
|
||||
let tMaxX = dx !== 0 ? ((stepX > 0 ? (cx + 1) * chunkSize : cx * chunkSize) - x0) / dx : Infinity;
|
||||
let tMaxZ = dz !== 0 ? ((stepZ > 0 ? (cz + 1) * chunkSize : cz * chunkSize) - z0) / dz : Infinity;
|
||||
addEdgeCell(id, cx, cz);
|
||||
let guard = 0;
|
||||
while ((cx !== ecx || cz !== ecz) && guard++ < 100000) {
|
||||
if (tMaxX < tMaxZ) { cx += stepX; tMaxX += tDeltaX; } else { cz += stepZ; tMaxZ += tDeltaZ; }
|
||||
addEdgeCell(id, cx, cz);
|
||||
}
|
||||
};
|
||||
const nodeById = new Map(plan.streets.nodes.map(n => [n.id, n]));
|
||||
for (const e of plan.streets.edges) {
|
||||
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
|
||||
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
|
||||
const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw; // perpendicular kerb offset
|
||||
traverse(e.id, a.x, a.z, b.x, b.z);
|
||||
if (hw > 0) {
|
||||
traverse(e.id, a.x + px, a.z + pz, b.x + px, b.z + pz);
|
||||
traverse(e.id, a.x - px, a.z - pz, b.x - px, b.z - pz);
|
||||
const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw; // perpendicular half-corridor
|
||||
const pieces = Math.max(1, Math.ceil(len / chunkSize));
|
||||
for (let s = 0; s < pieces; s++) {
|
||||
const x0 = a.x + dx * s / pieces, z0 = a.z + dz * s / pieces;
|
||||
const x1 = a.x + dx * (s + 1) / pieces, z1 = a.z + dz * (s + 1) / pieces;
|
||||
let minx = Infinity, maxx = -Infinity, minz = Infinity, maxz = -Infinity; // AABB of this piece's corridor rect
|
||||
for (const [qx, qz] of [[x0 + px, z0 + pz], [x0 - px, z0 - pz], [x1 + px, z1 + pz], [x1 - px, z1 - pz]]) {
|
||||
if (qx < minx) minx = qx; if (qx > maxx) maxx = qx; if (qz < minz) minz = qz; if (qz > maxz) maxz = qz;
|
||||
}
|
||||
for (let cx = cell(minx); cx <= cell(maxx); cx++)
|
||||
for (let cz = cell(minz); cz <= cell(maxz); cz++) addEdgeCell(e.id, cx, cz);
|
||||
}
|
||||
}
|
||||
return { chunkSize, chunks };
|
||||
|
||||
@ -40,7 +40,7 @@ for (const s of SEEDS) {
|
||||
// ── 1b. golden fingerprint: guards against silent output DRIFT across code revisions ────
|
||||
// (If you intentionally change generator output, run selfcheck, copy the printed hash here.)
|
||||
section('golden fingerprint (output-drift guard)');
|
||||
const GOLDEN = { seed: 20261990, hash: 0xb5d5cc13 };
|
||||
const GOLDEN = { seed: 20261990, hash: 0x098eec2b };
|
||||
{
|
||||
const hash = xmur3(JSON.stringify(generatePlan(GOLDEN.seed)))() >>> 0;
|
||||
ok(hash === GOLDEN.hash, `seed ${GOLDEN.seed}: fingerprint 0x${hash.toString(16)} matches golden 0x${(GOLDEN.hash >>> 0).toString(16)}`);
|
||||
@ -140,20 +140,28 @@ for (const s of SEEDS) {
|
||||
}
|
||||
ok(plan.lots.every(l => covered.has(l.id)), `seed ${s}: chunkIndex covers every lot`);
|
||||
|
||||
// CHUNKINDEX edge coverage: every chunk the centreline densely passes through is listed (review D3)
|
||||
// CHUNKINDEX corridor coverage: every chunk the road CORRIDOR passes through lists the edge — not
|
||||
// just the centreline. Samples densely along the edge AND across the ±width/2 verge band (incl.
|
||||
// offsets BETWEEN the chunkIndex rails, so this genuinely tests band interior, not just the rails).
|
||||
// Guards the Lane B furniture-drop class: verge props must never fall in a chunk that omits their edge.
|
||||
const cell = v => Math.floor(v / CHUNK);
|
||||
let edgeGap = null;
|
||||
for (const e of plan.streets.edges) {
|
||||
const a = nodeById.get(e.a), b = nodeById.get(e.b); if (!a || !b) continue;
|
||||
const len = Math.hypot(b.x - a.x, b.z - a.z), steps = Math.max(1, Math.ceil(len / 2)); // 2m dense samples
|
||||
for (let k = 0; k <= steps && !edgeGap; k++) {
|
||||
const x = a.x + (b.x - a.x) * k / steps, z = a.z + (b.z - a.z) * k / steps;
|
||||
const bk = idx.chunks[`${cell(x)},${cell(z)}`];
|
||||
if (!bk || !bk.edges.includes(e.id)) edgeGap = [e.id, cell(x), cell(z)];
|
||||
const dx = b.x - a.x, dz = b.z - a.z, len = Math.hypot(dx, dz) || 1;
|
||||
const hw = (e.width || 0) / 2, px = -dz / len * hw, pz = dx / len * hw;
|
||||
const steps = Math.max(1, Math.ceil(len / 2)); // 2m dense along-edge samples
|
||||
for (const f of [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1]) { // across the corridor band
|
||||
for (let k = 0; k <= steps && !edgeGap; k++) {
|
||||
const x = a.x + dx * k / steps + px * f, z = a.z + dz * k / steps + pz * f;
|
||||
const bk = idx.chunks[`${cell(x)},${cell(z)}`];
|
||||
if (!bk || !bk.edges.includes(e.id)) edgeGap = [e.id, f, cell(x), cell(z)];
|
||||
}
|
||||
if (edgeGap) break;
|
||||
}
|
||||
if (edgeGap) break;
|
||||
}
|
||||
ok(!edgeGap, `seed ${s}: chunkIndex lists every edge in every chunk its centreline crosses` + (edgeGap ? ` (edge ${edgeGap[0]} missing @ ${edgeGap[1]},${edgeGap[2]})` : ''));
|
||||
ok(!edgeGap, `seed ${s}: chunkIndex covers the full road corridor (road+verge)` + (edgeGap ? ` (edge ${edgeGap[0]} @ off ${edgeGap[1]} missing @ ${edgeGap[2]},${edgeGap[3]})` : ''));
|
||||
|
||||
// JSON round-trip is lossless (and carries no stray non-schema keys)
|
||||
ok(JSON.stringify(JSON.parse(JSON.stringify(plan))) === JSON.stringify(plan), `seed ${s}: JSON round-trip lossless`);
|
||||
@ -167,6 +175,11 @@ for (const s of SEEDS) {
|
||||
const milkbars = plan.shops.filter(sh => sh.type === 'milkbar').length;
|
||||
ok(milkbars >= 2 && milkbars <= 4, `seed ${s}: 2–4 corner milk bars (${milkbars})`);
|
||||
|
||||
// exactly one deterministic late-night landmark (Lane F's night gate keys off `openLate`, not a threshold)
|
||||
const late = plan.shops.filter(sh => sh.openLate);
|
||||
ok(late.length === 1, `seed ${s}: exactly one open-late shop (${late.length})`);
|
||||
ok(late.every(sh => sh.hours[1] >= 22 && sh.type !== 'stall'), `seed ${s}: open-late shop closes ≥22:00 and isn't a market stall`);
|
||||
|
||||
for (const sh of plan.shops) facadeSet.add(sh.facadeSkin);
|
||||
}
|
||||
|
||||
|
||||
@ -112,7 +112,7 @@ export const SHOP_TYPES = {
|
||||
},
|
||||
stall: {
|
||||
label: 'Market Stall',
|
||||
facades: ['facade-market.jpg'],
|
||||
facades: ['facade-market.jpg', 'facade-corrugated.jpg'], // canvas market + corrugated shed backdrop (≥2 per Lane E)
|
||||
sign: { bg: '#e8e2d0', fg: '#5c3b1e', accent: '#c0392b', style: 'handpainted' },
|
||||
interior: 'open_stall',
|
||||
fittings: ['trestle_tables', 'crates'],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user