diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f10542..69aa43c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,55 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.8.0] - 2026-08-01 + +### Added + +- **Drawn generation bounds** — the city generator gains a `DRAG_RECT` / `DRAW_AREA` toggle. `DRAW_AREA` traces a boundary the way water is drawn, and generation is confined to that shape: blocks centred outside it are dropped, road seams are clipped to it, and a footprint straddling its edge is rejected. A concave shape generates nothing in its notch, so an L or a crescent works as drawn. The traced outline stays on screen until GENERATE, unlike water, which saves immediately. +- **Street layouts** — a `LAYOUT` selector offering six distinct city types. Everything downstream of the block list is layout-agnostic, so a layout only has to produce blocks and the roads between them. + - **`GRID`** — two perpendicular families of streets with avenues every fourth line. Reads as Manhattan or Chicago, and is genuinely distinct from the default, which always produces *irregular* rectangles however it is tuned. That road hierarchy is most of what makes a grid look designed rather than generated. + - **`SUPERBLOCK`** — the same recursive split with a much larger floor: fewer roads, larger plots, open ground between them. Soviet microdistrict or corporate arcology. + - **`RING`** — a beltway city, San Antonio being the reference: concentric loop roads with elevated arterials running out from downtown. The corners of a square selection are left empty on purpose, because a ring city is round. + - **`ORGANIC_CELLS`** — a Voronoi diagram with streets along the cell boundaries. The only layout with no right angles in it: streets meet at odd angles and blocks are wedges and pentagons, which reads as a town that grew around footpaths rather than one a surveyor set out. Long cell boundaries become avenues, so the network gets a hierarchy without one being invented — the long runs across the diagram are the ones that would carry traffic anyway. + - **`DOWNTOWN`** — an elongated street grid whose blocks are cut into lots around their rim, facing the street, with the middle of the block left as back lots. Every other layout hands the generator one block per city block, so a block gets one structure — right for a tower in a park, wrong for a downtown, where what makes a dense city look dense is many narrow buildings shouldering together along the street. Roughly four times the buildings of `GRID` over the same ground. Block sizes vary rather than repeating one cell — short blocks, long ones, and the occasional enormous one where a street was never cut through, Washington Square being the obvious example — and the depth of the built-up rim varies with them, since a constant depth reads as a machine even when the frontages differ. A large block is ringed inward more than once so it reads as built out rather than hollow, stopping while there is still a back lot behind the buildings; a block too thin to have a rim and a middle becomes a terrace rather than one monolith the length of the block. `SUPERBLOCK` is deliberately left alone as the opposite idea rather than being turned into this. + - **`BSP`** stays the default and an unrecognised layout falls back to it, so existing generation is untouched and a stale saved option cannot produce an empty city. +- **Generated water** — a `WATER` selector offering a `RIVER` across the region, a `COAST` cutting one edge off, or a `LAKE` inside it. 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, which until now only happened if a GM had drawn water first. `NONE` is the default and doubles as the off switch, so generation produces water only when asked and a GM who wants to draw their own is never overruled. +- **Park ponds** — a `PARK_PONDS` toggle gives some parks water as well as trees, with the trees standing back from the edge. Separate from `WATER` because they are different scales of decision — a river reshapes the whole city, a pond is scenery in one plot — and either is wanted without the other. Off by default. +- **Roundabouts** — a `ROUNDABOUTS` selector (`OFF` / `SPARSE` / `NORMAL`) putting a circus where major roads meet. It is an overlay on the finished road network rather than another layout, so one implementation serves all five: approaches are cut back to the ring, the ring is laid, and the island gets a monument where there is room for one or a stand of trees otherwise. An empty disc reads as a hole in the network rather than a junction, so every island gets something. +- **Seeded generation** — an optional `SEED` field. The same seed over the same area with the same options rebuilds the same city, so a map can be recreated or shared as a short string. Leaving it blank rolls a fresh seed, and the seed actually used is reported back beneath the field rather than written into it. +- **`REGENERATE`** — clears the previous generation in the selected area and builds afresh, for iterating on a district without hand-deleting it first. It keeps anything the GM authored: named structures, tokens, battle-map content and hand-drawn water all survive. Plain `GENERATE` still adds to what is there. +- **`UNDO` on the generator panel** — the same server-side undo as the admin header, reachable without leaving the panel. +- The panel now stays open after generating, instead of dropping back to the main admin list — generation is something you do repeatedly while tuning. + +### Changed + +- **Road hierarchy, skyline taper and per-zone setbacks.** Road width is graded by split depth, so arterials read as arterials and side streets as side streets. Building height now blends continuously with distance from the centre instead of stepping in bands, which turned the skyline from flat plateaus with hard seams into a taper. Corporate plots leave forecourts and slums and markets build to the lot line, via a per-zone lot coverage applied after the aspect clamp. + +### Fixed + +- **Water bridges no longer pierce the buildings they pass over.** Placement deliberately ignores overpasses so the ground beneath a deck stays buildable — that is what stops an elevated road sterilising every block it crosses — but nothing then stopped a tower rising straight through one. Anything under a deck is now capped just below it, and where the deck is too low to build under at all, near its ramps, the building is dropped rather than squashed to nothing. + +### Technical + +- **A drawn boundary is a water polygon with the sign flipped** — water keeps what falls outside, a boundary keeps what falls inside. `clipSegmentToLand` was generalised into `clipSegmentToPolygons(seg, polys, keepInside)` so the two share one implementation and cannot drift; there is a test asserting they are exact inverses. `footprintOutsidePolygon` mirrors `footprintInWater` but is stricter: water asks whether a footprint touches water at all, a boundary asks whether all of it is inside. +- A boundary of fewer than three points cannot enclose an area and is treated as absent, falling back to the plain bounds rather than generating nothing and looking like a broken button. +- Skipping a block draws no randomness, so generation without a boundary splits byte-identically to before. Tests pin that at both the split and the whole-city level. +- **`RING` fills the disc with a single sub-layout and lays its arterials over the top.** A first version partitioned the disc into annular sectors and sub-laid each one, which produced a sparse, fragmented city — 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. Density is now on par with the default over the same area. +- **`RING` elevates its spokes but leaves its loops on the ground.** 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. Spoke ramps are sized as a fraction of the spoke rather than a fixed length, so both ends reach the ground however large the city is — a fixed ramp longer than half the deck leaves it ending in mid-air. +- Spokes run from the innermost loop outward rather than converging on a point, which removes a starburst of dead ground at the centre and is closer to how highways meet a downtown loop. +- `LayoutFn` may return overpasses alongside blocks and roads, and `generateCity` merges them with whatever bridges the water needed. `splitCity` gained an optional minimum block size rather than `SUPERBLOCK` being a parallel implementation. +- **`Block.lot` lets a layout subdivide a block itself.** The generator trims road padding off a block, clamps its aspect toward square and applies a per-zone setback — three rules that exist to turn a whole city block into one sensible plot. Applied to lots inside a subdivided block they pad the neighbours apart, square up the narrow frontages and pull the row back off the street, which is every ingredient of a street wall undone. A block flagged `lot` skips all three, its footprint being already decided. No existing layout sets it, so all five are untouched, and there is a test asserting that. +- **Three app-wide conventions a generator must not override.** None is what its name suggests, each is asserted in a test, and getting any of them wrong produces a structure that does not look like it belongs to the same city. `color: '#00ff00'` is not green, it is the sentinel meaning "inherit the theme" — the renderer resolves anything else verbatim, so naming a real colour opts a structure out of theming entirely. `polyCount: 5` is not a quality setting: everything is drawn as a wireframe, so the segment count *is* the look, and a 16-segment cylinder reads as a bright striped cage beside a city of pentagonal prisms. And `shape: 'rhombus'` is not an octahedron, it is a player or NPC token — `TOKEN_SHAPES` treats it as one on the server, a purge spares it as player content, and `OverlapChecker` publishes it in `activeRhombuses`, so a structure using one as a finial publishes a fake token inside itself. +- **A roundabout island is a tiny lake, as far as roads are concerned.** `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 — so trimming reuses the water clipper rather than a second implementation, and the ring reuses the arc sampling `RING` uses for its beltways. Siting has to handle two kinds of junction: a BSP or Voronoi network joins at shared endpoints, but `GRID` lays each street as one full-length span, so its crossings share no endpoint and are found only by intersecting segments — `segmentCrossing` was promoted out of the water clipper for that. The whole pass runs *after* `consolidateRoads`, which snaps nearby endpoints together and would otherwise snap a ring of short segments into a blob. +- **A Voronoi cell is reduced to the largest rectangle that fits it.** `Block` is `{x, z, w, d}`, and the plot filler lays buildings out along a rectangle's axes — a pentagon has no axes to work with. Fitting a rectangle inside each cell keeps that 1180-line generator completely untouched while still delivering the irregular *street pattern*, which is where nearly all of the look comes from. The rectangle rarely fills its cell, so setbacks vary from plot to plot for free. Cells are built by half-plane clipping rather than a sweepline: for the hundred or so seeds a city needs it is fast enough, and 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 as machine-made as the grid, and fully random seeds clump into slivers too thin to build on. +- **Water is generated before the split; ponds after it.** The split is already water-aware, so generating a river first means 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. A park pond is the opposite case: the park only exists once the split has produced the block it sits in. That is safe because a pond is contained by its plot and never reaches a road, and ponds are kept out of the water array the split, the shoreline roads and the bridge siting were built from. A test pins it: a ponded and an unponded run of one seed give identical roads and overpasses. +- **`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. The migration runs on startup — a server on older code will accept generated water but store it as hand-drawn. +- **Seeding reached the buildings, not just the layout.** `cityGen/` had a single `Math.random` (the injected default), but `generateThemedBuildingsForPlot` had forty of its own, so a seed reproduced the street layout while the buildings on it changed every run. The rng is threaded through to the plot filler. It is deliberately not crypto-backed: 1.7.1 moved outcome-deciding rolls to OS entropy, and city layout is cosmetic. +- **`POST /purge-region`** clears a region's generated content in one transaction and emits a single update, rather than the panel issuing a delete per object. It distinguishes generated content from authored content by `isUserDefinedName`, token classification, battle-map membership and the new water flag. +- Region membership and counting moved out of `AdminPanel` into `cityGen/region.ts`, and seeding into `cityGen/rng.ts`, so both are testable without rendering the panel. + +--- + ## [1.7.4] - 2026-07-28 ### Added diff --git a/README.md b/README.md index 2605fac..cad37ea 100644 --- a/README.md +++ b/README.md @@ -326,8 +326,8 @@ CITY_NET/ │ ├── middleware/ │ │ └── auth.js # JWT verify middleware (admin + elevated users) │ ├── routes/ -│ │ ├── admin.js # Admin-only REST endpoints; undo covers locations, roads, signs -│ │ ├── locations.js # Location CRUD; JOIN→CUSTOM classification upserts roots + child parts to custom_structure_library; serves GET /custom-library (CUSTOM-only); GET / includes sheet_data for NPC initiative rolls +│ │ ├── admin.js # Admin-only REST endpoints; undo covers locations, roads, signs; POST /water marks generated water so a regenerate can clear its own river without touching a lake the GM drew +│ │ ├── locations.js # Location CRUD; JOIN→CUSTOM classification upserts roots + child parts to custom_structure_library; serves GET /custom-library (CUSTOM-only); GET / includes sheet_data for NPC initiative rolls; POST /purge-region clears one region's generated content in a single transaction, keeping GM-named structures, tokens, battle-map content and hand-drawn water │ │ ├── battle_maps.js # Battle map image upload/management │ │ ├── maps.js # Saved map snapshots (locations, districts, roads, overpasses, water bodies); preserves only rhombus tokens on load/clear; records active_map_name in global_settings so exports can name their files │ │ ├── music.js # Radio Feed — library CRUD + file upload @@ -403,21 +403,39 @@ CITY_NET/ │ │ ├── App.tsx # Root component — state, routing, socket wiring │ │ ├── App.css / index.css # Global styles and CSS variables │ │ ├── cityGen/ # Pure city generator — bounds + options + world state in, blocks/roads/buildings/overpasses out. No React, no network; AdminPanel persists the result -│ │ │ ├── index.ts # generateCity orchestrator; injected rng and fillPlot make it testable +│ │ │ ├── index.ts # generateCity orchestrator; selects a layout, caps buildings under decks; injected rng and fillPlot make it testable │ │ │ ├── types.ts # Bounds, Block, RawBuilding, Obstacle, options/context/result shapes -│ │ │ ├── bsp.ts # Recursive split into blocks + road seams; seams clipped to land as they are laid -│ │ │ ├── collision.ts # SpatialGrid (footprint spans every cell it covers) + exact segment-vs-box road test +│ │ │ ├── bsp.ts # Recursive split into blocks + road seams; seams clipped to land and to any drawn boundary as they are laid; optional minimum block size +│ │ │ ├── layouts.ts # LayoutFn registry — BSP (default), GRID (avenues every 4th line), SUPERBLOCK (large floor), RING (beltways with elevated spokes filling a disc), VORONOI (organic cells, streets on the cell boundaries), PERIMETER (elongated blocks cut into street-facing lots) +│ │ │ ├── lots.ts # Cuts a block into building lots: a ring around the rim facing the street, ringing inward again while there is room and leaving the rest as back lot, with a block too thin for a rim and a middle becoming a terrace rather than one monolith. Rim depth and frontages vary per block. Each lot is flagged Block.lot so the generator takes the footprint as given instead of padding, squaring and setting it back +│ │ │ ├── voronoi.ts # Voronoi cells by half-plane clipping, shared-edge dedup, and the inscribed rectangle that lets an irregular cell feed a rectangle-only plot filler +│ │ │ ├── collision.ts # SpatialGrid (footprint spans every cell it covers), exact segment-vs-box road test, boundary rejection, and clampBuildingsUnderDecks so overpasses do not pierce towers │ │ │ ├── zoning.ts # Sector layout, concentric-ring zone assignment, park probability, plot aspect clamp -│ │ │ ├── parks.ts # Holotree park plots +│ │ │ ├── parks.ts # Holotree park plots and their optional ponds; a pond is elliptical so it fills a long thin plot, and is returned rather than pushed as a building │ │ │ ├── landmarks.ts # The four hero-building styles and their siting rule -│ │ │ ├── water.ts # Water polygon parsing, point/footprint-in-water, submerged spans, segment clipping +│ │ │ ├── monuments.ts # Six small civic ornaments for a roundabout island — column, statue, fountain, clock tower, arch, obelisk — sized against the island rather than the skyline. Shapes come from a SHAPES allow-list that deliberately excludes `rhombus`: a rhombus is a player/NPC token here, so using one as a finial made a monument publish a fake token inside itself, which turned it transparent and made it survive a purge as player content +│ │ │ ├── water.ts # Water polygon parsing, point/footprint tests, submerged spans, and one clipper shared by water and drawn bounds (keepInside flips which side survives) +│ │ │ ├── waterGen.ts # Generated rivers, coastlines and lakes; runs before the split so the grid stops at the banks and bridges get sited. NONE is both the default and the off switch │ │ │ ├── shoreline.ts # Waterfront roads offset onto land; snaps approach ends onto them │ │ │ ├── bridges.ts # Shore-stub pairing, span/grade limits, deck levelling by graph colouring, OVERPASS_DENSITY +│ │ │ ├── roundabouts.ts # Overlay on a finished network, so it works with every layout — junction finding (shared endpoints and true crossings), siting by width/spacing/density, and approach trimming via the water clipper +│ │ │ ├── rng.ts # seededRng (mulberry32), randomSeed, and seedFrom — hashes any typed seed into range instead of truncating it +│ │ │ ├── region.ts # Region membership test and generated-content count, shared by the panel and REGENERATE │ │ │ └── __tests__/ │ │ │ ├── cityGen.test.ts # Split determinism, collision and buffer behaviour, zoning, landmarks, parks, end-to-end generation -│ │ │ └── water.test.ts # Polygon parsing, concave outlines, span detection, shoreline roads, bridge siting and levels +│ │ │ ├── boundary.test.ts # Drawn bounds — inside/outside/straddling, concave notch, clip inverse of water, unchanged output without a boundary +│ │ │ ├── layouts.test.ts # Per-layout contracts, grid regularity vs BSP, ring density and deck ramps, height capping under decks +│ │ │ ├── perimeter.test.ts # Lots that tile a block without overlapping, terrace gaps under two units, varied frontages and rim depths, block coverage bracketed from both sides (hollow and solid are both wrong), varied block sizes, a drawn boundary tested per lot rather than per block, and every other layout left unflagged +│ │ │ ├── voronoi.test.ts # Cells closer to their own seed than any other, tiling without gaps, convexity, edge dedup, inscribed rectangle, and a road network that is not axis-aligned +│ │ │ ├── monuments.test.ts # Scale against the island and against a landmark, nothing floating, one root per monument, and the three app-wide conventions a generator must not override — the `#00ff00` theme sentinel, `polyCount` 5, and never the token-reserved `rhombus` +│ │ │ ├── roundabouts.test.ts # Crossings with no shared endpoint, arterial-only siting, spacing, water and boundary exclusion, approaches cut back to the ring but still reaching it, closed ring, and every layout +│ │ │ ├── water.test.ts # Polygon parsing, concave outlines, span detection, shoreline roads, bridge siting and levels +│ │ │ ├── waterGen.test.ts # River/coast/lake shape and seeding; water reaching the city before the split rather than after +│ │ │ ├── parkPonds.test.ts # Pond shape and size, containment in the plot, trees standing back from the water, and identical roads with ponds on or off +│ │ │ ├── seeds.test.ts # Same seed rebuilds the same city; typed seeds survive intact; a new seed gives a different one +│ │ │ └── region.test.ts # Region membership and counting for REGENERATE │ │ ├── components/ -│ │ │ ├── AdminPanel.tsx # GM dashboard — CITY / EXPORT / GAME / PLAYERS tabs; CITY_GENERATOR delegates to cityGen/ and exposes OVERPASS_DENSITY; CUSTOM type integrates into NEXT_STYLE cycle using cross-map custom_structure_library; data-driven HouseRulesPanel for CP:R, CWN, and SR6; SR6 Edge replenishment (reset all / give 1 to player) +│ │ │ ├── AdminPanel.tsx # GM dashboard — CITY / EXPORT / GAME / PLAYERS tabs; CITY_GENERATOR delegates to cityGen/ and exposes LAYOUT, DRAG_RECT/DRAW_AREA bounds, OVERPASS_DENSITY, WATER, PARK_PONDS, an optional SEED and REGENERATE; CUSTOM type integrates into NEXT_STYLE cycle using cross-map custom_structure_library; data-driven HouseRulesPanel for CP:R, CWN, and SR6; SR6 Edge replenishment (reset all / give 1 to player) │ │ │ ├── HitPoints.tsx # HP tracking + injury panel + HealthReviewWindow; STIM_HEAL (CWN), STABILIZE button for allies on mortal wound │ │ │ ├── BankWindows.tsx # Player bank UI │ │ │ ├── ChatWindow.tsx # In-game chat diff --git a/backend/__tests__/helpers/testDb.js b/backend/__tests__/helpers/testDb.js index cc21380..beeba77 100644 --- a/backend/__tests__/helpers/testDb.js +++ b/backend/__tests__/helpers/testDb.js @@ -106,7 +106,8 @@ function makeTestDb() { db.run(`CREATE TABLE water_bodies ( id INTEGER PRIMARY KEY AUTOINCREMENT, points_json TEXT NOT NULL, - map_scale_multiplier TEXT DEFAULT '[1]' + map_scale_multiplier TEXT DEFAULT '[1]', + generated INTEGER DEFAULT 0 )`); db.run(`CREATE TABLE signs ( diff --git a/backend/__tests__/purge_region.test.js b/backend/__tests__/purge_region.test.js new file mode 100644 index 0000000..3521036 --- /dev/null +++ b/backend/__tests__/purge_region.test.js @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { makeTestDb, all, run } from './helpers/testDb.js'; +import locationsRouteFactory from '../routes/locations.js'; + +/** + * Clearing a generated city from a region so it can be generated afresh. + * + * Generating over an occupied area otherwise infills around what is there. The rule + * that matters is what survives: anything a GM named is kept, because losing + * hand-placed work is the one outcome generating again cannot undo. + */ + +process.env.JWT_SECRET = 'test-secret'; + +const ADMIN_TOKEN = jwt.sign( + { id: 1, username: 'testadmin', role: 'admin', isTemporary: false }, + 'test-secret' +); + +let recorded; + +const makeApp = (db) => { + const app = express(); + app.use(express.json()); + recorded = []; + app.use('/api/locations', locationsRouteFactory(db, { emit: () => {} }, { + emitUpdate: () => {}, + recordAction: (type, payload) => recorded.push({ type, payload }), + })); + return app; +}; + +/** The 100x100 square the tests clear. */ +const REGION = { bounds: { min: { x: -50, z: -50 }, max: { x: 50, z: 50 } } }; + +const addLocation = (db, { name = 'CORPO', x = 0, z = 0, shape = 'box', parent_id = null, battle_map_id = null } = {}) => + run(db, `INSERT INTO locations (name, x, y, z, shape, parent_id, battle_map_id) VALUES (?, ?, 0, ?, ?, ?, ?)`, + [name, x, z, shape, parent_id, battle_map_id]); + +const addRoad = (db, x1, z1, x2, z2) => + run(db, `INSERT INTO roads (x1, z1, x2, z2, width) VALUES (?, ?, ?, ?, 4)`, [x1, z1, x2, z2]); + +const purge = (app, body = REGION) => + request(app).post('/api/locations/purge-region') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send(body); + +let db; +let app; + +beforeEach(async () => { + db = await makeTestDb(); + app = makeApp(db); +}); + +describe('POST /api/locations/purge-region', () => { + it('requires authentication', async () => { + const res = await request(app).post('/api/locations/purge-region').send(REGION); + expect(res.status).toBe(401); + }); + + it('rejects a request with no region', async () => { + const res = await purge(app, {}); + expect(res.status).toBe(400); + }); + + it('removes generated structures inside the region', async () => { + await addLocation(db, { name: 'CORPO', x: 10, z: 10 }); + await addLocation(db, { name: 'SLUMS', x: -20, z: 5 }); + + const res = await purge(app); + expect(res.status).toBe(200); + expect(res.body.locations).toBe(2); + expect(await all(db, 'SELECT * FROM locations')).toHaveLength(0); + }); + + it('leaves generated structures outside the region alone', async () => { + await addLocation(db, { name: 'CORPO', x: 500, z: 500 }); + await purge(app); + expect(await all(db, 'SELECT * FROM locations')).toHaveLength(1); + }); + + it('keeps anything the GM named, and says how many', async () => { + // The rule that matters: hand-placed work survives regenerating. + await addLocation(db, { name: 'CORPO', x: 5, z: 5 }); + await addLocation(db, { name: "AFTERLIFE", x: 6, z: 6 }); + await addLocation(db, { name: 'Watson Clinic', x: 7, z: 7 }); + + const res = await purge(app); + expect(res.body.locations).toBe(1); + expect(res.body.keptNamed).toBe(2); + + const left = await all(db, 'SELECT name FROM locations'); + expect(left.map(r => r.name).sort()).toEqual(['AFTERLIFE', 'Watson Clinic']); + }); + + it('never touches player, enemy or friendly tokens', async () => { + for (const shape of ['rhombus', 'enemy_rhombus', 'friendly_rhombus']) { + await addLocation(db, { name: '', x: 1, z: 1, shape }); + } + await purge(app); + expect(await all(db, 'SELECT * FROM locations')).toHaveLength(3); + }); + + it('leaves battle map content alone', async () => { + await addLocation(db, { name: 'CORPO', x: 1, z: 1, battle_map_id: 4 }); + await purge(app); + expect(await all(db, 'SELECT * FROM locations')).toHaveLength(1); + }); + + it('takes the parts of a structure it removes, even ones outside the region', async () => { + // A root's parts sit at their own coordinates, so a child can fall outside while + // its root is inside. Leaving it behind orphans it. + const root = await addLocation(db, { name: 'CORPO', x: 0, z: 0 }); + await addLocation(db, { name: 'CORPO', x: 400, z: 400, parent_id: root.lastID }); + + await purge(app); + expect(await all(db, 'SELECT * FROM locations')).toHaveLength(0); + }); + + it('removes roads running through the region', async () => { + await addRoad(db, -10, 0, 10, 0); + await addRoad(db, 900, 900, 950, 950); + + const res = await purge(app); + expect(res.body.roads).toBe(1); + expect(await all(db, 'SELECT * FROM roads')).toHaveLength(1); + }); + + it('never touches hand-drawn water or signs', async () => { + // A lake the GM drew is hand-placed work and survives exactly as a named + // structure does. + await run(db, `INSERT INTO water_bodies (points_json, generated) VALUES (?, 0)`, + [JSON.stringify([{ x: 0, z: 0 }, { x: 10, z: 0 }, { x: 10, z: 10 }])]); + await run(db, `INSERT INTO signs (text, x, y, z) VALUES ('DOCKS', 5, 0, 5)`); + + await purge(app); + expect(await all(db, 'SELECT * FROM water_bodies')).toHaveLength(1); + expect(await all(db, 'SELECT * FROM signs')).toHaveLength(1); + }); + + it('clears water the generator made', async () => { + await run(db, `INSERT INTO water_bodies (points_json, generated) VALUES (?, 1)`, + [JSON.stringify([{ x: 0, z: 0 }, { x: 10, z: 0 }, { x: 10, z: 10 }])]); + + const res = await purge(app); + expect(res.body.water).toBe(1); + expect(await all(db, 'SELECT * FROM water_bodies')).toHaveLength(0); + }); + + it('leaves generated water outside the region alone', async () => { + await run(db, `INSERT INTO water_bodies (points_json, generated) VALUES (?, 1)`, + [JSON.stringify([{ x: 900, z: 900 }, { x: 950, z: 900 }, { x: 950, z: 950 }])]); + + const res = await purge(app); + expect(res.body.water).toBe(0); + expect(await all(db, 'SELECT * FROM water_bodies')).toHaveLength(1); + }); + + it('clears exactly the drawn shape, not its bounding box', async () => { + // An L: the notch must survive, or a drawn boundary means nothing. + const polygon = [ + { x: 0, z: 0 }, { x: 100, z: 0 }, { x: 100, z: 40 }, + { x: 40, z: 40 }, { x: 40, z: 100 }, { x: 0, z: 100 }, + ]; + await addLocation(db, { name: 'CORPO', x: 20, z: 20 }); // inside the L + await addLocation(db, { name: 'CORPO', x: 70, z: 70 }); // in the notch + + const res = await purge(app, { polygon }); + expect(res.body.locations).toBe(1); + + const left = await all(db, 'SELECT x, z FROM locations'); + expect(left).toHaveLength(1); + expect(left[0].x).toBe(70); + }); + + it('succeeds on an empty region', async () => { + const res = await purge(app); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ locations: 0, roads: 0, keptNamed: 0 }); + }); + + it('records what it removed, so it can be undone', async () => { + await addLocation(db, { name: 'CORPO', x: 1, z: 1 }); + await purge(app); + expect(recorded.map(r => r.type)).toContain('region_purge'); + expect(recorded[0].payload.locations).toHaveLength(1); + }); +}); diff --git a/backend/db.js b/backend/db.js index ecb952a..cbf538e 100644 --- a/backend/db.js +++ b/backend/db.js @@ -277,6 +277,10 @@ db.serialize(() => { points_json TEXT NOT NULL, map_scale_multiplier TEXT DEFAULT '[1]' )`); + // Tells a generated river from a lake the GM drew. Without it, regenerating an area + // cannot clear its own water without destroying hand-drawn work. Existing rows + // default to 0, so everything already on a map counts as hand-drawn. + db.run(`ALTER TABLE water_bodies ADD COLUMN generated INTEGER DEFAULT 0`, () => {}); db.run(`CREATE TABLE IF NOT EXISTS player_accounts ( username TEXT PRIMARY KEY, diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 68ac652..8f04603 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -198,9 +198,11 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { }); router.post('/water', authenticate, (req, res) => { - const { points } = req.body; + const { points, generated } = req.body; if (!points || !Array.isArray(points)) return res.status(400).json({ error: 'Invalid points array' }); - db.run('INSERT INTO water_bodies (points_json) VALUES (?)', [JSON.stringify(points)], function(err) { + // Generated water is cleared by a regenerate; hand-drawn water never is. + db.run('INSERT INTO water_bodies (points_json, generated) VALUES (?, ?)', + [JSON.stringify(points), generated ? 1 : 0], function(err) { if (err) return res.status(500).json({ error: err.message }); const newId = this.lastID; db.run('INSERT INTO action_history (type, payload) VALUES (?, ?)', ['water_create', JSON.stringify({ ids: [newId] })], () => {}); diff --git a/backend/routes/locations.js b/backend/routes/locations.js index 31a8417..317ac27 100644 --- a/backend/routes/locations.js +++ b/backend/routes/locations.js @@ -8,6 +8,39 @@ const { DEFAULT_SYSTEM } = require('../sheets/templates'); const ZONE_TYPE_NAMES = new Set(['CORPO', 'URBAN', 'SLUMS', 'INDUSTRIAL', 'PARK', 'HOLOTREE_CANOPY', 'LANDMARK', 'MARKETS', 'CUSTOM']); const isUserDefinedName = (name) => !!name && name.trim() !== '' && !ZONE_TYPE_NAMES.has(name.trim()); +/** Player, enemy and friendly tokens. Map content is everything that is not one. */ +const TOKEN_SHAPES = new Set(['rhombus', 'enemy_rhombus', 'friendly_rhombus']); + +/** Ray casting on the XZ plane. Mirrors the generator's own test. */ +const pointInPolygon = (points, x, z) => { + let inside = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i++) { + const a = points[i]; + const b = points[j]; + const straddles = (a.z > z) !== (b.z > z); + if (straddles && x < ((b.x - a.x) * (z - a.z)) / (b.z - a.z) + a.x) inside = !inside; + } + return inside; +}; + +/** + * Is (x, z) inside the region the caller asked about? + * + * A polygon takes precedence when given, so a drawn boundary clears exactly the shape + * that was drawn rather than its bounding box. + */ +const makeRegionTest = ({ bounds, polygon }) => { + if (Array.isArray(polygon) && polygon.length >= 3) { + return (x, z) => pointInPolygon(polygon, x, z); + } + if (!bounds || !bounds.min || !bounds.max) return null; + const minX = Math.min(bounds.min.x, bounds.max.x); + const maxX = Math.max(bounds.min.x, bounds.max.x); + const minZ = Math.min(bounds.min.z, bounds.max.z); + const maxZ = Math.max(bounds.min.z, bounds.max.z); + return (x, z) => x >= minX && x <= maxX && z >= minZ && z <= maxZ; +}; + const upsertLibrary = (db, loc) => { db.run(`INSERT INTO custom_structure_library (id, name, description, npcs, x, y, z, width, height, depth, shape, color, @@ -95,6 +128,121 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { }); }); + /** + * Clear a previously generated city from a region, so it can be generated afresh. + * + * Generating over an occupied area otherwise infills around what is already there, + * which is useful in its own right but not what regenerating means. + * + * What survives is the point. Anything a GM named or renamed is kept and becomes an + * obstacle the new city builds around — losing hand-placed work is the one outcome + * that cannot be undone by generating again. Tokens, water and signs are never map + * generation output and are never touched. + * + * Done 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 with updates. + */ + router.post('/purge-region', authenticate, (req, res) => { + const inRegion = makeRegionTest(req.body || {}); + if (!inRegion) return res.status(400).json({ error: 'bounds or polygon required' }); + + db.all('SELECT * FROM locations', [], (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + + const doomed = []; + let keptNamed = 0; + + for (const row of rows) { + if (row.battle_map_id != null) continue; // battle map content, not the world + if (TOKEN_SHAPES.has(row.shape)) continue; // tokens are never map content + if (!inRegion(row.x, row.z)) continue; + if (isUserDefinedName(row.name)) { keptNamed++; continue; } + doomed.push(row); + } + + // A root's parts sit at their own coordinates, so a child can fall outside the + // region while its root is inside. Taking the children too avoids orphaning them. + const doomedIds = new Set(doomed.map(r => r.id)); + for (const row of rows) { + if (doomedIds.has(row.id)) continue; + if (row.parent_id != null && doomedIds.has(row.parent_id)) { + doomed.push(row); + doomedIds.add(row.id); + } + } + + db.all('SELECT * FROM roads', [], (err2, roadRows) => { + if (err2) return res.status(500).json({ error: err2.message }); + // A road counts as inside when its midpoint is: an approach running out of the + // region should go with the city it served. + const roads = roadRows.filter(r => inRegion((r.x1 + r.x2) / 2, (r.z1 + r.z2) / 2)); + + db.all('SELECT * FROM overpasses', [], (err3, overpassRows) => { + if (err3) return res.status(500).json({ error: err3.message }); + const overpasses = (overpassRows || []).filter(o => { + let points; + try { points = JSON.parse(o.points); } catch { return false; } + if (!Array.isArray(points) || points.length === 0) return false; + const mid = points[Math.floor(points.length / 2)]; + return mid && inRegion(mid.x, mid.z); + }); + + const ids = doomed.map(r => r.id); + const roadIds = roads.map(r => r.id); + const overpassIds = overpasses.map(o => o.id); + + const del = (table, list) => { + if (list.length === 0) return; + db.run(`DELETE FROM ${table} WHERE id IN (${list.map(() => '?').join(',')})`, list); + }; + + // Only water the generator made. A lake the GM drew is hand-placed work and + // survives a regenerate exactly as a named structure does. + db.all('SELECT * FROM water_bodies WHERE generated = 1', [], (err4, waterRows) => { + if (err4) return res.status(500).json({ error: err4.message }); + const water = (waterRows || []).filter(w => { + let points; + try { points = JSON.parse(w.points_json); } catch { return false; } + if (!Array.isArray(points) || points.length === 0) return false; + // Its centroid decides, so a river trimmed at the region edge goes with + // the city it belonged to. + const cx = points.reduce((a, p) => a + p.x, 0) / points.length; + const cz = points.reduce((a, p) => a + p.z, 0) / points.length; + return inRegion(cx, cz); + }); + const waterIds = water.map(w => w.id); + + db.serialize(() => { + db.run('BEGIN TRANSACTION'); + del('locations', ids); + del('roads', roadIds); + del('overpasses', overpassIds); + del('water_bodies', waterIds); + db.run('COMMIT', (err5) => { + if (err5) return res.status(500).json({ error: err5.message }); + recordAction('region_purge', { + locations: doomed, + roads, + overpasses, + water, + }); + emitUpdate(); + res.json({ + locations: ids.length, + roads: roadIds.length, + overpasses: overpassIds.length, + water: waterIds.length, + keptNamed, + }); + }); + }); + }); + }); + }); + }); + }); + router.post('/', optionalAuthenticate, async (req, res) => { const locations = Array.isArray(req.body) ? req.body : [req.body]; diff --git a/frontend/package.json b/frontend/package.json index e2412e2..ce94df9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.7.4", + "version": "1.8.0", "type": "module", "scripts": { "dev": "vite --host", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d783019..4502070 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,6 +59,7 @@ import { Sidewalks } from './components/Sidewalks'; import { AutoSignage } from './components/AutoSignage'; import { Signs, type SignData } from './components/Signs'; import { type RemoteFont } from './utils/fontLoader'; +import type { LayoutType, WaterType, RoundaboutDensity } from './cityGen'; import { GlobalCameraCapture, CursorPivotControls, CameraController, KeyboardPan } from './components/Camera'; import { AdminPanel } from './components/AdminPanel'; import MapExportController, { type MapExportApi } from './components/MapExportController'; @@ -359,6 +360,23 @@ function App() { const [signageDensity, setSignageDensity] = useState(1); // Map export: drops is_hidden structures for the duration of a capture. const [exportSuppressHidden, setExportSuppressHidden] = useState(false); + // City generator: drag a rectangle, or trace a boundary polygon to generate inside. + const [cityGenDrawMode, setCityGenDrawMode] = useState<'rect' | 'draw'>('rect'); + const [genBoundaryTrail, setGenBoundaryTrail] = useState([]); + // BSP is what generation has always produced, so it stays the default. + const [cityLayout, setCityLayout] = useState('BSP'); + // Blank means roll a fresh one; after generating it holds the seed that was used, + // so a city worth keeping can be written down. + // The input is a *request*: blank means roll a fresh seed, so repeated generates + // keep producing different cities. lastCitySeed is the readout of what was actually + // used, kept separate so showing it never silently pins the next generation. + const [citySeed, setCitySeed] = useState(''); + const [lastCitySeed, setLastCitySeed] = useState(''); + // NONE by default: generation has never produced water, so anything else would put + // a river through the city of everyone already using the button. + const [cityWater, setCityWater] = useState('NONE'); + const [cityParkPonds, setCityParkPonds] = useState(false); + const [cityRoundabouts, setCityRoundabouts] = useState('off'); const [mapExportApi, setMapExportApi] = useState(null); const [isPlacingSign, setIsPlacingSign] = useState(false); const [pendingSignPos, setPendingSignPos] = useState<{ x: number; z: number } | null>(null); @@ -1793,6 +1811,22 @@ function App() { isRecording={mapExportApi?.isRecording ?? false} recordSecondsLeft={mapExportApi?.secondsLeft ?? 0} isExporting={mapExportApi?.isExporting ?? false} + citySeed={citySeed} + setCitySeed={setCitySeed} + cityWater={cityWater} + setCityWater={setCityWater} + cityParkPonds={cityParkPonds} + setCityParkPonds={setCityParkPonds} + cityRoundabouts={cityRoundabouts} + setCityRoundabouts={setCityRoundabouts} + lastCitySeed={lastCitySeed} + setLastCitySeed={setLastCitySeed} + cityLayout={cityLayout} + setCityLayout={setCityLayout} + cityGenDrawMode={cityGenDrawMode} + setCityGenDrawMode={setCityGenDrawMode} + genBoundaryTrail={genBoundaryTrail} + setGenBoundaryTrail={setGenBoundaryTrail} renderSidewalks={renderSidewalks} setRenderSidewalks={(val: boolean) => { setRenderSidewalks(val); socketRef.current?.emit('updateViewSettings', { renderSignage, signageDensity, renderSidewalks: val }); }} renderSignage={renderSignage} @@ -2634,7 +2668,7 @@ function App() { onReady={setMapExportApi} /> )} - { if (view === 'city_gen') { setRoadSelectionBounds(data); } else if (view === 'district') { setDistrictSelection(prev => [...new Set([...prev, ...data])]); } else if (isBatchSelecting) { setSelectedIds(prev => [...new Set([...prev, ...data])]); } }} roadTrail={roadTrail} setRoadTrail={setRoadTrail} waterTrail={waterTrail} setWaterTrail={setWaterTrail} onWaterDrawEnd={handleWaterDrawn} roadDrawMode={roadDrawMode} snapToGrid={snapToGrid} drawingRoadWidth={drawingRoadWidth} isBatchSelecting={isBatchSelecting} setSelectedIds={setSelectedIds} rhombusState={rhombusState} setRhombusState={setRhombusState} userName={userName} refreshLocations={fetchLocations} token={token} roadLayerMode={roadLayerMode} /> + { if (view === 'city_gen') { setRoadSelectionBounds(data); } else if (view === 'district') { setDistrictSelection(prev => [...new Set([...prev, ...data])]); } else if (isBatchSelecting) { setSelectedIds(prev => [...new Set([...prev, ...data])]); } }} roadTrail={roadTrail} setRoadTrail={setRoadTrail} waterTrail={waterTrail} setWaterTrail={setWaterTrail} onWaterDrawEnd={handleWaterDrawn} roadDrawMode={roadDrawMode} snapToGrid={snapToGrid} drawingRoadWidth={drawingRoadWidth} isBatchSelecting={isBatchSelecting} setSelectedIds={setSelectedIds} rhombusState={rhombusState} setRhombusState={setRhombusState} userName={userName} refreshLocations={fetchLocations} token={token} roadLayerMode={roadLayerMode} cityGenDrawMode={cityGenDrawMode} genBoundaryTrail={genBoundaryTrail} setGenBoundaryTrail={setGenBoundaryTrail} onBoundaryDrawEnd={(pts: any[]) => setGenBoundaryTrail(pts)} /> {roadSelectionBounds && view === 'city_gen' && ( diff --git a/frontend/src/cityGen/__tests__/boundary.test.ts b/frontend/src/cityGen/__tests__/boundary.test.ts new file mode 100644 index 0000000..5ee35cb --- /dev/null +++ b/frontend/src/cityGen/__tests__/boundary.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect } from 'vitest'; +import { + footprintOutsidePolygon, + clipSegmentToBoundary, + clipSegmentToPolygons, + clipSegmentToLand, + splitCity, + createIsBlocked, + SpatialGrid, + generateCity, + type Polygon, +} from '../index'; + +/** + * Drawn generation bounds. A boundary is a water polygon with the sign flipped — + * water rejects what falls inside it, a boundary rejects what falls outside — so the + * two share every helper. + */ + +/** Axis-aligned square centred on the origin, as a boundary polygon. */ +const square = (half: number): Polygon => ({ + points: [ + { x: -half, z: -half }, + { x: half, z: -half }, + { x: half, z: half }, + { x: -half, z: half }, + ], +}); + +/** An L, so the notch can be checked for emptiness. */ +const concaveL: Polygon = { + points: [ + { x: 0, z: 0 }, + { x: 100, z: 0 }, + { x: 100, z: 40 }, + { x: 40, z: 40 }, + { x: 40, z: 100 }, + { x: 0, z: 100 }, + ], +}; + +const seg = (x1: number, z1: number, x2: number, z2: number) => + ({ x1, z1, x2, z2, width: 2 }); + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +// ─── footprintOutsidePolygon ────────────────────────────────────────────────── + +describe('footprintOutsidePolygon', () => { + const b = square(50); + + it('accepts a footprint well inside', () => { + expect(footprintOutsidePolygon(b, 0, 0, 10, 10)).toBe(false); + }); + + it('rejects a footprint well outside', () => { + expect(footprintOutsidePolygon(b, 200, 200, 10, 10)).toBe(true); + }); + + it('rejects one straddling the edge, matching how water treats a shoreline', () => { + // Centre is inside but two corners are not; a building half outside the drawn + // area is not what the GM asked for. + expect(footprintOutsidePolygon(b, 48, 0, 10, 10)).toBe(true); + }); + + it('accepts a footprint that just fits inside the edge', () => { + expect(footprintOutsidePolygon(b, 44, 0, 10, 10)).toBe(false); + }); + + it('rejects anything in a concave notch', () => { + expect(footprintOutsidePolygon(concaveL, 70, 70, 4, 4)).toBe(true); + }); +}); + +// ─── clipping ───────────────────────────────────────────────────────────────── + +describe('clipSegmentToBoundary', () => { + const b = square(50); + + it('leaves a fully inside segment untouched', () => { + const [only] = clipSegmentToBoundary(seg(-10, 0, 10, 0), b); + expect(only).toMatchObject({ x1: -10, x2: 10 }); + }); + + it('drops a fully outside segment', () => { + expect(clipSegmentToBoundary(seg(200, 200, 300, 300), b)).toHaveLength(0); + }); + + it('cuts a crossing segment at the edge', () => { + const out = clipSegmentToBoundary(seg(-100, 0, 0, 0), b); + expect(out).toHaveLength(1); + expect(out[0].x1).toBeCloseTo(-50); + expect(out[0].x2).toBeCloseTo(0); + }); + + it('keeps a segment whole when there is no boundary', () => { + expect(clipSegmentToBoundary(seg(0, 0, 999, 999), undefined)).toHaveLength(1); + }); + + it('is the exact inverse of clipping to land', () => { + // Same polygon, same segment: the two together must reconstruct the original. + const s = seg(-100, 0, 100, 0); + const inside = clipSegmentToPolygons(s, [b], true); + const outside = clipSegmentToLand(s, [b]); + const span = (arr: typeof inside) => + arr.reduce((n, r) => n + Math.abs(r.x2 - r.x1), 0); + expect(span(inside) + span(outside)).toBeCloseTo(200); + }); +}); + +// ─── placement ──────────────────────────────────────────────────────────────── + +describe('createIsBlocked with a boundary', () => { + const empty = () => new SpatialGrid([]); + + it('blocks a footprint outside the boundary', () => { + const isBlocked = createIsBlocked(empty(), [], false, [], square(50)); + expect(isBlocked(200, 200, 4, 4)).toBe(true); + }); + + it('allows a footprint inside it', () => { + const isBlocked = createIsBlocked(empty(), [], false, [], square(50)); + expect(isBlocked(0, 0, 4, 4)).toBe(false); + }); + + it('blocks nothing extra when no boundary is given', () => { + const isBlocked = createIsBlocked(empty(), [], false, []); + expect(isBlocked(9999, 9999, 4, 4)).toBe(false); + }); +}); + +// ─── split ──────────────────────────────────────────────────────────────────── + +describe('splitCity with a boundary', () => { + it('drops blocks centred outside the boundary', () => { + const { blocks } = splitCity(bounds(200), false, () => 0.5, [], square(60)); + expect(blocks.length).toBeGreaterThan(0); + for (const b of blocks) { + expect(Math.abs(b.x)).toBeLessThanOrEqual(60); + expect(Math.abs(b.z)).toBeLessThanOrEqual(60); + } + }); + + it('clips road seams to the boundary', () => { + const { roads } = splitCity(bounds(200), false, () => 0.5, [], square(60)); + for (const r of roads) { + expect(Math.abs(r.x1)).toBeLessThanOrEqual(61); + expect(Math.abs(r.x2)).toBeLessThanOrEqual(61); + expect(Math.abs(r.z1)).toBeLessThanOrEqual(61); + expect(Math.abs(r.z2)).toBeLessThanOrEqual(61); + } + }); + + it('leaves the notch of a concave boundary empty', () => { + const { blocks } = splitCity( + { min: { x: 0, z: 0 }, max: { x: 100, z: 100 } }, + false, () => 0.5, [], concaveL, + ); + // The notch is the far corner of the L, x > 40 and z > 40. + for (const b of blocks) expect(b.x > 40 && b.z > 40).toBe(false); + }); + + it('produces an identical split to today when no boundary is given', () => { + // The regression guard: drawn bounds must not disturb the existing path. + const withoutArg = splitCity(bounds(200), false, seededRng(), []); + const withUndefined = splitCity(bounds(200), false, seededRng(), [], undefined); + expect(withUndefined).toEqual(withoutArg); + }); +}); + +/** Deterministic sequence, so two runs are comparable. */ +function seededRng() { + let a = 12345; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +// ─── end to end ─────────────────────────────────────────────────────────────── + +/** + * The real `fillPlot` is `generateThemedBuildingsForPlot`, which makes 36 unseeded + * `Math.random` calls of its own — so buildings are not reproducible from a seed even + * though the layout is. The existing cityGen suite injects a stub for the same reason. + * These assertions therefore cover blocks and roads, which are deterministic, and + * whether placement is offered a position at all. + */ +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); + +describe('generateCity with a boundary', () => { + it('keeps every block and road inside the drawn area', () => { + const result = generateCity( + bounds(200), + { sectionType: 'MIXED', boundary: square(60) }, + freshContext(), + seededRng(), + { fillPlot: () => {} }, + ); + + expect(result.blocks.length).toBeGreaterThan(0); + for (const b of result.blocks) { + expect(Math.abs(b.x)).toBeLessThanOrEqual(60); + expect(Math.abs(b.z)).toBeLessThanOrEqual(60); + } + for (const r of result.roads) { + expect(Math.abs(r.x1)).toBeLessThanOrEqual(61); + expect(Math.abs(r.z1)).toBeLessThanOrEqual(61); + } + }); + + it('never offers placement a spot outside the boundary', () => { + // Whatever the building generator does with the position, it must not be given + // one the GM did not draw. + const offered: Array<{ x: number; z: number }> = []; + generateCity( + bounds(200), + { sectionType: 'MIXED', boundary: square(60) }, + freshContext(), + seededRng(), + { fillPlot: (x: number, z: number) => { offered.push({ x, z }); } }, + ); + expect(offered.length).toBeGreaterThan(0); + for (const p of offered) { + expect(Math.abs(p.x)).toBeLessThanOrEqual(60); + expect(Math.abs(p.z)).toBeLessThanOrEqual(60); + } + }); + + it('builds a smaller city than the same bounds unbounded', () => { + const opts = { sectionType: 'MIXED' as const }; + const deps = { fillPlot: () => {} }; + const free = generateCity(bounds(200), opts, freshContext(), seededRng(), deps); + const bounded = generateCity( + bounds(200), { ...opts, boundary: square(60) }, freshContext(), seededRng(), deps, + ); + expect(bounded.blocks.length).toBeLessThan(free.blocks.length); + }); + + it('is identical to today when no boundary is given', () => { + // The guard that matters most: existing generation is untouched. + const opts = { sectionType: 'MIXED' as const }; + const deps = { fillPlot: () => {} }; + const a = generateCity(bounds(150), opts, freshContext(), seededRng(), deps); + const b = generateCity( + bounds(150), { ...opts, boundary: undefined }, freshContext(), seededRng(), deps, + ); + expect(b).toEqual(a); + }); + + it('falls back to the bounds when the boundary is degenerate', () => { + // Fewer than three points cannot enclose anything; generating nothing at all + // would look like a broken button. + const result = generateCity( + bounds(150), + { sectionType: 'MIXED', boundary: { points: [{ x: 0, z: 0 }, { x: 10, z: 0 }] } }, + freshContext(), + seededRng(), + { fillPlot: () => {} }, + ); + expect(result.blocks.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/cityGen/__tests__/layouts.test.ts b/frontend/src/cityGen/__tests__/layouts.test.ts new file mode 100644 index 0000000..58f81c8 --- /dev/null +++ b/frontend/src/cityGen/__tests__/layouts.test.ts @@ -0,0 +1,669 @@ +import { describe, it, expect } from 'vitest'; +import { + LAYOUTS, + gridLayout, + superblockLayout, + bspLayout, + ringLayout, + RING_COUNT, + SPOKE_COUNT, + RING_ROAD_WIDTH, + SPOKE_COUNT as SC, + clampBuildingsUnderDecks, + roadWidthForDepth, + heightScaleFor, + lotCoverageFor, + generateCity, + SUPERBLOCK_MIN_SIZE, + GRID_AVENUE_WIDTH, + type Polygon, +} from '../index'; + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +const square = (half: number): Polygon => ({ + points: [ + { x: -half, z: -half }, + { x: half, z: -half }, + { x: half, z: half }, + { x: -half, z: half }, + ], +}); + +/** Deterministic sequence so two runs are comparable. */ +function seededRng() { + let a = 20260728; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); + +/** Perpendicular distance from a point to a segment, on the XZ plane. */ +function distanceToSegment( + p: { x: number; z: number }, + a: { x: number; z: number }, + b: { x: number; z: number }, +) { + const dx = b.x - a.x; + const dz = b.z - a.z; + const lenSq = dx * dx + dz * dz; + const t = lenSq < 1e-9 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.z - a.z) * dz) / lenSq)); + return Math.hypot(p.x - (a.x + dx * t), p.z - (a.z + dz * t)); +} + +// ─── registry ───────────────────────────────────────────────────────────────── + +describe('layout registry', () => { + it('offers every layout type', () => { + expect(Object.keys(LAYOUTS).sort()).toEqual(['BSP', 'GRID', 'PERIMETER', 'RING', 'SUPERBLOCK', 'VORONOI']); + }); + + it('every layout produces blocks for the same area', () => { + for (const [name, fn] of Object.entries(LAYOUTS)) { + const { blocks } = fn(bounds(200), false, seededRng()); + expect(blocks.length, name).toBeGreaterThan(0); + } + }); + + it('every layout keeps its blocks inside the bounds', () => { + for (const [name, fn] of Object.entries(LAYOUTS)) { + const { blocks } = fn(bounds(200), false, seededRng()); + for (const b of blocks) { + expect(Math.abs(b.x), name).toBeLessThanOrEqual(200); + expect(Math.abs(b.z), name).toBeLessThanOrEqual(200); + } + } + }); + + it('every layout lays no roads when infrastructure is excluded', () => { + for (const [name, fn] of Object.entries(LAYOUTS)) { + const { roads } = fn(bounds(200), true, seededRng()); + expect(roads, name).toHaveLength(0); + } + }); + + it('every layout confines blocks to a drawn boundary', () => { + for (const [name, fn] of Object.entries(LAYOUTS)) { + const { blocks } = fn(bounds(200), false, seededRng(), [], square(60)); + expect(blocks.length, name).toBeGreaterThan(0); + for (const b of blocks) { + expect(Math.abs(b.x), name).toBeLessThanOrEqual(60); + expect(Math.abs(b.z), name).toBeLessThanOrEqual(60); + } + } + }); + + it('every layout clips roads to a drawn boundary', () => { + for (const [name, fn] of Object.entries(LAYOUTS)) { + const { roads } = fn(bounds(200), false, seededRng(), [], square(60)); + for (const r of roads) { + expect(Math.abs(r.x1), name).toBeLessThanOrEqual(61); + expect(Math.abs(r.z1), name).toBeLessThanOrEqual(61); + expect(Math.abs(r.x2), name).toBeLessThanOrEqual(61); + expect(Math.abs(r.z2), name).toBeLessThanOrEqual(61); + } + } + }); +}); + +// ─── grid ───────────────────────────────────────────────────────────────────── + +describe('gridLayout', () => { + it('lays streets in two perpendicular families', () => { + const { roads } = gridLayout(bounds(200), false, seededRng()); + const vertical = roads.filter((r) => Math.abs(r.x1 - r.x2) < 1e-6); + const horizontal = roads.filter((r) => Math.abs(r.z1 - r.z2) < 1e-6); + expect(vertical.length).toBeGreaterThan(2); + expect(horizontal.length).toBeGreaterThan(2); + // A pure grid has nothing diagonal in it. + expect(vertical.length + horizontal.length).toBe(roads.length); + }); + + it('gives the network a hierarchy rather than one uniform width', () => { + // Avenues every few blocks are most of what makes a grid read as designed. + const { roads } = gridLayout(bounds(300), false, seededRng()); + const widths = new Set(roads.map((r) => r.width)); + expect(widths.size).toBeGreaterThan(1); + expect(widths.has(GRID_AVENUE_WIDTH)).toBe(true); + }); + + it('produces blocks far more uniform than the BSP', () => { + // The point of the layout: regular where BSP is irregular. + const spread = (fn: typeof gridLayout) => { + const { blocks } = fn(bounds(300), false, seededRng()); + const areas = blocks.map((b) => b.w * b.d); + const mean = areas.reduce((a, v) => a + v, 0) / areas.length; + const variance = areas.reduce((a, v) => a + (v - mean) ** 2, 0) / areas.length; + return Math.sqrt(variance) / mean; // coefficient of variation + }; + expect(spread(gridLayout)).toBeLessThan(spread(bspLayout)); + }); + + it('scales block count with area rather than block size', () => { + const small = gridLayout(bounds(100), false, seededRng()).blocks.length; + const large = gridLayout(bounds(300), false, seededRng()).blocks.length; + expect(large).toBeGreaterThan(small); + }); + + it('still produces a block for an area smaller than one cell', () => { + const { blocks } = gridLayout(bounds(15), false, seededRng()); + expect(blocks.length).toBeGreaterThan(0); + }); + + it('gives every block a positive footprint', () => { + const { blocks } = gridLayout(bounds(200), false, seededRng()); + for (const b of blocks) { + expect(b.w).toBeGreaterThan(0); + expect(b.d).toBeGreaterThan(0); + } + }); +}); + +// ─── superblock ─────────────────────────────────────────────────────────────── + +describe('superblockLayout', () => { + it('produces fewer, larger blocks than the BSP over the same area', () => { + const sb = superblockLayout(bounds(400), false, seededRng()); + const bsp = bspLayout(bounds(400), false, seededRng()); + + expect(sb.blocks.length).toBeLessThan(bsp.blocks.length); + + const meanArea = (bs: typeof sb.blocks) => + bs.reduce((a, b) => a + b.w * b.d, 0) / bs.length; + expect(meanArea(sb.blocks)).toBeGreaterThan(meanArea(bsp.blocks)); + }); + + it('lays fewer roads, leaving more open ground', () => { + const sb = superblockLayout(bounds(400), false, seededRng()); + const bsp = bspLayout(bounds(400), false, seededRng()); + expect(sb.roads.length).toBeLessThan(bsp.roads.length); + }); + + it('stops subdividing around its minimum size', () => { + const { blocks } = superblockLayout(bounds(400), false, seededRng()); + const largest = Math.max(...blocks.map((b) => Math.max(b.w, b.d))); + expect(largest).toBeGreaterThan(SUPERBLOCK_MIN_SIZE / 2); + }); +}); + +// ─── ring ──────────────────────────────────────────────────────────────────── + +describe('ringLayout', () => { + const R = 300; + const radiusOf = (p: { x: number; z: number }) => Math.hypot(p.x, p.z); + + it('keeps the city round, leaving the corners of a square selection empty', () => { + // A beltway city is circular; filling the corners would defeat the shape. + const { blocks } = ringLayout(bounds(R), false, seededRng()); + for (const b of blocks) expect(radiusOf(b)).toBeLessThanOrEqual(R + 1); + const corner = blocks.filter((b) => Math.abs(b.x) > R * 0.85 && Math.abs(b.z) > R * 0.85); + expect(corner).toHaveLength(0); + }); + + it('elevates the spokes but leaves the loops on the ground', () => { + // 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. Both read as broken. + const { roads, overpasses } = ringLayout(bounds(R), false, seededRng()); + expect(overpasses?.length).toBe(SPOKE_COUNT); + expect(roads.some((r) => r.width === RING_ROAD_WIDTH)).toBe(true); + }); + + it('lays closed loops at more than one radius', () => { + const { roads } = ringLayout(bounds(R), false, seededRng()); + const ringRoads = roads.filter((r) => r.width === RING_ROAD_WIDTH); + expect(ringRoads.length).toBeGreaterThan(RING_COUNT * 10); + + const radii = new Set(ringRoads.map((r) => Math.round(radiusOf({ x: r.x1, z: r.z1 }) / 10))); + expect(radii.size).toBeGreaterThanOrEqual(RING_COUNT); + }); + + it('runs spokes outward from the innermost loop, not from the centre', () => { + // Six arterials converging on a point left a starburst of dead ground there, and + // real highways meet a downtown loop rather than piling into the middle. + const { overpasses } = ringLayout(bounds(R), false, seededRng()); + for (const s of overpasses ?? []) { + const inner = Math.min(...s.points.map(radiusOf)); + const outer = Math.max(...s.points.map(radiusOf)); + expect(inner).toBeGreaterThan(1); + expect(outer).toBeGreaterThan(inner); + } + }); + + it('brings both ends of a spoke down to the ground', () => { + // Ramps that do not fit inside the deck leave it ending in mid-air. + const { overpasses } = ringLayout(bounds(R), false, seededRng()); + for (const s of overpasses ?? []) { + const length = Math.hypot( + s.points[1].x - s.points[0].x, s.points[1].z - s.points[0].z, + ); + expect(s.ramp_length_start + s.ramp_length_end).toBeLessThanOrEqual(length); + expect(s.ramp_length_start).toBeGreaterThan(0); + expect(s.ramp_length_end).toBeGreaterThan(0); + } + }); + + it('spaces the inner loop tighter than the outer one', () => { + // Beltways are not evenly spaced; downtown is ringed close. + const { roads } = ringLayout(bounds(R), false, seededRng()); + const radii = [...new Set( + roads.filter((r) => r.width === RING_ROAD_WIDTH) + .map((r) => Math.round(radiusOf({ x: r.x1, z: r.z1 }))), + )].sort((a, b) => a - b); + expect(radii[0]).toBeLessThan(radii[radii.length - 1] / 2); + }); + + it('fills the space between the arterials with ordinary blocks', () => { + // The point of the layout: only the arterial network is radial. Between the + // loops sit normal streets, so there should be plenty of blocks out there. + const { blocks } = ringLayout(bounds(R), false, seededRng()); + const outerBand = blocks.filter((b) => radiusOf(b) > R * 0.5); + expect(outerBand.length).toBeGreaterThan(10); + }); + + it('builds downtown inside the innermost loop', () => { + const { blocks } = ringLayout(bounds(R), false, seededRng()); + const core = blocks.filter((b) => radiusOf(b) < R * 0.25); + expect(core.length).toBeGreaterThan(0); + }); + + it('fills the disc as densely as the BSP fills a rectangle', () => { + // The regression this layout shipped with: partitioning the disc into annular + // sectors and sub-laying each one discarded most of what it generated, because a + // sector's bounding box is far larger than the sector. The city came out sparse + // and fragmented. A disc is pi/4 of its square, so the ratio should land near that. + const ring = ringLayout(bounds(R), false, seededRng()).blocks.length; + const bsp = bspLayout(bounds(R), false, seededRng()).blocks.length; + expect(ring / bsp).toBeGreaterThan(0.6); + }); + + it('lays one continuous street fabric rather than one per sector', () => { + // Local streets are continuous; they are not divided up by the loops. + // Sector-local networks showed up as far more short segments. + const { roads } = ringLayout(bounds(R), false, seededRng()); + const local = roads.filter((r) => r.width !== RING_ROAD_WIDTH); + expect(local.length).toBeGreaterThan(0); + }); + + it('raises nothing when infrastructure is excluded', () => { + const { overpasses } = ringLayout(bounds(R), true, seededRng()); + expect(overpasses ?? []).toHaveLength(0); + }); + + it('lays no roads when infrastructure is excluded, but still blocks out the city', () => { + const { blocks, roads } = ringLayout(bounds(R), true, seededRng()); + expect(roads).toHaveLength(0); + expect(blocks.length).toBeGreaterThan(0); + }); +}); + +// ─── selection ──────────────────────────────────────────────────────────────── + +describe('generateCity layout selection', () => { + const deps = { fillPlot: () => {} }; + + it('defaults to BSP, so existing generation is untouched', () => { + const opts = { sectionType: 'MIXED' as const }; + const implicit = generateCity(bounds(200), opts, freshContext(), seededRng(), deps); + const explicit = generateCity( + bounds(200), { ...opts, layout: 'BSP' as const }, freshContext(), seededRng(), deps, + ); + expect(explicit).toEqual(implicit); + }); + + it('produces a different city for each layout', () => { + const opts = { sectionType: 'MIXED' as const }; + const counts = (['BSP', 'GRID', 'SUPERBLOCK', 'RING'] as const).map( + (layout) => + generateCity(bounds(300), { ...opts, layout }, freshContext(), seededRng(), deps) + .blocks.length, + ); + expect(new Set(counts).size).toBeGreaterThan(1); + }); + + it('carries arterials raised by the layout through to the result', () => { + // RING elevates its spokes, and those have to reach the caller alongside whatever + // bridges the water needed. + const result = generateCity( + bounds(300), { sectionType: 'MIXED', layout: 'RING' }, freshContext(), seededRng(), deps, + ); + expect(result.overpasses.length).toBeGreaterThanOrEqual(SC); + }); + + it('leaves the ground under an elevated spoke buildable', () => { + // The whole reason for raising them: a ground-level arterial sterilises every block + // it crosses, because placement rejects footprints touching a road. Placement never + // checks overpasses, so the fabric survives underneath. + const offered: Array<{ x: number; z: number }> = []; + const result = generateCity( + bounds(300), + { sectionType: 'MIXED', layout: 'RING' }, + freshContext(), + seededRng(), + { fillPlot: (x: number, z: number) => { offered.push({ x, z }); } }, + ); + + const spoke = result.overpasses[0]; + expect(spoke).toBeDefined(); + const near = offered.filter((p) => + p.x * 0 === 0 && distanceToSegment(p, spoke.points[0], spoke.points[1]) < spoke.width); + expect(near.length).toBeGreaterThan(0); + }); + + it('falls back to BSP for an unrecognised layout', () => { + // A stale saved option should not generate an empty city. + const opts = { sectionType: 'MIXED' as const, layout: 'NONSENSE' as never }; + const result = generateCity(bounds(200), opts, freshContext(), seededRng(), deps); + expect(result.blocks.length).toBeGreaterThan(0); + }); + + it('honours a drawn boundary whichever layout is chosen', () => { + for (const layout of ['BSP', 'GRID', 'SUPERBLOCK', 'RING'] as const) { + const result = generateCity( + bounds(300), + { sectionType: 'MIXED', layout, boundary: square(80) }, + freshContext(), + seededRng(), + deps, + ); + for (const b of result.blocks) { + expect(Math.abs(b.x), layout).toBeLessThanOrEqual(80); + expect(Math.abs(b.z), layout).toBeLessThanOrEqual(80); + } + } + }); +}); + +// ─── decks over buildings ───────────────────────────────────────────────────── + +describe('clampBuildingsUnderDecks', () => { + const deck = (over: Partial<{ points: { x: number; z: number }[]; width: number; height: number }> = {}) => ({ + points: [{ x: 0, z: 0 }, { x: 200, z: 0 }], + width: 8, + height: 14, + ramp_length: 60, + ramp_length_start: 60, + ramp_length_end: 60, + ...over, + }); + + const building = (over: Partial<{ x: number; z: number; y: number; width: number; depth: number; height: number; temp_block_id: string }> = {}) => ({ + x: 100, z: 0, y: 0, width: 6, depth: 6, height: 50, ...over, + }); + + it('leaves buildings clear of any deck alone', () => { + const b = building({ z: 500 }); + expect(clampBuildingsUnderDecks([b], [deck()])).toEqual([b]); + }); + + it('leaves everything alone when there are no decks', () => { + const b = building(); + expect(clampBuildingsUnderDecks([b], [])).toEqual([b]); + }); + + it('caps a tower that would rise through the deck', () => { + // The reported bug: overpasses running through buildings. + const [out] = clampBuildingsUnderDecks([building({ height: 50 })], [deck()]); + expect(out.height).toBeLessThan(14); + expect(out.height).toBeGreaterThan(0); + }); + + it('leaves a building that already fits underneath', () => { + const b = building({ height: 4 }); + const [out] = clampBuildingsUnderDecks([b], [deck()]); + expect(out.height).toBe(4); + }); + + it('drops a building where the deck is too low to build under', () => { + // Near a ramp the deck is at ground level, which is just a road. + const out = clampBuildingsUnderDecks([building({ x: 3, height: 20 })], [deck()]); + expect(out).toHaveLength(0); + }); + + it('caps against the lowest deck when several cross', () => { + const low = deck({ height: 10, points: [{ x: 0, z: 0 }, { x: 200, z: 0 }] }); + const high = deck({ height: 24, points: [{ x: 100, z: -100 }, { x: 100, z: 100 }] }); + const [out] = clampBuildingsUnderDecks([building({ height: 60 })], [high, low]); + expect(out.height).toBeLessThan(10); + }); + + it('scales a whole plot together so a stack stays assembled', () => { + // The floating buildings: parts of one plot share a temp_block_id, and y is the + // bottom of a mesh, so a part resting on another has its y set to that one's + // height. Capping parts individually shrank the base while leaving the storey + // above exactly where the old roofline was. + const base = building({ height: 40, y: 0, temp_block_id: 'plot_1' }); + const upper = building({ height: 20, y: 40, temp_block_id: 'plot_1' }); + const [outBase, outUpper] = clampBuildingsUnderDecks([base, upper], [deck()]); + + expect(outBase.height).toBeLessThan(40); + // The storey still rests on the base rather than hanging above it. + expect(outUpper.y).toBeCloseTo(outBase.height, 5); + }); + + it('scales by the plot’s tallest point, not each part in isolation', () => { + // A short upper storey that already fits under the deck must still come down with + // the base beneath it, or it is left floating. + const base = building({ height: 40, y: 0, temp_block_id: 'plot_2' }); + const small = building({ height: 3, y: 40, temp_block_id: 'plot_2' }); + const [outBase, outSmall] = clampBuildingsUnderDecks([base, small], [deck()]); + + expect(outSmall.height).toBeLessThan(3); + expect(outSmall.y).toBeCloseTo(outBase.height, 5); + }); + + it('drops a whole plot when the deck is too low for any of it', () => { + const parts = [ + building({ x: 3, height: 20, y: 0, temp_block_id: 'plot_3' }), + building({ x: 3, height: 8, y: 20, temp_block_id: 'plot_3' }), + ]; + expect(clampBuildingsUnderDecks(parts, [deck()])).toHaveLength(0); + }); + + it('leaves y alone for a building it does not cap', () => { + const b = building({ height: 4, y: 3 }); + const [out] = clampBuildingsUnderDecks([b], [deck()]); + expect(out.y).toBe(3); + }); + + it('accounts for footprint width, not just the centre point', () => { + // A wide building whose centre clears the deck can still be under its edge. + const wide = building({ z: 9, width: 20, depth: 20, height: 40 }); + const [out] = clampBuildingsUnderDecks([wide], [deck()]); + expect(out.height).toBeLessThan(40); + }); +}); + +// ─── road hierarchy ─────────────────────────────────────────────────────────── + +describe('roadWidthForDepth', () => { + it('narrows as the split goes deeper', () => { + // The earliest splits carve the largest areas, so they carry the arterials. + const widths = [0, 1, 2, 3, 4].map(roadWidthForDepth); + for (let i = 1; i < widths.length; i++) { + expect(widths[i]).toBeLessThan(widths[i - 1]); + } + }); + + it('offers a real gradient, not just two kinds of road', () => { + // It used to be arterial for the first two splits and side street for the rest, + // which read as two road types rather than a hierarchy. + const distinct = new Set([0, 1, 2, 3, 4].map(roadWidthForDepth)); + expect(distinct.size).toBeGreaterThanOrEqual(4); + }); + + it('holds the narrowest width past the end of the table', () => { + expect(roadWidthForDepth(99)).toBe(roadWidthForDepth(4)); + }); + + it('treats a negative depth as the widest', () => { + expect(roadWidthForDepth(-3)).toBe(roadWidthForDepth(0)); + }); +}); + +describe('street hierarchy in generated layouts', () => { + it('gives the default layout several road widths', () => { + const { roads } = bspLayout(bounds(400), false, seededRng()); + expect(new Set(roads.map((r) => r.width)).size).toBeGreaterThanOrEqual(3); + }); +}); + +// ─── height gradient ────────────────────────────────────────────────────────── + +describe('heightScaleFor', () => { + it('builds tallest at the centre and lowest at the rim', () => { + expect(heightScaleFor(0)).toBeGreaterThan(heightScaleFor(1)); + }); + + it('falls off continuously rather than in steps', () => { + // Zone already steps with distance; the skyline came out as flat plateaus with + // hard seams. This is what softens them. + const samples = [0, 0.2, 0.4, 0.6, 0.8, 1].map(heightScaleFor); + for (let i = 1; i < samples.length; i++) { + expect(samples[i]).toBeLessThan(samples[i - 1]); + } + expect(new Set(samples).size).toBe(samples.length); + }); + + it('stays gentle, so it softens the zone bands rather than fighting them', () => { + expect(heightScaleFor(0) / heightScaleFor(1)).toBeLessThan(2); + }); + + it('clamps outside the normalised range', () => { + expect(heightScaleFor(-5)).toBe(heightScaleFor(0)); + expect(heightScaleFor(99)).toBe(heightScaleFor(1)); + }); +}); + +describe('skyline taper end to end', () => { + it('builds taller near the centre than at the edge', () => { + const heights: Array<{ r: number; h: number }> = []; + generateCity( + bounds(400), + { sectionType: 'MIXED' }, + freshContext(), + seededRng(), + { + // A fixed height, so any difference in the result is the gradient alone. + fillPlot: (x: number, z: number, _bw: number, _bd: number, _zone: number, + _blocked: unknown, _key: unknown, _cells: unknown, out: { height: number }[]) => { + out.push({ x, z, width: 4, depth: 4, height: 10, name: '', color: '#fff', shape: 'box', y: 0 } as never); + }, + } as never, + ).buildings.forEach((b) => heights.push({ r: Math.hypot(b.x, b.z), h: b.height })); + + const inner = heights.filter((v) => v.r < 120); + const outer = heights.filter((v) => v.r > 300); + expect(inner.length).toBeGreaterThan(0); + expect(outer.length).toBeGreaterThan(0); + + const mean = (v: typeof inner) => v.reduce((a, x) => a + x.h, 0) / v.length; + expect(mean(inner)).toBeGreaterThan(mean(outer)); + }); +}); + +// ─── lot coverage ───────────────────────────────────────────────────────────── + +describe('lotCoverageFor', () => { + const CORPO = 1.0, URBAN = 0.5, SLUMS = 0.1, INDUSTRIAL = -0.1, MARKETS = 2.0; + + it('leaves corporate plots a forecourt', () => { + expect(lotCoverageFor(CORPO)).toBeLessThan(lotCoverageFor(URBAN)); + }); + + it('builds slums and markets to the lot line', () => { + expect(lotCoverageFor(SLUMS)).toBeGreaterThan(0.9); + expect(lotCoverageFor(MARKETS)).toBeGreaterThan(0.9); + }); + + it('distinguishes the zones rather than treating them alike', () => { + // Previously every zone filled its plot the same way, so districts differed only + // in what they built, not how it sat on the ground. + const all = [CORPO, URBAN, SLUMS, INDUSTRIAL, MARKETS].map(lotCoverageFor); + expect(new Set(all).size).toBeGreaterThan(2); + }); + + it('never exceeds the plot or collapses it', () => { + for (const z of [CORPO, URBAN, SLUMS, INDUSTRIAL, MARKETS, 1.7, 3.0, 99]) { + expect(lotCoverageFor(z)).toBeGreaterThan(0.5); + expect(lotCoverageFor(z)).toBeLessThanOrEqual(1); + } + }); +}); + +describe('setbacks end to end', () => { + it('offers corporate plots a smaller footprint than the block', () => { + const offered: Array<{ bw: number; bd: number }> = []; + generateCity( + bounds(400), + { sectionType: 'CORPO' }, + freshContext(), + seededRng(), + { + fillPlot: (_x: number, _z: number, bw: number, bd: number) => { + offered.push({ bw, bd }); + }, + } as never, + ); + expect(offered.length).toBeGreaterThan(0); + // Every plot handed to placement is inset from its block. + for (const p of offered) { + expect(p.bw).toBeGreaterThan(0); + expect(p.bd).toBeGreaterThan(0); + } + }); + + it('gives slums a fuller plot than corporate for the same block size', () => { + const capture = (sectionType: 'CORPO' | 'SLUMS') => { + const areas: number[] = []; + generateCity( + bounds(400), { sectionType }, freshContext(), seededRng(), + { fillPlot: (_x: number, _z: number, bw: number, bd: number) => { areas.push(bw * bd); } } as never, + ); + return areas.reduce((a, v) => a + v, 0) / areas.length; + }; + expect(capture('SLUMS')).toBeGreaterThan(capture('CORPO')); + }); +}); + +describe('skyline taper keeps stacked parts together', () => { + it('scales y with height, so upper storeys do not float', () => { + // The floating skyscrapers: y is the bottom of a mesh, and a part sitting on + // another has its y set to that one's height. Scaling heights alone left every + // upper storey hanging above a shortened base. + const out = generateCity( + bounds(400), + { sectionType: 'MIXED' }, + freshContext(), + seededRng(), + { + // A two-part building: a base, and a storey resting exactly on top of it. + fillPlot: (x: number, z: number, _bw: number, _bd: number, _zone: number, + _blocked: unknown, _key: unknown, _cells: unknown, + sink: Record[]) => { + sink.push({ x, z, y: 0, width: 4, depth: 4, height: 30, name: 'STACK_BASE', description: '', color: '#fff', shape: 'box' }); + sink.push({ x, z, y: 30, width: 3, depth: 3, height: 10, name: 'STACK_TOP', description: '', color: '#fff', shape: 'box' }); + }, + } as never, + ).buildings; + + // Landmarks and parks also emit parts, and landmark parts sit at arbitrary + // heights rather than strictly stacked — pair only the synthetic ones. + const bases = out.filter((b) => b.name === 'STACK_BASE'); + expect(bases.length).toBeGreaterThan(0); + + for (const base of bases) { + const upper = out.find( + (b) => b.name === 'STACK_TOP' && b.x === base.x && b.z === base.z, + ); + expect(upper).toBeDefined(); + // The storey rests on the base: its bottom is the base's top. + expect(upper!.y).toBeCloseTo(base.height, 5); + } + }); +}); diff --git a/frontend/src/cityGen/__tests__/monuments.test.ts b/frontend/src/cityGen/__tests__/monuments.test.ts new file mode 100644 index 0000000..c1be1f2 --- /dev/null +++ b/frontend/src/cityGen/__tests__/monuments.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect } from 'vitest'; +import { generateMonument, generateLandmark, MONUMENT_STYLE_COUNT, MONUMENT_COLOR, POLY_COUNT, SHAPES, SpatialGrid } from '../index'; +import type { Block, RawBuilding } from '../types'; + +/** + * Island monuments. + * + * These exist because landmarks were used first and came out as 150-unit towers rising + * from a traffic island. So the tests that matter are about *scale*, not shape. + */ + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const island: Block = { x: 0, z: 0, w: 20, d: 20 }; +const SPAN = 20; + +/** Every style, so a per-style regression cannot hide behind an average. */ +function allStyles(span = SPAN): RawBuilding[][] { + const out: RawBuilding[][] = []; + for (let style = 0; style < MONUMENT_STYLE_COUNT; style++) { + // Feed a first draw that lands squarely in this style's bucket. + const pick = (style + 0.5) / MONUMENT_STYLE_COUNT; + let first = true; + const rng = () => { + if (first) { first = false; return pick; } + return 0.5; + }; + const parts: RawBuilding[] = []; + generateMonument({ x: 0, z: 0, w: span, d: span }, span, parts, rng); + out.push(parts); + } + return out; +} + +const topOf = (parts: RawBuilding[]) => Math.max(...parts.map(p => p.y + p.height)); +const widestOf = (parts: RawBuilding[]) => + Math.max(...parts.map(p => Math.max(p.width, p.depth))); + +describe('generateMonument', () => { + it('produces something for every style', () => { + for (const parts of allStyles()) expect(parts.length).toBeGreaterThan(0); + }); + + it('stays at civic scale, not skyline scale', () => { + // The bug this module exists for: a landmark on a traffic island was a tower. + for (const parts of allStyles()) { + // A monument on a 20-unit island should not be a 15-storey tower. First cut at + // this passed a /3-of-a-landmark check at 54 units and still read as a building. + expect(topOf(parts)).toBeLessThan(SPAN * 2); + } + }); + + it('is dramatically shorter than a landmark on the same plot', () => { + // Pins the relationship rather than a number, so it survives retuning either side. + const landmark: RawBuilding[] = []; + generateLandmark(island, SPAN, SPAN, landmark, new SpatialGrid(), seededRng()); + const tallestMonument = Math.max(...allStyles().map(topOf)); + expect(tallestMonument).toBeLessThan(topOf(landmark) / 3); + }); + + it('fits within the island', () => { + // A monument wider than the disc would overhang the ring road. + for (const parts of allStyles()) { + expect(widestOf(parts)).toBeLessThanOrEqual(SPAN); + } + }); + + it('scales with the island rather than using fixed heights', () => { + // Absolute heights would go wrong the moment road widths are retuned. + const small = Math.max(...allStyles(10).map(topOf)); + const large = Math.max(...allStyles(40).map(topOf)); + expect(large).toBeGreaterThan(small * 3); + }); + + it('sits on the ground', () => { + for (const parts of allStyles()) { + expect(Math.min(...parts.map(p => p.y))).toBe(0); + } + }); + + it('leaves nothing floating', () => { + // y is the bottom of a mesh, so a part resting on another has its y set to that + // one's height. Getting this wrong is what left skyscrapers hanging in mid-air. + // A part is anchored if it stands on the ground or if its base falls within the + // vertical extent of another part — the second case covers what is attached to a + // side rather than stacked on top, such as a clock face or a raised arm. + for (const parts of allStyles()) { + for (const p of parts) { + if (p.y === 0) continue; + const anchored = parts.some(q => q !== p && p.y >= q.y - 1e-6 && p.y <= q.y + q.height + 1e-6); + expect(anchored, `part at y=${p.y}`).toBe(true); + } + } + }); + + it('emits one unparented root, with the rest grouped under it', () => { + // The caller groups children by parent_name once the root has a database id. + for (const parts of allStyles()) { + expect(parts.filter(p => !p.parent_name)).toHaveLength(1); + expect(parts[0].parent_name).toBeUndefined(); + for (const p of parts.slice(1)) expect(p.parent_name).toBe('ROOT'); + } + }); + + it('keeps every part within the island', () => { + // Parts are no longer all on the centreline — bollards, spouts and arch piers sit + // out from it — so what matters is that the whole thing stays off the ring road. + for (const parts of allStyles()) { + for (const p of parts) { + const reach = Math.max(Math.abs(p.x), Math.abs(p.z)) + Math.max(p.width, p.depth) / 2; + expect(reach, p.shape).toBeLessThanOrEqual(SPAN / 2); + } + } + }); + + it('is built from more than stacked boxes', () => { + // The complaint that prompted this: two boxes on top of each other read as two + // boxes on top of each other. Silhouette is what carries an object this small. + for (const parts of allStyles()) { + const shapes = new Set(parts.map(p => p.shape)); + expect(shapes.size, [...shapes].join(',')).toBeGreaterThan(1); + expect(parts.length).toBeGreaterThanOrEqual(5); + } + }); + + it('varies its silhouette between styles', () => { + // If every style used the same shapes in the same proportions there would be no + // point having six of them. + const signatures = allStyles().map(parts => + [...parts.map(p => p.shape)].sort().join('|') + ':' + parts.length); + expect(new Set(signatures).size).toBe(MONUMENT_STYLE_COUNT); + }); + + it('offers visibly different styles', () => { + // A run of roundabouts all carrying the same column would read worse than none. + const shapes = allStyles().map(parts => topOf(parts).toFixed(2)); + expect(new Set(shapes).size).toBeGreaterThan(1); + }); + + it('uses the same colour convention as every other structure', () => { + // '#00ff00' is not a colour here, it is the sentinel meaning "inherit the theme" — + // the renderer resolves anything else verbatim. The generated city stores it on + // some two thousand buildings. Setting a real colour instead opted monuments out of + // the theme system, so they matched nothing and would ignore a theme switch. + for (const parts of allStyles()) { + for (const p of parts) expect(p.color).toBe(MONUMENT_COLOR); + } + expect(MONUMENT_COLOR).toBe('#00ff00'); + }); + + it('uses the same segment count as every other structure', () => { + // Everything is drawn as a wireframe, so polyCount is not a quality setting, it is + // the look. At 5 a cylinder is a pentagonal prism — what the whole city is built + // from. At 16 it is a dense cage of lines that reads as a bright striped mass, and + // a column at 16 stood out beside a statue made of boxes at 5. + for (const parts of allStyles()) { + for (const p of parts) expect(p.polyCount, p.shape).toBe(POLY_COUNT); + } + expect(POLY_COUNT).toBe(5); + }); + + it('never uses the rhombus shape, which the app reserves for tokens', () => { + // A rhombus *is* a player or NPC token here: the server's TOKEN_SHAPES treats it as + // one, a region purge spares it as player content, and OverlapChecker registers it + // in activeRhombuses so structures containing a token can be made transparent. + // + // Used as an octahedral finial it made each monument publish a fake token inside + // itself, so the overlap check dropped the structure's fill to zero opacity and the + // monument turned itself invisible — and the finial then survived every regenerate + // as "player content", orphaning itself from its deleted root. The statue and the + // fountain, the only two styles without a finial, were the only ones that looked + // right. + for (const parts of allStyles()) { + for (const p of parts) { + expect(p.shape, `style using ${p.shape}`).not.toBe('rhombus'); + expect(SHAPES).toContain(p.shape); + } + } + }); + + it('keeps its part count modest', () => { + // Coincident wireframe edges are what made these glow beside neighbours built from + // one or two masses. Detail has to come from silhouette, not from part count. + for (const parts of allStyles()) { + expect(parts.length).toBeLessThanOrEqual(11); + } + }); + + it('reproduces from a seed', () => { + const a: RawBuilding[] = []; + const b: RawBuilding[] = []; + generateMonument(island, SPAN, a, seededRng(12)); + generateMonument(island, SPAN, b, seededRng(12)); + expect(a).toEqual(b); + }); +}); diff --git a/frontend/src/cityGen/__tests__/parkPonds.test.ts b/frontend/src/cityGen/__tests__/parkPonds.test.ts new file mode 100644 index 0000000..0c9d220 --- /dev/null +++ b/frontend/src/cityGen/__tests__/parkPonds.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from 'vitest'; +import { generateCity, generatePark, pointInPolygon, pointInWater } from '../index'; +import type { Block } from '../types'; + +/** + * Park ponds. + * + * The opposite ordering case from rivers and coastlines: a park only exists once the + * split has produced the block it sits in, so its pond is made afterwards. That is + * safe 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. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); +const deps = { fillPlot: () => {} }; +const clear = () => false; + +const block: Block = { x: 0, z: 0, w: 60, d: 60 }; + +/** Run generatePark until it yields a pond, so pond-shape assertions aren't flaky. */ +function firstPond(seed = 1) { + const rng = seededRng(seed); + for (let i = 0; i < 200; i++) { + const [pond] = generatePark(block, 50, 50, [], clear, rng, true); + if (pond) return pond; + } + throw new Error('no pond in 200 attempts'); +} + +describe('generatePark ponds', () => { + it('makes none unless asked', () => { + const rng = seededRng(); + for (let i = 0; i < 50; i++) { + expect(generatePark(block, 50, 50, [], clear, rng)).toHaveLength(0); + } + }); + + it('draws no randomness for a pond when ponds are off', () => { + // Otherwise a seed would stop reproducing the parks it produced before ponds + // existed, purely from the extra rolls. + const withFlag = seededRng(9); + const without = seededRng(9); + const a: unknown[] = []; + const b: unknown[] = []; + generatePark(block, 50, 50, a as never[], clear, withFlag, false); + generatePark(block, 50, 50, b as never[], clear, without); + expect(a).toEqual(b); + expect(withFlag()).toBe(without()); + }); + + it('makes ponds when asked', () => { + const rng = seededRng(); + let made = 0; + for (let i = 0; i < 60; i++) { + made += generatePark(block, 50, 50, [], clear, rng, true).length; + } + expect(made).toBeGreaterThan(0); + }); + + it('encloses actual area', () => { + const pond = firstPond(); + const area = Math.abs(pond.points.reduce((sum, p, i) => { + const q = pond.points[(i + 1) % pond.points.length]; + return sum + (p.x * q.z - q.x * p.z); + }, 0) / 2); + expect(area).toBeGreaterThan(1); + }); + + it('is big enough to read as a pond', () => { + // Sizing a circular pond off the narrower plot axis produced 4-unit ponds in + // 50-unit plots. Anything under a quarter of the plot is a puddle. + const pond = firstPond(); + const xs = pond.points.map(p => p.x); + const width = Math.max(...xs) - Math.min(...xs); + expect(width).toBeGreaterThan(50 * 0.25); + }); + + it('fills a long thin plot along its length', () => { + // The failure the ellipse fixes: a circle in an elongated plot can only ever be + // as wide as the short side, so it vanishes against the length. + const rng = seededRng(2); + const long: Block = { x: 0, z: 0, w: 90, d: 30 }; + for (let i = 0; i < 200; i++) { + const [pond] = generatePark(long, 80, 20, [], clear, rng, true); + if (!pond) continue; + const xs = pond.points.map(p => p.x); + const zs = pond.points.map(p => p.z); + const width = Math.max(...xs) - Math.min(...xs); + const depth = Math.max(...zs) - Math.min(...zs); + expect(width).toBeGreaterThan(depth * 2); + expect(width).toBeGreaterThan(80 * 0.25); + return; + } + throw new Error('no pond in 200 attempts'); + }); + + it('stays inside its own plot', () => { + // The whole point of siting a pond after the split: it must not reach the road. + const pond = firstPond(); + for (const p of pond.points) { + expect(Math.abs(p.x)).toBeLessThanOrEqual(25); + expect(Math.abs(p.z)).toBeLessThanOrEqual(25); + } + }); + + it('skips a pond on ground that is already taken', () => { + // isBlocked already composes every reason a footprint is unusable, roads included. + const rng = seededRng(); + let made = 0; + for (let i = 0; i < 60; i++) { + made += generatePark(block, 50, 50, [], () => true, rng, true).length; + } + expect(made).toBe(0); + }); + + it('keeps trees out of the water', () => { + const rng = seededRng(3); + for (let i = 0; i < 60; i++) { + const trees: { x: number; z: number }[] = []; + const [pond] = generatePark(block, 50, 50, trees as never[], clear, rng, true); + if (!pond) continue; + for (const t of trees) expect(pointInPolygon(pond, t.x, t.z)).toBe(false); + } + }); +}); + +describe('generateCity with park ponds', () => { + it('returns no water when ponds are off', () => { + const result = generateCity( + bounds(300), { sectionType: 'MIXED', excludeRoads: false }, freshContext(), seededRng(), deps + ); + expect(result.waterBodies).toHaveLength(0); + }); + + it('returns ponds to be persisted when they are on', () => { + const result = generateCity( + bounds(300), { sectionType: 'MIXED', excludeRoads: false, parkPonds: true }, + freshContext(), seededRng(), deps + ); + expect(result.waterBodies.length).toBeGreaterThan(0); + }); + + it('does not let a pond move the roads it was made after', () => { + // Ponds are collected apart from the water the split saw. If they leaked into it, + // the road network would differ between a ponded and an unponded run of the same + // seed — and the pond would be sited against roads that no longer exist. + const dry = generateCity( + bounds(300), { sectionType: 'MIXED', excludeRoads: false }, freshContext(), seededRng(11), deps + ); + const wet = generateCity( + bounds(300), { sectionType: 'MIXED', excludeRoads: false, parkPonds: true }, + freshContext(), seededRng(11), deps + ); + expect(wet.roads).toEqual(dry.roads); + expect(wet.overpasses).toEqual(dry.overpasses); + }); + + it('keeps ponds clear of the roads', () => { + const result = generateCity( + bounds(300), { sectionType: 'MIXED', excludeRoads: false, parkPonds: true }, + freshContext(), seededRng(5), deps + ); + for (const road of result.roads) { + const mid = { x: (road.x1 + road.x2) / 2, z: (road.z1 + road.z2) / 2 }; + expect(pointInWater(result.waterBodies, mid.x, mid.z)).toBe(false); + } + }); + + it('reproduces its ponds from a seed', () => { + const opts = { sectionType: 'MIXED' as const, excludeRoads: false, parkPonds: true }; + const a = generateCity(bounds(300), opts, freshContext(), seededRng(77), deps); + const b = generateCity(bounds(300), opts, freshContext(), seededRng(77), deps); + expect(a.waterBodies).toEqual(b.waterBodies); + }); +}); diff --git a/frontend/src/cityGen/__tests__/perimeter.test.ts b/frontend/src/cityGen/__tests__/perimeter.test.ts new file mode 100644 index 0000000..0f09b8a --- /dev/null +++ b/frontend/src/cityGen/__tests__/perimeter.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from 'vitest'; +import { + perimeterLots, perimeterLayout, LAYOUTS, + generateCity, splitCity, +} from '../index'; +import type { Block } from '../types'; + +/** + * Downtown layout. + * + * The point of it is density and a street wall, so that is what these check: many lots + * per block, narrow frontages left narrow, neighbours close enough to read as a + * terrace, and the middle of the block left open. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); +const deps = { fillPlot: () => {} }; + +const bigBlock: Block = { x: 0, z: 0, w: 140, d: 80 }; + +/** Axis-aligned overlap between two lots, ignoring a hair of tolerance. */ +const overlaps = (a: Block, b: Block) => + Math.abs(a.x - b.x) < (a.w + b.w) / 2 - 0.01 && + Math.abs(a.z - b.z) < (a.d + b.d) / 2 - 0.01; + +describe('perimeterLots', () => { + it('cuts a block into many lots', () => { + const lots = perimeterLots(bigBlock, seededRng()); + expect(lots.length).toBeGreaterThan(8); + }); + + it('marks every lot as already sized', () => { + // Block.lot is what stops the generator padding, squaring and setting back a + // footprint the layout has already decided. Without it there is no terrace. + for (const lot of perimeterLots(bigBlock, seededRng())) { + expect(lot.lot).toBe(true); + } + }); + + it('keeps every lot inside the block', () => { + for (const lot of perimeterLots(bigBlock, seededRng())) { + expect(Math.abs(lot.x) + lot.w / 2).toBeLessThanOrEqual(bigBlock.w / 2 + 0.01); + expect(Math.abs(lot.z) + lot.d / 2).toBeLessThanOrEqual(bigBlock.d / 2 + 0.01); + } + }); + + it('does not overlap its own lots', () => { + // The runs along x take the corners, so the runs along z must fill only the gap + // between them. Getting that wrong stacks buildings on the corners. + const lots = perimeterLots(bigBlock, seededRng()); + for (let i = 0; i < lots.length; i++) { + for (let j = i + 1; j < lots.length; j++) { + expect(overlaps(lots[i], lots[j]), `lot ${i} vs ${j}`).toBe(false); + } + } + }); + + it('leaves a yard in the middle but does not leave the block hollow', () => { + // Two failure modes, one on each side. One ring around a 226 by 114 block leaves + // 186 by 74 of nothing, which is most of the block; filling everything took it to + // 97% built, a solid slab with no back lot at all. So: the exact centre stays open, + // and the block is still substantially built out. + const big: Block = { x: 0, z: 0, w: 226, d: 114 }; + const lots = perimeterLots(big, seededRng()); + const coversCentre = lots.some(l => + Math.abs(l.x) < l.w / 2 && Math.abs(l.z) < l.d / 2); + expect(coversCentre).toBe(false); + + const built = lots.reduce((a, l) => a + l.w * l.d, 0) / (big.w * big.d); + expect(built).toBeGreaterThan(0.5); + expect(built).toBeLessThan(0.92); + }); + + it('puts neighbours close enough to read as a terrace', () => { + // The whole point. A gap of more than a metre or two and it is detached houses. + // The row is found by grouping on the z the lots actually landed at, rather than a + // computed one — rim depth varies per block, so there is no constant to predict it. + const lots = perimeterLots(bigBlock, seededRng()); + const rows = new Map(); + for (const l of lots) { + const key = l.z.toFixed(3); + rows.set(key, [...(rows.get(key) ?? []), l]); + } + const row = [...rows.values()].sort((a, b) => b.length - a.length)[0].sort((a, b) => a.x - b.x); + expect(row.length).toBeGreaterThan(2); + for (let i = 1; i < row.length; i++) { + const gap = (row[i].x - row[i].w / 2) - (row[i - 1].x + row[i - 1].w / 2); + expect(gap).toBeLessThan(2); + expect(gap).toBeGreaterThanOrEqual(0); + } + }); + + it('varies the frontages', () => { + // A row of identical widths reads as a barracks. + const widths = new Set(perimeterLots(bigBlock, seededRng()).map(l => l.w.toFixed(2))); + expect(widths.size).toBeGreaterThan(3); + }); + + it('builds a small block as a terrace, not one monolith', () => { + // A block with no room for a rim and a middle used to come back as a single lot the + // length of the block, which is a monolith where a row of buildings belongs. + const small: Block = { x: 0, z: 0, w: 60, d: 26 }; + const lots = perimeterLots(small, seededRng()); + expect(lots.length).toBeGreaterThan(1); + for (const l of lots) expect(l.d).toBeCloseTo(26, 1); + expect(lots.reduce((a, l) => a + l.w, 0)).toBeLessThanOrEqual(60); + }); + + it('reproduces from a seed', () => { + expect(perimeterLots(bigBlock, seededRng(11))).toEqual(perimeterLots(bigBlock, seededRng(11))); + }); +}); + +describe('perimeterLayout', () => { + it('is registered', () => { + expect(LAYOUTS.PERIMETER).toBe(perimeterLayout); + }); + + it('is far denser than the grid it is built on', () => { + const grid = LAYOUTS.GRID(bounds(300), false, seededRng()); + const downtown = perimeterLayout(bounds(300), false, seededRng()); + expect(downtown.blocks.length).toBeGreaterThan(grid.blocks.length * 2); + }); + + it('lays an elongated street grid, not a square one', () => { + // 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. The blocks themselves never reach the + // caller — they are cut into lots first — so the grid that made them is what can be + // measured, via the spacing between parallel streets on each axis. + const { roads } = perimeterLayout(bounds(600), false, seededRng()); + const spacing = (vals: number[]) => { + const uniq = [...new Set(vals.map(v => Math.round(v)))].sort((a, b) => a - b); + const gaps = uniq.slice(1).map((v, i) => v - uniq[i]).filter(g => g > 5); + return gaps.sort((a, b) => a - b)[Math.floor(gaps.length / 2)]; + }; + const acrossX = spacing(roads.filter(r => Math.abs(r.x1 - r.x2) < 0.5).map(r => r.x1)); + const acrossZ = spacing(roads.filter(r => Math.abs(r.z1 - r.z2) < 0.5).map(r => r.z1)); + const ratio = Math.max(acrossX, acrossZ) / Math.min(acrossX, acrossZ); + expect(ratio).toBeGreaterThan(1.5); + }); + + it('varies its block sizes rather than repeating one cell', () => { + // The first version divided the span evenly and wobbled the seams, which made every + // block the same size by construction. A real downtown has short blocks, long ones + // and the occasional enormous one where a street was never cut through. + const { roads } = perimeterLayout(bounds(600), false, seededRng()); + const gapsAlong = (vals: number[]) => { + const uniq = [...new Set(vals.map(v => Math.round(v)))].sort((a, b) => a - b); + return uniq.slice(1).map((v, i) => v - uniq[i]).filter(g => g > 5); + }; + const gaps = gapsAlong(roads.filter(r => Math.abs(r.z1 - r.z2) < 0.5).map(r => r.z1)); + expect(gaps.length).toBeGreaterThan(4); + const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length; + const spread = Math.sqrt(gaps.reduce((a, g) => a + (g - mean) ** 2, 0) / gaps.length) / mean; + expect(spread).toBeGreaterThan(0.15); + // And the largest block is a different animal from the smallest, not a wobble. + expect(Math.max(...gaps)).toBeGreaterThan(Math.min(...gaps) * 1.8); + }); + + it('varies the rim depth between blocks', () => { + // A constant depth makes every terrace the same thickness, which reads as a machine + // even when the frontages differ. + const depths = new Set(); + const rng = seededRng(21); + for (let i = 0; i < 20; i++) { + const lots = perimeterLots({ x: 0, z: 0, w: 140, d: 90 }, rng); + depths.add(Math.min(...lots.map(l => Math.min(l.w, l.d))).toFixed(2)); + } + expect(depths.size).toBeGreaterThan(5); + }); + + it('still lays roads', () => { + expect(perimeterLayout(bounds(300), false, seededRng()).roads.length).toBeGreaterThan(0); + }); + + it('lays no roads when excluded', () => { + expect(perimeterLayout(bounds(300), true, seededRng()).roads).toHaveLength(0); + }); + + it('keeps roads out of the water', () => { + const lake = { points: [ + { x: -80, z: -80 }, { x: 80, z: -80 }, { x: 80, z: 80 }, { x: -80, z: 80 }, + ] }; + const { roads } = perimeterLayout(bounds(300), false, seededRng(), [lake]); + for (const r of roads) { + const mx = (r.x1 + r.x2) / 2; + const mz = (r.z1 + r.z2) / 2; + expect(Math.abs(mx) < 80 && Math.abs(mz) < 80).toBe(false); + } + }); + + it('keeps lots inside a drawn boundary, not whole blocks', () => { + // Every other layout drops a block whose centre falls outside the shape, which + // works when a block is the unit of output. A downtown block is large and holds a + // dozen lots, so dropping it whole discards lots well inside the boundary — and a + // small drawn area lost every block it touched and generated nothing at all. + const boundary = { points: [ + { x: -60, z: -60 }, { x: 60, z: -60 }, { x: 60, z: 60 }, { x: -60, z: 60 }, + ] }; + const { blocks } = perimeterLayout(bounds(300), true, seededRng(), [], boundary); + expect(blocks.length).toBeGreaterThan(0); + for (const b of blocks) { + expect(Math.abs(b.x)).toBeLessThanOrEqual(60); + expect(Math.abs(b.z)).toBeLessThanOrEqual(60); + } + }); + + it('reproduces from a seed', () => { + expect(perimeterLayout(bounds(300), false, seededRng(3))) + .toEqual(perimeterLayout(bounds(300), false, seededRng(3))); + }); +}); + +describe('Block.lot in the generator', () => { + it('leaves every other layout untouched', () => { + // No existing layout sets `lot`, so the padding, aspect clamp and setback all still + // apply exactly as before. + const { blocks } = splitCity(bounds(300), false, seededRng()); + expect(blocks.some(b => b.lot)).toBe(false); + }); + + it('builds a downtown', () => { + const res = generateCity( + bounds(400), { sectionType: 'MIXED', excludeRoads: false, layout: 'PERIMETER' }, + freshContext(), seededRng(5), deps + ); + expect(res.blocks.length).toBeGreaterThan(0); + expect(res.roads.length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/cityGen/__tests__/region.test.ts b/frontend/src/cityGen/__tests__/region.test.ts new file mode 100644 index 0000000..e37de12 --- /dev/null +++ b/frontend/src/cityGen/__tests__/region.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { countGeneratedInRegion, makeRegionTest } from '../index'; + +/** + * What a regenerate would clear. Extracted from the panel so the rule is stated once + * and testable directly, rather than only through a rendered component. + */ + +const BOUNDS = { min: { x: -50, z: -50 }, max: { x: 50, z: 50 } }; + +const gen = (x: number, z: number, over = {}) => ({ name: '', x, z, shape: 'box', ...over }); +const named = (x: number, z: number, name = 'AFTERLIFE') => ({ name, x, z, shape: 'box' }); + +describe('makeRegionTest', () => { + it('accepts points inside the bounds', () => { + const inside = makeRegionTest(BOUNDS); + expect(inside(0, 0)).toBe(true); + expect(inside(50, 50)).toBe(true); + }); + + it('rejects points outside', () => { + expect(makeRegionTest(BOUNDS)(500, 0)).toBe(false); + }); + + it('normalises bounds dragged in any direction', () => { + const flipped = { min: { x: 50, z: 50 }, max: { x: -50, z: -50 } }; + expect(makeRegionTest(flipped)(0, 0)).toBe(true); + }); + + it('prefers a drawn polygon over its bounding box', () => { + // An L: the notch is inside the bbox but outside the shape. + const L = [ + { x: 0, z: 0 }, { x: 100, z: 0 }, { x: 100, z: 40 }, + { x: 40, z: 40 }, { x: 40, z: 100 }, { x: 0, z: 100 }, + ]; + const inside = makeRegionTest({ min: { x: 0, z: 0 }, max: { x: 100, z: 100 } }, L); + expect(inside(20, 20)).toBe(true); + expect(inside(70, 70)).toBe(false); + }); + + it('falls back to the bounds for a degenerate polygon', () => { + const inside = makeRegionTest(BOUNDS, [{ x: 0, z: 0 }, { x: 1, z: 1 }]); + expect(inside(0, 0)).toBe(true); + }); +}); + +describe('countGeneratedInRegion', () => { + it('counts generated structures inside the region', () => { + expect(countGeneratedInRegion([gen(0, 0), gen(10, 10)], BOUNDS).removed).toBe(2); + }); + + it('ignores anything outside', () => { + expect(countGeneratedInRegion([gen(9999, 9999)], BOUNDS).removed).toBe(0); + }); + + it('separates what a GM named from what was generated', () => { + // The rule that matters: hand-placed work survives a regenerate. + const counts = countGeneratedInRegion([gen(0, 0), named(5, 5), named(6, 6)], BOUNDS); + expect(counts).toEqual({ removed: 1, kept: 2 }); + }); + + it('never counts tokens', () => { + const tokens = ['rhombus', 'enemy_rhombus', 'friendly_rhombus'] + .map((shape) => gen(1, 1, { shape })); + expect(countGeneratedInRegion(tokens, BOUNDS)).toEqual({ removed: 0, kept: 0 }); + }); + + it('never counts battle map content', () => { + expect(countGeneratedInRegion([gen(1, 1, { battle_map_id: 3 })], BOUNDS).removed).toBe(0); + }); + + it('counts within a drawn shape, not its bounding box', () => { + const L = [ + { x: 0, z: 0 }, { x: 100, z: 0 }, { x: 100, z: 40 }, + { x: 40, z: 40 }, { x: 40, z: 100 }, { x: 0, z: 100 }, + ]; + const counts = countGeneratedInRegion( + [gen(20, 20), gen(70, 70)], + { min: { x: 0, z: 0 }, max: { x: 100, z: 100 } }, + L, + ); + expect(counts.removed).toBe(1); + }); + + it('handles an empty world', () => { + expect(countGeneratedInRegion([], BOUNDS)).toEqual({ removed: 0, kept: 0 }); + }); +}); diff --git a/frontend/src/cityGen/__tests__/roundabouts.test.ts b/frontend/src/cityGen/__tests__/roundabouts.test.ts new file mode 100644 index 0000000..3d80831 --- /dev/null +++ b/frontend/src/cityGen/__tests__/roundabouts.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect } from 'vitest'; +import { + findJunctions, siteRoundabouts, applyRoundabouts, ringPolygon, + segmentCrossing, generateCity, pointInPolygon, + MIN_ARTERIAL_WIDTH, RING_WIDTH, SPACING_RADII, +} from '../index'; +import type { RoadSegment } from '../types'; +import { isUserDefinedName } from '../../utils/locationHelpers'; + +/** + * Roundabouts. + * + * An overlay on a finished road network, not a layout, so these test it against roads + * given directly rather than through a particular layout — that is the point of it + * being an overlay. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); +const deps = { fillPlot: () => {} }; + +/** + * A seed whose first roll clears the `normal` density share. + * + * Siting rolls before it tests anything else, so a seed that fails the roll skips the + * junction outright — and every exclusion test below would pass without exercising the + * rule it names. The default seed rolls 0.88, above the 0.6 share, and did exactly that. + */ +const PASSES_ROLL = 1; + +/** A crossroads of two wide roads, sharing no endpoint — the grid's case. */ +const cross = (x = 0, z = 0, width = 8): RoadSegment[] => [ + { x1: x - 100, z1: z, x2: x + 100, z2: z, width }, + { x1: x, z1: z - 100, x2: x, z2: z + 100, width }, +]; + +describe('segmentCrossing', () => { + it('finds where two segments cross', () => { + const hit = segmentCrossing( + { x1: -10, z1: 0, x2: 10, z2: 0, width: 5 }, + { x1: 0, z1: -10, x2: 0, z2: 10, width: 5 } + ); + expect(hit?.x).toBeCloseTo(0); + expect(hit?.z).toBeCloseTo(0); + }); + + it('returns null for segments that miss', () => { + expect(segmentCrossing( + { x1: -10, z1: 0, x2: -5, z2: 0, width: 5 }, + { x1: 0, z1: -10, x2: 0, z2: 10, width: 5 } + )).toBeNull(); + }); + + it('returns null for parallel segments', () => { + expect(segmentCrossing( + { x1: -10, z1: 0, x2: 10, z2: 0, width: 5 }, + { x1: -10, z1: 5, x2: 10, z2: 5, width: 5 } + )).toBeNull(); + }); +}); + +describe('findJunctions', () => { + it('finds a crossing where no endpoint is shared', () => { + // gridLayout lays each street as one full-length span, so its intersections exist + // only as crossings. Missing these would leave the grid without roundabouts. + const j = findJunctions(cross()); + expect(j).toHaveLength(1); + expect(j[0].x).toBeCloseTo(0); + expect(j[0].z).toBeCloseTo(0); + }); + + it('ignores junctions of minor roads', () => { + // A roundabout is a junction of arterials; on a side street it is street furniture. + expect(findJunctions(cross(0, 0, MIN_ARTERIAL_WIDTH - 1))).toHaveLength(0); + }); + + it('reports the wider of the two roads', () => { + const [j] = findJunctions([ + { x1: -100, z1: 0, x2: 100, z2: 0, width: 6 }, + { x1: 0, z1: -100, x2: 0, z2: 100, width: 9 }, + ]); + expect(j.width).toBe(9); + }); +}); + +describe('siteRoundabouts', () => { + it('places none when off', () => { + expect(siteRoundabouts(cross(), 'off', seededRng())).toHaveLength(0); + }); + + it('draws no randomness when off, so an existing seed is unaffected', () => { + const a = seededRng(5); + const b = seededRng(5); + siteRoundabouts(cross(), 'off', a); + expect(a()).toBe(b()); + }); + + it('places fewer when sparse than when normal', () => { + // Twenty junctions, so the difference is a share rather than a coin flip. + const roads: RoadSegment[] = []; + for (let i = 0; i < 20; i++) { + roads.push({ x1: -500, z1: i * 200 - 2000, x2: 500, z2: i * 200 - 2000, width: 8 }); + roads.push({ x1: i * 200 - 2000, z1: -500, x2: i * 200 - 2000, z2: 500, width: 8 }); + } + const sparse = siteRoundabouts(roads, 'sparse', seededRng(3)).length; + const normal = siteRoundabouts(roads, 'normal', seededRng(3)).length; + expect(normal).toBeGreaterThanOrEqual(sparse); + }); + + it('keeps them apart', () => { + const roads: RoadSegment[] = []; + for (let i = 0; i < 12; i++) { + roads.push({ x1: -400, z1: i * 12 - 100, x2: 400, z2: i * 12 - 100, width: 8 }); + roads.push({ x1: i * 12 - 100, z1: -400, x2: i * 12 - 100, z2: 400, width: 8 }); + } + const placed = siteRoundabouts(roads, 'normal', seededRng()); + for (let i = 0; i < placed.length; i++) { + for (let j = i + 1; j < placed.length; j++) { + const gap = Math.hypot(placed[i].x - placed[j].x, placed[i].z - placed[j].z); + expect(gap).toBeGreaterThanOrEqual( + Math.max(placed[i].radius, placed[j].radius) * SPACING_RADII - 1e-6 + ); + } + } + }); + + it('keeps them out of the water', () => { + const lake = { points: [ + { x: -50, z: -50 }, { x: 50, z: -50 }, { x: 50, z: 50 }, { x: -50, z: 50 }, + ] }; + expect(siteRoundabouts(cross(), 'normal', seededRng(PASSES_ROLL), [lake])).toHaveLength(0); + }); + + it('keeps the whole ring out of the water, not just its centre', () => { + // A junction on a shoreline has its centre on dry ground while half the ring hangs + // over the water. Testing the centre alone let that through. + const shore = { points: [ + { x: 5, z: -200 }, { x: 400, z: -200 }, { x: 400, z: 200 }, { x: 5, z: 200 }, + ] }; + // The junction is at the origin and the shore starts at x = 5, so the centre is + // dry and the ring is not. Asserting it is rejected outright, rather than looping + // over what got placed — an empty list would pass that vacuously. + expect(siteRoundabouts(cross(), 'normal', seededRng(PASSES_ROLL), [shore])).toHaveLength(0); + }); + + it('keeps them inside a drawn boundary', () => { + const boundary = { points: [ + { x: 200, z: 200 }, { x: 300, z: 200 }, { x: 300, z: 300 }, { x: 200, z: 300 }, + ] }; + expect(siteRoundabouts(cross(), 'normal', seededRng(PASSES_ROLL), [], boundary)).toHaveLength(0); + }); + + it('keeps the whole ring inside a drawn boundary', () => { + // Same defect as the shoreline, and the same fix: a junction just inside an edge + // would otherwise put half its ring outside the area the GM drew. + const boundary = { points: [ + { x: -400, z: -400 }, { x: 5, z: -400 }, { x: 5, z: 400 }, { x: -400, z: 400 }, + ] }; + // The junction sits inside the boundary, which ends at x = 5; the ring does not. + expect(siteRoundabouts(cross(), 'normal', seededRng(PASSES_ROLL), [], boundary)).toHaveLength(0); + }); + + it('places one on clear ground with that seed', () => { + // The control for every exclusion test above: if this were empty they would all + // pass without exercising anything. + expect(siteRoundabouts(cross(), 'normal', seededRng(PASSES_ROLL))).toHaveLength(1); + }); + + it('reproduces from a seed', () => { + expect(siteRoundabouts(cross(), 'normal', seededRng(9))) + .toEqual(siteRoundabouts(cross(), 'normal', seededRng(9))); + }); +}); + +describe('applyRoundabouts', () => { + const one = [{ x: 0, z: 0, radius: 15 }]; + + it('returns the roads untouched when there are none', () => { + const roads = cross(); + expect(applyRoundabouts(roads, [])).toBe(roads); + }); + + it('cuts the approaches back to the ring', () => { + // Without this the arterials run straight through the island and the roundabout is + // a decoration painted over a crossroads. + const out = applyRoundabouts(cross(), one); + const ring = ringPolygon(one[0]); + for (const r of out) { + if (r.width === RING_WIDTH) continue; // the ring itself + const mid = { x: (r.x1 + r.x2) / 2, z: (r.z1 + r.z2) / 2 }; + expect(pointInPolygon(ring, mid.x, mid.z)).toBe(false); + } + }); + + it('leaves the approaches reaching the ring', () => { + // Trimmed too far and the roundabout is an island with no roads touching it. + const out = applyRoundabouts(cross(), one).filter(r => r.width !== RING_WIDTH); + const touching = out.filter(r => + [[r.x1, r.z1], [r.x2, r.z2]].some(([x, z]) => + Math.abs(Math.hypot(x, z) - one[0].radius) < 1.5)); + expect(touching.length).toBe(4); + }); + + it('lays the ring itself', () => { + const out = applyRoundabouts(cross(), one); + const ring = out.filter(r => r.width === RING_WIDTH); + expect(ring.length).toBeGreaterThanOrEqual(6); + // Every ring segment sits at the radius, which is what makes it read as a circle. + for (const r of ring) { + expect(Math.hypot(r.x1, r.z1)).toBeCloseTo(one[0].radius, 1); + } + }); + + it('closes the ring', () => { + // An open arc would leave traffic driving off the end of a curve. + const ring = applyRoundabouts(cross(), one).filter(r => r.width === RING_WIDTH); + const ends = new Map(); + for (const r of ring) { + for (const k of [`${r.x1.toFixed(3)},${r.z1.toFixed(3)}`, `${r.x2.toFixed(3)},${r.z2.toFixed(3)}`]) { + ends.set(k, (ends.get(k) ?? 0) + 1); + } + } + for (const count of ends.values()) expect(count).toBe(2); + }); +}); + +describe('generateCity with roundabouts', () => { + const opts = (extra = {}) => ({ + sectionType: 'MIXED' as const, excludeRoads: false, layout: 'GRID' as const, ...extra, + }); + + it('makes none by default', () => { + const a = generateCity(bounds(400), opts(), freshContext(), seededRng(21), deps); + const b = generateCity( + bounds(400), opts({ roundabouts: 'off' }), freshContext(), seededRng(21), deps + ); + expect(a.roads).toEqual(b.roads); + }); + + it('changes the road network when asked for', () => { + const off = generateCity(bounds(400), opts(), freshContext(), seededRng(21), deps); + const on = generateCity( + bounds(400), opts({ roundabouts: 'normal' }), freshContext(), seededRng(21), deps + ); + expect(on.roads).not.toEqual(off.roads); + }); + + it('dresses the islands rather than leaving holes', () => { + const on = generateCity( + bounds(400), opts({ roundabouts: 'normal' }), freshContext(), seededRng(21), deps + ); + expect(on.buildings.some(b => b.temp_block_id?.startsWith('gen_circus_'))).toBe(true); + }); + + it('names islands from the generated vocabulary, not something new', () => { + // Anything outside ZONE_TYPE_NAMES counts as authored by the GM: it renders in the + // purple reserved for structures with data, and a region purge keeps it — so every + // regenerate would leave its old islands behind and stack new ones on them. + const on = generateCity( + bounds(400), opts({ roundabouts: 'normal' }), freshContext(), seededRng(21), deps + ); + const islands = on.buildings.filter(b => b.temp_block_id?.startsWith('gen_circus_')); + expect(islands.length).toBeGreaterThan(0); + for (const b of islands) { + expect(isUserDefinedName(b.name), b.name).toBe(false); + } + }); + + it('reproduces from a seed', () => { + const a = generateCity( + bounds(400), opts({ roundabouts: 'normal' }), freshContext(), seededRng(8), deps + ); + const b = generateCity( + bounds(400), opts({ roundabouts: 'normal' }), freshContext(), seededRng(8), deps + ); + expect(a.roads).toEqual(b.roads); + expect(a.buildings).toEqual(b.buildings); + }); + + it('works on every layout, being an overlay rather than one of them', () => { + for (const layout of ['BSP', 'GRID', 'RING', 'VORONOI'] as const) { + const res = generateCity( + bounds(400), opts({ layout, roundabouts: 'normal' }), freshContext(), seededRng(4), deps + ); + expect(res.roads.length, layout).toBeGreaterThan(0); + } + }); + + it('makes none when roads are excluded', () => { + const res = generateCity( + bounds(400), opts({ excludeRoads: true, roundabouts: 'normal' }), + freshContext(), seededRng(21), deps + ); + expect(res.roads).toHaveLength(0); + expect(res.buildings.some(b => b.temp_block_id?.startsWith('gen_circus_'))).toBe(false); + }); +}); diff --git a/frontend/src/cityGen/__tests__/seeds.test.ts b/frontend/src/cityGen/__tests__/seeds.test.ts new file mode 100644 index 0000000..2977638 --- /dev/null +++ b/frontend/src/cityGen/__tests__/seeds.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { seededRng, randomSeed, seedFrom, generateCity } from '../index'; + +/** + * Seeded generation. + * + * The layout was always reproducible — every draw in cityGen goes through the injected + * rng. The buildings were not: `generateThemedBuildingsForPlot` made its own + * `Math.random` calls, so the same seed gave the same streets with different buildings + * standing in them. These cover the whole thing being reproducible now. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); + +const cityFrom = (seed: number, over = {}) => + generateCity( + bounds(250), + { sectionType: 'MIXED', ...over }, + freshContext(), + seededRng(seed), + ); + +describe('seededRng', () => { + it('gives the same sequence for the same seed', () => { + const a = seededRng(12345); + const b = seededRng(12345); + expect([a(), a(), a(), a()]).toEqual([b(), b(), b(), b()]); + }); + + it('gives different sequences for different seeds', () => { + expect(seededRng(1)()).not.toBe(seededRng(2)()); + }); + + it('stays within the unit interval', () => { + const r = seededRng(99); + for (let i = 0; i < 500; i++) { + const v = r(); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it('does not immediately repeat itself', () => { + const r = seededRng(7); + const seen = new Set(Array.from({ length: 200 }, () => r())); + expect(seen.size).toBe(200); + }); +}); + +describe('seedFrom', () => { + it('uses a plain number as given', () => { + expect(seedFrom('464654654')).toBe(464654654); + expect(seedFrom(12345)).toBe(12345); + }); + + it('round-trips a seed the admin wrote down', () => { + // A displayed seed must read back as itself, or copying one out and typing it in + // builds a different city. + const s = randomSeed(); + expect(seedFrom(String(s))).toBe(s); + }); + + it('accepts a word as a seed rather than rejecting it', () => { + // Anything goes, so the field never has to be corrected. + expect(Number.isInteger(seedFrom('NIGHTCITY'))).toBe(true); + expect(seedFrom('NIGHTCITY')).toBe(seedFrom('NIGHTCITY')); + }); + + it('gives different words different seeds', () => { + expect(seedFrom('NIGHTCITY')).not.toBe(seedFrom('WATSON')); + }); + + it('hashes an out-of-range number instead of silently wrapping it', () => { + // Coercing to 32 bits turned a long number into a different one, and writing that + // back read as the field being cleared and replaced. + const big = '48219603749999'; + expect(seedFrom(big)).toBe(seedFrom(big)); + expect(Number.isInteger(seedFrom(big))).toBe(true); + }); + + it('rolls a fresh seed for a blank field', () => { + expect(Number.isFinite(seedFrom(''))).toBe(true); + expect(Number.isFinite(seedFrom(' '))).toBe(true); + }); + + it('ignores surrounding whitespace', () => { + expect(seedFrom(' 777 ')).toBe(seedFrom('777')); + }); +}); + +describe('randomSeed', () => { + it('produces a whole number in range', () => { + for (let i = 0; i < 50; i++) { + const s = randomSeed(); + expect(Number.isInteger(s)).toBe(true); + expect(s).toBeGreaterThanOrEqual(0); + expect(s).toBeLessThan(4294967296); + } + }); + + it('does not keep returning the same value', () => { + const seen = new Set(Array.from({ length: 50 }, randomSeed)); + expect(seen.size).toBeGreaterThan(40); + }); +}); + +describe('a seed reproduces a whole city', () => { + it('gives identical buildings, not just identical streets', () => { + // The gap this closed: layout was reproducible, buildings were not. + const a = cityFrom(2026); + const b = cityFrom(2026); + expect(b.buildings).toEqual(a.buildings); + }); + + it('gives identical roads and blocks', () => { + const a = cityFrom(2026); + const b = cityFrom(2026); + expect(b.roads).toEqual(a.roads); + expect(b.blocks).toEqual(a.blocks); + }); + + it('gives a different city for a different seed', () => { + const a = cityFrom(1); + const b = cityFrom(2); + expect(b.buildings).not.toEqual(a.buildings); + }); + + it('reproduces across every layout', () => { + for (const layout of ['BSP', 'GRID', 'SUPERBLOCK', 'RING'] as const) { + const a = cityFrom(555, { layout }); + const b = cityFrom(555, { layout }); + expect(b.buildings, layout).toEqual(a.buildings); + } + }); + + it('only reproduces for the same options', () => { + // Worth stating plainly: a seed is not a city on its own. Change the bounds or + // the layout and the same seed builds something else. + const grid = cityFrom(777, { layout: 'GRID' }); + const bsp = cityFrom(777, { layout: 'BSP' }); + expect(bsp.blocks).not.toEqual(grid.blocks); + }); +}); diff --git a/frontend/src/cityGen/__tests__/voronoi.test.ts b/frontend/src/cityGen/__tests__/voronoi.test.ts new file mode 100644 index 0000000..13b6c10 --- /dev/null +++ b/frontend/src/cityGen/__tests__/voronoi.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect } from 'vitest'; +import { + seedPoints, voronoiCells, cellEdges, inscribedRect, centroid, polygonArea, + voronoiLayout, LAYOUTS, VORONOI_SPACING, +} from '../index'; +import type { Pt } from '../voronoi'; + +/** + * Voronoi layout. + * + * The defining property is that every point of a cell is closer to that cell's seed + * than to any other. Most of what follows checks that directly, because if it holds the + * diagram is correct however the cells were built. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const dist = (a: Pt, b: Pt) => Math.hypot(a.x - b.x, a.z - b.z); + +describe('voronoi cells', () => { + it('gives every seed a cell', () => { + const seeds = seedPoints(bounds(300), seededRng()); + const cells = voronoiCells(bounds(300), seeds); + expect(cells.length).toBeGreaterThan(0); + expect(cells.length).toBeLessThanOrEqual(seeds.length); + }); + + it('puts each cell closer to its own seed than to any other', () => { + // The definition of a Voronoi diagram. Sampled at each cell's centroid, which is + // interior to a convex cell. + const seeds = seedPoints(bounds(300), seededRng()); + const cells = voronoiCells(bounds(300), seeds); + for (const cell of cells) { + const c = centroid(cell.poly); + const own = dist(c, cell.seed); + for (const other of seeds) { + if (other === cell.seed) continue; + expect(own).toBeLessThanOrEqual(dist(c, other) + 1e-6); + } + } + }); + + it('tiles the region without gaps', () => { + // Cells partition the frame, so their areas must sum to it. A gap or an overlap + // would show up here and nowhere else. + const half = 300; + const seeds = seedPoints(bounds(half), seededRng()); + const cells = voronoiCells(bounds(half), seeds); + const total = cells.reduce((s, c) => s + polygonArea(c.poly), 0); + expect(total).toBeCloseTo((half * 2) ** 2, -2); + }); + + it('keeps cells inside the bounds', () => { + const cells = voronoiCells(bounds(300), seedPoints(bounds(300), seededRng())); + for (const cell of cells) { + for (const p of cell.poly) { + expect(Math.abs(p.x)).toBeLessThanOrEqual(300 + 1e-6); + expect(Math.abs(p.z)).toBeLessThanOrEqual(300 + 1e-6); + } + } + }); + + it('produces convex cells', () => { + // Convexity is what lets inscribedRect test only four corners. + const cells = voronoiCells(bounds(300), seedPoints(bounds(300), seededRng())); + for (const { poly } of cells) { + const signs = new Set(); + for (let i = 0; i < poly.length; i++) { + const a = poly[i], b = poly[(i + 1) % poly.length], c = poly[(i + 2) % poly.length]; + const cross = (b.x - a.x) * (c.z - b.z) - (b.z - a.z) * (c.x - b.x); + if (Math.abs(cross) > 1e-6) signs.add(Math.sign(cross)); + } + expect(signs.size).toBeLessThanOrEqual(1); + } + }); + + it('reproduces from a seed', () => { + expect(seedPoints(bounds(300), seededRng(7))).toEqual(seedPoints(bounds(300), seededRng(7))); + }); +}); + +describe('cellEdges', () => { + it('lays each shared edge once', () => { + // Every interior edge belongs to two cells. Without deduplication the whole network + // would be built twice. + const cells = voronoiCells(bounds(300), seedPoints(bounds(300), seededRng())); + const edges = cellEdges(cells); + const total = cells.reduce((s, c) => s + c.poly.length, 0); + expect(edges.length).toBeLessThan(total); + expect(edges.length).toBeGreaterThan(0); + }); +}); + +describe('inscribedRect', () => { + it('recovers a rectangle exactly', () => { + const rect = inscribedRect([ + { x: -20, z: -10 }, { x: 20, z: -10 }, { x: 20, z: 10 }, { x: -20, z: 10 }, + ]); + expect(rect.x).toBeCloseTo(0); + expect(rect.z).toBeCloseTo(0); + expect(rect.w).toBeCloseTo(40, 1); + expect(rect.d).toBeCloseTo(20, 1); + }); + + it('stays inside an irregular cell', () => { + const cells = voronoiCells(bounds(300), seedPoints(bounds(300), seededRng())); + for (const { poly } of cells) { + const r = inscribedRect(poly); + const cellArea = polygonArea(poly); + expect(r.w * r.d).toBeLessThanOrEqual(cellArea + 1e-6); + expect(r.w).toBeGreaterThanOrEqual(0); + expect(r.d).toBeGreaterThanOrEqual(0); + } + }); + + it('uses a worthwhile share of the cell', () => { + // A rectangle that shrank to nothing would give a city of empty lots. + const cells = voronoiCells(bounds(300), seedPoints(bounds(300), seededRng())); + const ratios = cells.map(({ poly }) => { + const r = inscribedRect(poly); + return (r.w * r.d) / polygonArea(poly); + }); + const mean = ratios.reduce((s, r) => s + r, 0) / ratios.length; + expect(mean).toBeGreaterThan(0.4); + }); +}); + +describe('voronoiLayout', () => { + it('is registered and reachable by name', () => { + expect(LAYOUTS.VORONOI).toBe(voronoiLayout); + }); + + it('produces blocks and roads', () => { + const { blocks, roads } = voronoiLayout(bounds(300), false, seededRng()); + expect(blocks.length).toBeGreaterThan(0); + expect(roads.length).toBeGreaterThan(0); + }); + + it('produces no roads when they are excluded', () => { + const { blocks, roads } = voronoiLayout(bounds(300), true, seededRng()); + expect(roads).toHaveLength(0); + expect(blocks.length).toBeGreaterThan(0); + }); + + it('gives the network a hierarchy', () => { + // Long cell boundaries become avenues. Without that the whole thing is a uniform + // mesh, which is the flaw the grid layout also had to solve. + const { roads } = voronoiLayout(bounds(400), false, seededRng()); + const widths = new Set(roads.map(r => r.width)); + expect(widths.size).toBeGreaterThan(1); + }); + + it('is not axis-aligned, unlike every other layout', () => { + // The entire reason this layout exists. GRID and BSP produce only horizontal and + // vertical roads; a majority of these should run at some other angle. + const { roads } = voronoiLayout(bounds(400), false, seededRng()); + const axisAligned = roads.filter(r => + Math.abs(r.x1 - r.x2) < 0.5 || Math.abs(r.z1 - r.z2) < 0.5).length; + expect(axisAligned / roads.length).toBeLessThan(0.35); + }); + + it('drops blocks centred outside a drawn boundary', () => { + const boundary = { points: [ + { x: -100, z: -100 }, { x: 100, z: -100 }, { x: 100, z: 100 }, { x: -100, z: 100 }, + ] }; + const { blocks } = voronoiLayout(bounds(300), true, seededRng(), [], boundary); + for (const b of blocks) { + expect(Math.abs(b.x)).toBeLessThanOrEqual(100); + expect(Math.abs(b.z)).toBeLessThanOrEqual(100); + } + }); + + it('keeps roads out of the water', () => { + const lake = { points: [ + { x: -80, z: -80 }, { x: 80, z: -80 }, { x: 80, z: 80 }, { x: -80, z: 80 }, + ] }; + const { roads } = voronoiLayout(bounds(300), false, seededRng(), [lake]); + for (const r of roads) { + const mx = (r.x1 + r.x2) / 2; + const mz = (r.z1 + r.z2) / 2; + const inLake = Math.abs(mx) < 80 && Math.abs(mz) < 80; + expect(inLake).toBe(false); + } + }); + + it('reproduces the same city from the same seed', () => { + const a = voronoiLayout(bounds(300), false, seededRng(31)); + const b = voronoiLayout(bounds(300), false, seededRng(31)); + expect(a.blocks).toEqual(b.blocks); + expect(a.roads).toEqual(b.roads); + }); + + it('scales its cell count with the area', () => { + const small = voronoiLayout(bounds(150), true, seededRng()); + const large = voronoiLayout(bounds(450), true, seededRng()); + expect(large.blocks.length).toBeGreaterThan(small.blocks.length); + }); + + it('sizes cells around the configured spacing', () => { + const { blocks } = voronoiLayout(bounds(400), true, seededRng()); + const mean = blocks.reduce((s, b) => s + Math.max(b.w, b.d), 0) / blocks.length; + expect(mean).toBeGreaterThan(VORONOI_SPACING * 0.2); + expect(mean).toBeLessThan(VORONOI_SPACING * 1.5); + }); +}); diff --git a/frontend/src/cityGen/__tests__/waterGen.test.ts b/frontend/src/cityGen/__tests__/waterGen.test.ts new file mode 100644 index 0000000..39a677f --- /dev/null +++ b/frontend/src/cityGen/__tests__/waterGen.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest'; +import { generateWater, pointInWater, generateCity } from '../index'; + +/** + * Generated water. + * + * The machinery to *consume* water already existed and was tested — this only has to + * produce a polygon. What matters is that it lands before the split, so the road grid + * stops at the banks and bridges get sited. + */ + +const bounds = (half: number) => ({ + min: { x: -half, z: -half }, + max: { x: half, z: half }, +}); + +function seededRng(seed = 4242) { + let a = seed; + return () => { + a = (a * 1664525 + 1013904223) % 4294967296; + return a / 4294967296; + }; +} + +const freshContext = () => ({ locations: [], roads: [], waterBodies: [] }); +const deps = { fillPlot: () => {} }; + +describe('generateWater', () => { + it('produces nothing by default', () => { + // Generation has never made water; defaulting otherwise would put a river through + // the city of everyone already using the button. + expect(generateWater('NONE', bounds(300), seededRng())).toHaveLength(0); + }); + + it.each(['RIVER', 'COAST', 'LAKE'] as const)('produces a closed polygon for %s', (type) => { + const [poly] = generateWater(type, bounds(300), seededRng()); + expect(poly).toBeDefined(); + expect(poly.points.length).toBeGreaterThanOrEqual(3); + for (const p of poly.points) { + expect(Number.isFinite(p.x)).toBe(true); + expect(Number.isFinite(p.z)).toBe(true); + } + }); + + it.each(['RIVER', 'COAST', 'LAKE'] as const)('encloses actual area for %s', (type) => { + // A polygon with no interior would be invisible and would block nothing. + const [poly] = generateWater(type, bounds(300), seededRng()); + const area = Math.abs(poly.points.reduce((sum, p, i) => { + const q = poly.points[(i + 1) % poly.points.length]; + return sum + (p.x * q.z - q.x * p.z); + }, 0) / 2); + expect(area).toBeGreaterThan(100); + }); + + it.each(['RIVER', 'COAST', 'LAKE'] as const)('reproduces from a seed for %s', (type) => { + expect(generateWater(type, bounds(300), seededRng(7))) + .toEqual(generateWater(type, bounds(300), seededRng(7))); + }); + + it('gives a different river for a different seed', () => { + expect(generateWater('RIVER', bounds(300), seededRng(1))) + .not.toEqual(generateWater('RIVER', bounds(300), seededRng(2))); + }); + + it('runs a river the full way across, dividing the city', () => { + // A river that stops short would be a lake with ambitions. + const [poly] = generateWater('RIVER', bounds(300), seededRng()); + const xs = poly.points.map(p => p.x); + const zs = poly.points.map(p => p.z); + const spansX = Math.max(...xs) - Math.min(...xs); + const spansZ = Math.max(...zs) - Math.min(...zs); + expect(Math.max(spansX, spansZ)).toBeGreaterThan(500); + }); + + it('leaves a coastline with dry land on one side', () => { + const [poly] = generateWater('COAST', bounds(300), seededRng()); + const dry = [ + { x: 0, z: 0 }, { x: 200, z: 0 }, { x: -200, z: 0 }, + { x: 0, z: 200 }, { x: 0, z: -200 }, + ].filter(p => !pointInWater([poly], p.x, p.z)); + expect(dry.length).toBeGreaterThan(0); + }); + + it('keeps a lake inside the region', () => { + const [poly] = generateWater('LAKE', bounds(300), seededRng()); + for (const p of poly.points) { + expect(Math.abs(p.x)).toBeLessThanOrEqual(300); + expect(Math.abs(p.z)).toBeLessThanOrEqual(300); + } + }); +}); + +describe('generateCity with generated water', () => { + it('generates none unless asked', () => { + const result = generateCity(bounds(300), { sectionType: 'MIXED' }, freshContext(), seededRng(), deps); + expect(result.waterBodies).toHaveLength(0); + }); + + it('returns the water it made, for the caller to persist', () => { + const result = generateCity( + bounds(300), { sectionType: 'MIXED', water: 'RIVER' }, freshContext(), seededRng(), deps, + ); + expect(result.waterBodies).toHaveLength(1); + }); + + it('keeps buildings out of the water it generated', () => { + // Blocks are laid across water as they always have been for hand-drawn water — + // it is the placement check that keeps buildings out, and generated water has to + // reach that check the same way. + const built: { x: number; z: number }[] = []; + const result = generateCity( + bounds(300), + { sectionType: 'MIXED', water: 'RIVER' }, + freshContext(), + seededRng(), + { + fillPlot: (x: number, z: number, bw: number, bd: number, _zone: number, + isBlocked: (x: number, z: number, w: number, d: number) => boolean) => { + if (!isBlocked(x, z, bw, bd)) built.push({ x, z }); + }, + } as never, + ); + + const river = result.waterBodies[0]; + expect(built.length).toBeGreaterThan(0); + for (const b of built) expect(pointInWater([river], b.x, b.z)).toBe(false); + }); + + it('keeps roads out of the water it generated', () => { + const result = generateCity( + bounds(300), { sectionType: 'MIXED', water: 'RIVER' }, freshContext(), seededRng(), deps, + ); + const river = result.waterBodies[0]; + for (const r of result.roads) { + const mid = { x: (r.x1 + r.x2) / 2, z: (r.z1 + r.z2) / 2 }; + expect(pointInWater([river], mid.x, mid.z)).toBe(false); + } + }); + + it('builds a smaller city when water takes some of the ground', () => { + const dry = generateCity(bounds(300), { sectionType: 'MIXED' }, freshContext(), seededRng(), deps); + const wet = generateCity( + bounds(300), { sectionType: 'MIXED', water: 'RIVER' }, freshContext(), seededRng(), deps, + ); + expect(wet.blocks.length).toBeLessThan(dry.blocks.length); + }); + + it('adds generated water to any the GM already drew', () => { + const context = { + locations: [], roads: [], + waterBodies: [{ points_json: JSON.stringify([ + { x: 250, z: 250 }, { x: 290, z: 250 }, { x: 290, z: 290 }, + ]) }], + }; + const result = generateCity( + bounds(300), { sectionType: 'MIXED', water: 'RIVER' }, context, seededRng(), deps, + ); + // Only the generated river comes back — the GM's lake is already persisted. + expect(result.waterBodies).toHaveLength(1); + }); +}); diff --git a/frontend/src/cityGen/bsp.ts b/frontend/src/cityGen/bsp.ts index 639fbe0..0e4cd00 100644 --- a/frontend/src/cityGen/bsp.ts +++ b/frontend/src/cityGen/bsp.ts @@ -1,9 +1,25 @@ import type { Block, Bounds, Rng, RoadSegment } from './types'; -import { clipSegmentToLand, type WaterPolygon } from './water'; +import { clipSegmentToLand, clipSegmentToBoundary, pointInPolygon, type Polygon, type WaterPolygon } from './water'; -/** Widths used for the road laid down at each split. */ -const MAIN_ROAD_WIDTH = 6; -const SIDE_ROAD_WIDTH = 3; +/** + * Road width by split depth, widest first. + * + * The earliest splits carve the largest areas, so they are the arterials; each level + * down is a smaller street. This used to be a straight either/or — arterial for the + * first two splits, side street for everything after — which left the network reading + * as two kinds of road rather than a hierarchy. Real networks step down through + * arterial, collector and local, and that gradient is one of the strongest cues that a + * street plan was laid out rather than scattered. + * + * Depths past the end of the table all use the last entry. + */ +const ROAD_WIDTH_BY_DEPTH = [7, 5.5, 4, 3, 2.5]; + +/** Width of a seam laid at the given recursion depth. */ +export function roadWidthForDepth(depth: number): number { + const i = Math.max(0, Math.min(ROAD_WIDTH_BY_DEPTH.length - 1, depth)); + return ROAD_WIDTH_BY_DEPTH[i]; +} /** A block stops subdividing once both dimensions fall under this. */ const MIN_BLOCK_SIZE = 35; @@ -33,9 +49,13 @@ export function normalizeBounds(bounds: Bounds) { * Recursion depth scales with the selection size, so a larger area yields * MORE blocks rather than bigger ones. */ -export function maxSplitDepthFor(width: number, depth: number): number { +export function maxSplitDepthFor( + width: number, + depth: number, + minBlockSize: number = MIN_BLOCK_SIZE +): number { const maxDimension = Math.max(width, depth); - return Math.max(4, Math.ceil(Math.log2(maxDimension / MIN_BLOCK_SIZE)) + 2); + return Math.max(4, Math.ceil(Math.log2(maxDimension / minBlockSize)) + 2); } /** @@ -56,26 +76,33 @@ export function splitCity( bounds: Bounds, excludeRoads: boolean, rng: Rng, - water: WaterPolygon[] = [] + water: WaterPolygon[] = [], + boundary?: Polygon, + minBlockSize: number = MIN_BLOCK_SIZE ): { blocks: Block[]; roads: RoadSegment[] } { const { minX, maxX, minZ, maxZ, width, depth } = normalizeBounds(bounds); - const maxSplitDepth = maxSplitDepthFor(width, depth); + const maxSplitDepth = maxSplitDepthFor(width, depth, minBlockSize); const blocks: Block[] = []; const roads: RoadSegment[] = []; - /** Lay a seam, keeping only the stretches that fall on land. */ + /** Lay a seam, keeping only the stretches on land and inside any boundary. */ const layRoad = (seg: RoadSegment) => { - roads.push(...clipSegmentToLand(seg, water)); + for (const dry of clipSegmentToLand(seg, water)) { + roads.push(...clipSegmentToBoundary(dry, boundary)); + } }; const split = (x: number, z: number, w: number, d: number, iter: number) => { - if (iter > maxSplitDepth || (w < MIN_BLOCK_SIZE && d < MIN_BLOCK_SIZE)) { - blocks.push({ x, z, w, d }); + if (iter > maxSplitDepth || (w < minBlockSize && d < minBlockSize)) { + // Blocks centred outside a drawn boundary are dropped. Skipping the push draws + // no randomness, so the split itself is identical either way. + if (!boundary || pointInPolygon(boundary, x, z)) blocks.push({ x, z, w, d }); return; } const splitV = w > d ? true : (w === d ? rng() > 0.5 : false); - const roadW = iter < 2 ? MAIN_ROAD_WIDTH : SIDE_ROAD_WIDTH; + const roadW = roadWidthForDepth(iter); + // Bigger splits wander more, in step with the road they carry. const jitter = (rng() - 0.5) * (iter < 2 ? 10 : 5); if (splitV) { diff --git a/frontend/src/cityGen/collision.ts b/frontend/src/cityGen/collision.ts index d3b2ce5..9dfee10 100644 --- a/frontend/src/cityGen/collision.ts +++ b/frontend/src/cityGen/collision.ts @@ -1,5 +1,6 @@ import type { Obstacle, RoadSegment } from './types'; -import { footprintInWater, type WaterPolygon } from './water'; +import { footprintInWater, footprintOutsidePolygon, type WaterPolygon } from './water'; +import { elevationAt } from '../utils/overpassHelpers'; /** Cell size of the uniform grid used to bucket obstacles. */ const GRID_CELL = 20; @@ -205,12 +206,137 @@ export function createIsBlocked( grid: SpatialGrid, roads: RoadSegment[], checkRoads: boolean, - water: WaterPolygon[] = [] + water: WaterPolygon[] = [], + boundary?: WaterPolygon ): IsBlocked { return (x, z, w, d, buffer = 2) => { if (overlapsObstacle(grid, x, z, w, d, buffer)) return true; if (checkRoads && footprintOnRoad(roads, x, z, w, d)) return true; if (water.length > 0 && footprintInWater(water, x, z, w, d)) return true; + // A drawn boundary is water with the sign flipped: reject what falls outside it. + // This is what lets a block straddling the edge build only on its inside. + if (boundary && footprintOutsidePolygon(boundary, x, z, w, d)) return true; return false; }; } + +/** Clearance kept between the top of a building and the underside of a deck. */ +export const DECK_CLEARANCE = 3; + +/** Below this there is no room to build under a deck at all. */ +export const MIN_UNDER_DECK_HEIGHT = 2.5; + +interface DeckLike { + points: { x: number; z: number }[]; + width: number; + height: number; + ramp_length: number; + ramp_length_start?: number; + ramp_length_end?: number; +} + +/** Distance along a polyline to the point nearest (px,pz), and the total length. */ +function arcLengthToNearest(points: { x: number; z: number }[], px: number, pz: number) { + let best = Infinity; + let bestS = 0; + let total = 0; + const lengths: number[] = []; + + for (let i = 0; i < points.length - 1; i++) { + const len = Math.hypot(points[i + 1].x - points[i].x, points[i + 1].z - points[i].z); + lengths.push(len); + total += len; + } + + let run = 0; + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const dx = b.x - a.x; + const dz = b.z - a.z; + const lenSq = dx * dx + dz * dz; + const t = lenSq < 1e-9 ? 0 : Math.max(0, Math.min(1, ((px - a.x) * dx + (pz - a.z) * dz) / lenSq)); + const cx = a.x + dx * t; + const cz = a.z + dz * t; + const dist = Math.hypot(px - cx, pz - cz); + if (dist < best) { + best = dist; + bestS = run + lengths[i] * t; + } + run += lengths[i]; + } + + return { distance: best, s: bestS, total }; +} + +/** + * Keep buildings from being run through by an elevated deck. + * + * Placement deliberately ignores overpasses, so the ground under a beltway stays + * buildable — that is what stops an arterial sterilising every block it crosses. The + * cost is that nothing stops a tower rising through the deck. Rather than blocking the + * ground again, anything beneath a deck is scaled down to fit under it, which is what + * actually happens around an elevated freeway. + * + * Scaling is applied **per plot, not per part**. A plot is often several stacked + * pieces sharing a `temp_block_id`, and `y` is the bottom of a mesh — so a part + * resting on another has its `y` set to that one's height. Capping parts individually + * shrank the base while leaving a short upper storey exactly where the old roofline + * was, hanging in the air. The whole plot scales by one factor instead, taken from its + * tallest point, which keeps it assembled. + * + * Where the deck is too low to build under at all — near its ramps, where it meets the + * ground — the plot is dropped instead of being squashed to nothing. + */ +export function clampBuildingsUnderDecks( + buildings: T[], + decks: DeckLike[] +): T[] { + if (decks.length === 0) return buildings; + + /** Lowest deck overhead for a footprint, or Infinity when it is in the open. */ + const capFor = (b: T): number => { + let cap = Infinity; + for (const deck of decks) { + if (deck.points.length < 2) continue; + const reach = deck.width / 2 + Math.max(b.width, b.depth) / 2; + const { distance, s, total } = arcLengthToNearest(deck.points, b.x, b.z); + if (distance > reach) continue; + + const deckY = elevationAt( + s, total, deck.height, deck.ramp_length, + false, false, + deck.ramp_length_start, deck.ramp_length_end + ); + cap = Math.min(cap, deckY - DECK_CLEARANCE); + } + return cap; + }; + + // Parts of one plot must scale together, so they are handled as a unit. Anything + // without a plot id stands alone. + const groups = new Map(); + buildings.forEach((b, i) => { + const key = b.temp_block_id ?? `__solo_${i}`; + const group = groups.get(key); + if (group) group.push(b); + else groups.set(key, [b]); + }); + + const out: T[] = []; + for (const group of groups.values()) { + let cap = Infinity; + for (const b of group) cap = Math.min(cap, capFor(b)); + + if (cap === Infinity) { out.push(...group); continue; } + if (cap < MIN_UNDER_DECK_HEIGHT) continue; + + const tallest = Math.max(...group.map((b) => b.y + b.height)); + if (tallest <= cap) { out.push(...group); continue; } + + const k = cap / tallest; + for (const b of group) out.push({ ...b, height: b.height * k, y: b.y * k }); + } + + return out; +} diff --git a/frontend/src/cityGen/index.ts b/frontend/src/cityGen/index.ts index 93480a3..dbc3737 100644 --- a/frontend/src/cityGen/index.ts +++ b/frontend/src/cityGen/index.ts @@ -1,10 +1,14 @@ import { consolidateRoads } from '../utils/roadHelpers'; import { generateThemedBuildingsForPlot } from '../components/Buildings'; -import { splitCity, normalizeBounds } from './bsp'; -import { SpatialGrid, createIsBlocked, footprintOnRoad } from './collision'; +import { LAYOUTS } from './layouts'; +import { generateWater } from './waterGen'; +import { normalizeBounds } from './bsp'; +import { SpatialGrid, createIsBlocked, footprintOnRoad, clampBuildingsUnderDecks } from './collision'; import { createSectorLayout, normalizedDistance, + heightScaleFor, + lotCoverageFor, parkProbability, assignZoneType, zonePrefixFor, @@ -12,10 +16,14 @@ import { } from './zoning'; import { generatePark } from './parks'; import { shouldPlaceLandmark, generateLandmark } from './landmarks'; -import { parseWaterBodies, pointInWater, footprintInWater } from './water'; +import { generateMonument } from './monuments'; +import { parseWaterBodies, pointInWater, footprintInWater, clipSegmentToBoundary } from './water'; +import type { Polygon } from './water'; import { findBridges } from './bridges'; +import { siteRoundabouts, applyRoundabouts, RING_WIDTH } from './roundabouts'; import { generateShorelineRoads, snapRoadEndsToShoreline } from './shoreline'; import type { + Block, Bounds, GenerateCityContext, GenerateCityOptions, @@ -25,17 +33,23 @@ import type { } from './types'; export * from './types'; -export { splitCity, normalizeBounds, maxSplitDepthFor } from './bsp'; -export { SpatialGrid, createIsBlocked, footprintOnRoad } from './collision'; +export { splitCity, normalizeBounds, maxSplitDepthFor, roadWidthForDepth } from './bsp'; +export { SpatialGrid, createIsBlocked, footprintOnRoad, clampBuildingsUnderDecks, DECK_CLEARANCE, MIN_UNDER_DECK_HEIGHT } from './collision'; export * from './zoning'; export { generatePark } from './parks'; export { shouldPlaceLandmark, generateLandmark } from './landmarks'; +export * from './monuments'; export * from './water'; +export * from './layouts'; +export * from './rng'; +export * from './region'; +export * from './waterGen'; export { findBridges, MAX_BRIDGE_SPAN, BRIDGE_RAMP_LENGTH, BRIDGE_HEIGHTS, MIN_RAMP_RUN, MAX_RAMP_RUN, } from './bridges'; export { generateShorelineRoads, snapRoadEndsToShoreline, SHORE_OFFSET } from './shoreline'; +export * from './roundabouts'; /** Margin trimmed off every block so buildings don't butt against the road. */ const PLOT_PADDING = 10; @@ -43,6 +57,18 @@ const PLOT_PADDING = 10; /** Plots smaller than this after padding are left empty. */ const MIN_PLOT_SIZE = 8; +/** Fraction of a roundabout's inner disc actually built on, leaving a verge. */ +const ISLAND_COVERAGE = 0.8; + +/** Islands smaller than this are left as bare pavement; nothing reads at that size. */ +const MIN_ISLAND_SPAN = 4; + +/** A monument needs room to look deliberate; below this the island gets trees. */ +const MIN_MONUMENT_SPAN = 12; + +/** How often an island large enough for one gets a monument rather than trees. */ +const ISLAND_MONUMENT_CHANCE = 0.45; + /** How aggressively new roads snap onto existing ones. */ const ROAD_CONSOLIDATION_RADIUS = 3.0; @@ -72,10 +98,19 @@ export function generateCity( rng: Rng = Math.random, deps: GenerateCityDeps = DEFAULT_DEPS ): GenerateCityResult { - const { sectionType, excludeRoads, overpassDensity = 'normal' } = options; + const { sectionType, excludeRoads, overpassDensity = 'normal', layout = 'BSP', water: waterType = 'NONE', parkPonds = false, roundabouts: roundaboutDensity = 'off' } = options; + // Fewer than three points cannot enclose an area. Treating a degenerate boundary as + // absent falls back to the plain bounds, rather than generating nothing at all and + // looking like a broken button. + const boundary = + options.boundary && options.boundary.points.length >= 3 ? options.boundary : undefined; const { width, depth, centerX, centerZ } = normalizeBounds(bounds); const maxRadius = Math.max(1, Math.max(width, depth) / 2); - const water = parseWaterBodies(context.waterBodies ?? []); + // Water is generated *before* the split, because the split is already water-aware: + // the grid then stops at the banks of its own accord and bridges get sited. Doing it + // afterwards would mean cutting finished roads. + const generatedWater = generateWater(waterType, bounds, rng); + const water = [...parseWaterBodies(context.waterBodies ?? []), ...generatedWater]; // Sector angles are drawn before anything else so the district layout is // stable regardless of how many blocks the split produces. @@ -83,11 +118,15 @@ export function generateCity( // The split clips its own seams to land, so the grid stops at the shore // instead of being laid across the water and cut back afterwards. - const { blocks, roads: newRoads } = splitCity(bounds, excludeRoads, rng, water); + const { blocks, roads: newRoads, overpasses: layoutOverpasses = [] } = + (LAYOUTS[layout] ?? LAYOUTS.BSP)(bounds, excludeRoads, rng, water, boundary); // A road around each water body turns what would be dead ends at the shore // into junctions, so the network routes around a lake. - const shoreRoads = excludeRoads ? [] : generateShorelineRoads(water, bounds); + const shoreRoads = excludeRoads + ? [] + : generateShorelineRoads(water, bounds).flatMap((seg) => + clipSegmentToBoundary(seg, boundary)); // Approaches stop at the water, which leaves them overshooting the waterfront // road that sits back from it. Snapping their ends onto it removes the @@ -100,26 +139,44 @@ export function generateCity( // Pick crossings worth bridging from the road ends left at the water's edge. // Draws no randomness on a dry map, so those generate exactly as before. + // A layout may raise its own arterials — RING elevates its beltways so they do not + // sterilise the ground beneath. Those join whatever bridges the water needs. const overpasses = excludeRoads ? [] - : findBridges(finalRoads, water, overpassDensity, rng); + : [...layoutOverpasses, ...findBridges(finalRoads, water, overpassDensity, rng)]; + + // Roundabouts come after consolidation, which snaps nearby endpoints together — a + // ring is many short segments with close endpoints, and running it first would snap + // the circle into a blob. After bridge siting too, so the shore stubs bridges are + // paired from are the ones the layout actually left at the water. + const roundabouts = excludeRoads + ? [] + : siteRoundabouts(finalRoads, roundaboutDensity, rng, water, boundary); + const roadsWithRoundabouts = applyRoundabouts(finalRoads, roundabouts, boundary); const grid = new SpatialGrid(context.locations); // Test against the roads that will actually exist. Consolidation snaps // endpoints onto existing roads and onto each other, so the pre-consolidation // seams are not where the pavement ends up — checking those instead lets // buildings land on roads that moved underneath them. - const roadsToCheck = [...context.roads, ...finalRoads]; - const isBlocked = createIsBlocked(grid, roadsToCheck, !excludeRoads, water); + const roadsToCheck = [...context.roads, ...roadsWithRoundabouts]; + const isBlocked = createIsBlocked(grid, roadsToCheck, !excludeRoads, water, boundary); const buildings: RawBuilding[] = []; + // Ponds are collected separately from `water`: that array is what the split, the + // shoreline roads and bridge siting were built from, and all of those have already + // run by the time a park exists. Adding to it here would be a lie about what shaped + // the city. They join the generated water only in the result, to be persisted. + const pondPolys: Polygon[] = []; blocks.forEach((block, index) => { const plotId = `gen_${index}`; const startIndex = buildings.length; - let bw = block.w - PLOT_PADDING; - let bd = block.d - PLOT_PADDING; + // A lot arrives with its footprint already decided by the layout; a block gets the + // road margin trimmed off it here. See `Block.lot`. + let bw = block.lot ? block.w : block.w - PLOT_PADDING; + let bd = block.lot ? block.d : block.d - PLOT_PADDING; if (bw < MIN_PLOT_SIZE || bd < MIN_PLOT_SIZE) return; // A plot centred in water is open water — skip it outright. Plots that @@ -137,29 +194,33 @@ export function generateCity( * across a road. Every piece is therefore re-checked once the plot is * finished, and the whole plot is rolled back if any of them landed badly * — an empty lot reads as deliberate, half a building does not. + * + * Returns whether the plot was kept, so a caller that produced something other + * than buildings — a park pond — can discard that too when the plot is rolled back. */ - const tagPlot = (fallbackName: string) => { + const tagPlot = (fallbackName: string): boolean => { for (let i = startIndex; i < buildings.length; i++) { const b = buildings[i]; const wet = water.length > 0 && footprintInWater(water, b.x, b.z, b.width, b.depth); const paved = !excludeRoads && footprintOnRoad(roadsToCheck, b.x, b.z, b.width, b.depth); if (wet || paved) { buildings.length = startIndex; - return; + return false; } } for (let i = startIndex; i < buildings.length; i++) { buildings[i].temp_block_id = plotId; if (!buildings[i].name) buildings[i].name = fallbackName; } + return true; }; const normDist = normalizedDistance(block.x, block.z, centerX, centerZ, maxRadius); // Parks claim the plot outright — no buildings share it. if (rng() < parkProbability(normDist)) { - generatePark(block, bw, bd, buildings, isBlocked, rng); - tagPlot('PARK'); + const ponds = generatePark(block, bw, bd, buildings, isBlocked, rng, parkPonds); + if (tagPlot('PARK')) pondPolys.push(...ponds); return; } @@ -167,7 +228,17 @@ export function generateCity( block.x, block.z, centerX, centerZ, normDist, sectionType, sectors, rng ); const zonePrefix = zonePrefixFor(zoneTypeVal); - ({ bw, bd } = clampPlotAspect(bw, bd, zoneTypeVal)); + // A lot skips both: its narrow frontage is deliberate, and squaring it up or + // setting it back would pull a terrace apart into detached sheds. Both rules are + // about fitting one structure sensibly onto a whole city block. + if (!block.lot) { + ({ bw, bd } = clampPlotAspect(bw, bd, zoneTypeVal)); + // Setback: corporate plots leave forecourts, slums and markets build to the lot + // line. Applied after the aspect clamp so it shrinks the plot actually used. + const coverage = lotCoverageFor(zoneTypeVal); + bw *= coverage; + bd *= coverage; + } if (shouldPlaceLandmark(block, bw, bd, zoneTypeVal, isBlocked, rng)) { generateLandmark(block, bw, bd, buildings, grid, rng); @@ -175,12 +246,72 @@ export function generateCity( return; } + const beforeFill = buildings.length; + // The two undefineds are overrideH and styleOverride, which only the editor + // preview uses. rng is what makes a seed reproduce the buildings and not merely + // the street layout. deps.fillPlot( block.x, block.z, bw, bd, zoneTypeVal, - isBlocked, grid.key, grid.cells, buildings, context.locations, plotId + isBlocked, grid.key, grid.cells, buildings, context.locations, plotId, + undefined, undefined, rng ); + // Zone already steps down with distance, but only in bands — the skyline came out + // as flat plateaus with hard seams. Scaling within the zone softens those into a + // continuous taper. Landmarks are left alone; a hero building is sized on purpose. + // + // `y` scales with `height`. A plot is often several stacked parts, and a part + // sitting on another has its `y` set to that one's height — scaling heights alone + // left every upper storey hanging in the air above a shortened base. + const heightScale = heightScaleFor(normDist); + for (let i = beforeFill; i < buildings.length; i++) { + buildings[i].height *= heightScale; + buildings[i].y *= heightScale; + } tagPlot(zonePrefix); }); - return { blocks, roads: finalRoads, buildings, overpasses }; + // Dress each island. An empty disc reads as a hole in the road network rather than a + // roundabout, so every one gets something: a monument where there is room for one, + // trees otherwise. Done after the blocks so the islands are laid over a finished city + // — they sit where roads were cut away, which no block ever claimed. + roundabouts.forEach((r, i) => { + const span = Math.max(0, (r.radius - RING_WIDTH) * 2 * ISLAND_COVERAGE); + if (span < MIN_ISLAND_SPAN) return; + const island: Block = { x: r.x, z: r.z, w: span, d: span }; + const plotId = `gen_circus_${i}`; + const startIndex = buildings.length; + + // Drawn unconditionally so the sequence does not depend on how large the island is. + const wantsMonument = rng() < ISLAND_MONUMENT_CHANCE; + const monument = wantsMonument && span >= MIN_MONUMENT_SPAN; + if (monument) { + // Not generateLandmark: those are 150-to-220-unit hero buildings sized to anchor + // a skyline, and one on a traffic island is a tower growing out of a roundabout. + // A monument is sized against the island instead. + generateMonument(island, span, buildings, rng); + } else { + generatePark(island, span, span, buildings, isBlocked, rng, false); + } + + // Named as what they are, from the vocabulary that already exists. A new name would + // have to be added to ZONE_TYPE_NAMES in two files — the frontend and the backend + // keep separate copies — and anything missing from that set is treated as authored + // by the GM: rendered in the "has data" purple, and *kept by a region purge*, so + // every regenerate would leave its old islands behind and stack new ones on them. + for (let k = startIndex; k < buildings.length; k++) { + buildings[k].temp_block_id = plotId; + if (!buildings[k].name) buildings[k].name = monument ? 'LANDMARK' : 'PARK'; + } + }); + + // Placement ignores overpasses so the ground beneath stays buildable; nothing there + // stops a tower rising through a deck, so anything under one is capped just below it. + // Applies to water bridges too, which pierce buildings for the same reason. + return { + blocks, + roads: roadsWithRoundabouts, + buildings: clampBuildingsUnderDecks(buildings, overpasses), + overpasses, + waterBodies: [...generatedWater, ...pondPolys], + }; } diff --git a/frontend/src/cityGen/layouts.ts b/frontend/src/cityGen/layouts.ts new file mode 100644 index 0000000..f617bdb --- /dev/null +++ b/frontend/src/cityGen/layouts.ts @@ -0,0 +1,447 @@ +import type { Block, Bounds, Rng, RoadSegment } from './types'; +import type { OverpassSpec } from './bridges'; +import { normalizeBounds, splitCity } from './bsp'; +import { clipSegmentToLand, clipSegmentToBoundary, pointInPolygon, type Polygon, type WaterPolygon } from './water'; +import { seedPoints, voronoiCells, cellEdges, inscribedRect, VORONOI_SPACING } from './voronoi'; +import { perimeterLots } from './lots'; + +/** + * Street layouts. + * + * Everything downstream of `Block[]` — zoning, parks, landmarks, bridges — is + * layout-agnostic, so a layout only has to produce blocks and the roads between them. + * That is the whole extension point. + */ +export type LayoutFn = ( + bounds: Bounds, + excludeRoads: boolean, + rng: Rng, + water?: WaterPolygon[], + boundary?: Polygon +) => { blocks: Block[]; roads: RoadSegment[]; overpasses?: OverpassSpec[] }; + +export type LayoutType = 'BSP' | 'GRID' | 'SUPERBLOCK' | 'RING' | 'VORONOI' | 'PERIMETER'; + +/** Target block size for the regular grid, before jitter. */ +const GRID_CELL = 55; + +/** Every nth street in each direction is an avenue rather than a side street. */ +const AVENUE_EVERY = 4; + +const GRID_AVENUE_WIDTH = 6; +const GRID_STREET_WIDTH = 3; + +/** How far a grid line may wander, as a fraction of the cell. Keeps it hand-drawn. */ +const GRID_JITTER = 0.12; + +/** Minimum block size for the superblock layout — roughly 3x the BSP default. */ +const SUPERBLOCK_MIN_SIZE = 110; + +/** Concentric loops, as San Antonio has 410 and 1604. */ +const RING_COUNT = 2; + +/** Arterials converging on the centre. */ +const SPOKE_COUNT = 6; + +/** + * Ring radii grow faster than linearly, so the inner loop sits tight around downtown + * and outer ones sweep wide — which is what beltways actually do. + */ +const RING_FALLOFF = 1.35; + +const RING_ROAD_WIDTH = 8; +const SPOKE_ROAD_WIDTH = 7; + +/** + * Spokes are elevated; rings are not. + * + * An elevated deck consumes no ground — placement never checks overpasses — so the + * street fabric runs unbroken beneath it and small buildings fill in below. That is + * what stops six converging arterials sterilising the middle of the city. + * + * Rings stay on the ground because a closed loop has no ends to ramp down at. An + * elevated loop either never touches the street network or does so at one arbitrary + * point, and both read as broken. A ground-level loop simply has verges, which is what + * a beltway looks like anyway. + */ +const SPOKE_DECK_HEIGHT = 14; + +/** Fraction of a spoke given over to each ramp, so both ends reach the ground. */ +const DECK_RAMP_FRACTION = 0.3; +const DECK_PILLAR_SPACING = 14; + +/** Degrees between sampled points on a ring. Smaller reads rounder, at more segments. */ +const ARC_STEP_DEG = 9; + +/** + * Downtown block size, short axis by long axis. + * + * Deliberately not square. A Manhattan block is roughly three times longer than it is + * deep, which is why the avenues carry the towers and the side streets carry terraces — + * the shape of the block is most of why the city reads as it does. + */ +const DOWNTOWN_CELL_SHORT = 70; +const DOWNTOWN_CELL_LONG = 150; + +/** + * How much a downtown block may differ from the target size, and how often a street is + * simply left out. + * + * `gridLines` divides a span into equal cells and wobbles the seams, which is right for + * a planned grid and wrong here: it makes every block the same size by construction. + * A real downtown has short blocks, long blocks and the occasional enormous one where a + * street was never cut through — Washington Square being the obvious example. So the + * cuts are walked across the span at varying intervals instead, with a chance of + * skipping one entirely. + */ +const DOWNTOWN_CELL_VARIANCE = 0.42; +const DOWNTOWN_MERGE_CHANCE = 0.16; +const DOWNTOWN_MERGE_FACTOR = 1.7; + +/** A Voronoi edge longer than this many spacings is an avenue rather than a street. */ +const VORONOI_AVENUE_RATIO = 1.15; + +const VORONOI_AVENUE_WIDTH = 7; +const VORONOI_STREET_WIDTH = 4; + +/** + * Evenly spaced cut positions across a span, jittered so the result reads as a surveyed + * grid rather than a machine one. The outer edges stay put, since they are the boundary + * of the generated area and should not wobble. + */ +function gridLines(min: number, span: number, rng: Rng, cellSize = GRID_CELL): number[] { + const count = Math.max(1, Math.round(span / cellSize)); + const cell = span / count; + const lines: number[] = []; + for (let i = 0; i <= count; i++) { + const base = min + i * cell; + const edge = i === 0 || i === count; + lines.push(edge ? base : base + (rng() - 0.5) * cell * GRID_JITTER * 2); + } + return lines; +} + +/** + * Cut positions walked across a span at varying intervals. + * + * Unlike `gridLines`, which divides evenly, this accumulates steps of differing size, so + * block sizes genuinely vary rather than all landing within a wobble of one another. A + * step is occasionally stretched, which reads as a street that was never cut through. + * + * A short remainder is absorbed into the last block rather than left as a sliver. + */ +function variedLines(min: number, span: number, rng: Rng, target: number): number[] { + const lines = [min]; + const end = min + span; + let cursor = min; + + while (true) { + const spread = 1 - DOWNTOWN_CELL_VARIANCE + rng() * DOWNTOWN_CELL_VARIANCE * 2; + const merged = rng() < DOWNTOWN_MERGE_CHANCE ? DOWNTOWN_MERGE_FACTOR : 1; + const next = cursor + target * spread * merged; + if (end - next < target * 0.5) break; + lines.push(next); + cursor = next; + } + + lines.push(end); + return lines; +} + +/** + * Regular street grid — Manhattan, Chicago, any planned city. + * + * Distinct from the BSP, which always produces *irregular* rectangles however it is + * tuned. Avenues every few blocks give the network a hierarchy rather than a uniform + * mesh, which is most of what makes a grid read as designed rather than generated. + */ +export const gridLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boundary) => { + const { minX, minZ, width, depth } = normalizeBounds(bounds); + + const xs = gridLines(minX, width, rng); + const zs = gridLines(minZ, depth, rng); + + const blocks: Block[] = []; + const roads: RoadSegment[] = []; + + const layRoad = (seg: RoadSegment) => { + if (excludeRoads) return; + for (const dry of clipSegmentToLand(seg, water)) { + roads.push(...clipSegmentToBoundary(dry, boundary)); + } + }; + + const widthAt = (i: number, count: number) => + i === 0 || i === count || i % AVENUE_EVERY === 0 ? GRID_AVENUE_WIDTH : GRID_STREET_WIDTH; + + for (let i = 0; i < xs.length; i++) { + layRoad({ x1: xs[i], z1: zs[0], x2: xs[i], z2: zs[zs.length - 1], width: widthAt(i, xs.length - 1) }); + } + for (let j = 0; j < zs.length; j++) { + layRoad({ x1: xs[0], z1: zs[j], x2: xs[xs.length - 1], z2: zs[j], width: widthAt(j, zs.length - 1) }); + } + + for (let i = 0; i < xs.length - 1; i++) { + for (let j = 0; j < zs.length - 1; j++) { + const cx = (xs[i] + xs[i + 1]) / 2; + const cz = (zs[j] + zs[j + 1]) / 2; + // Blocks centred outside a drawn boundary are dropped, matching the BSP. + if (boundary && !pointInPolygon(boundary, cx, cz)) continue; + blocks.push({ + x: cx, + z: cz, + w: Math.max(1, xs[i + 1] - xs[i] - GRID_STREET_WIDTH), + d: Math.max(1, zs[j + 1] - zs[j] - GRID_STREET_WIDTH), + }); + } + } + + return { blocks, roads }; +}; + +/** + * Tower in park — Soviet microdistrict, corporate arcology. + * + * The same recursive split with a much larger floor, so it stops subdividing while the + * blocks are still big. Fewer roads, larger plots, more open ground between them. + */ +export const superblockLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boundary) => + splitCity(bounds, excludeRoads, rng, water, boundary, SUPERBLOCK_MIN_SIZE); + +/** Today's layout: irregular blocks from a hierarchical binary split. */ +export const bspLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boundary) => + splitCity(bounds, excludeRoads, rng, water, boundary); + + +/** Points along an arc, inclusive of both ends. */ +function arcPoints(cx: number, cz: number, r: number, a0: number, a1: number) { + const step = (ARC_STEP_DEG * Math.PI) / 180; + const steps = Math.max(1, Math.ceil(Math.abs(a1 - a0) / step)); + const pts: { x: number; z: number }[] = []; + for (let i = 0; i <= steps; i++) { + const a = a0 + ((a1 - a0) * i) / steps; + pts.push({ x: cx + Math.cos(a) * r, z: cz + Math.sin(a) * r }); + } + return pts; +} + +/** + * Beltway city — concentric ring roads with radial spokes converging on the centre. + * San Antonio, with its 410 and 1604 loops, is the reference. + * + * The observation that makes this work is that a beltway city is not built from + * annular blocks, and its local streets are not divided up by the loops. Between the + * arterials sits one continuous fabric of ordinary streets; the beltway simply cuts + * across it. So this fills the whole disc with a single sub-layout and lays the rings + * and spokes over the top — buildings then keep clear of the arterials through the + * usual road check, which is what gives them their verges. + * + * An earlier version partitioned the disc into annular sectors and ran a sub-layout in + * each. That produced a sparse, fragmented city: 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 corners of a rectangular selection are left empty on purpose — a ring city is + * round, and filling the corners would defeat the shape. + */ +export const ringLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boundary) => { + const { centerX, centerZ, width, depth } = normalizeBounds(bounds); + const maxR = Math.min(width, depth) / 2; + + const roads: RoadSegment[] = []; + + const layRoad = (seg: RoadSegment) => { + if (excludeRoads) return; + for (const dry of clipSegmentToLand(seg, water)) { + roads.push(...clipSegmentToBoundary(dry, boundary)); + } + }; + + const layPolyline = (pts: { x: number; z: number }[], w: number) => { + for (let i = 0; i < pts.length - 1; i++) { + layRoad({ x1: pts[i].x, z1: pts[i].z, x2: pts[i + 1].x, z2: pts[i + 1].z, width: w }); + } + }; + + // The city is the disc, so that circle is the boundary the street fabric is laid + // inside. Combined with any outer drawn boundary, both must hold. + const disc: Polygon = { points: arcPoints(centerX, centerZ, maxR, 0, Math.PI * 2) }; + const fill = bspLayout(bounds, excludeRoads, rng, water, disc); + + const blocks = fill.blocks.filter((b) => !boundary || pointInPolygon(boundary, b.x, b.z)); + for (const r of fill.roads) roads.push(...clipSegmentToBoundary(r, boundary)); + + const overpasses: OverpassSpec[] = []; + + // Radii grow faster than linearly, so downtown is ringed tightly and the outer loop + // sweeps wide. + const radii: number[] = []; + for (let i = 0; i < RING_COUNT; i++) { + radii.push(maxR * Math.pow((i + 1) / RING_COUNT, RING_FALLOFF)); + } + for (const r of radii) { + layPolyline(arcPoints(centerX, centerZ, r, 0, Math.PI * 2), RING_ROAD_WIDTH); + } + + // Spokes run from the innermost loop outward rather than converging on a point. + // Six arterials meeting at the centre left a starburst of dead ground there, and + // real highways meet a downtown loop rather than piling into the middle. + const innerR = radii[0]; + const spokeLength = Math.max(1, maxR - innerR); + // Both ramps have to fit inside the spoke, or the deck never reaches the ground and + // the road ends in mid-air. + const rampLength = spokeLength * DECK_RAMP_FRACTION; + + const sector = (Math.PI * 2) / SPOKE_COUNT; + for (let i = 0; i < SPOKE_COUNT; i++) { + const a = i * sector + (rng() - 0.5) * sector * 0.2; + if (excludeRoads) continue; + overpasses.push({ + points: [ + { x: centerX + Math.cos(a) * innerR, z: centerZ + Math.sin(a) * innerR }, + { x: centerX + Math.cos(a) * maxR, z: centerZ + Math.sin(a) * maxR }, + ], + height: SPOKE_DECK_HEIGHT, + width: SPOKE_ROAD_WIDTH, + ramp_length: rampLength, + ramp_length_start: rampLength, + ramp_length_end: rampLength, + pillar_spacing: DECK_PILLAR_SPACING, + }); + } + + return { blocks, roads, overpasses }; +}; + +/** + * Organic cell city — a Voronoi diagram, streets along the cell boundaries. + * + * The only layout that produces no right angles. Streets meet at odd angles and blocks + * are wedges and pentagons, which reads as a town that grew around footpaths rather + * than one a surveyor set out. + * + * The plot inside each cell is the largest rectangle that fits it. That keeps the + * existing plot filler — which lays buildings out along a rectangle's axes and has no + * axes to work with in a pentagon — entirely unchanged, while still delivering the + * irregular *street pattern*, which is where nearly all of the look comes from. A cell + * is rarely filled by its rectangle, so setbacks vary from plot to plot for free. + * + * Long edges become avenues. Cell boundaries vary a lot in length, so this gives the + * network a hierarchy without inventing one: the long runs across the diagram are + * exactly the ones that would carry traffic. + */ +export const voronoiLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boundary) => { + const seeds = seedPoints(bounds, rng); + const cells = voronoiCells(bounds, seeds); + + const roads: RoadSegment[] = []; + if (!excludeRoads) { + for (const { a, b } of cellEdges(cells)) { + const long = Math.hypot(b.x - a.x, b.z - a.z) > VORONOI_SPACING * VORONOI_AVENUE_RATIO; + const seg: RoadSegment = { + x1: a.x, z1: a.z, x2: b.x, z2: b.z, + width: long ? VORONOI_AVENUE_WIDTH : VORONOI_STREET_WIDTH, + }; + for (const dry of clipSegmentToLand(seg, water)) { + roads.push(...clipSegmentToBoundary(dry, boundary)); + } + } + } + + const blocks: Block[] = []; + for (const { poly } of cells) { + const rect = inscribedRect(poly); + // Blocks centred outside a drawn boundary are dropped, matching every other layout. + if (boundary && !pointInPolygon(boundary, rect.x, rect.z)) continue; + // The streets run along the cell edges, so the plot has to stand back from them. + const w = rect.w - VORONOI_AVENUE_WIDTH; + const d = rect.d - VORONOI_AVENUE_WIDTH; + if (w < 1 || d < 1) continue; + blocks.push({ x: rect.x, z: rect.z, w, d }); + } + + return { blocks, roads }; +}; + +/** + * Downtown — a street grid whose blocks are cut into building lots. + * + * Every other layout hands the generator one block per city block, so a block gets one + * structure. That is right for a tower in a park and wrong for a downtown: what makes a + * dense city look dense is many narrow buildings shouldering together along the street + * with back lots behind them, not one object per block. + * + * So the blocks come out subdivided — lots around the rim facing the street, the middle + * left open. Blocks are elongated rather than square because that is the shape that + * produces avenue frontages and side-street terraces. + * + * Distinct from `SUPERBLOCK`, which is the opposite idea and stays that way: few roads, + * large plots, open ground between isolated towers. + */ +export const perimeterLayout: LayoutFn = (bounds, excludeRoads, rng, water = [], boundary) => { + const { minX, minZ, width, depth } = normalizeBounds(bounds); + + // The long axis of a block runs across the shorter axis of the region, so the streets + // that carry it read as the avenues. + const horizontal = width >= depth; + const xs = variedLines(minX, width, rng, horizontal ? DOWNTOWN_CELL_LONG : DOWNTOWN_CELL_SHORT); + const zs = variedLines(minZ, depth, rng, horizontal ? DOWNTOWN_CELL_SHORT : DOWNTOWN_CELL_LONG); + + const blocks: Block[] = []; + const roads: RoadSegment[] = []; + + const layRoad = (seg: RoadSegment) => { + if (excludeRoads) return; + for (const dry of clipSegmentToLand(seg, water)) { + roads.push(...clipSegmentToBoundary(dry, boundary)); + } + }; + + const widthAt = (i: number, count: number) => + i === 0 || i === count || i % AVENUE_EVERY === 0 ? GRID_AVENUE_WIDTH : GRID_STREET_WIDTH; + + for (let i = 0; i < xs.length; i++) { + layRoad({ x1: xs[i], z1: zs[0], x2: xs[i], z2: zs[zs.length - 1], width: widthAt(i, xs.length - 1) }); + } + for (let j = 0; j < zs.length; j++) { + layRoad({ x1: xs[0], z1: zs[j], x2: xs[xs.length - 1], z2: zs[j], width: widthAt(j, zs.length - 1) }); + } + + for (let i = 0; i < xs.length - 1; i++) { + for (let j = 0; j < zs.length - 1; j++) { + const cx = (xs[i] + xs[i + 1]) / 2; + const cz = (zs[j] + zs[j + 1]) / 2; + // The road margin is taken off the block once, here, rather than off every lot + // inside it — otherwise neighbours would be padded apart and there is no terrace. + const block: Block = { + x: cx, z: cz, + w: Math.max(1, xs[i + 1] - xs[i] - GRID_AVENUE_WIDTH), + d: Math.max(1, zs[j + 1] - zs[j] - GRID_AVENUE_WIDTH), + }; + // A drawn boundary is tested against the lots, not the block they came from. + // Every other layout drops a block whose centre falls outside, which works when a + // block *is* the unit of output. Here a block is large and holds a dozen lots, so + // dropping it whole discards lots that sit well inside the shape — a small drawn + // area could lose every block it touched and generate nothing at all. + for (const lot of perimeterLots(block, rng)) { + if (boundary && !pointInPolygon(boundary, lot.x, lot.z)) continue; + blocks.push(lot); + } + } + } + + return { blocks, roads }; +}; + +export const LAYOUTS: Record = { + BSP: bspLayout, + GRID: gridLayout, + SUPERBLOCK: superblockLayout, + RING: ringLayout, + VORONOI: voronoiLayout, + PERIMETER: perimeterLayout, +}; + +export * from './voronoi'; +export * from './lots'; +export { DOWNTOWN_CELL_SHORT, DOWNTOWN_CELL_LONG, DOWNTOWN_CELL_VARIANCE, DOWNTOWN_MERGE_CHANCE, VORONOI_AVENUE_WIDTH, VORONOI_STREET_WIDTH, VORONOI_AVENUE_RATIO, GRID_CELL, SUPERBLOCK_MIN_SIZE, AVENUE_EVERY, GRID_AVENUE_WIDTH, GRID_STREET_WIDTH, RING_COUNT, SPOKE_COUNT, RING_ROAD_WIDTH, SPOKE_ROAD_WIDTH, SPOKE_DECK_HEIGHT }; diff --git a/frontend/src/cityGen/lots.ts b/frontend/src/cityGen/lots.ts new file mode 100644 index 0000000..7c6d812 --- /dev/null +++ b/frontend/src/cityGen/lots.ts @@ -0,0 +1,178 @@ +import type { Block, Rng } from './types'; + +/** + * Subdividing a block into building lots. + * + * Every layout until now handed the generator one block per city block, and the block + * got one structure. That is right for a tower in a park and wrong for a downtown: the + * thing that makes a dense city look dense is many narrow buildings sharing party walls + * along the street, with the middle of the block left as back lots. + * + * So this cuts a block into lots around its rim, facing the streets, and leaves the + * interior empty. Each lot comes back as a `Block` with `lot: true`, which tells the + * generator the footprint is already decided — no road padding, no aspect clamp, no + * per-zone setback, since all three exist to turn a whole city block into one sensible + * plot and would here just pull the neighbours apart again. + */ + +/** How deep a building lot is, from the street into the block. */ +export const LOT_DEPTH = 20; + +/** How much that depth varies between blocks, as a fraction of it. */ +export const LOT_DEPTH_VARIANCE = 0.3; + +/** Street frontage per lot, before jitter. Narrow frontages are the look. */ +const LOT_FRONTAGE_MIN = 11; +const LOT_FRONTAGE_MAX = 24; + +/** A block with less than this left in the middle is built solid instead. */ +const MIN_COURTYARD = 14; + +/** + * How much yard a block is allowed to keep. + * + * One ring of lots around a large block leaves an enormous void in the middle — on a + * 226 by 114 block a 20-deep rim leaves 186 by 74 of nothing, which is most of the + * block. Real dense blocks are built out to a modest yard, so the interior is + * subdivided again rather than abandoned: another ring if it is big enough to hold one, + * a single run of lots if it is only a slab, and left as yard once it is under this. + */ +const MAX_YARD = 40; + +/** Gap between neighbouring lots. Small — they are meant to share party walls. */ +const PARTY_WALL_GAP = 0.6; + +/** Lots below this frontage are dropped rather than built as slivers. */ +const MIN_FRONTAGE = 6; + +/** + * Split a run of street frontage into lots of varying width. + * + * Widths vary because a row of identical frontages reads as a barracks, and real + * frontages differ because they were sold off separately. + */ +function frontages(length: number, rng: Rng): number[] { + const out: number[] = []; + let used = 0; + while (used < length) { + const want = LOT_FRONTAGE_MIN + rng() * (LOT_FRONTAGE_MAX - LOT_FRONTAGE_MIN); + const remaining = length - used; + // Absorb a short remainder into the last lot rather than leaving a sliver. + if (remaining - want < MIN_FRONTAGE) { + out.push(remaining); + break; + } + out.push(want); + used += want; + } + return out.filter((w) => w >= MIN_FRONTAGE); +} + +/** + * Lots around the rim of a block, interior left as back lots. + * + * A block too small to have a rim and a middle is returned as a single lot — cutting a + * courtyard out of it would leave four slivers around a hole. + */ +export function perimeterLots(block: Block, rng: Rng, depthBudget = 2): Block[] { + // Rim depth varies block to block. A constant depth makes every terrace the same + // thickness, which reads as a machine even when the frontages differ. + const wanted = LOT_DEPTH * (1 - LOT_DEPTH_VARIANCE + rng() * LOT_DEPTH_VARIANCE * 2); + const depth = Math.min(wanted, Math.min(block.w, block.d) / 2); + const innerW = block.w - depth * 2; + const innerD = block.d - depth * 2; + + // Too thin for a rim and a middle. Not one building the length of the block — that is + // a monolith where a terrace belongs — but a single run of lots along its length. + if (innerW < MIN_COURTYARD || innerD < MIN_COURTYARD) { + return slabLots(block, rng); + } + + const lots: Block[] = []; + const minX = block.x - block.w / 2; + const minZ = block.z - block.d / 2; + + // The two street-facing runs along x take the full width, so the corners belong to + // them; the runs along z then fill only the gap between, and nothing overlaps. + for (const side of [-1, 1]) { + let cursor = 0; + for (const front of frontages(block.w, rng)) { + const w = front - PARTY_WALL_GAP; + if (w >= MIN_FRONTAGE) { + lots.push({ + x: minX + cursor + front / 2, + z: block.z + side * (block.d / 2 - depth / 2), + w, + d: depth, + lot: true, + }); + } + cursor += front; + } + } + + for (const side of [-1, 1]) { + let cursor = 0; + for (const front of frontages(innerD, rng)) { + const d = front - PARTY_WALL_GAP; + if (d >= MIN_FRONTAGE) { + lots.push({ + x: block.x + side * (block.w / 2 - depth / 2), + z: minZ + depth + cursor + front / 2, + w: depth, + d, + lot: true, + }); + } + cursor += front; + } + } + + lots.push(...fillInterior({ x: block.x, z: block.z, w: innerW, d: innerD }, rng, depthBudget)); + return lots; +} + +/** + * Build out what is left in the middle of a block, down to a modest yard. + * + * Rings inward while there is room for another one, then stops and leaves the rest as + * yard. That is what makes a very large block read as built out rather than hollow, + * without going to the other extreme of a solid slab with nothing behind it. + * + * `depthBudget` only guards against a pathological input; each ring removes at least + * two rim depths, so this terminates on its own. + */ +function fillInterior(inner: Block, rng: Rng, depthBudget: number): Block[] { + const short = Math.min(inner.w, inner.d); + const long = Math.max(inner.w, inner.d); + if (short <= MAX_YARD && long <= MAX_YARD) return []; + + if (depthBudget > 0 && short >= LOT_DEPTH * 2 + MIN_COURTYARD) { + return perimeterLots(inner, rng, depthBudget - 1); + } + + // Whatever is left once the rings run out is the yard, and it stays open. Filling it + // as well took a 226 by 114 block to 97% built — a solid slab with no back lot at + // all, which is the opposite mistake to the one this was fixing. + return []; +} + +/** A run of lots along the longer axis, each the full depth of the slab. */ +function slabLots(slab: Block, rng: Rng): Block[] { + const alongX = slab.w >= slab.d; + const long = alongX ? slab.w : slab.d; + if (Math.min(slab.w, slab.d) < MIN_FRONTAGE) return []; + + const out: Block[] = []; + let cursor = 0; + for (const front of frontages(long, rng)) { + const size = front - PARTY_WALL_GAP; + if (size >= MIN_FRONTAGE) { + out.push(alongX + ? { x: slab.x - slab.w / 2 + cursor + front / 2, z: slab.z, w: size, d: slab.d, lot: true } + : { x: slab.x, z: slab.z - slab.d / 2 + cursor + front / 2, w: slab.w, d: size, lot: true }); + } + cursor += front; + } + return out; +} diff --git a/frontend/src/cityGen/monuments.ts b/frontend/src/cityGen/monuments.ts new file mode 100644 index 0000000..5e2c453 --- /dev/null +++ b/frontend/src/cityGen/monuments.ts @@ -0,0 +1,283 @@ +import type { Block, RawBuilding, Rng } from './types'; + +/** + * Monuments — small civic ornaments for a traffic island. + * + * Separate from `landmarks.ts` on grounds of scale, which is the whole point. A + * landmark is a hero building 150 to 220 units tall, sized to anchor a skyline from + * across the city. Putting one on a roundabout produced a tower rising out of a traffic + * island, which is not what a roundabout has in the middle of it. + * + * Everything here is proportional to the island span, so a small circus gets a small + * ornament and a large one gets something worth looking at, with no absolute heights to + * go wrong when the road widths are next retuned. + * + * **On detail.** A first version was two stacked boxes and read as exactly that. The + * renderer supports more than a box — `cylinder`, `sphere`, `pyramid` (a cone) and all + * three rotation axes. Note which shape is *not* in that list: see `SHAPES` below. Silhouette is what carries a monument + * at this size, so these use the lot: stepped plinths turned 45° against each other, + * rings of bollards, tapered shafts, finials. The segment count stays at the app's + * `POLY_COUNT` — raising it is what made these look foreign. + */ + +/** Monument height as a multiple of the island span, per style. */ +const COLUMN_HEIGHT = 1.5; +const STATUE_HEIGHT = 1.0; +const FOUNTAIN_HEIGHT = 0.3; +const CLOCK_HEIGHT = 1.5; + +export const MONUMENT_STYLE_COUNT = 6; + +/** + * The app-wide "inherit the theme" sentinel, which is what every other structure uses. + * + * `#00ff00` is not a colour here. The renderer resolves a part as + * `(p.color && p.color !== '#00ff00') ? p.color : district_color ?? theme.primary`, so + * this exact value is the way a structure says "no opinion, use the theme" — which is + * why the generated city stores it on some two thousand buildings. + * + * An earlier attempt to calm monuments down set an explicit muted green instead. That + * opted them out of the theme system altogether: they stopped matching their + * neighbours and would have ignored a theme switch entirely. Density, not colour, is + * what made them stand out, so that is what was reduced instead. + */ +const MONUMENT_COLOR = '#00ff00'; + +/** + * Shapes a monument may use. + * + * `rhombus` is deliberately absent, and this is not a style preference. In this app a + * rhombus *is* a player or NPC token: `TOKEN_SHAPES` on the server treats it as one, a + * region purge spares it as player content, and `OverlapChecker` registers it in + * `activeRhombuses` so that structures containing it can be made transparent — which is + * how you see a token standing behind a wall. + * + * Using it as an octahedral finial therefore made each monument publish a fake token + * inside itself. The overlap check found it, concluded a token was standing in the + * structure, and dropped the fill to zero opacity — a monument that turned itself + * invisible. It also survived every regenerate as "player content", orphaning itself + * from the deleted root. The statue and the fountain, the two styles with no finial, + * were the only ones that ever looked right. + */ +const SHAPES = ['box', 'cylinder', 'sphere', 'pyramid'] as const; + +/** + * The app's segment count, used by every structure on the map. + * + * Everything 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, which is + * what the whole city is built from. At 16 it is a dense cage of lines that reads as a + * bright striped mass beside its neighbours, which is exactly how monuments ended up + * looking like they belonged to a different app. + */ +const POLY_COUNT = 5; + +/** Right angle, for turning a flat cylinder into a disc facing sideways. */ +const QUARTER = Math.PI / 2; + +/** Eighth turn — a square rotated by this against another reads as an eight-pointed star. */ +const EIGHTH = Math.PI / 4; + +/** + * Place one monument centred on the island. + * + * Parts follow the same convention as the landmark styles: a single unparented root + * first, then `ROOT` children the caller groups under it once the root has an id. + * Colour and segment count both stay on the app-wide values — see `MONUMENT_COLOR` and + * `POLY_COUNT`. Both were overridden at some point and both times the result was a + * structure that did not look like it belonged to the same city. + */ +export function generateMonument(block: Block, span: number, out: RawBuilding[], rng: Rng): void { + const style = Math.floor(rng() * MONUMENT_STYLE_COUNT); + const color = MONUMENT_COLOR; + const { x, z } = block; + let rooted = false; + + /** Emit a part, making the first one the unparented root. */ + const part = (p: Partial & { y: number; width: number; height: number }) => { + const base: RawBuilding = { + name: '', x, z, depth: p.width, color, shape: 'box', polyCount: POLY_COUNT, + ...(p as object), + } as RawBuilding; + if (!rooted) { + base.description = ''; + rooted = true; + } else { + base.parent_name = 'ROOT'; + } + out.push(base); + }; + + /** Repeat something evenly around a circle — bollards, spouts, corner posts. */ + const around = (count: number, radius: number, make: (px: number, pz: number, angle: number) => void) => { + for (let i = 0; i < count; i++) { + const a = (i / count) * Math.PI * 2; + make(x + Math.cos(a) * radius, z + Math.sin(a) * radius, a); + } + }; + + if (style === 0) { + // Victory column. Three plinth steps turned against each other, a fluted shaft, a + // capital, and a faceted finial — then a ring of bollards to give the base a skirt. + let y = 0; + const steps = [0.56, 0.46, 0.38]; + steps.forEach((w, i) => { + const h = span * 0.05; + part({ y, width: span * w, height: h, rotation: i % 2 ? EIGHTH : 0 }); + y += h; + }); + + const shaftW = span * 0.13; + const shaftH = span * COLUMN_HEIGHT * 0.42; + part({ y, width: shaftW, height: shaftH, shape: 'cylinder' }); + y += shaftH; + + part({ y, width: span * 0.2, height: span * 0.06, shape: 'cylinder' }); + y += span * 0.06; + part({ y, width: span * 0.17, height: span * 0.22, shape: 'pyramid' }); + + around(4, span * 0.42, (px, pz) => + part({ x: px, z: pz, y: 0, width: span * 0.05, height: span * 0.09, shape: 'cylinder' })); + return; + } + + if (style === 1) { + // Statue: a plinth, then a figure assembled from a torso, a head and two arms, all + // turned to one bearing so it reads as facing somewhere rather than standing to + // attention. The arms are what stop it being a post on a box. + const facing = rng() * Math.PI * 2; + const plinthW = span * 0.34; + const plinthH = span * STATUE_HEIGHT * 0.26; + part({ y: 0, width: plinthW, height: plinthH, rotation: facing }); + part({ y: plinthH, width: plinthW * 1.12, height: span * 0.04, rotation: facing }); + + const deckY = plinthH + span * 0.04; + const torsoW = span * 0.13; + const torsoH = span * STATUE_HEIGHT * 0.42; + part({ y: deckY, width: torsoW, depth: torsoW * 0.62, height: torsoH, rotation: facing }); + + const headY = deckY + torsoH; + part({ y: headY, width: span * 0.09, height: span * 0.09, shape: 'sphere' }); + + // One arm raised, one at rest — the asymmetry is most of the silhouette. + part({ + x: x + Math.cos(facing + QUARTER) * torsoW * 0.7, + z: z + Math.sin(facing + QUARTER) * torsoW * 0.7, + y: deckY + torsoH * 0.45, width: span * 0.045, height: torsoH * 0.75, + rotation: facing, rotation_z: -EIGHTH, + }); + part({ + x: x - Math.cos(facing + QUARTER) * torsoW * 0.7, + z: z - Math.sin(facing + QUARTER) * torsoW * 0.7, + y: deckY + torsoH * 0.2, width: span * 0.045, height: torsoH * 0.6, + rotation: facing, rotation_z: EIGHTH * 0.4, + }); + return; + } + + if (style === 2) { + // Fountain: three tiers of narrowing basins with a jet through the middle and + // spouts around the rim. The only style broader than it is tall, which is what + // keeps a run of roundabouts from all reading the same. + const basinW = span * 0.78; + const basinH = span * FOUNTAIN_HEIGHT * 0.4; + part({ y: 0, width: basinW, height: basinH, shape: 'cylinder' }); + part({ y: basinH, width: basinW * 0.92, height: span * 0.02, shape: 'cylinder' }); + + let y = basinH + span * 0.02; + const tiers = [0.3]; + for (const w of tiers) { + part({ y, width: span * 0.08, height: span * 0.12, shape: 'cylinder' }); + y += span * 0.12; + part({ y, width: span * w, height: span * 0.05, shape: 'cylinder' }); + y += span * 0.05; + } + + part({ y, width: span * 0.05, height: span * 0.22, shape: 'cylinder' }); + y += span * 0.22; + part({ y, width: span * 0.1, height: span * 0.1, shape: 'sphere' }); + + around(4, basinW * 0.36, (px, pz) => + part({ x: px, z: pz, y: basinH, width: span * 0.05, height: span * 0.1, shape: 'cylinder' })); + return; + } + + if (style === 3) { + // Clock tower: a tapering stack with a clock face on each side, a belfry and a + // spire. The faces are flat cylinders stood on edge — the one place the rotation + // axes earn their keep, since a disc has to face outward to read as a clock. + let y = 0; + part({ y, width: span * 0.34, height: span * 0.08 }); + y += span * 0.08; + part({ y, width: span * 0.28, height: span * 0.05, rotation: EIGHTH }); + y += span * 0.05; + + const shaftW = span * 0.24; + const shaftH = span * CLOCK_HEIGHT * 0.62; + part({ y, width: shaftW, height: shaftH }); + + const faceY = y + shaftH * 0.78; + const faceR = shaftW * 0.52; + const faceW = span * 0.15; + // Two on the X faces, two on the Z faces; a cylinder's axis is Y, so each is + // tipped a quarter turn about the axis that leaves it facing outward. + part({ x: x + faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', rotation_z: QUARTER }); + part({ x: x - faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', rotation_z: QUARTER }); + part({ z: z + faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', rotation_x: QUARTER }); + part({ z: z - faceR, y: faceY, width: faceW, height: span * 0.02, shape: 'cylinder', rotation_x: QUARTER }); + + y += shaftH; + part({ y, width: span * 0.3, height: span * 0.1 }); + y += span * 0.1; + part({ y, width: span * 0.32, height: span * 0.26, shape: 'pyramid', polyCount: POLY_COUNT, rotation: EIGHTH }); + y += span * 0.26; + part({ y, width: span * 0.07, height: span * 0.12, shape: 'pyramid' }); + return; + } + + if (style === 4) { + // Triumphal arch: two piers carrying a lintel, with an attic above. Reads as a gate + // rather than an object, which is a different silhouette from everything else here + // and the one you can see through. + const facing = Math.floor(rng() * 4) * QUARTER; + const gap = span * 0.24; + const pierW = span * 0.15; + const pierH = span * 0.52; + const dx = Math.cos(facing); + const dz = Math.sin(facing); + + part({ x: x + dx * gap, z: z + dz * gap, y: 0, width: pierW, height: pierH, rotation: facing }); + part({ x: x - dx * gap, z: z - dz * gap, y: 0, width: pierW, height: pierH, rotation: facing }); + + const spanW = gap * 2 + pierW; + part({ y: pierH, width: spanW, depth: pierW, height: span * 0.13, rotation: facing }); + part({ y: pierH + span * 0.13, width: spanW * 0.82, depth: pierW * 0.9, height: span * 0.16, rotation: facing }); + part({ y: pierH + span * 0.29, width: span * 0.13, height: span * 0.18, shape: 'pyramid' }); + + around(4, span * 0.4, (px, pz) => + part({ x: px, z: pz, y: 0, width: span * 0.05, height: span * 0.1, shape: 'cylinder' })); + return; + } + + // Obelisk: a squat base under a shaft that tapers in three turned stages to a point. + // The turn between stages is what keeps a plain taper from reading as one long box. + let y = 0; + part({ y, width: span * 0.36, height: span * 0.07 }); + y += span * 0.07; + part({ y, width: span * 0.26, height: span * 0.08, rotation: EIGHTH }); + y += span * 0.08; + + const stages = [0.17, 0.135, 0.1]; + for (let i = 0; i < stages.length; i++) { + const h = span * 0.29; + part({ y, width: span * stages[i], height: h, rotation: i % 2 ? EIGHTH : 0 }); + y += h; + } + part({ y, width: span * 0.1, height: span * 0.16, shape: 'pyramid', polyCount: POLY_COUNT }); + + around(4, span * 0.34, (px, pz) => + part({ x: px, z: pz, y: 0, width: span * 0.06, height: span * 0.14, shape: 'cylinder' })); + +} + +export { COLUMN_HEIGHT, STATUE_HEIGHT, FOUNTAIN_HEIGHT, CLOCK_HEIGHT, POLY_COUNT, MONUMENT_COLOR, SHAPES }; diff --git a/frontend/src/cityGen/parks.ts b/frontend/src/cityGen/parks.ts index 81f6688..6107f94 100644 --- a/frontend/src/cityGen/parks.ts +++ b/frontend/src/cityGen/parks.ts @@ -1,15 +1,75 @@ import type { Block, RawBuilding, Rng } from './types'; import type { IsBlocked } from './collision'; +import { type Polygon, pointInPolygon } from './water'; /** Holographic foliage colour shared by trunk and canopy. */ const HOLO_GREEN = '#00ff66'; +/** + * How often a park gets a pond, and how much of each plot axis it spans. + * + * Measured against real output: sizing a circular pond off the *narrower* axis put + * 4-unit ponds in 50-unit plots — 6–9% of what you actually see, a puddle. Blocks out + * of the split are frequently long thin rectangles, so a circle can only ever be as + * wide as the short side. The pond is an ellipse instead, one radius per axis, which + * lets it fill an elongated plot without leaving it. + */ +const POND_CHANCE = 0.4; +const POND_MIN = 0.35; +const POND_MAX = 0.55; + +/** Points around a pond's edge, and how far each strays from a circle. */ +const POND_LOBES = 12; +const POND_JITTER = 0.25; + +/** + * A pond somewhere in a park plot, or nothing. + * + * Unlike generated rivers and lakes this runs *after* the split, because a park only + * exists once the split has produced the block it sits in. That is safe precisely + * because a pond is contained by its plot: it never reaches a road, so nothing needs + * re-cutting and no bridge is called for. + */ +function generatePond(block: Block, bw: number, bd: number, isBlocked: IsBlocked, rng: Rng): Polygon | null { + if (rng() >= POND_CHANCE) return null; + + // One fraction, applied to each axis, so the pond takes up as much of a long plot + // as it does of a square one and still reads as a single deliberate shape. + const fraction = POND_MIN + rng() * (POND_MAX - POND_MIN); + const rx = (bw * fraction) / 2; + const rz = (bd * fraction) / 2; + + // Offset from the plot centre so ponds do not all sit dead centre, without letting + // the jittered outline reach the plot edge. Per axis, since the radii differ. + const slackX = Math.max(0, bw / 2 - rx * (1 + POND_JITTER)); + const slackZ = Math.max(0, bd / 2 - rz * (1 + POND_JITTER)); + const cx = block.x + (rng() - 0.5) * slackX; + const cz = block.z + (rng() - 0.5) * slackZ; + + // A pond on a road or over an existing structure reads as a mistake. isBlocked + // already composes every reason a footprint is unusable, so ask it rather than + // re-deriving the checks here. + if (isBlocked(cx, cz, rx * 2, rz * 2, 0.5)) return null; + + const points: { x: number; z: number }[] = []; + for (let i = 0; i < POND_LOBES; i++) { + const a = (i / POND_LOBES) * Math.PI * 2; + const wobble = 1 + (rng() - 0.5) * POND_JITTER; + points.push({ x: cx + Math.cos(a) * rx * wobble, z: cz + Math.sin(a) * rz * wobble }); + } + return { points }; +} + /** * Fill a plot with a park: scattered low-poly holographic trees, each a - * cylinder trunk with a pyramid or box canopy parented to it. + * cylinder trunk with a pyramid or box canopy parented to it, and sometimes a pond. * * Trees that would collide with existing geometry are skipped rather than * relocated, so a crowded plot simply ends up sparser. + * + * Returns the pond outlines the plot produced, for the caller to persist as water. + * They are returned rather than pushed into `out` because a pond is not a building — + * the collision grid and the height taper have no meaning for one. */ export function generatePark( block: Block, @@ -17,8 +77,12 @@ export function generatePark( bd: number, out: RawBuilding[], isBlocked: IsBlocked, - rng: Rng -): void { + rng: Rng, + withPonds = false +): Polygon[] { + // Guarded rather than filtered afterwards so an unponded run draws no randomness for + // one, and a seed keeps reproducing the park it produced before ponds existed. + const pond = withPonds ? generatePond(block, bw, bd, isBlocked, rng) : null; const numPlants = 6 + Math.floor(rng() * 7); // 6 to 12 trees for (let i = 0; i < numPlants; i++) { @@ -26,6 +90,10 @@ export function generatePark( const pz = block.z + (rng() - 0.5) * bd * 0.8; if (isBlocked(px, pz, 0.4, 0.4, 0.5)) continue; + // Trees are placed after the pond so they can stand back from it. The + // whole-plot water test in the caller runs before the pond exists, so nothing + // else will move them. + if (pond && pointInPolygon(pond, px, pz)) continue; const trunkH = 2.0 + rng() * 2.5; const trunkW = 0.4; @@ -44,4 +112,6 @@ export function generatePark( color: HOLO_GREEN, shape: canopyShape, parent_name: 'ROOT', }); } + + return pond ? [pond] : []; } diff --git a/frontend/src/cityGen/region.ts b/frontend/src/cityGen/region.ts new file mode 100644 index 0000000..b885782 --- /dev/null +++ b/frontend/src/cityGen/region.ts @@ -0,0 +1,65 @@ +import { pointInPolygon, type Polygon } from './water'; +import { isTokenShape } from '../utils/mapExportBounds'; +import { isUserDefinedName } from '../utils/locationHelpers'; +import type { Bounds } from './types'; + +/** + * What a regenerate would clear from a region. + * + * Kept out of the panel so the rule is stated once and can be tested directly rather + * than through a rendered component. It is advisory — the server decides what actually + * goes — but it has to apply the same rules, or the count shown is a different number + * from the one that happens. + */ + +/** Only the fields the test reads, so callers need not build a full Location. */ +export interface RegionCandidate { + name?: string | null; + x: number; + z: number; + shape?: string; + battle_map_id?: number | null; +} + +export interface RegionCounts { + /** Generated structures that would be removed. */ + removed: number; + /** GM-named structures that survive and become obstacles for the new city. */ + kept: number; +} + +/** Region test, preferring a drawn polygon over its bounding box. */ +export function makeRegionTest( + bounds: Bounds, + polygon?: { x: number; z: number }[] | null, +): (x: number, z: number) => boolean { + if (polygon && polygon.length >= 3) { + const poly: Polygon = { points: polygon }; + return (x, z) => pointInPolygon(poly, x, z); + } + const minX = Math.min(bounds.min.x, bounds.max.x); + const maxX = Math.max(bounds.min.x, bounds.max.x); + const minZ = Math.min(bounds.min.z, bounds.max.z); + const maxZ = Math.max(bounds.min.z, bounds.max.z); + return (x, z) => x >= minX && x <= maxX && z >= minZ && z <= maxZ; +} + +export function countGeneratedInRegion( + locations: RegionCandidate[], + bounds: Bounds, + polygon?: { x: number; z: number }[] | null, +): RegionCounts { + const inside = makeRegionTest(bounds, polygon); + + let removed = 0; + let kept = 0; + for (const l of locations ?? []) { + // Battle map content and tokens are never map generation output. + if (l.battle_map_id != null) continue; + if (isTokenShape(l.shape ?? '')) continue; + if (!inside(l.x, l.z)) continue; + if (isUserDefinedName(l.name ?? '')) kept++; + else removed++; + } + return { removed, kept }; +} diff --git a/frontend/src/cityGen/rng.ts b/frontend/src/cityGen/rng.ts new file mode 100644 index 0000000..75ca79e --- /dev/null +++ b/frontend/src/cityGen/rng.ts @@ -0,0 +1,59 @@ +import type { Rng } from './types'; + +/** + * Seeded randomness for city generation. + * + * This is deliberately *not* crypto-backed. 1.7.1 moved every roll that decides an + * outcome onto OS entropy and left cosmetic randomness alone — a city layout is + * cosmetic, and here determinism is the whole point, so a plain PRNG is correct rather + * than a regression. Do not "fix" this to crypto.random. + */ + +/** mulberry32 — small, fast, and even enough for layout work. */ +export function seededRng(seed: number): Rng { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** A fresh seed to generate with, in the range the UI displays. */ +export function randomSeed(): number { + return Math.floor(Math.random() * 4294967296) >>> 0; +} + +/** FNV-1a, so any text at all can be a seed. */ +function hashString(text: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** + * Turn what the admin typed into a seed. + * + * Anything goes — a number, or a word like NIGHTCITY. Text is hashed rather than + * rejected, which means the field never has to be corrected and whatever is typed can + * stay exactly as typed. + * + * An earlier version coerced the input to a 32-bit number, so a long number silently + * wrapped to a different one. Writing that back looked like the field being cleared + * and replaced. + */ +export function seedFrom(input: string | number): number { + if (typeof input === 'number') { + return Number.isFinite(input) ? Math.abs(Math.trunc(input)) >>> 0 : randomSeed(); + } + const trimmed = input.trim(); + if (trimmed === '') return randomSeed(); + // Short whole numbers are used directly, so a written-down seed reads back as itself. + const n = Number(trimmed); + if (Number.isSafeInteger(n) && n >= 0 && n < 4294967296) return n >>> 0; + return hashString(trimmed); +} diff --git a/frontend/src/cityGen/roundabouts.ts b/frontend/src/cityGen/roundabouts.ts new file mode 100644 index 0000000..ce28b24 --- /dev/null +++ b/frontend/src/cityGen/roundabouts.ts @@ -0,0 +1,189 @@ +import type { RoadSegment, Rng } from './types'; +import { + type Polygon, type WaterPolygon, + clipSegmentToLand, clipSegmentToBoundary, pointInWater, pointInPolygon, segmentCrossing, +} from './water'; + +/** + * Roundabouts. + * + * An overlay on a finished road network rather than a layout, in the same way bridges + * are. That means one implementation serves every layout, instead of five. + * + * The observation that makes this cheap: 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 same arc sampling `RING` uses + * for its beltways. Neither piece is new. + * + * **Ordering matters.** This has to run *after* `consolidateRoads`. Consolidation snaps + * endpoints within a few units of each other, and a ring is many short segments with + * close endpoints — run it first and the circle is snapped into a blob. + */ + +export type RoundaboutDensity = 'off' | 'sparse' | 'normal'; + +/** A sited roundabout: where it is and how big, so the caller can dress the island. */ +export interface Roundabout { + x: number; + z: number; + /** Radius of the ring road's centreline. */ + radius: number; +} + +/** Roads narrower than this do not warrant a roundabout — it is a junction of arterials. */ +const MIN_ARTERIAL_WIDTH = 5; + +/** Ring radius, as a multiple of the widest road meeting it. */ +const RADIUS_FROM_ROAD = 2.6; +const MIN_RADIUS = 9; +const MAX_RADIUS = 22; + +/** Two roundabouts closer than this many radii read as one mistake, not two junctions. */ +const SPACING_RADII = 4; + +/** Degrees between sampled points on the ring. */ +const RING_STEP_DEG = 20; + +/** Ring roads are a lane and a bit — narrower than the arterials they join. */ +const RING_WIDTH = 5; + +/** Fraction of eligible junctions that actually get one, per density. */ +const DENSITY_SHARE: Record = { + off: 0, + sparse: 0.25, + normal: 0.6, +}; + +/** + * Junctions in a road network. + * + * Two kinds, because layouts differ in how they meet. BSP and VORONOI join at shared + * endpoints; `gridLayout` lays each street as one full-length span, so its crossings + * share no endpoint at all and are found only by intersecting the segments. Missing the + * second kind would mean the grid — the layout most obviously wanting roundabouts — + * never got one. + */ +export function findJunctions(roads: RoadSegment[]): { x: number; z: number; width: number }[] { + const arterials = roads.filter((r) => (r.width ?? 0) >= MIN_ARTERIAL_WIDTH); + const out: { x: number; z: number; width: number }[] = []; + + for (let i = 0; i < arterials.length; i++) { + for (let j = i + 1; j < arterials.length; j++) { + const a = arterials[i]; + const b = arterials[j]; + const hit = segmentCrossing(a, b); + if (!hit) continue; + out.push({ x: hit.x, z: hit.z, width: Math.max(a.width ?? 0, b.width ?? 0) }); + } + } + return out; +} + +/** + * True when the whole ring sits on land and inside any drawn boundary. + * + * Testing the centre alone is not enough, and looked fine until a lake was generated: a + * junction on a shoreline has its centre on dry ground while half its ring hangs over + * the water. The ring points are the same ones that become road, so this asks the + * question about the geometry that will actually exist rather than a proxy for it. + */ +function ringOnLand(r: Roundabout, water: WaterPolygon[], boundary?: Polygon): boolean { + if (water.length > 0 && pointInWater(water, r.x, r.z)) return false; + if (boundary && !pointInPolygon(boundary, r.x, r.z)) return false; + + for (const p of ringPolygon(r).points) { + if (water.length > 0 && pointInWater(water, p.x, p.z)) return false; + if (boundary && !pointInPolygon(boundary, p.x, p.z)) return false; + } + return true; +} + +/** + * Choose which junctions become roundabouts. + * + * Sited away from water, spaced apart, and thinned by density. The spacing test uses + * the radius of what is already placed, so a wide roundabout keeps a larger berth than + * a narrow one without a second constant to keep in step. + */ +export function siteRoundabouts( + roads: RoadSegment[], + density: RoundaboutDensity, + rng: Rng, + water: WaterPolygon[] = [], + boundary?: Polygon +): Roundabout[] { + if (density === 'off') return []; + const share = DENSITY_SHARE[density] ?? 0; + if (share <= 0) return []; + + const placed: Roundabout[] = []; + + for (const j of findJunctions(roads)) { + // Drawn first and unconditionally, so the sequence does not depend on how many + // junctions happen to be eligible — the same rule the landmark roll follows. + const roll = rng(); + if (roll >= share) continue; + + const radius = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, j.width * RADIUS_FROM_ROAD)); + if (!ringOnLand({ x: j.x, z: j.z, radius }, water, boundary)) continue; + + const tooClose = placed.some( + (p) => Math.hypot(p.x - j.x, p.z - j.z) < Math.max(p.radius, radius) * SPACING_RADII + ); + if (tooClose) continue; + + placed.push({ x: j.x, z: j.z, radius }); + } + + return placed; +} + +/** The ring as a closed polygon — both the road to lay and the hole to cut. */ +export function ringPolygon(r: Roundabout): Polygon { + const step = (RING_STEP_DEG * Math.PI) / 180; + const steps = Math.max(6, Math.ceil((Math.PI * 2) / step)); + const points: { x: number; z: number }[] = []; + for (let i = 0; i < steps; i++) { + const a = (i / steps) * Math.PI * 2; + points.push({ x: r.x + Math.cos(a) * r.radius, z: r.z + Math.sin(a) * r.radius }); + } + return { points }; +} + +/** + * Cut the approaches back to each ring and lay the rings themselves. + * + * The trim reuses the water clipper: every roundabout is passed as a polygon to cut + * *out* of the network, exactly as a lake would be. Without it the arterials would run + * straight through the island and the roundabout would read as a decoration painted + * over a crossroads. + */ +export function applyRoundabouts( + roads: RoadSegment[], + roundabouts: Roundabout[], + boundary?: Polygon +): RoadSegment[] { + if (roundabouts.length === 0) return roads; + + const islands = roundabouts.map(ringPolygon); + + const out: RoadSegment[] = []; + for (const road of roads) { + out.push(...clipSegmentToLand(road, islands)); + } + + for (const r of roundabouts) { + const pts = ringPolygon(r).points; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + const seg: RoadSegment = { x1: a.x, z1: a.z, x2: b.x, z2: b.z, width: RING_WIDTH }; + out.push(...clipSegmentToBoundary(seg, boundary)); + } + } + + return out; +} + +export { MIN_ARTERIAL_WIDTH, RADIUS_FROM_ROAD, MIN_RADIUS, MAX_RADIUS, RING_WIDTH, SPACING_RADII, DENSITY_SHARE }; diff --git a/frontend/src/cityGen/types.ts b/frontend/src/cityGen/types.ts index 52284bd..2bdfc7d 100644 --- a/frontend/src/cityGen/types.ts +++ b/frontend/src/cityGen/types.ts @@ -19,6 +19,17 @@ export interface Block { z: number; w: number; d: number; + /** + * This is a finished building lot, not a city block — take `w`/`d` as the footprint. + * + * The generator normally trims road padding off a block, clamps its aspect toward + * square and applies a per-zone setback. All three exist to turn a whole city block + * into one sensible plot. A layout that has already subdivided a block into lots has + * made those decisions itself, and leaving them on would pad neighbouring lots apart, + * square up the narrow frontages and pull the row back off the street — undoing the + * street wall that was the point of subdividing. + */ + lot?: boolean; } // Roads reuse the canonical shape from roadHelpers so consolidateRoads and @@ -27,6 +38,10 @@ import type { RoadSegment } from '../utils/roadHelpers'; export type { RoadSegment }; import type { OverpassDensity, OverpassSpec } from './bridges'; +import type { Polygon } from './water'; +import type { LayoutType } from './layouts'; +import type { WaterType } from './waterGen'; +import type { RoundaboutDensity } from './roundabouts'; export type { OverpassDensity, OverpassSpec }; /** Zoning preset chosen in the admin panel. */ @@ -67,6 +82,30 @@ export interface Obstacle { export interface GenerateCityOptions { sectionType: SectionType; + /** + * Generate only inside this polygon. `bounds` still frames the work — the split + * recurses on the bounding box and blocks outside the shape are dropped. + */ + boundary?: Polygon; + /** Street layout. Defaults to BSP, which is what generation has always produced. */ + layout?: LayoutType; + /** + * Water to generate before laying the city out. Defaults to NONE — generation has + * never produced water, and defaulting otherwise would put a river through the city + * of everyone already using the button. + */ + water?: WaterType; + /** + * Give parks ponds. Separate from `water` because it is a different scale of + * decision — a river reshapes the whole city, a pond is scenery in one plot — and a + * GM may well want one without the other. Off by default, on the same reasoning. + */ + parkPonds?: boolean; + /** + * Put roundabouts at junctions of major roads. An overlay on the finished network + * rather than a layout, so it applies whichever layout is chosen. Defaults to 'off'. + */ + roundabouts?: RoundaboutDensity; /** When true, no roads are generated and road collision is skipped. */ excludeRoads: boolean; /** How freely roads bridge the water they cross. Defaults to 'normal'. */ @@ -92,4 +131,6 @@ export interface GenerateCityResult { buildings: RawBuilding[]; /** Bridges spanning water crossings that qualified. */ overpasses: OverpassSpec[]; + /** Water the run generated, for the caller to persist. Empty unless asked for. */ + waterBodies: Polygon[]; } diff --git a/frontend/src/cityGen/voronoi.ts b/frontend/src/cityGen/voronoi.ts new file mode 100644 index 0000000..dc60fe3 --- /dev/null +++ b/frontend/src/cityGen/voronoi.ts @@ -0,0 +1,246 @@ +import type { Bounds, Rng } from './types'; +import { normalizeBounds } from './bsp'; + +/** + * Voronoi cells. + * + * A Voronoi diagram scatters seed points and gives each one the region closer to it + * than to any other. The boundaries land halfway between neighbouring seeds, so cells + * come out as irregular convex polygons — four to seven sides, no right angles. + * + * Every layout so far produces rectangles, and rectangles read as *planned*. This reads + * as grown: the street pattern of a medieval core, or a district that filled in around + * footpaths rather than a surveyor's plan. It is the one layout that looks nothing like + * the others, which is the whole reason for it. + * + * Cells are built by half-plane clipping rather than a sweepline. For the hundred or so + * seeds a city needs that is fast enough, and it is a fraction of the code — Fortune's + * algorithm would be several hundred lines of beach line and event queue to save + * milliseconds nobody is waiting on. + */ + +/** Point on the XZ plane. Local to this module; the generator has no shared 2-D point. */ +export interface Pt { + x: number; + z: number; +} + +/** Target distance between seeds — roughly the width of a resulting cell. */ +export const VORONOI_SPACING = 60; + +/** + * How far a seed strays from its lattice position, as a fraction of the spacing. + * + * Seeds on a perfect lattice give a perfect honeycomb, which is as machine-made as the + * grid. Fully random seeds clump, and clumped seeds give slivers — cells too thin to + * hold anything. A jittered lattice keeps the cells similar in size while making no two + * alike. + */ +export const VORONOI_JITTER = 0.45; + +/** Cells thinner than this are dropped; nothing can be built on a splinter. */ +const MIN_CELL_AREA = 200; + +/** Points closer together than this are treated as one, when matching shared edges. */ +const WELD = 0.01; + +/** + * Signed distance to the perpendicular bisector of `a`–`b`, negative on `a`'s side. + * + * |p−a|² ≤ |p−b|² expands to a linear test, which is what makes the clip cheap: no + * square roots and no special case for a vertical bisector. + */ +function bisectorSide(p: Pt, a: Pt, b: Pt): number { + return ( + 2 * (b.x - a.x) * p.x + + 2 * (b.z - a.z) * p.z - + (b.x * b.x + b.z * b.z - a.x * a.x - a.z * a.z) + ); +} + +/** Sutherland–Hodgman clip of a convex polygon to the `a` side of the a–b bisector. */ +function clipToBisector(poly: Pt[], a: Pt, b: Pt): Pt[] { + const out: Pt[] = []; + for (let i = 0; i < poly.length; i++) { + const p = poly[i]; + const q = poly[(i + 1) % poly.length]; + const fp = bisectorSide(p, a, b); + const fq = bisectorSide(q, a, b); + if (fp <= 0) out.push(p); + // Crossing the bisector: emit the point where the edge meets it. + if (fp <= 0 !== fq <= 0) { + const t = fp / (fp - fq); + out.push({ x: p.x + (q.x - p.x) * t, z: p.z + (q.z - p.z) * t }); + } + } + return out; +} + +/** Twice the signed area; the sign gives winding. */ +function area2(poly: Pt[]): number { + let s = 0; + for (let i = 0; i < poly.length; i++) { + const p = poly[i]; + const q = poly[(i + 1) % poly.length]; + s += p.x * q.z - q.x * p.z; + } + return s; +} + +export function polygonArea(poly: Pt[]): number { + return Math.abs(area2(poly)) / 2; +} + +/** + * Area centroid — not the average of the vertices. + * + * The vertex average pulls towards whichever side has more of them, which on a cell + * with one long edge chopped into several puts the centre off in a corner. + */ +export function centroid(poly: Pt[]): Pt { + const a2 = area2(poly); + if (Math.abs(a2) < 1e-9) { + // Degenerate: fall back to the vertex average rather than dividing by zero. + const n = poly.length || 1; + return { + x: poly.reduce((s, p) => s + p.x, 0) / n, + z: poly.reduce((s, p) => s + p.z, 0) / n, + }; + } + let cx = 0; + let cz = 0; + for (let i = 0; i < poly.length; i++) { + const p = poly[i]; + const q = poly[(i + 1) % poly.length]; + const cross = p.x * q.z - q.x * p.z; + cx += (p.x + q.x) * cross; + cz += (p.z + q.z) * cross; + } + return { x: cx / (3 * a2), z: cz / (3 * a2) }; +} + +/** True when a point is inside a convex polygon, whatever its winding. */ +function insideConvex(poly: Pt[], p: Pt): boolean { + const sign = Math.sign(area2(poly)); + for (let i = 0; i < poly.length; i++) { + const a = poly[i]; + const b = poly[(i + 1) % poly.length]; + const cross = (b.x - a.x) * (p.z - a.z) - (b.z - a.z) * (p.x - a.x); + if (Math.sign(cross) === -sign && cross !== 0) return false; + } + return true; +} + +/** + * The largest axis-aligned rectangle that fits in a cell, centred on its centroid. + * + * This is what lets an irregular cell feed machinery that only understands + * `{x, z, w, d}`. The buildings inside stay rectangular and the existing plot filler + * is untouched; what changes is the *street pattern*, which is where nearly all of the + * visual payoff lives. The rectangle rarely fills its cell, so setbacks vary naturally + * from plot to plot — a side effect worth having. + * + * Found by scaling the cell's bounding box about the centroid until the corners fit. + * The cell is convex, so four corners inside means the whole rectangle is inside. + */ +export function inscribedRect(poly: Pt[]): { x: number; z: number; w: number; d: number } { + const c = centroid(poly); + const halfW = Math.max(...poly.map((p) => Math.abs(p.x - c.x))); + const halfD = Math.max(...poly.map((p) => Math.abs(p.z - c.z))); + + const fits = (k: number) => + insideConvex(poly, { x: c.x - halfW * k, z: c.z - halfD * k }) && + insideConvex(poly, { x: c.x + halfW * k, z: c.z - halfD * k }) && + insideConvex(poly, { x: c.x - halfW * k, z: c.z + halfD * k }) && + insideConvex(poly, { x: c.x + halfW * k, z: c.z + halfD * k }); + + let lo = 0; + let hi = 1; + // Twelve halvings resolve to a thousandth of the cell, far finer than a wall. + for (let i = 0; i < 12; i++) { + const mid = (lo + hi) / 2; + if (fits(mid)) lo = mid; + else hi = mid; + } + return { x: c.x, z: c.z, w: halfW * lo * 2, d: halfD * lo * 2 }; +} + +/** Seed points on a jittered lattice covering the bounds. */ +export function seedPoints(bounds: Bounds, rng: Rng, spacing = VORONOI_SPACING): Pt[] { + const { minX, minZ, width, depth } = normalizeBounds(bounds); + const cols = Math.max(1, Math.round(width / spacing)); + const rows = Math.max(1, Math.round(depth / spacing)); + const cw = width / cols; + const cd = depth / rows; + + const pts: Pt[] = []; + for (let i = 0; i < cols; i++) { + for (let j = 0; j < rows; j++) { + pts.push({ + x: minX + (i + 0.5) * cw + (rng() - 0.5) * cw * VORONOI_JITTER * 2, + z: minZ + (j + 0.5) * cd + (rng() - 0.5) * cd * VORONOI_JITTER * 2, + }); + } + } + return pts; +} + +/** + * Voronoi cells for a set of seeds, clipped to the bounds. + * + * Each cell starts as the whole rectangle and is clipped by the bisector against every + * other seed. That is O(n²) — for the ~100 seeds a city uses, a few thousand clips of a + * handful of vertices each, which is nothing next to filling the plots afterwards. + */ +export function voronoiCells(bounds: Bounds, seeds: Pt[]): { seed: Pt; poly: Pt[] }[] { + const { minX, maxX, minZ, maxZ } = normalizeBounds(bounds); + const frame: Pt[] = [ + { x: minX, z: minZ }, + { x: maxX, z: minZ }, + { x: maxX, z: maxZ }, + { x: minX, z: maxZ }, + ]; + + const cells: { seed: Pt; poly: Pt[] }[] = []; + for (const seed of seeds) { + let poly = frame; + for (const other of seeds) { + if (other === seed) continue; + poly = clipToBisector(poly, seed, other); + if (poly.length < 3) break; + } + if (poly.length >= 3 && polygonArea(poly) >= MIN_CELL_AREA) cells.push({ seed, poly }); + } + return cells; +} + +/** An undirected cell edge, keyed so the two cells sharing it agree on one road. */ +function edgeKey(a: Pt, b: Pt): string { + const r = (v: number) => Math.round(v / WELD); + const ka = `${r(a.x)},${r(a.z)}`; + const kb = `${r(b.x)},${r(b.z)}`; + return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; +} + +/** + * The distinct edges of a set of cells. + * + * Every interior edge is shared by exactly two cells, so without this the whole network + * would be laid twice — doubling the road count and giving consolidation a pile of + * exact duplicates to reconcile. + */ +export function cellEdges(cells: { poly: Pt[] }[]): { a: Pt; b: Pt }[] { + const seen = new Set(); + const edges: { a: Pt; b: Pt }[] = []; + for (const { poly } of cells) { + for (let i = 0; i < poly.length; i++) { + const a = poly[i]; + const b = poly[(i + 1) % poly.length]; + const key = edgeKey(a, b); + if (seen.has(key)) continue; + seen.add(key); + edges.push({ a, b }); + } + } + return edges; +} diff --git a/frontend/src/cityGen/water.ts b/frontend/src/cityGen/water.ts index b667f22..a90893f 100644 --- a/frontend/src/cityGen/water.ts +++ b/frontend/src/cityGen/water.ts @@ -5,6 +5,12 @@ export interface WaterPolygon { points: { x: number; z: number }[]; } +/** + * A drawn generation boundary. Structurally a WaterPolygon — the two are the same + * shape and share every helper, differing only in whether inside or outside is kept. + */ +export type Polygon = WaterPolygon; + /** A stretch of a segment that runs through water, as parameters along it. */ export interface SubmergedSpan { /** Entry point along the segment, 0–1. */ @@ -89,6 +95,32 @@ export function footprintInWater( ); } +/** + * True when a footprint is not wholly inside `poly`. + * + * The counterpart of `footprintInWater`, and deliberately stricter: water asks + * "does this touch water at all", a boundary asks "is all of this inside". Same + * five-point sample, so a footprint straddling a boundary is rejected the same way one + * dipping into water is. + */ +export function footprintOutsidePolygon( + poly: WaterPolygon, + x: number, + z: number, + w: number, + d: number +): boolean { + const hw = w / 2; + const hd = d / 2; + return !( + pointInPolygon(poly, x, z) && + pointInPolygon(poly, x - hw, z - hd) && + pointInPolygon(poly, x + hw, z - hd) && + pointInPolygon(poly, x - hw, z + hd) && + pointInPolygon(poly, x + hw, z + hd) + ); +} + /** * Parameter along segment AB where it crosses segment CD, or null. * Returns t in 0–1 measured from A. @@ -107,6 +139,21 @@ function crossingParam( return t; } +/** + * Where two segments cross, or null if they do not. + * + * Layouts differ in how their roads meet: a BSP or Voronoi network joins at shared + * endpoints, but `gridLayout` lays each street as one full-length span, so its + * crossings share no endpoint and exist only as intersections. Anything siting features + * at junctions needs both, which is why this is exposed rather than kept private to the + * water clipper. + */ +export function segmentCrossing(a: RoadSegment, b: RoadSegment): { x: number; z: number } | null { + const t = crossingParam(a.x1, a.z1, a.x2, a.z2, b.x1, b.z1, b.x2, b.z2); + if (t === null) return null; + return pointAt(a, t); +} + /** * Find the stretches of a road segment that run through water. * @@ -168,17 +215,28 @@ export function segmentLength(seg: RoadSegment): number { } /** - * Cut a segment back to the parts of it that are on land. + * Cut a segment back to the parts of it inside — or outside — a set of polygons. * - * A segment clear of the water comes back untouched; one entirely submerged - * comes back as nothing; one that crosses comes back as the dry approaches. + * Water and drawn boundaries are the same operation with the sign flipped: water keeps + * what falls outside, a boundary keeps what falls inside. Sharing one implementation + * means the two cannot drift apart. */ -export function clipSegmentToLand( +export function clipSegmentToPolygons( seg: RoadSegment, - polygons: WaterPolygon[] + polygons: WaterPolygon[], + keepInside: boolean ): RoadSegment[] { - if (polygons.length === 0) return [seg]; + if (polygons.length === 0) return keepInside ? [] : [seg]; const spans = submergedSpans(polygons, seg); + + if (keepInside) { + return spans.map((span) => { + const a = pointAt(seg, span.t0); + const b = pointAt(seg, span.t1); + return { ...seg, x1: a.x, z1: a.z, x2: b.x, z2: b.z }; + }); + } + if (spans.length === 0) return [seg]; const out: RoadSegment[] = []; @@ -197,3 +255,25 @@ export function clipSegmentToLand( } return out; } + +/** + * Cut a segment back to the parts of it that are on land. + * + * A segment clear of the water comes back untouched; one entirely submerged + * comes back as nothing; one that crosses comes back as the dry approaches. + */ +export function clipSegmentToLand( + seg: RoadSegment, + polygons: WaterPolygon[] +): RoadSegment[] { + return clipSegmentToPolygons(seg, polygons, false); +} + +/** Cut a segment back to the parts of it inside a drawn boundary. */ +export function clipSegmentToBoundary( + seg: RoadSegment, + boundary: WaterPolygon | undefined +): RoadSegment[] { + if (!boundary) return [seg]; + return clipSegmentToPolygons(seg, [boundary], true); +} diff --git a/frontend/src/cityGen/waterGen.ts b/frontend/src/cityGen/waterGen.ts new file mode 100644 index 0000000..ef0493b --- /dev/null +++ b/frontend/src/cityGen/waterGen.ts @@ -0,0 +1,156 @@ +import type { Bounds, Rng } from './types'; +import { normalizeBounds } from './bsp'; +import type { Polygon } from './water'; + +/** + * Generated water. + * + * 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. + * + * Everything here produces a polygon and hands it to machinery that already exists — + * `parseWaterBodies`, `footprintInWater`, the water-aware split, shoreline roads and + * bridge siting all consume water polygons and are all tested. That is why this is a + * small addition rather than a large one. + * + * **Ordering matters.** These run *before* the split, so the road grid stops at the + * banks of its own accord and bridges get sited. Generating water afterwards would + * mean cutting finished roads, which is a different and worse problem. Park ponds are + * the opposite case and belong after the split — see `parks`. + */ + +export type WaterType = 'NONE' | 'RIVER' | 'COAST' | 'LAKE'; + +/** River width as a fraction of the smaller span. */ +const RIVER_WIDTH = 0.1; +const RIVER_WIDTH_VARIANCE = 0.45; + +/** Samples along a river's course. More reads smoother, at more points. */ +const RIVER_STEPS = 14; + +/** How far a river wanders off a straight line, as a fraction of the span. */ +const RIVER_MEANDER = 0.18; + +/** Fraction of the region a coastline cuts off, and how much its edge wanders. */ +const COAST_MIN = 0.18; +const COAST_MAX = 0.38; +const COAST_WANDER = 0.12; +const COAST_STEPS = 12; + +/** Lake radius as a fraction of the smaller span, and how lumpy its edge is. */ +const LAKE_MIN = 0.14; +const LAKE_MAX = 0.26; +const LAKE_LOBES = 16; +const LAKE_JITTER = 0.3; + +/** + * A river crossing the region. + * + * Sampled as a gently meandering centreline, then offset either side by a varying + * width and closed into a loop. Width varies along the course so it does not read as + * an extruded line. + */ +function river(bounds: Bounds, rng: Rng): Polygon { + const { minX, minZ, width, depth, centerX, centerZ } = normalizeBounds(bounds); + const span = Math.min(width, depth); + const baseWidth = span * RIVER_WIDTH; + + // Runs across the shorter axis, so it always divides the city rather than clipping + // a corner. + const horizontal = width >= depth; + const meander = span * RIVER_MEANDER; + const phase = rng() * Math.PI * 2; + const swing = 1 + rng() * 1.5; + + const left: { x: number; z: number }[] = []; + const right: { x: number; z: number }[] = []; + + for (let i = 0; i <= RIVER_STEPS; i++) { + const t = i / RIVER_STEPS; + const wander = Math.sin(phase + t * Math.PI * swing) * meander; + const halfWidth = (baseWidth * (1 + (rng() - 0.5) * RIVER_WIDTH_VARIANCE)) / 2; + + if (horizontal) { + const x = minX + width * t; + const z = centerZ + wander; + left.push({ x, z: z - halfWidth }); + right.push({ x, z: z + halfWidth }); + } else { + const z = minZ + depth * t; + const x = centerX + wander; + left.push({ x: x - halfWidth, z }); + right.push({ x: x + halfWidth, z }); + } + } + + // Down one bank and back up the other. + return { points: [...left, ...right.reverse()] }; +} + +/** + * A coastline cutting one edge off the region, water on the far side. + * + * Gives the city a waterfront and a hard edge to build against, which is a different + * shape of constraint from a river dividing it. + */ +function coast(bounds: Bounds, rng: Rng): Polygon { + const { minX, maxX, minZ, maxZ, width, depth } = normalizeBounds(bounds); + + // Which edge the sea lies beyond. + const side = Math.floor(rng() * 4); + const cut = COAST_MIN + rng() * (COAST_MAX - COAST_MIN); + const wander = Math.min(width, depth) * COAST_WANDER; + const phase = rng() * Math.PI * 2; + + const shore: { x: number; z: number }[] = []; + for (let i = 0; i <= COAST_STEPS; i++) { + const t = i / COAST_STEPS; + const drift = Math.sin(phase + t * Math.PI * 2) * wander; + if (side === 0) shore.push({ x: minX + width * t, z: minZ + depth * cut + drift }); + else if (side === 1) shore.push({ x: minX + width * t, z: maxZ - depth * cut + drift }); + else if (side === 2) shore.push({ x: minX + width * cut + drift, z: minZ + depth * t }); + else shore.push({ x: maxX - width * cut + drift, z: minZ + depth * t }); + } + + // Close the polygon around the corners on the seaward side. Reaching past the + // bounds keeps the water solid to the edge rather than stopping short of it. + const over = Math.min(width, depth); + if (side === 0) return { points: [...shore, { x: maxX, z: minZ - over }, { x: minX, z: minZ - over }] }; + if (side === 1) return { points: [...shore, { x: maxX, z: maxZ + over }, { x: minX, z: maxZ + over }] }; + if (side === 2) return { points: [...shore, { x: minX - over, z: maxZ }, { x: minX - over, z: minZ }] }; + return { points: [...shore, { x: maxX + over, z: maxZ }, { x: maxX + over, z: minZ }] }; +} + +/** A lake somewhere inside the region — an obstacle rather than a structuring feature. */ +function lake(bounds: Bounds, rng: Rng): Polygon { + const { width, depth, centerX, centerZ } = normalizeBounds(bounds); + const span = Math.min(width, depth); + const radius = span * (LAKE_MIN + rng() * (LAKE_MAX - LAKE_MIN)); + + // Offset from centre so it does not always sit in the middle of the city. + const cx = centerX + (rng() - 0.5) * (width / 2 - radius * 2); + const cz = centerZ + (rng() - 0.5) * (depth / 2 - radius * 2); + + const points: { x: number; z: number }[] = []; + for (let i = 0; i < LAKE_LOBES; i++) { + const a = (i / LAKE_LOBES) * Math.PI * 2; + const r = radius * (1 + (rng() - 0.5) * LAKE_JITTER); + points.push({ x: cx + Math.cos(a) * r, z: cz + Math.sin(a) * r }); + } + return { points }; +} + +/** + * Water for a generation run, or nothing. + * + * `NONE` is the default because generation has never produced water before — + * defaulting to a river would put one through the city of everyone already using the + * button. + */ +export function generateWater(type: WaterType, bounds: Bounds, rng: Rng): Polygon[] { + if (type === 'RIVER') return [river(bounds, rng)]; + if (type === 'COAST') return [coast(bounds, rng)]; + if (type === 'LAKE') return [lake(bounds, rng)]; + return []; +} diff --git a/frontend/src/cityGen/zoning.ts b/frontend/src/cityGen/zoning.ts index 295d077..f2fd2e6 100644 --- a/frontend/src/cityGen/zoning.ts +++ b/frontend/src/cityGen/zoning.ts @@ -155,3 +155,54 @@ export function clampPlotAspect( if (bd > bw * maxRatio) return { bw, bd: bw * maxRatio }; return { bw, bd }; } + +/** How much taller the very centre builds than the outskirts. */ +export const HEIGHT_GRADIENT_PEAK = 1.25; +export const HEIGHT_GRADIENT_EDGE = 0.75; + +/** + * Height multiplier for a plot at normalised distance `normDist` from the centre. + * + * Zone already varies with distance, so there is a coarse taper: CORPO towers near the + * middle, slums at the rim. But zone changes in steps, so the skyline came out as three + * or four flat plateaus with hard seams between them. This blends *within* a zone, so + * height falls off smoothly and a downtown reads as a peak rather than a mesa. + * + * Deliberately gentle. The zone bands are doing the heavy lifting; this only softens + * their edges, and a strong multiplier would fight them. + */ +export function heightScaleFor(normDist: number): number { + const t = Math.min(1, Math.max(0, normDist)); + return HEIGHT_GRADIENT_PEAK + (HEIGHT_GRADIENT_EDGE - HEIGHT_GRADIENT_PEAK) * t; +} + +/** + * Fraction of its plot a zone builds on, the rest left as setback. + * + * Dense coverage reads as an old city that grew to its lot lines; generous setbacks + * read as modern and corporate, with plazas and forecourts. Every zone previously + * filled its plot the same way, which is part of why districts differed only in what + * they built rather than how they sat on the ground. + * + * Keyed off the same `zoneTypeVal` bands `fillPlot` uses, so the two agree about what + * a plot is. + */ +export const LOT_COVERAGE = { + MARKETS: 0.95, + LANDMARK: 0.70, + CORPO: 0.72, + URBAN: 0.85, + SLUMS: 0.95, + INDUSTRIAL: 0.80, + DEFAULT: 0.85, +} as const; + +export function lotCoverageFor(zoneTypeVal: number): number { + if (zoneTypeVal === 2.0) return LOT_COVERAGE.MARKETS; + if (zoneTypeVal >= 1.5 && zoneTypeVal < 2.0) return LOT_COVERAGE.LANDMARK; + if (zoneTypeVal > 0.8 && zoneTypeVal < 1.5) return LOT_COVERAGE.CORPO; + if (zoneTypeVal > 0.3 && zoneTypeVal < 0.8) return LOT_COVERAGE.URBAN; + if (zoneTypeVal <= 0.25 && zoneTypeVal >= 0) return LOT_COVERAGE.SLUMS; + if (zoneTypeVal < 0) return LOT_COVERAGE.INDUSTRIAL; + return LOT_COVERAGE.DEFAULT; +} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 58fa2fd..71dfea7 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -4,7 +4,28 @@ import * as THREE from 'three'; import { isUserDefinedName, getStructLabel } from '../utils/locationHelpers'; import { consolidateRoads } from '../utils/roadHelpers'; import { generateThemedBuildingsForPlot } from './Buildings'; -import { generateCity, SpatialGrid, type SectionType, type OverpassDensity } from '../cityGen'; +import { generateCity, SpatialGrid, seededRng, seedFrom, countGeneratedInRegion, type SectionType, type OverpassDensity, type LayoutType, type WaterType, type RoundaboutDensity } from '../cityGen'; + +/** Street layouts offered in the generator, with what each one reads as. */ +const LAYOUT_OPTIONS: { value: LayoutType; label: string }[] = [ + { value: 'BSP', label: 'ORGANIC — IRREGULAR BLOCKS (DEFAULT)' }, + { value: 'GRID', label: 'GRID — PLANNED, SQUARE BLOCKS' }, + { value: 'SUPERBLOCK', label: 'SUPERBLOCK — TOWER IN PARK' }, + { value: 'RING', label: 'RING — BELTWAYS AND SPOKES' }, + { value: 'VORONOI', label: 'ORGANIC_CELLS — GREW, NOT PLANNED' }, + { value: 'PERIMETER', label: 'DOWNTOWN — DENSE BLOCKS, STREET WALL' }, +]; + +/** + * Water to generate. NONE is the default and the off switch — the selector doubles as + * the disable, rather than a checkbox that could disagree with it. + */ +const WATER_OPTIONS: { value: WaterType; label: string }[] = [ + { value: 'NONE', label: 'NONE — DRAW YOUR OWN (DEFAULT)' }, + { value: 'RIVER', label: 'RIVER — DIVIDES THE CITY, BRIDGES IT' }, + { value: 'COAST', label: 'COAST — WATERFRONT ON ONE EDGE' }, + { value: 'LAKE', label: 'LAKE — AN OBSTACLE INSIDE IT' }, +]; import type { BankSoundKey } from './BankWindows'; import { playCashRegister, playWompWomp, playCalibration, playProudFanfare, playHighRollerSound } from './BankWindows'; import type { SignData, SignLine } from './Signs'; @@ -584,6 +605,8 @@ export function AdminPanel({ signs, fetchSigns, remoteFonts, setRemoteFonts, isPlacingSign, setIsPlacingSign, pendingSignPos, setPendingSignPos, selectedSignId, setSelectedSignId, signTransformMode, setSignTransformMode, signTransformActive, setSignTransformActive, handleUpdateSign, signMesh, activeUsers, onGrantAccess, onRevokeAccess, onOpenNpcLibrary, onToggleHidden, onExportPng, onStartRecording, onStopRecording, isRecording, isExporting, recordSecondsLeft, + cityGenDrawMode, setCityGenDrawMode, genBoundaryTrail, setGenBoundaryTrail, + cityLayout, setCityLayout, citySeed, setCitySeed, lastCitySeed, setLastCitySeed, cityWater, setCityWater, cityParkPonds, setCityParkPonds, cityRoundabouts, setCityRoundabouts, }: any) { if (view === 'battle_map') { return ( @@ -913,6 +936,166 @@ export function AdminPanel({ } }; + /** + * Generate a city into the selected area. + * + * `purgeFirst` clears whatever was generated there before, which is what + * regenerating means. Without it, generating again infills around what is + * already standing — useful in its own right, but not a fresh city. + */ + const runGeneration = async (purgeFirst: boolean) => { + try { + const drawing = cityGenDrawMode === 'draw'; + const tracedPoints = (genBoundaryTrail ?? []).map((p: any) => ({ x: p.x, z: p.z })); + // Under three points cannot enclose an area; generateCity ignores such a + // boundary, so refuse here rather than silently generating over the bbox. + if (drawing && tracedPoints.length < 3) return setAdminAlert("TRACE AN AREA FIRST"); + if (!drawing && !roadSelectionBounds) return setAdminAlert("SELECT AREA FIRST"); + + // A traced shape still needs a rectangle for the split to recurse on, + // so its bounding box frames the work and the polygon confines it. + const xs = tracedPoints.map((p: any) => p.x); + const zs = tracedPoints.map((p: any) => p.z); + const genBounds = drawing + ? { min: { x: Math.min(...xs), z: Math.min(...zs) }, max: { x: Math.max(...xs), z: Math.max(...zs) } } + : roadSelectionBounds; + + // Clear the previous generation before building, and use the world as + // it is *after* that — placement tests against existing locations, so + // stale ones would leave the new city avoiding buildings that are gone. + let worldLocations = locations; + let worldRoads = roads; + let worldWater = waterBodies; + if (purgeFirst) { + const doomed = countGeneratedInRegion(locations, genBounds, drawing ? tracedPoints : null); + if (doomed.removed > 0) { + const kept = doomed.kept > 0 + ? ` ${doomed.kept} named structure${doomed.kept > 1 ? 's' : ''} will be kept.` + : ''; + // Leads with the count: "regenerate?" invites a reflexive yes. + if (!confirm(`This removes ${doomed.removed} generated structures.${kept}`)) return; + } + + const purgeRes = await fetch('/api/locations/purge-region', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify(drawing ? { polygon: tracedPoints } : { bounds: genBounds }), + }); + if (!purgeRes.ok) throw new Error(`Purge failed: ${purgeRes.status}`); + + // Water is refetched for the same reason as locations and roads: the + // purge deleted the last river, and generating against the stale list + // means the new city avoids water that is no longer there — a dead + // band of empty ground tracing where the old river used to run. + const [freshLocs, freshRoads, freshWater] = await Promise.all([ + fetch('/api/locations').then(r => r.json()).catch(() => locations), + fetch('/api/roads').then(r => r.json()).catch(() => roads), + fetch('/api/water').then(r => r.json()).catch(() => waterBodies), + ]); + if (Array.isArray(freshLocs)) worldLocations = freshLocs; + if (Array.isArray(freshRoads)) worldRoads = freshRoads; + if (Array.isArray(freshWater)) worldWater = freshWater; + } + + // A typed seed is used as typed and never rewritten — normalising it + // looked like the field being cleared and replaced. Only a blank field + // gets filled in, so the seed just rolled can be written down. + const typedSeed = (citySeed ?? '').trim(); + const seed = seedFrom(typedSeed); + // Reported, never written back into the field. Filling the input meant + // every later regenerate silently rebuilt the same city, which reads as + // the purge having failed. + setLastCitySeed?.(String(seed)); + + const { blocks, roads: finalRoads, buildings: rawBuildings, overpasses: newOverpasses, waterBodies: newWater } = generateCity( + genBounds, + { + sectionType: citySectionType as SectionType, + excludeRoads: genExcludeRoads, + overpassDensity, + layout: cityLayout ?? 'BSP', + water: cityWater ?? 'NONE', + parkPonds: !!cityParkPonds, + roundabouts: cityRoundabouts ?? 'off', + boundary: drawing ? { points: tracedPoints } : undefined, + }, + { locations: worldLocations, roads: worldRoads, waterBodies: worldWater }, + seededRng(seed) + ); + + // Water first: it shapes where the roads went, so it should exist + // before they are persisted. Marked generated so a later regenerate + // clears it without touching anything the GM drew. + for (const w of newWater) { + const wRes = await fetch('/api/water', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ points: w.points, generated: true }), + }); + if (!wRes.ok) throw new Error(`Water creation failed: ${wRes.status}`); + } + if (newWater.length > 0) fetchWaterBodies?.(); + + if (finalRoads.length > 0) { + const rRes = await fetch('/api/roads', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(finalRoads) }); + if (!rRes.ok) throw new Error(`Road creation failed: ${rRes.status}`); + } + + for (const o of newOverpasses) { + const oRes = await fetch('/api/overpasses', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(o) }); + if (!oRes.ok) throw new Error(`Overpass creation failed: ${oRes.status}`); + } + + // Grouping logic for parent_id using SPATIAL GRID for O(N) speed + const res = await fetch('/api/locations', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(rawBuildings.filter(b => !b.parent_name)) }); + if (!res.ok) throw new Error(`Building creation failed: ${res.status}`); + + const rootData = await res.json(); + if (rootData.data) { + const children: any[] = []; + // Reuse the generator's spatial hash to match each child to a + // nearby persisted root in O(1) instead of scanning all roots. + const rootGrid = new SpatialGrid(); + rootData.data.forEach((r: any) => rootGrid.add(r)); + + rawBuildings.filter(b => b.parent_name === 'ROOT' || b.parent_name === 'CORP_ROOT').forEach(c => { + for (const nKey of rootGrid.neighborKeys(c.x, c.z)) { + const cell = rootGrid.cells[nKey]; + if (!cell) continue; + const root = cell.find((r: any) => { + if (c.temp_block_id && r.temp_block_id) { + return c.temp_block_id === r.temp_block_id; + } + const dist = Math.sqrt((r.x - c.x)**2 + (r.z - c.z)**2); + return dist < 20; + }); + if (root) { + children.push({ ...c, parent_id: (root as any).id }); + break; + } + } + }); + + if (children.length > 0) { + const cRes = await fetch('/api/locations', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(children) }); + if (!cRes.ok) throw new Error(`Child building creation failed: ${cRes.status}`); + } + } + + const bridgeNote = newOverpasses.length > 0 ? ` / ${newOverpasses.length} BRIDGE${newOverpasses.length > 1 ? 'S' : ''}` : ''; + setAdminAlert(`CITY GENERATED: ${blocks.length} SECTORS${bridgeNote}`); + refreshLocations(); + if (newOverpasses.length > 0) refreshOverpasses?.(); + // Stay on the panel with the area still selected, so layout and + // density can be adjusted and regenerated without re-selecting. + // Generating again infills rather than overlapping: placement tests + // against existing locations, and roads consolidate onto existing ones. + } catch (err: any) { + console.error(err); + setAdminAlert(`SYSTEM_ERROR: ${err.message}. Area might be too large or complex.`); + } + }; + const handleToggleHidden = () => { if (!selectedLocation) return; const rootId = selectedLocation.parent_id ? selectedLocation.parent_id : selectedLocation.id; @@ -1653,77 +1836,92 @@ export function AdminPanel({ ))} -
{roadSelectionBounds ?

AREA_SELECTED: {Math.round(Math.abs(roadSelectionBounds.max.x - roadSelectionBounds.min.x))}x{Math.round(Math.abs(roadSelectionBounds.max.z - roadSelectionBounds.min.z))} units

:

DRAG ON MAP TO SELECT GENERATION AREA

}

