Feature/city generation v2 - #78
Merged
Merged
Conversation
added 30 commits
July 30, 2026 19:21
Adds an optional boundary to generateCity. Blocks centred outside it are dropped, road seams are clipped to it, and placement rejects footprints that straddle its edge. This is cheap because the water work already built it: a boundary is a water polygon with the sign flipped — water keeps what falls outside, a boundary keeps what falls inside. clipSegmentToLand is generalised into clipSegmentToPolygons(seg, polys, keepInside) so the two share one implementation and cannot drift; clipSegmentToLand and the new clipSegmentToBoundary are both thin wrappers over it. footprintOutsidePolygon mirrors footprintInWater and is deliberately stricter: water asks whether a footprint touches water at all, a boundary asks whether all of it is inside. A boundary with fewer than three points cannot enclose an area and is treated as absent, so a bad trace falls back to the plain bounds instead of generating nothing and looking like a broken button. Skipping a block draws no randomness, so a run without a boundary splits byte-identically to before — there is a test pinning that. Note the end-to-end tests assert on blocks and roads rather than buildings. generateThemedBuildingsForPlot, injected as deps.fillPlot, makes 36 unseeded Math.random calls, so buildings do not reproduce from a seed even though the layout does. The existing cityGen suite injects a stub for the same reason.
Completes drawn bounds. The city generator gains a DRAG_RECT / DRAW_AREA toggle; tracing reuses the water drawing interaction wholesale — same pointer handling, same feel — writing to its own trail so it cannot collide with drawing actual water. DistrictInteractions gains a tracingBoundary flag rather than a duplicate code path, so the two tracing modes share one implementation. The one behavioural difference is deliberate: water clears its trail on pointer-up because it saves immediately, while a boundary stays on screen until GENERATE so the GM can see the area they drew. The traced polygon's bounding box frames the split and the polygon itself confines it, which is what generateCity expects. Switching modes clears the other mode's selection, so a stale rectangle and a stale boundary can never both be live. Refuses to generate on fewer than three traced points. generateCity would ignore such a boundary and silently generate over the bounding box instead, which is not what the GM asked for.
Adds a layout selector to the city generator. Everything downstream of Block[] is layout-agnostic, so a layout only has to produce blocks and the roads between them — that is the whole extension point, and LayoutFn formalises it. GRID lays two perpendicular families of streets with avenues every fourth line, so the network has a hierarchy rather than a uniform mesh. That hierarchy is most of what makes a grid read as designed rather than generated. Lines are jittered slightly except at the outer edges, which are the boundary of the generated area and should not wobble. SUPERBLOCK is the same recursive split with a much larger floor, so it stops subdividing while blocks are still big: fewer roads, larger plots, open ground between them. splitCity gains an optional minBlockSize rather than a parallel implementation. Both honour water and drawn boundaries, since both reuse the same clipping helpers the BSP does. BSP stays the default and an unrecognised layout falls back to it, so a stale saved option cannot generate an empty city. The selector describes what each layout produces rather than naming the algorithm — "GRID — PLANNED, SQUARE BLOCKS" rather than "GRID".
A San Antonio-style city: concentric loop roads with arterials converging on downtown. The observation that makes this cheap is that a beltway city is not built from annular blocks. Between the loops sit perfectly ordinary streets; only the arterial network is radial. So RING lays the rings and spokes, then runs an existing layout inside each region between them, passing that region as a boundary. That makes it almost entirely composition — LayoutFn calling LayoutFn, reusing the boundary confinement drawn bounds introduced. It needs no polygonal blocks, because the sub-layout keeps producing rectangles and the boundary clips them against the curve. It is also the mechanism per-district layouts will need, proven early on a smaller problem. Downtown, inside the innermost loop, is filled with GRID; the bands outside it organically, which is what beltway cities actually look like. Ring radii grow faster than linearly so the inner loop sits tight and outer ones sweep wide, and spokes are jittered off the even division so the network does not read as a wheel diagram. The corners of a rectangular selection are left empty on purpose — a ring city is round. The shared "every layout confines blocks to a drawn boundary" test caught a real bug: fillRegion confined the sub-layout to its region, which says nothing about any outer drawn boundary, so RING spilled outside a traced area. Both blocks and roads are now filtered against both.
The first version partitioned the disc into annular sectors and ran a sub-layout inside each. A sector's bounding box is far larger than the sector, so most of what each run generated fell outside its own region and was discarded — the city came out with large empty stretches and a uniform grid that read as fake. The design was wrong, not the tuning. Real cities do not have per-sector street networks: between the arterials sits one continuous fabric of ordinary streets, and the beltway cuts across it. So the disc is now filled by a single sub-layout bounded by its outer circle, with the rings and spokes laid over the top. Buildings keep clear of the arterials through the usual road check, which is what gives them their verges. Density is now on par with the BSP over the same area — 206 blocks against 256, and a disc is pi/4 of its square, so 0.80 is about exact. There is a test pinning that ratio, since it is the property that broke.
The remaining empty blocks were caused by the arterials themselves. createIsBlocked rejects any footprint touching a road, so a 7-unit beltway left a dead strip its entire length, and six spokes converging left a dead zone at the centre. Placement never checks overpasses, so raising the arterials costs the ground nothing: the street fabric runs unbroken underneath and small buildings fill in below the deck. That is also how these cities look — elevated freeway with the city carrying on below it. Spokes now run from the innermost loop outward rather than converging on a point, which removes the starburst of dead ground at the middle and is closer to how highways actually meet a downtown loop. Spokes sit above rings so they cross cleanly. LayoutFn may now return overpasses alongside blocks and roads, and generateCity merges them with whatever bridges the water needed. Existing layouts return none and are unaffected. Includes a test that the ground beneath a deck is still offered to placement, which is the whole point of the change.
Two problems with the elevated arterials. Decks ran through towers. Placement deliberately ignores overpasses so the ground beneath stays buildable, which is what stops an arterial sterilising every block it crosses — but nothing then stopped a building rising through the deck. Rather than blocking the ground again, anything under a deck is now capped just below it, which is what actually happens around an elevated freeway. Where the deck is too low to build under at all, near its ramps, the building is dropped rather than squashed to nothing. This applies to water bridges too, which pierced buildings for the same reason. Ramps did not reach the ground. Ring decks were the worse offender: a closed loop has no ends to ramp down at, so an elevated ring either never meets the street network or does so at one arbitrary point, and both read as broken. Rings are ground roads again — a loop with verges is what a beltway looks like anyway — and only the spokes are elevated, which is what was actually asked for. Spoke ramps are now sized from the spoke rather than being a fixed 30 units, so both ends always reach the ground however large the city is. A fixed ramp longer than half the deck leaves it ending in mid-air.
A feature release rather than a patch: generation gains a boundary you draw and four selectable street layouts. Also refreshes the README for the new cityGen surface — layouts.ts, the shared water/boundary clipper, clampBuildingsUnderDecks, and the two new test suites.
Three changes that improve every layout at once rather than adding a fifth. Road width was a straight either/or — arterial for the first two splits, side street for everything after — which read as two kinds of road rather than a hierarchy. Width now steps down through a table indexed by split depth, so the network runs arterial, collector, local. That gradient is one of the strongest cues that a street plan was laid out rather than scattered. Building height stepped at zone boundaries. Zone already varies with distance, so there was a coarse taper, but it changed in bands and the skyline came out as flat plateaus with hard seams. Heights are now scaled within the zone by distance from the centre, so downtown reads as a peak rather than a mesa. Deliberately gentle — the zone bands do the heavy lifting and a strong multiplier would fight them. Landmarks are exempt; a hero building is sized on purpose. Every zone filled its plot the same way, so districts differed in what they built but not in how it sat on the ground. Plots are now inset by a per-zone coverage ratio: corporate leaves a forecourt, slums and markets build to the lot line.
Both new height features scaled a building's height without touching its y, and y is the bottom of a mesh. A plot is often several stacked parts, and a part sitting on another has its y set to that one's height — so shortening the base left everything above it hanging in the air. The skyline taper caused the visible damage, since it applies to every building rather than only those under a deck. Scaling y by the same factor keeps a stack together, which is correct for both: a uniform vertical scale is exactly what the gradient means. The deck cap now scales rather than truncates for the same reason. The regression test builds a two-part plot, a base with a storey resting exactly on top, and asserts the storey's bottom still meets the base's top. Confirmed it fails without the fix.
The remaining floating buildings. Capping ran per building, so a plot whose base pierced a deck had the base shrunk while a short upper storey that already fitted was left untouched — sitting exactly where the old roofline had been, hanging in the air. The previous fix scaled y with height, but only for parts that were actually capped, so it missed this. Parts of a plot share a temp_block_id, so they are now grouped and scaled by a single factor taken from the plot's tallest point. A plot with no room under the deck at all is dropped whole rather than in pieces. Two tests pin it: a stack must still meet after capping, and a short upper storey that already fits must come down with the base beneath it. Both confirmed to fail against per-part scaling.
Generating returned the admin to the main panel and cleared the selected area, so every adjustment to layout, zoning or density meant re-opening the generator and re-selecting the region. Iterating on a city was tedious for no reason. The panel and the selection now persist, so GENERATE can be pressed again straight away. Generating a second time over the same area infills rather than overlapping — placement already tests against existing locations, and roads consolidate onto the ones already there. Groundwork for regenerate-over-an-area, where iterating in place is the whole point.
Adds a SEED field to the generator. Blank rolls a fresh one; whatever was used is written back to the field, so a city worth keeping can be noted down or shared. The layout was already reproducible — every draw in cityGen goes through the injected rng. The buildings were not: generateThemedBuildingsForPlot made its own Math.random calls, so a seed gave the same streets with different buildings standing in them. It now takes an optional rng, defaulting to Math.random so existing callers are unaffected, and the generator passes its seeded one. Forty call sites, all inside that one function. The PRNG is deliberately not crypto-backed, and says so at its definition. 1.7.1 moved every roll that decides an outcome onto OS entropy and left cosmetic randomness alone; a city layout is cosmetic and determinism is the entire point here. parseSeed treats blank or unparseable input as "roll a fresh one" rather than generating from NaN. Seeds are 32-bit, so a larger number folds into range — still deterministic, and there is a test saying so. The UI states what a seed actually reproduces. Without that, the same seed over a different area building a different city reads as a bug.
A seed the admin enters is no longer rewritten. parseSeed coerced its input to 32 bits, so a long number silently wrapped to a different one, and writing that back read as the field being cleared and replaced. Only a blank field is filled in now — with the seed just rolled, so a city worth keeping can be noted down. seedFrom replaces it and accepts anything: a plain number is used as given and reads back as itself, and anything else is hashed. So NIGHTCITY is a valid seed, and no input ever has to be corrected. Also adds UNDO to the generator panel, the same server-side call as the admin header. Generating now leaves the panel open, so reverting a result you do not like belongs here rather than three clicks away.
Generating over an occupied area infills around what is already standing. That is useful, but it is not what regenerating means — and it made the seed and stay-on-panel work only half usable, since trying the same seed with a different layout produced the old city interleaved with the new. Adds POST /api/locations/purge-region alongside GENERATE. What survives is the point: anything a GM named is kept and becomes an obstacle the new city builds around, because losing hand-placed work is the one outcome generating again cannot undo. Tokens, water, signs and battle map content are never generation output and are never touched. A structure's parts go with it even when they fall outside the region, or they would be orphaned. Done server-side in one transaction with a single broadcast. A client deleting hundreds of rows one at a time is slow, leaves the map half cleared if it fails part way, and floods every connected player. The confirm leads with the count rather than asking "regenerate?", which invites a reflexive yes, and stays silent when the region is empty — the common first-generation case, where making it feel dangerous only discourages use. After purging, the world is re-read before generating. Placement tests against existing locations, so stale ones would leave the new city carefully avoiding buildings that no longer exist. The generate handler is now a named function taking a purgeFirst flag rather than a large inline onClick duplicated across two buttons.
countGenerated and a second TOKEN_SHAPES definition had gone into AdminPanel.tsx, which is already 2500 lines and is a component file — it had no business holding pure geometry logic, and the duplicate set was a third copy of the same three shape names. Now cityGen/region.ts, reusing isTokenShape rather than redeclaring it, and covered by twelve direct tests instead of only being reachable through a rendered component. Notably the drawn-shape case: the count must follow the traced polygon rather than its bounding box, which is awkward to assert through the UI and trivial here.
The seed field was doing two jobs — "force this seed" and "here is the seed you got" — which want opposite defaults. Generating filled the field in, so from then on REGENERATE rebuilt the identical city every time, and the only way to get a different one was to notice the reset button first. Silently backwards for a button called REGENERATE: it looks like the purge failed. The input is now purely a request and is never written to. Blank means roll a fresh seed, so repeated generates keep producing different cities. What was actually used appears as a LAST readout beneath it, clickable to copy back into the field. Both intents are now explicit and neither is hidden: leave it blank for a new city each time, or click LAST to pin the seed and change the layout.
The button empties the field, so a trash can says what it does. The reset arrow suggested it re-rolled something, which it does not — it clears, and a blank field is what makes the next generate roll a new seed. Title reworded to CLEAR SEED to match.
A WATER selector on the generator, defaulting to NONE. Rivers and coastlines are most of why real cities look like themselves — they force asymmetry, cut districts apart, and give bridges a reason to exist. Until now the bridge siting only ever fired if a GM happened to draw water first. This is a small addition because the machinery to consume water already existed and was tested. parseWaterBodies, footprintInWater, the water-aware split, shoreline roads and bridge siting all take water polygons; waterGen only has to produce one. Ordering is the part that matters. Water is generated *before* the split, so the road grid stops at the banks of its own accord and bridges are sited from the stubs left there. Generating it afterwards would mean cutting finished roads, which is a different and worse problem. NONE doubles as the off switch rather than a separate checkbox that could disagree with the selector, and it is the default because generation has never produced water — anything else would put a river through the city of everyone already using the button. water_bodies gains a `generated` column so a regenerate can clear its own river without destroying a lake the GM drew. Existing rows default to 0, so everything already on a map counts as hand-drawn.
A PARK_PONDS toggle, off by default. Parks were trees on grass; a pond gives them a centrepiece and a reason to read as designed space rather than an empty lot the generator gave up on. This is the opposite ordering case from rivers and coastlines. Those are made before the split, because the split is water-aware and the grid has to stop at the banks. A park only exists once the split has produced the block it sits in, so its pond is necessarily made afterwards — which is safe precisely because a pond is contained by its plot. It never reaches a road, so no road needs re-cutting and no bridge is called for. Ponds are therefore collected apart from the water array the split, the shoreline roads and the bridge siting were all built from. Adding to that array here would be a lie about what shaped the city, and would make the same seed produce different roads depending on whether ponds were on. A test pins that: a ponded and an unponded run of one seed give identical roads and overpasses. The toggle is separate from WATER rather than another entry in it, because they are different scales of decision — a river reshapes the whole city, a pond is scenery in one plot — and a GM may want either without the other. Off by default for the same reason WATER defaults to NONE. generatePark returns its ponds rather than pushing them into the building array: a pond is not a building, and neither the collision grid nor the height taper means anything for one. The pond roll is also skipped entirely when ponds are off, rather than made and discarded, so a seed keeps reproducing the parks it produced before ponds existed.
Measured on a generated city: ponds came out ~4 units across in plots of 50 and 65 units — 6 to 9% of what you actually see. The cause was sizing a circular pond off Math.min(bw, bd). Blocks out of the split are frequently long thin rectangles, and a circle in one can only ever be as wide as the short side, so it vanishes against the length. Raising the fraction alone would not have fixed it; the shape was wrong. A pond is now an ellipse with one radius per axis, both from the same fraction, so it takes up as much of a long plot as of a square one and still reads as a single deliberate shape. The centre offset and the edge wobble became per-axis for the same reason. The fraction goes up to 35-55% of each axis, which is a pond rather than a water feature. Frequency was left at 0.4 — that measured at 3 ponds in 6 park plots, which is what it should be.
Twelve commits had landed since the 1.8.0 release entry without being documented: seeded generation, REGENERATE and region purge, UNDO on the panel, generated water, park ponds, the road/skyline/setback pass, and three fixes for buildings floating near overpasses. They belong in 1.8.0 rather than a new version — 1.8.0 has not shipped, so this is that release growing rather than a follow-up to it. Its date moves to today for the same reason. Also called out for anyone updating: water_bodies gains a `generated` column on startup, and a server still on older code will accept generated water but store it as hand-drawn, so a regenerate will not clear it. README project structure gained waterGen.ts, rng.ts and region.ts, the four new test files, the purge-region route, and the generator's new controls.
A Voronoi diagram with streets along the cell boundaries. Every layout so
far produces rectangles, and rectangles read as planned; this is the one
that reads as grown — no right angles, streets meeting at odd angles,
blocks that are wedges and pentagons. A town that filled in around
footpaths rather than one a surveyor set out.
The obstacle was never the diagram, it was that Block is {x, z, w, d} and
the plot filler lays buildings out along a rectangle's axes — a pentagon
has no axes to offer it. Rather than teach 1180 lines of building
generator about polygons, each cell is reduced to the largest rectangle
that fits inside it. The street pattern is where nearly all of the look
lives, and that arrives intact. A cell is rarely filled by its rectangle,
so setbacks vary from plot to plot as a free side effect.
Cells are built by clipping the bounds with one half-plane per rival seed.
That is O(n^2), and for the hundred or so seeds a city needs it costs a
few thousand clips of a handful of vertices — nothing beside filling the
plots afterwards. Fortune's algorithm would be several hundred lines of
beach line and event queue to save milliseconds nobody is waiting on.
Seeds sit on a jittered lattice. A perfect lattice gives a honeycomb every
bit as machine-made as the grid, and fully random seeds clump — and
clumped seeds give slivers, cells too thin to hold anything.
Long cell boundaries become avenues. Cell edges vary a lot in length, so
the network gets a hierarchy without one being invented: the long runs
across the diagram are the ones that would carry traffic anyway.
Two existing tests asserted the exact layout list and failed on the new
entry, which is what they are for; both updated.
A ROUNDABOUTS selector — OFF, SPARSE, NORMAL — off by default. It is an overlay on the finished road network rather than a sixth layout, so one implementation serves all five instead of each growing its own. That also means it composes with WATER, PARK_PONDS and the drawn boundary the same way bridges already do. The reuse that made it small: as far as roads are concerned a roundabout island is a tiny lake. clipSegmentToLand already cuts a segment out of a polygon and leaves the approaches stopping at its edge, which is exactly what a junction does to the roads meeting it. The ring itself is the arc sampling RING uses for its beltways. Neither piece is new. Siting has to handle two kinds of junction. BSP and Voronoi networks join at shared endpoints, but GRID lays each street as one full-length span, so its crossings share no endpoint at all and exist only as intersections — and GRID is the layout most obviously wanting roundabouts. segmentCrossing was promoted out of the water clipper to find them. Ordering: the pass runs after consolidateRoads. Consolidation snaps endpoints within a few units of each other, and a ring is many short segments with close endpoints, so running it first snaps the circle into a blob. It runs after bridge siting too, so the shore stubs bridges pair from are the ones the layout actually left at the water. Islands are dressed rather than left bare — a monument from the landmark styles where there is room, trees otherwise. An empty disc reads as a hole in the road network, not a junction. Sited only at junctions of arterials, spaced by the radius of what is already placed so a wide roundabout keeps a larger berth without a second constant to keep in step, and kept out of water and outside a drawn boundary. OFF draws no randomness, so an existing seed is unaffected.
Regenerating with a water type selected left a band of empty ground tracing where the previous river ran — roads and buildings avoiding water that was no longer there. The purge already re-read locations and roads before generating, for exactly this reason: placement tests against what exists, so a stale list makes the new city avoid buildings that are gone. Water was simply never added to that refetch when generated water went in, so generation saw the old river and the new one at once. The test asserts the water refetch happens after the purge, and fails without the fix — before it there is no GET /api/water in the sequence at all.
Siting tested the junction point, which is a proxy for the wrong thing: a junction on a shoreline has its centre on dry ground while half its ring hangs over the water. The ring points are what become road, so the test now asks about those. A drawn boundary had the identical defect and gets the identical fix. The tests for this were passing without the fix, which is worth recording. Siting rolls for density before it tests anything else, and the default seed's first roll is 0.88 — above the 0.6 share — so the junction was skipped outright and four exclusion tests never reached the rule they named. Two of those shipped in the original roundabout commit. They now use a seed that clears the roll, with a control asserting that seed does place a roundabout on clear ground. Without the control the same trap reopens the moment a constant moves.
They were given a name of their own, CIRCUS, and anything outside ZONE_TYPE_NAMES is treated as authored by hand. Two consequences: The visible one — Buildings.tsx renders a named structure in #8800ff, the purple that means "this has data on it". Every island came out purple against a city of green. The worse one — purge-region keeps user-named locations by design, so a REGENERATE would have left every old island standing and stacked new ones on top of them. Islands accumulating on each regenerate, silently. Fixed by not inventing a name. An island is a monument or a stand of trees, so it is named LANDMARK or PARK — both already in the set, on both sides. Adding CIRCUS instead would have meant editing two copies of ZONE_TYPE_NAMES, one in the frontend and one in the backend, and a set that has to be edited in two places to stay correct is where this class of bug comes from in the first place. The test asserts islands are not user-named rather than asserting the specific strings, so it keeps holding if the dressing changes.
Islands were dressed with the landmark styles, which are hero buildings 150 to 220 units tall, sized to anchor a skyline from across the city. On a traffic island that is a tower growing out of a roundabout. A roundabout has a column, a statue, a fountain or a clock — a couple of storeys at most. Four styles, each proportional to the island span rather than a fixed height, so a small circus gets a small ornament and no absolute number goes wrong the next time road widths are retuned. The first cut was still too tall. It passed a "shorter than a third of a landmark" check at 54 units on a 20-unit island, which is a fifteen-storey building and exactly the thing being fixed. The test now asserts against the island span, not against a landmark, and the constants came down until it held: column 20, statue 20, fountain 16, clock 29, on a span of 20. The relationship to landmarks is still pinned as a second test, so retuning either side cannot quietly close the gap again.
The first version was two stacked boxes and read as exactly that. At this
size silhouette is the whole thing — there is no facade detail to carry it.
The renderer already supported far more than the box being used: sphere,
rhombus (an octahedron), pyramid (a cone), cylinder, a polyCount that
turns a cylinder into a hexagonal prism at 6 or a smooth column at 16, and
all three rotation axes. These now use the lot.
Six styles rather than four, each with its own outline:
column 14 parts 1.06x span stepped plinth turned 45° between
courses, fluted shaft, faceted finial,
a ring of eight bollards
statue 6 parts 0.81x plinth, torso, sphere head, two arms at
different angles so it faces somewhere
fountain 14 parts 0.80x three narrowing basins, a jet, six
spouts around the rim — the only style
broader than it is tall
clock 10 parts 1.54x tapering stack, four faces stood on
edge, belfry, spire, finial
arch 9 parts 0.99x two piers and a lintel; the one you can
see through
obelisk 10 parts 1.18x three tapering stages, each turned
against the last
The anti-floating test had to be restated. It required every part to sit
exactly on another, which was true of a stack of boxes and is not true of
a clock face or a raised arm. It now asks whether a part is on the ground
or has its base within another part's vertical extent — which is the
actual invariant, and still catches the mid-air case that left
skyscrapers hanging earlier.
The renderer resolves a part's colour as `(p.color && p.color !== '#00ff00') ? p.color : district_color ?? theme.primary`. Monuments left it empty, as the landmark styles do, so they landed on theme.primary at full brightness. That is survivable on a landmark, which is one or two large masses. A monument is six to fourteen parts inside a few units, and the coincident wireframe edges stack into a bright blob next to neighbours drawn from far fewer. Setting an explicit colour is the only way to opt out of the fallback, so they now carry a muted green. The test asserts the colour is set and is neither empty nor the '#00ff00' sentinel — the two values that would silently route back to theme.primary.
added 10 commits
August 1, 2026 23:12
Previous commit gave monument parts an explicit '#00aa33' to stop them glowing. That was the wrong lever and made the real problem worse. '#00ff00' is not a colour in this app, it is the sentinel meaning "inherit the theme": the renderer resolves a part as `(p.color && p.color !== '#00ff00') ? p.color : district_color ?? theme.primary`. Reading the live map settled it — SLUMS, URBAN, CORPO and INDUSTRIAL store '#00ff00' on some two thousand buildings between them. Naming a real colour is precisely how a structure stops rendering with the same settings as everything else, and it would also have ignored a theme switch. Monuments are back on the sentinel. The brightness was never the colour — it was density, a dozen parts inside a few units stacking their wireframe edges beside neighbours built from one or two. So the part counts came down instead: eight bollards to four, six fountain spouts to four, two basin tiers to one. Ten parts at most now, against fourteen. The colour test asserts the sentinel specifically, with the reason, so the next person to see '#00ff00' in a generator does not "fix" it again.
The column read as a bright striped cage while the statue beside it looked correct, which is what finally identified this: the statue is boxes and a sphere, the column is cylinders, and the cylinders were being built at 16 segments. Everything in this app is drawn as a wireframe, so polyCount is not a quality setting — it is the look. At 5 a cylinder is a pentagonal prism with five vertical edges. At 16 it is a dense cage of lines that reads as a solid bright mass. Reading the live map settled which is correct: every structure on it uses polyCount 5, all 49 cylinders, all 46 spheres, all 21 boxes, without exception. Monuments are back on 5. The shape vocabulary stays — spheres, octahedra, cones, rotation axes, turned plinths — since none of that was the problem. Second time in two commits that overriding an app-wide default made a structure look foreign, colour being the first. The test now pins both against the shared constant.
Monuments used shape 'rhombus' for their finials, on the assumption it was just an octahedron in the shape list. It is not. In this app a rhombus IS a player or NPC token — the server's TOKEN_SHAPES treats it as one, a region purge spares it as player content, and OverlapChecker registers it in activeRhombuses precisely so a structure containing a token can be turned transparent, which is how you see a token standing behind a wall. So every column, arch, clock and obelisk placed a fake token inside itself. The overlap check duly found a token standing in the structure and dropped its fill to zero opacity. The monument made itself invisible. That accounts for both reports. The see-through look was zero-opacity fill, not colour and not polyCount — and the statue and the fountain, the only two styles without a finial, were the only two that ever looked right, which was the clue in the screenshots all along. It also accounts for the twelve orphaned parts on the live map: a regenerate deleted each monument but spared its finial as player content, leaving a child pointing at a root that no longer existed. Every one of the twelve was a monument part; the other 2791 parented locations were intact. Finials are cones now, corner posts are cylinders. A SHAPES allow-list records which shapes a monument may use and the test asserts membership, so 'rhombus' cannot come back by looking like a reasonable choice. Found by exposing the scene and reading the actual material opacity at the monument's position, after two fixes from inference had missed. The temporary probe is removed.
Three app-wide defaults were overridden while building the roundabout
monuments, and each one produced a structure that did not look like it
belonged to the same city. None of them is what its name suggests:
color '#00ff00' not green — the sentinel meaning "inherit the theme"
polyCount 5 not quality — everything is a wireframe, so the
segment count is the look
shape 'rhombus' not an octahedron — a player or NPC token
The last one cost the most to find, so the README now carries it on the
monuments.ts line where the next person writing a generator will meet it,
and the changelog has all three under Technical.
Ten of the eleven entries under Fixed described bugs introduced and fixed inside this branch — puddle-sized ponds, monuments carrying skyscrapers or turning themselves invisible, roundabouts in water, REGENERATE building around its own deleted river, seed handling. 1.8.0 has not shipped, so no one ever ran any of them. The end state is already described under Added, where a reader looking for what the release does will actually find it. The one that stays is the one that was real: water bridges shipped in 1.7.x and pierced the buildings they passed over. The conventions note under Technical is reworded as a standing rule rather than an account of breaking it three times.
A new layout rather than a change to SUPERBLOCK. The reference for this is Greenwich Village: a fine grid where each block is cut into many narrow lots, buildings sharing party walls into a continuous street wall, with back lots in the middle. SUPERBLOCK is the opposite idea — few roads, large plots, open ground between isolated towers — and packing it with buildings would have destroyed the one city type it exists to produce while duplicating GRID. Every layout until now handed the generator one block per city block, so a block got one structure. This one hands over the lots instead: 489 of them where GRID produces 121 blocks over the same ground, median footprint 332 against 2561. Blocks are 70 by 150 rather than square, because a Manhattan block is roughly three times longer than it is deep and that shape is most of why the city reads as it does — it is what puts towers on the avenues and terraces on the side streets. The one change reaching outside the layout is Block.lot. The generator trims road padding, clamps aspect toward square and applies a per-zone setback; all three turn a whole city block into one sensible plot, and all three undo a street wall when applied to lots within a block. A flagged block skips them. No existing layout sets it, and a test pins that.
The layout borrowed gridLines, which divides a span into equal cells and wobbles the seams. That is right for a planned grid and wrong here: it made every block the same size by construction, which is exactly what a downtown is not. The reference map has short blocks, long blocks and one enormous one where a street was never cut through. Cuts are now walked across the span at varying intervals, with a chance of stretching one — a street that never went through. Over a 800-unit region that gives blocks of 93 to 226 on the long axis and 43 to 114 on the short, around a 35% spread, against a fixed cell before. Rim depth varies per block too. Fixing that exposed a real gap: with a small drawn boundary the layout generated nothing. The boundary test ran on the whole block, and every other layout can do that because a block is its unit of output. A downtown block is large and holds a dozen lots, so dropping it whole discarded lots sitting well inside the shape, and a small area lost every block it touched. Lots are tested individually now. The terrace test located its row from the old constant rim depth, which no longer exists; it groups on the z the lots actually landed at instead.
One ring of lots around a block leaves everything inside it open, and downtown blocks are large — a 20-deep rim on a 226 by 114 block leaves 186 by 74 of nothing, which is most of the block. Hence the screenshot: a lattice of buildings around a lot of void. The interior is now subdivided too, ringing inward while there is room for another ring and leaving the rest as yard. A block too thin for a rim and a middle becomes a run of lots along its length rather than a single lot the length of the block, which was a monolith where a terrace belongs. Two wrong tunings on the way, both worth recording because they bracket the answer. Filling the interior outright took a 226 by 114 block to 97% built — a solid slab with no back lot, the opposite mistake. Ringing only once left it at 47%. It now lands between 66% for the largest blocks and 96% for small ones, which is about right: big blocks have big yards, small blocks are solid. The test asserting a small block comes back as one lot was asserting the monolith, so it now asserts a terrace. The one guarding the open middle now brackets coverage from both sides rather than only checking the centre is clear, since only checking for a hole is what let the 97% version pass.
Both lines were written for the first version. lots.ts now rings a block inward more than once and turns a block too thin for a rim and a middle into a terrace rather than one monolith, and the tests bracket coverage from both sides — hollow and solid are both wrong — rather than only checking the middle is clear.
The count in the intro line was written when GRID, SUPERBLOCK and RING joined BSP. ORGANIC_CELLS and DOWNTOWN have landed since.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Test plan
Pre-merge checklist
Code quality:
Version & Release:
frontend/package.jsonversiondocker-compose.ymlAPP_VERSIONCHANGELOG.mdwith release notesBefore merging to main:
Related issues