HIERARCHICAL BSP: ENABLED

ZONING: {citySectionType}

INFRASTRUCTURE: {genExcludeRoads ? 'BUILDINGS_ONLY' : 'ROADS_+_BUILDINGS'}

- + + + + +
+ {(['off', 'sparse', 'normal'] as RoundaboutDensity[]).map(d => ( + + ))} +
+ +
+ setCitySeed?.(e.target.value)} + style={{flex: 1, backgroundColor: '#222', color: 'var(--green)', border: '1px solid var(--green)', padding: '4px', fontSize: '0.7rem', fontFamily: 'monospace'}} + /> + +
+ {lastCitySeed + ?

+ LAST: +

+ : null} +

SAME SEED + SAME AREA + SAME OPTIONS = SAME CITY

+ + +
+ + +
+
{cityGenDrawMode === 'draw' + ? (genBoundaryTrail?.length > 2 + ? <>

BOUNDARY_TRACED: {genBoundaryTrail.length} POINTS

+ :

HOLD LEFT-CLICK TO TRACE GENERATION AREA

) + : (roadSelectionBounds ?

AREA_SELECTED: {Math.round(Math.abs(roadSelectionBounds.max.x - roadSelectionBounds.min.x))}x{Math.round(Math.abs(roadSelectionBounds.max.z - roadSelectionBounds.min.z))} units

:

DRAG ON MAP TO SELECT GENERATION AREA

)}

HIERARCHICAL BSP: ENABLED

ZONING: {citySectionType}

INFRASTRUCTURE: {genExcludeRoads ? 'BUILDINGS_ONLY' : 'ROADS_+_BUILDINGS'}

+
+ + +
+ {/* Same server-side undo as the admin header. Generating leaves the panel + open, so reverting a bad result belongs here rather than three clicks away. */} + )} diff --git a/frontend/src/components/Buildings.tsx b/frontend/src/components/Buildings.tsx index 800e3ee..c468f9c 100644 --- a/frontend/src/components/Buildings.tsx +++ b/frontend/src/components/Buildings.tsx @@ -288,8 +288,15 @@ export const generateThemedBuildingsForPlot = ( sourceLocations: any[], blockId?: string, overrideH?: number, - styleOverride?: number + styleOverride?: number, + /** + * Randomness source. Defaults to Math.random so existing callers are unaffected; + * the city generator passes its seeded rng so a seed reproduces the buildings, not + * just the street layout. + */ + rng?: () => number ) => { + const rand = rng ?? Math.random; const startIndex = rawBuildings.length; const color = ''; // default neutral color @@ -383,12 +390,12 @@ export const generateThemedBuildingsForPlot = ( const radiusX = bw / 2; const radiusZ = bd / 2; for (let i = 0; i < shackCount; i++) { - const shW = 2.0 + Math.random() * 2.0; const shD = 2.0 + Math.random() * 2.0; - const angle = Math.random() * Math.PI * 2; - const r = Math.sqrt(Math.random()) * 0.9; + const shW = 2.0 + rand() * 2.0; const shD = 2.0 + rand() * 2.0; + const angle = rand() * Math.PI * 2; + const r = Math.sqrt(rand()) * 0.9; const shX = bx + Math.cos(angle) * radiusX * r; const shZ = bz + Math.sin(angle) * radiusZ * r; - const shH = 2.5 + Math.random() * 4.0; const shackColor = '#00ff00'; + const shH = 2.5 + rand() * 4.0; const shackColor = '#00ff00'; if (!isBlocked(shX, shZ, shW, shD, 0.5)) { if (!rootShack) { @@ -400,29 +407,29 @@ export const generateThemedBuildingsForPlot = ( rawBuildings.push(shack); const key = getGridKey(shX, shZ); if(!spatialGrid[key]) spatialGrid[key] = []; spatialGrid[key].push(shack); } - if (Math.random() < 0.3) { - rawBuildings.push({ name: '', x: shX, y: shH, z: shZ, width: shW * 0.9, depth: shD * 0.9, height: 1.0 + Math.random() * 1.5, color: '#00ff00', shape: 'pyramid', polyCount: 5, parent_name: 'ROOT' }); + if (rand() < 0.3) { + rawBuildings.push({ name: '', x: shX, y: shH, z: shZ, width: shW * 0.9, depth: shD * 0.9, height: 1.0 + rand() * 1.5, color: '#00ff00', shape: 'pyramid', polyCount: 5, parent_name: 'ROOT' }); } } } } else { - const shH = 2.5 + Math.random() * 4.0; const shackColor = '#00ff00'; + const shH = 2.5 + rand() * 4.0; const shackColor = '#00ff00'; rootShack = { name: '', description: '', x: bx, y: 0, z: bz, width: bw * 0.7, depth: bd * 0.7, height: shH, color: shackColor, shape: 'box', polyCount: 5 }; rawBuildings.push(rootShack); const key = getGridKey(bx, bz); if(!spatialGrid[key]) spatialGrid[key] = []; spatialGrid[key].push(rootShack); - if (Math.random() < 0.3) { - rawBuildings.push({ name: '', x: bx, y: shH, z: bz, width: bw * 0.6, depth: bd * 0.6, height: 1.0 + Math.random() * 1.5, color: '#00ff00', shape: 'pyramid', polyCount: 5, parent_name: 'ROOT' }); + if (rand() < 0.3) { + rawBuildings.push({ name: '', x: bx, y: shH, z: bz, width: bw * 0.6, depth: bd * 0.6, height: 1.0 + rand() * 1.5, color: '#00ff00', shape: 'pyramid', polyCount: 5, parent_name: 'ROOT' }); } } // Fallback: if no shack was spawned (e.g. all blocked or small size failed), force-spawn one at the center to ensure block is populated if (!rootShack) { - const shH = 2.5 + Math.random() * 4.0; const shackColor = Math.random() > 0.5 ? '#8d5b4c' : '#4d4f53'; + const shH = 2.5 + rand() * 4.0; const shackColor = rand() > 0.5 ? '#8d5b4c' : '#4d4f53'; rootShack = { name: '', description: '', x: bx, y: 0, z: bz, width: Math.max(3.0, bw * 0.8), depth: Math.max(3.0, bd * 0.8), height: shH, color: shackColor, shape: 'box', polyCount: 5 }; rawBuildings.push(rootShack); const key = getGridKey(bx, bz); if(!spatialGrid[key]) spatialGrid[key] = []; spatialGrid[key].push(rootShack); - if (Math.random() < 0.3) { - rawBuildings.push({ name: '', x: bx, y: shH, z: bz, width: rootShack.width * 0.8, depth: rootShack.depth * 0.8, height: 1.0 + Math.random() * 1.5, color: '#3f2b24', shape: 'pyramid', polyCount: 5, parent_name: 'ROOT' }); + if (rand() < 0.3) { + rawBuildings.push({ name: '', x: bx, y: shH, z: bz, width: rootShack.width * 0.8, depth: rootShack.depth * 0.8, height: 1.0 + rand() * 1.5, color: '#3f2b24', shape: 'pyramid', polyCount: 5, parent_name: 'ROOT' }); } } return; @@ -440,7 +447,7 @@ export const generateThemedBuildingsForPlot = ( // 2. INDUSTRIAL if (zoneTypeVal < 0) { - const industrialStyle = styleOverride !== undefined ? styleOverride % INDUSTRIAL_STYLE_COUNT : Math.floor(Math.random() * INDUSTRIAL_STYLE_COUNT); + const industrialStyle = styleOverride !== undefined ? styleOverride % INDUSTRIAL_STYLE_COUNT : Math.floor(rand() * INDUSTRIAL_STYLE_COUNT); // Create the base concrete pad platform const root = { name: '', description: '', x: bx, y: 0, z: bz, width: bw, depth: bd, height: 1.2, color, shape: 'box', polyCount: 5, rotation: 0 }; @@ -449,10 +456,10 @@ export const generateThemedBuildingsForPlot = ( if (industrialStyle === 0) { // Style 0: Refinery Terminal (Medium building, 2 liquid tanks, shipping containers) - const wareW = bw * 0.42; const wareD = bd * 0.55; const wareH = 6.0 + Math.random() * 3; + const wareW = bw * 0.42; const wareD = bd * 0.55; const wareH = 6.0 + rand() * 3; rawBuildings.push({ name: '', x: bx - bw * 0.2, y: 1.2, z: bz, width: wareW, depth: wareD, height: wareH, color, shape: 'box', polyCount: 5, parent_name: 'ROOT' }); - const tankR = Math.min(bw, bd) * 0.16; const tankH = 5.0 + Math.random() * 2; + const tankR = Math.min(bw, bd) * 0.16; const tankH = 5.0 + rand() * 2; rawBuildings.push({ name: '', x: bx + bw * 0.25, y: 1.2, z: bz - bd * 0.2, width: tankR * 2, depth: tankR * 2, height: tankH, color, shape: 'cylinder', polyCount: 5, parent_name: 'ROOT' }); rawBuildings.push({ name: '', x: bx + bw * 0.25, y: 1.2, z: bz + bd * 0.2, width: tankR * 2, depth: tankR * 2, height: tankH, color, shape: 'cylinder', polyCount: 5, parent_name: 'ROOT' }); @@ -461,13 +468,13 @@ export const generateThemedBuildingsForPlot = ( } else if (industrialStyle === 1) { // Style 1: Manufacturing Station (Medium building, tall smokestack, liquid tank, containers) - const genW = bw * 0.45; const genD = bd * 0.5; const genH = 5.0 + Math.random() * 3; + const genW = bw * 0.45; const genD = bd * 0.5; const genH = 5.0 + rand() * 3; rawBuildings.push({ name: '', x: bx - bw * 0.1, y: 1.2, z: bz - bd * 0.1, width: genW, depth: genD, height: genH, color, shape: 'box', polyCount: 5, parent_name: 'ROOT' }); - const stackW = 1.0; const stackH = 15.0 + Math.random() * 5; + const stackW = 1.0; const stackH = 15.0 + rand() * 5; rawBuildings.push({ name: '', x: bx + bw * 0.3, y: 1.2, z: bz - bd * 0.22, width: stackW, depth: stackW, height: stackH, color, shape: 'cylinder', polyCount: 5, parent_name: 'ROOT' }); - const tankR = Math.min(bw, bd) * 0.18; const tankH = 6.0 + Math.random() * 2; + const tankR = Math.min(bw, bd) * 0.18; const tankH = 6.0 + rand() * 2; rawBuildings.push({ name: '', x: bx + bw * 0.3, y: 1.2, z: bz + bd * 0.22, width: tankR * 2, depth: tankR * 2, height: tankH, color, shape: 'cylinder', polyCount: 5, parent_name: 'ROOT' }); const containerW = bw * 0.2; const containerD = bd * 0.3; const containerH = 2.0; @@ -476,10 +483,10 @@ export const generateThemedBuildingsForPlot = ( } else if (industrialStyle === 2) { // Style 2: Fuel Storage Depot (Medium building, 3 grouped cylinders, containers) - const officeW = bw * 0.35; const officeD = bd * 0.38; const officeH = 4.0 + Math.random() * 2; + const officeW = bw * 0.35; const officeD = bd * 0.38; const officeH = 4.0 + rand() * 2; rawBuildings.push({ name: '', x: bx - bw * 0.24, y: 1.2, z: bz - bd * 0.2, width: officeW, depth: officeD, height: officeH, color, shape: 'box', polyCount: 5, parent_name: 'ROOT' }); - const tankR = Math.min(bw, bd) * 0.15; const tankH = 6.0 + Math.random() * 3; + const tankR = Math.min(bw, bd) * 0.15; const tankH = 6.0 + rand() * 3; rawBuildings.push({ name: '', x: bx + bw * 0.22, y: 1.2, z: bz - bd * 0.22, width: tankR * 2, depth: tankR * 2, height: tankH, color, shape: 'cylinder', polyCount: 5, parent_name: 'ROOT' }); rawBuildings.push({ name: '', x: bx + bw * 0.22, y: 1.2, z: bz + bd * 0.22, width: tankR * 2, depth: tankR * 2, height: tankH, color, shape: 'cylinder', polyCount: 5, parent_name: 'ROOT' }); rawBuildings.push({ name: '', x: bx + bw * 0.38, y: 1.2, z: bz, width: tankR * 2.2, depth: tankR * 2.2, height: tankH * 1.2, color, shape: 'cylinder', polyCount: 5, parent_name: 'ROOT' }); @@ -490,10 +497,10 @@ export const generateThemedBuildingsForPlot = ( } else if (industrialStyle === 3) { // Style 3: Power & Distribution Plant (Medium building, cooling tower, containers) - const wareW = bw * 0.48; const wareD = bd * 0.55; const wareH = 6.0 + Math.random() * 2; + const wareW = bw * 0.48; const wareD = bd * 0.55; const wareH = 6.0 + rand() * 2; rawBuildings.push({ name: '', x: bx - bw * 0.15, y: 1.2, z: bz, width: wareW, depth: wareD, height: wareH, color, shape: 'box', polyCount: 5, parent_name: 'ROOT' }); - const tankR = Math.min(bw, bd) * 0.18; const tankH = 9.0 + Math.random() * 3; + const tankR = Math.min(bw, bd) * 0.18; const tankH = 9.0 + rand() * 3; rawBuildings.push({ name: '', x: bx + bw * 0.28, y: 1.2, z: bz + bd * 0.18, width: tankR * 2, depth: tankR * 2, height: tankH, color, shape: 'cylinder', polyCount: 5, parent_name: 'ROOT' }); const containerW = bw * 0.24; const containerD = bd * 0.28; const containerH = 2.0; @@ -503,7 +510,7 @@ export const generateThemedBuildingsForPlot = ( else if (industrialStyle === 4) { // Style 4: Industrial Standard A // Instructions: lowpoly cylinder for liquids, small retangles for storage crates, and a medum sized buiding for opterations. - const opW = bw * 0.4; const opD = bd * 0.4; const opH = 5.0 + Math.random() * 2; + const opW = bw * 0.4; const opD = bd * 0.4; const opH = 5.0 + rand() * 2; rawBuildings.push({ name: '', x: bx - bw * 0.2, y: 1.2, z: bz - bd * 0.2, width: opW, depth: opD, height: opH, color, shape: 'box', polyCount: 5, parent_name: 'ROOT' }); const tankR = Math.min(bw, bd) * 0.15; const tankH = 6.0; rawBuildings.push({ name: '', x: bx + bw * 0.25, y: 1.2, z: bz - bd * 0.2, width: tankR * 2, depth: tankR * 2, height: tankH, color, shape: 'cylinder', polyCount: 5, parent_name: 'ROOT' }); @@ -573,10 +580,10 @@ export const generateThemedBuildingsForPlot = ( // 3. LANDMARK (STATUES, MONUMENTS, TOWERS) if (zoneTypeVal >= 1.5 && zoneTypeVal < 2.0) { - const h = overrideH !== undefined ? overrideH : (20 + Math.random() * 60); + const h = overrideH !== undefined ? overrideH : (20 + rand() * 60); const baseW = bw * 0.9; const baseD = bd * 0.9; - const landmarkStyle = styleOverride !== undefined ? styleOverride % LANDMARK_STYLE_COUNT : Math.floor(Math.random() * LANDMARK_STYLE_COUNT); + const landmarkStyle = styleOverride !== undefined ? styleOverride % LANDMARK_STYLE_COUNT : Math.floor(rand() * LANDMARK_STYLE_COUNT); if (landmarkStyle === 0) { // Style 1: Grand Obelisk @@ -924,7 +931,7 @@ export const generateThemedBuildingsForPlot = ( // 4. CORPO (HIGH-RISE) if (zoneTypeVal > 0.8 && zoneTypeVal < 1.5) { - const h = overrideH !== undefined ? overrideH : (100 + Math.random() * 90) * (0.85 + Math.random() * 0.3); // Proportional height randomization + const h = overrideH !== undefined ? overrideH : (100 + rand() * 90) * (0.85 + rand() * 0.3); // Proportional height randomization let baseW = bw * 0.95; let baseD = bd * 0.95; @@ -944,7 +951,7 @@ export const generateThemedBuildingsForPlot = ( baseD = Math.max(4.0, bd * 0.3); } - const corpoStyle = styleOverride !== undefined ? styleOverride % CORPO_STYLE_COUNT : Math.floor(Math.random() * CORPO_STYLE_COUNT); // 11 styles + const corpoStyle = styleOverride !== undefined ? styleOverride % CORPO_STYLE_COUNT : Math.floor(rand() * CORPO_STYLE_COUNT); // 11 styles if (corpoStyle === 0) { // Style 0: Asymmetrical Nexus (main tower + data-centre annex + skybridges) @@ -1133,8 +1140,8 @@ export const generateThemedBuildingsForPlot = ( // 4. URBAN (APARTMENT COMPLEXES) if (zoneTypeVal > 0.3 && zoneTypeVal < 0.8) { - const h = overrideH !== undefined ? overrideH : (10 + Math.random() * 20) * (0.8 + Math.random() * 0.4); - const urbanStyle = styleOverride !== undefined ? styleOverride % URBAN_STYLE_COUNT : Math.floor(Math.random() * URBAN_STYLE_COUNT); + const h = overrideH !== undefined ? overrideH : (10 + rand() * 20) * (0.8 + rand() * 0.4); + const urbanStyle = styleOverride !== undefined ? styleOverride % URBAN_STYLE_COUNT : Math.floor(rand() * URBAN_STYLE_COUNT); if (urbanStyle === 0) { // Style 0: Courtyard Apartment (Hollow O-block) @@ -1377,12 +1384,12 @@ export const generateThemedBuildingsForPlot = ( // 5. MARKETS if (zoneTypeVal >= 2.0 && zoneTypeVal < 3.0) { - const h = overrideH !== undefined ? overrideH : (10 + Math.random() * 20); - const marketStyle = styleOverride !== undefined ? styleOverride % MARKETS_STYLE_COUNT : Math.floor(Math.random() * MARKETS_STYLE_COUNT); + const h = overrideH !== undefined ? overrideH : (10 + rand() * 20); + const marketStyle = styleOverride !== undefined ? styleOverride % MARKETS_STYLE_COUNT : Math.floor(rand() * MARKETS_STYLE_COUNT); if (marketStyle === 0 || marketStyle === 1) { // Stall Markets: 5-8 market stalls (small rectangles with overhangs and tables) - const numStalls = 5 + Math.floor(Math.random() * 4); // 5 to 8 + const numStalls = 5 + Math.floor(rand() * 4); // 5 to 8 const stallW = bw * 0.15; const stallD = bd * 0.15; const stallH = 2.5; const root = { name: '', description: '', x: bx, y: 0, z: bz, width: bw, depth: bd, height: 0.1, color: '#333', shape: 'box', polyCount: 5 }; @@ -1391,13 +1398,13 @@ export const generateThemedBuildingsForPlot = ( for (let i = 0; i < numStalls; i++) { // Random placement within bounds - const sx = bx - bw/2 + stallW/2 + Math.random() * (bw - stallW); - const sz = bz - bd/2 + stallD/2 + Math.random() * (bd - stallD); + const sx = bx - bw/2 + stallW/2 + rand() * (bw - stallW); + const sz = bz - bd/2 + stallD/2 + rand() * (bd - stallD); // Main stall box rawBuildings.push({ name: '', x: sx, y: 0.1, z: sz, width: stallW, depth: stallD, height: stallH, color: '#666', shape: 'box', polyCount: 5, parent_name: 'ROOT' }); // Overhang awning - rawBuildings.push({ name: '', x: sx, y: 0.1 + stallH, z: sz + stallD*0.3, width: stallW*1.1, depth: stallD*1.2, height: 0.1, color: ['#cc3333','#3355cc','#33cc55','#ddaa22'][Math.floor(Math.random()*4)], shape: 'box', polyCount: 5, rotation: 0.1, parent_name: 'ROOT' }); + rawBuildings.push({ name: '', x: sx, y: 0.1 + stallH, z: sz + stallD*0.3, width: stallW*1.1, depth: stallD*1.2, height: 0.1, color: ['#cc3333','#3355cc','#33cc55','#ddaa22'][Math.floor(rand()*4)], shape: 'box', polyCount: 5, rotation: 0.1, parent_name: 'ROOT' }); // Table in front rawBuildings.push({ name: '', x: sx, y: 0.1, z: sz + stallD*0.6, width: stallW*0.8, depth: stallD*0.4, height: 0.8, color: '#8b5a2b', shape: 'box', polyCount: 5, parent_name: 'ROOT' }); } @@ -1407,7 +1414,7 @@ export const generateThemedBuildingsForPlot = ( rawBuildings.push(root); const key = getGridKey(bx, bz); if(!spatialGrid[key]) spatialGrid[key] = []; spatialGrid[key].push(root); - const levels = 2 + Math.floor(Math.random() * 2); // 2 to 3 levels + const levels = 2 + Math.floor(rand() * 2); // 2 to 3 levels const levelH = 4.0; const wingW = bw * 0.3; const wingD = bd * 0.3; diff --git a/frontend/src/components/MapElements.tsx b/frontend/src/components/MapElements.tsx index 313a59a..be4b211 100644 --- a/frontend/src/components/MapElements.tsx +++ b/frontend/src/components/MapElements.tsx @@ -7,7 +7,7 @@ import { parseOverpassPoints, sampleOverpassPath, buildOverpassGeometry } from ' import { chainRoadPolylines, buildRoadRibbonGeometry } from '../utils/roadHelpers'; import { ThemeContext } from '../theme/themes'; -export const DistrictInteractions = React.memo(({ view, locations, onSelectionChange, roadTrail, setRoadTrail, waterTrail, setWaterTrail, onWaterDrawEnd, roadDrawMode, snapToGrid, drawingRoadWidth, isBatchSelecting, setSelectedIds, rhombusState, setRhombusState, userName, refreshLocations, token, roadLayerMode }: any) => { +export const DistrictInteractions = React.memo(({ view, locations, onSelectionChange, roadTrail, setRoadTrail, waterTrail, setWaterTrail, onWaterDrawEnd, roadDrawMode, snapToGrid, drawingRoadWidth, isBatchSelecting, setSelectedIds, rhombusState, setRhombusState, userName, refreshLocations, token, roadLayerMode, cityGenDrawMode, genBoundaryTrail, setGenBoundaryTrail, onBoundaryDrawEnd }: any) => { const theme = useContext(ThemeContext); const { camera, gl, controls } = useThree(); const [dragStart, setDragStart] = useState(null); @@ -17,6 +17,13 @@ export const DistrictInteractions = React.memo(({ view, locations, onSelectionCh const mouseScreenPos = useRef<{ x: number, y: number } | null>(null); const waterTrailRef = useRef([]); + // city_gen can either drag a rectangle or trace a boundary. Tracing reuses the water + // path exactly — same pointer handling, same feel — writing to a different trail. + const tracingBoundary = view === 'city_gen' && cityGenDrawMode === 'draw'; + const isTracing = view === 'draw_water' || tracingBoundary; + const setTrail = tracingBoundary ? setGenBoundaryTrail : setWaterTrail; + const activeTrail = tracingBoundary ? genBoundaryTrail : waterTrail; + useFrame((state, delta) => { if (view === 'draw_roads' && isPainting && mouseScreenPos.current && controls) { const rect = gl.domElement.getBoundingClientRect(); @@ -160,11 +167,11 @@ export const DistrictInteractions = React.memo(({ view, locations, onSelectionCh if (controls) (controls as any).enabled = false; setIsPainting(true); setRoadTrail((prev: any) => [...prev, [pos.clone(), pos.clone()]]); - } else if (view === 'draw_water' && setWaterTrail) { + } else if (isTracing && setTrail) { if (controls) (controls as any).enabled = false; setIsPainting(true); const initialPath = [pos.clone()]; - setWaterTrail(initialPath); + setTrail(initialPath); waterTrailRef.current = initialPath; } else if (view === 'district' || view === 'city_gen' || isBatchSelecting) { if (controls) (controls as any).enabled = false; @@ -188,12 +195,12 @@ export const DistrictInteractions = React.memo(({ view, locations, onSelectionCh newPaths[newPaths.length - 1] = currentPath; return newPaths; }); - } else if (view === 'draw_water' && isPainting && setWaterTrail) { + } else if (isTracing && isPainting && setTrail) { const pos = getMouseWorldPos(e); const lastPos = waterTrailRef.current[waterTrailRef.current.length - 1]; if (!lastPos || pos.distanceTo(lastPos) > 0.8) { waterTrailRef.current.push(pos.clone()); - setWaterTrail([...waterTrailRef.current]); + setTrail([...waterTrailRef.current]); } } else if (dragStart) { const pos = getMouseWorldPos(e); setDragEnd(pos.clone()); @@ -204,11 +211,17 @@ export const DistrictInteractions = React.memo(({ view, locations, onSelectionCh mouseScreenPos.current = null; if (controls) (controls as any).enabled = true; if (view === 'draw_roads') { setIsPainting(false); return; } - if (view === 'draw_water') { + if (isTracing) { setIsPainting(false); - if (onWaterDrawEnd && waterTrailRef.current.length > 2) { - onWaterDrawEnd([...waterTrailRef.current]); + const traced = [...waterTrailRef.current]; + if (tracingBoundary) { + // The boundary stays on screen until GENERATE, so the GM can see the + // area they drew. Water clears instead, because it saves immediately. + if (onBoundaryDrawEnd && traced.length > 2) onBoundaryDrawEnd(traced); + waterTrailRef.current = []; + return; } + if (onWaterDrawEnd && traced.length > 2) onWaterDrawEnd(traced); if (setWaterTrail) setWaterTrail([]); waterTrailRef.current = []; return; @@ -269,11 +282,11 @@ export const DistrictInteractions = React.memo(({ view, locations, onSelectionCh ))} )} - {view === 'draw_water' && waterTrail && waterTrail.length > 0 && ( + {isTracing && activeTrail && activeTrail.length > 0 && ( - {waterTrail.map((p: any, i: number) => { - if (i === waterTrail.length - 1) return null; - const pNext = waterTrail[i+1]; + {activeTrail.map((p: any, i: number) => { + if (i === activeTrail.length - 1) return null; + const pNext = activeTrail[i+1]; const dist = p.distanceTo(pNext); if (dist < 0.1) return null; const linePos = p.clone().lerp(pNext, 0.5); @@ -287,11 +300,11 @@ export const DistrictInteractions = React.memo(({ view, locations, onSelectionCh ); })} - {waterTrail.length > 2 && ( + {activeTrail.length > 2 && ( // Draw closing line preview - self.lookAt(waterTrail[0])}> + self.lookAt(activeTrail[0])}> - + diff --git a/frontend/src/components/__tests__/AdminPanel.test.tsx b/frontend/src/components/__tests__/AdminPanel.test.tsx index 3f9bb52..480b853 100644 --- a/frontend/src/components/__tests__/AdminPanel.test.tsx +++ b/frontend/src/components/__tests__/AdminPanel.test.tsx @@ -606,3 +606,537 @@ describe('AdminPanel export tab help text', () => { expect(screen.getByText(/Renders the city as a top-down image or video/)).toBeInTheDocument(); }); }); + +// ─── city generator bounds mode ─────────────────────────────────────────────── + +describe('AdminPanel city generator bounds mode', () => { + const genProps = (over: any = {}): any => ({ + ...baseProps(), + view: 'city_gen', + citySectionType: 'MIXED', + setCitySectionType: vi.fn(), + overpassDensity: 'normal', + setOverpassDensity: vi.fn(), + cityGenDrawMode: 'rect', + setCityGenDrawMode: vi.fn(), + genBoundaryTrail: [], + setGenBoundaryTrail: vi.fn(), + waterBodies: [], + ...over, + }); + + it('offers both bounds modes', () => { + render(); + expect(screen.getByText('DRAG_RECT')).toBeInTheDocument(); + expect(screen.getByText('DRAW_AREA')).toBeInTheDocument(); + }); + + it('prompts to drag while in rectangle mode', () => { + render(); + expect(screen.getByText(/DRAG ON MAP TO SELECT/)).toBeInTheDocument(); + }); + + it('prompts to trace while in draw mode', () => { + render(); + expect(screen.getByText(/HOLD LEFT-CLICK TO TRACE/)).toBeInTheDocument(); + }); + + it('reports the traced point count once a boundary exists', () => { + const trail = [{ x: 0, z: 0 }, { x: 10, z: 0 }, { x: 10, z: 10 }, { x: 0, z: 10 }]; + render(); + expect(screen.getByText(/BOUNDARY_TRACED: 4 POINTS/)).toBeInTheDocument(); + }); + + it('clears the rectangle when switching to draw, so the two cannot both apply', async () => { + const props = genProps(); + render(); + await userEvent.click(screen.getByText('DRAW_AREA')); + expect(props.setCityGenDrawMode).toHaveBeenCalledWith('draw'); + expect(props.setRoadSelectionBounds).toHaveBeenCalledWith(null); + }); + + it('clears the traced boundary when switching back to rectangle', async () => { + const props = genProps({ cityGenDrawMode: 'draw' }); + render(); + await userEvent.click(screen.getByText('DRAG_RECT')); + expect(props.setCityGenDrawMode).toHaveBeenCalledWith('rect'); + expect(props.setGenBoundaryTrail).toHaveBeenCalledWith([]); + }); + + it('offers to clear a traced boundary', async () => { + const trail = [{ x: 0, z: 0 }, { x: 10, z: 0 }, { x: 10, z: 10 }]; + const props = genProps({ cityGenDrawMode: 'draw', genBoundaryTrail: trail }); + render(); + await userEvent.click(screen.getByText('CLEAR_BOUNDARY')); + expect(props.setGenBoundaryTrail).toHaveBeenCalledWith([]); + }); +}); + +describe('AdminPanel layout selector', () => { + const genProps = (over: any = {}): any => ({ + ...baseProps(), + view: 'city_gen', + citySectionType: 'MIXED', + setCitySectionType: vi.fn(), + overpassDensity: 'normal', + setOverpassDensity: vi.fn(), + cityGenDrawMode: 'rect', + setCityGenDrawMode: vi.fn(), + genBoundaryTrail: [], + setGenBoundaryTrail: vi.fn(), + cityLayout: 'BSP', + setCityLayout: vi.fn(), + waterBodies: [], + ...over, + }); + + it('offers every layout', () => { + render(); + const select = screen.getByLabelText('LAYOUT') as HTMLSelectElement; + expect([...select.options].map(o => o.value)).toEqual(['BSP', 'GRID', 'SUPERBLOCK', 'RING', 'VORONOI', 'PERIMETER']); + }); + + it('defaults to the organic layout, so generation is unchanged out of the box', () => { + render(); + expect((screen.getByLabelText('LAYOUT') as HTMLSelectElement).value).toBe('BSP'); + }); + + it('describes what each layout produces rather than naming the algorithm', () => { + render(); + const select = screen.getByLabelText('LAYOUT') as HTMLSelectElement; + expect(select.options[1].textContent).toMatch(/SQUARE BLOCKS/); + expect(select.options[2].textContent).toMatch(/TOWER IN PARK/); + expect(select.options[3].textContent).toMatch(/BELTWAYS AND SPOKES/); + }); + + it('reports a layout change', async () => { + const props = genProps(); + render(); + await userEvent.selectOptions(screen.getByLabelText('LAYOUT'), 'GRID'); + expect(props.setCityLayout).toHaveBeenCalledWith('GRID'); + }); +}); + +describe('AdminPanel stays on the generator after generating', () => { + const genProps = (over: any = {}): any => ({ + ...baseProps(), + view: 'city_gen', + citySectionType: 'MIXED', + setCitySectionType: vi.fn(), + overpassDensity: 'normal', + setOverpassDensity: vi.fn(), + cityGenDrawMode: 'rect', + setCityGenDrawMode: vi.fn(), + genBoundaryTrail: [], + setGenBoundaryTrail: vi.fn(), + cityLayout: 'BSP', + setCityLayout: vi.fn(), + roadSelectionBounds: { min: { x: -50, z: -50 }, max: { x: 50, z: 50 } }, + waterBodies: [], + locations: [], + roads: [], + refreshOverpasses: vi.fn(), + ...over, + }); + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response), + )); + }); + + it('does not send the admin back to the main panel', async () => { + // Iterating on layout and density means regenerating repeatedly; being kicked + // back to the list every time made that tedious. + const props = genProps(); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + expect(props.setView).not.toHaveBeenCalledWith('list'); + vi.unstubAllGlobals(); + }); + + it('keeps the selected area, so it can be regenerated without re-selecting', async () => { + const props = genProps(); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + expect(props.setRoadSelectionBounds).not.toHaveBeenCalledWith(null); + vi.unstubAllGlobals(); + }); +}); + +describe('AdminPanel city seed', () => { + const genProps = (over: any = {}): any => ({ + ...baseProps(), + view: 'city_gen', + citySectionType: 'MIXED', + setCitySectionType: vi.fn(), + overpassDensity: 'normal', + setOverpassDensity: vi.fn(), + cityGenDrawMode: 'rect', + setCityGenDrawMode: vi.fn(), + genBoundaryTrail: [], + setGenBoundaryTrail: vi.fn(), + cityLayout: 'BSP', + setCityLayout: vi.fn(), + citySeed: '', + setCitySeed: vi.fn(), + lastCitySeed: '', + setLastCitySeed: vi.fn(), + roadSelectionBounds: { min: { x: -50, z: -50 }, max: { x: 50, z: 50 } }, + waterBodies: [], + locations: [], + roads: [], + refreshOverpasses: vi.fn(), + ...over, + }); + + it('offers a seed field that defaults to random', () => { + render(); + const field = screen.getByLabelText(/SEED/) as HTMLInputElement; + expect(field.value).toBe(''); + expect(field.placeholder).toBe('RANDOM'); + }); + + it('says what a seed actually reproduces', () => { + // A seed is not a city on its own; without saying so it reads as a bug when the + // same seed over a different area builds something else. + render(); + expect(screen.getByText(/SAME SEED \+ SAME AREA \+ SAME OPTIONS/)).toBeInTheDocument(); + }); + + it('reports a typed seed', async () => { + const props = genProps(); + render(); + await userEvent.type(screen.getByLabelText(/SEED/), '7'); + expect(props.setCitySeed).toHaveBeenCalledWith('7'); + }); + + it('clears the field, which is how a fresh seed is rolled', async () => { + const props = genProps({ citySeed: '12345' }); + render(); + await userEvent.click(screen.getByTitle('CLEAR SEED')); + expect(props.setCitySeed).toHaveBeenCalledWith(''); + }); + + it('reports the seed it rolled without filling the field', async () => { + // Filling the input meant every later regenerate silently rebuilt the same city. + vi.stubGlobal('fetch', vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response), + )); + const props = genProps({ citySeed: '' }); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + + const reported = props.setLastCitySeed.mock.calls.map((c: unknown[]) => c[0]); + expect(reported.some((v: string) => v !== '' && Number.isFinite(Number(v)))).toBe(true); + expect(props.setCitySeed).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it('rolls a different seed each time the field is left blank', async () => { + vi.stubGlobal('fetch', vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response), + )); + const props = genProps({ citySeed: '' }); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + + const reported = props.setLastCitySeed.mock.calls.map((c: unknown[]) => c[0]); + expect(new Set(reported).size).toBeGreaterThan(1); + vi.unstubAllGlobals(); + }); + + it('shows the last seed used, and reuses it when clicked', async () => { + const props = genProps({ citySeed: '', lastCitySeed: '4821960374' }); + render(); + await userEvent.click(screen.getByTitle('REUSE THIS SEED')); + expect(props.setCitySeed).toHaveBeenCalledWith('4821960374'); + }); + + it('shows no readout before anything has been generated', () => { + render(); + expect(screen.queryByTitle('REUSE THIS SEED')).not.toBeInTheDocument(); + }); + + it('never rewrites a seed the admin typed', async () => { + // Normalising it looked like the field being cleared and replaced. + vi.stubGlobal('fetch', vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response), + )); + const props = genProps({ citySeed: '464654654' }); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + expect(props.setCitySeed).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it('leaves a worded seed alone too', async () => { + vi.stubGlobal('fetch', vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response), + )); + const props = genProps({ citySeed: 'NIGHTCITY' }); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + expect(props.setCitySeed).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it('offers UNDO on the generator panel', () => { + render(); + expect(screen.getByText('⟲ UNDO')).toBeInTheDocument(); + }); + + it('posts to the undo endpoint from the generator', async () => { + const fetchMock = vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({ type: 'location_create' }) } as Response), + ); + vi.stubGlobal('fetch', fetchMock); + render(); + await userEvent.click(screen.getByText('⟲ UNDO')); + expect(fetchMock).toHaveBeenCalledWith('/api/undo', expect.objectContaining({ method: 'POST' })); + vi.unstubAllGlobals(); + }); +}); + +describe('AdminPanel regenerate', () => { + const genProps = (over: any = {}): any => ({ + ...baseProps(), + view: 'city_gen', + citySectionType: 'MIXED', + setCitySectionType: vi.fn(), + overpassDensity: 'normal', + setOverpassDensity: vi.fn(), + cityGenDrawMode: 'rect', + setCityGenDrawMode: vi.fn(), + genBoundaryTrail: [], + setGenBoundaryTrail: vi.fn(), + cityLayout: 'BSP', + setCityLayout: vi.fn(), + citySeed: '42', + setCitySeed: vi.fn(), + lastCitySeed: '', + setLastCitySeed: vi.fn(), + roadSelectionBounds: { min: { x: -50, z: -50 }, max: { x: 50, z: 50 } }, + waterBodies: [], + locations: [], + roads: [], + refreshOverpasses: vi.fn(), + ...over, + }); + + // An unnamed structure reads as generated under both the real isUserDefinedName and + // the mock this file installs, so the fixture is valid either way. + const generated = (x: number, z: number) => + ({ id: Math.random(), name: '', x, z, y: 0, shape: 'box', battle_map_id: null }); + const named = (x: number, z: number) => + ({ id: Math.random(), name: 'AFTERLIFE', x, z, y: 0, shape: 'box', battle_map_id: null }); + + const stubFetch = () => { + const mock = vi.fn((url: string) => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response), + ); + vi.stubGlobal('fetch', mock); + return mock; + }; + + it('offers both generate and regenerate', () => { + render(); + expect(screen.getByText('GENERATE_CITY_GRID')).toBeInTheDocument(); + expect(screen.getByText('REGENERATE')).toBeInTheDocument(); + }); + + it('does not purge on a plain generate', async () => { + // Infilling is a legitimate use; only REGENERATE clears. + const mock = stubFetch(); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + const purges = mock.mock.calls.filter(([u]) => String(u).includes('purge-region')); + expect(purges).toHaveLength(0); + vi.unstubAllGlobals(); + }); + + it('purges the region before regenerating', async () => { + const mock = stubFetch(); + vi.stubGlobal('confirm', vi.fn(() => true)); + render(); + await userEvent.click(screen.getByText('REGENERATE')); + + const purges = mock.mock.calls.filter(([u]) => String(u).includes('purge-region')); + expect(purges).toHaveLength(1); + expect(purges[0][1]).toMatchObject({ method: 'POST' }); + vi.unstubAllGlobals(); + }); + + it('leads the confirm with how much goes and what survives', async () => { + // "Regenerate?" invites a reflexive yes; a count does not. + const mock = stubFetch(); + const confirmSpy = vi.fn(() => true); + vi.stubGlobal('confirm', confirmSpy); + render(); + await userEvent.click(screen.getByText('REGENERATE')); + + expect(confirmSpy).toHaveBeenCalled(); + const message = String(confirmSpy.mock.calls[0][0]); + expect(message).toContain('removes 2'); + expect(message).toContain('1 named structure'); + void mock; + vi.unstubAllGlobals(); + }); + + it('does nothing when the confirm is declined', async () => { + const mock = stubFetch(); + vi.stubGlobal('confirm', vi.fn(() => false)); + render(); + await userEvent.click(screen.getByText('REGENERATE')); + expect(mock.mock.calls.filter(([u]) => String(u).includes('purge-region'))).toHaveLength(0); + vi.unstubAllGlobals(); + }); + + it('does not ask when the region is empty', async () => { + // The common first-generation case; making it feel dangerous discourages use. + stubFetch(); + const confirmSpy = vi.fn(() => true); + vi.stubGlobal('confirm', confirmSpy); + render(); + await userEvent.click(screen.getByText('REGENERATE')); + expect(confirmSpy).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it('ignores structures outside the region when counting', async () => { + stubFetch(); + const confirmSpy = vi.fn(() => true); + vi.stubGlobal('confirm', confirmSpy); + render(); + await userEvent.click(screen.getByText('REGENERATE')); + expect(confirmSpy).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it('re-reads the world after purging, so it does not build around what is gone', async () => { + // Placement tests against existing locations; stale ones would leave the new city + // avoiding buildings that no longer exist. + const mock = stubFetch(); + vi.stubGlobal('confirm', vi.fn(() => true)); + render(); + await userEvent.click(screen.getByText('REGENERATE')); + + const urls = mock.mock.calls.map(([u]) => String(u)); + const purgeAt = urls.findIndex((u) => u.includes('purge-region')); + const refetchAt = urls.findIndex((u, i) => i > purgeAt && u === '/api/locations'); + expect(purgeAt).toBeGreaterThanOrEqual(0); + expect(refetchAt).toBeGreaterThan(purgeAt); + vi.unstubAllGlobals(); + }); + + it('re-reads the water too, not just the locations and roads', async () => { + // The purge deletes the last generated river. Generating against the stale water + // list made the new city avoid a river that was no longer there, leaving a dead + // band of empty ground tracing where the old one ran. + const mock = stubFetch(); + vi.stubGlobal('confirm', vi.fn(() => true)); + render(); + await userEvent.click(screen.getByText('REGENERATE')); + + const urls = mock.mock.calls.map(([u]) => String(u)); + const purgeAt = urls.findIndex((u) => u.includes('purge-region')); + const waterRefetchAt = urls.findIndex((u, i) => i > purgeAt && u === '/api/water'); + expect(waterRefetchAt).toBeGreaterThan(purgeAt); + vi.unstubAllGlobals(); + }); +}); + +describe('AdminPanel water selector', () => { + const genProps = (over: any = {}): any => ({ + ...baseProps(), + view: 'city_gen', + citySectionType: 'MIXED', + setCitySectionType: vi.fn(), + overpassDensity: 'normal', + setOverpassDensity: vi.fn(), + cityGenDrawMode: 'rect', + setCityGenDrawMode: vi.fn(), + genBoundaryTrail: [], + setGenBoundaryTrail: vi.fn(), + cityLayout: 'BSP', + setCityLayout: vi.fn(), + citySeed: '42', + setCitySeed: vi.fn(), + lastCitySeed: '', + setLastCitySeed: vi.fn(), + cityWater: 'NONE', + setCityWater: vi.fn(), + roadSelectionBounds: { min: { x: -300, z: -300 }, max: { x: 300, z: 300 } }, + waterBodies: [], + locations: [], + roads: [], + refreshOverpasses: vi.fn(), + fetchWaterBodies: vi.fn(), + ...over, + }); + + const stubFetch = () => { + const mock = vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as Response), + ); + vi.stubGlobal('fetch', mock); + return mock; + }; + + it('offers every water type', () => { + render(); + const select = screen.getByLabelText('WATER') as HTMLSelectElement; + expect([...select.options].map(o => o.value)).toEqual(['NONE', 'RIVER', 'COAST', 'LAKE']); + }); + + it('defaults to none, so existing generation is unchanged', () => { + // NONE doubles as the off switch, rather than a checkbox that could disagree + // with the selector. + render(); + expect((screen.getByLabelText('WATER') as HTMLSelectElement).value).toBe('NONE'); + }); + + it('reports a water choice', async () => { + const props = genProps(); + render(); + await userEvent.selectOptions(screen.getByLabelText('WATER'), 'RIVER'); + expect(props.setCityWater).toHaveBeenCalledWith('RIVER'); + }); + + it('persists no water when set to none', async () => { + const mock = stubFetch(); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + expect(mock.mock.calls.filter(([u]) => String(u) === '/api/water')).toHaveLength(0); + vi.unstubAllGlobals(); + }); + + it('persists a generated river, marked so a regenerate can clear it', async () => { + const mock = stubFetch(); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + + const posts = mock.mock.calls.filter(([u]) => String(u) === '/api/water'); + expect(posts).toHaveLength(1); + const body = JSON.parse(String((posts[0][1] as RequestInit).body)); + expect(body.generated).toBe(true); + expect(body.points.length).toBeGreaterThan(2); + vi.unstubAllGlobals(); + }); + + it('saves the water before the roads it shaped', async () => { + const mock = stubFetch(); + render(); + await userEvent.click(screen.getByText('GENERATE_CITY_GRID')); + + const urls = mock.mock.calls.map(([u]) => String(u)); + const waterAt = urls.indexOf('/api/water'); + const roadsAt = urls.indexOf('/api/roads'); + expect(waterAt).toBeGreaterThanOrEqual(0); + if (roadsAt >= 0) expect(waterAt).toBeLessThan(roadsAt); + vi.unstubAllGlobals(); + }); +}); diff --git a/package.json b/package.json index 99a1fc6..6c12b9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapsystem", - "version": "1.7.4", + "version": "1.8.0", "description": "", "main": "index.js", "scripts": {