Layer-1 CityPlan generator per CITY_SPEC schema v1 — pure data, no THREE, all randomness
through core/prng, deterministic + JSON-serializable + <1ms.
- web/js/citygen/plan.js: generatePlan(citySeed) → CityPlan (spine, cross-streets, market
square + dept anchor, arcade, warehouse fringe, residential collar w/ corner milkbars,
laneways) + chunkIndex (Amanatides–Woo edge supercover) + corner overlap-resolution pass.
- web/js/core/registry.js: SHOP_TYPES (all 9) + district weights + enums (shared vocabulary).
- web/js/citygen/names.js + wordlists.js: seeded 90s-AU shop/town names, 50+ patterns.
- web/js/citygen/index.js: canonical barrel entry (adopts Lane F's integration shim).
- web/map.html: standalone Canvas-2D plan debugger (pan/zoom/hover, layers, export JSON).
- web/js/citygen/selfcheck.js: node acceptance harness — determinism, golden fingerprint,
facing, within- AND cross-block no-overlap, chunk+edge coverage, finiteness, assets. ALL GREEN
(1271/1271) over 6 seeds. web/package.json ({"type":"module"}) lets node run it.
- docs/shots/laneA/: 5 seeds screenshotted, each reads as a town.
Fixes from an adversarial multi-agent review (15 confirmed defects): market facades faced the
wrong way; cross-block corner overlaps; chunkIndex skipped chunks incl. the origin/spine;
stall frontEdge z-band; doubled-possessive names; unparseable map road colour; +others.
CITY_SPEC amendment (treaty, same commit): shop-types table aligned to the registry lanes
import; documented lot.ry/frontEdge, block.kind/district-id, and web/package.json.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
5.1 KiB
JavaScript
115 lines
5.1 KiB
JavaScript
// PROCITY shop & town name generator — seeded, deterministic, 90s-Australian flavour.
|
||
// Given a shop's stable seed, produces the same {name, sign} forever. `sign` is the short form
|
||
// that fits a signboard (the game renders it big; `name` is the full directory/label string).
|
||
//
|
||
// Pure: only imports prng (mulberry32) + wordlists. No THREE, no network, no Math.random.
|
||
|
||
import { mulberry32 } from '../core/prng.js';
|
||
import * as W from './wordlists.js';
|
||
|
||
const pick = (r, arr) => arr[(r() * arr.length) | 0];
|
||
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
||
const initials = (r) => pick(r, ['A', 'B', 'C', 'D', 'E', 'G', 'J', 'K', 'M', 'P', 'R', 'S', 'T', 'V'])
|
||
+ ' & ' + pick(r, ['A', 'B', 'C', 'D', 'E', 'G', 'J', 'K', 'M', 'P', 'R', 'S', 'T', 'V']);
|
||
|
||
// Token → wordlist resolver. Each token draws from the bank with the given stream.
|
||
function tok(r, t) {
|
||
switch (t) {
|
||
case 'Word': return pick(r, W.WORDS);
|
||
case 'Adj': return pick(r, W.ADJ);
|
||
case 'Noun': return pick(r, W.NOUN);
|
||
case 'Suburb': return pick(r, W.SUBURB);
|
||
case 'Family': return pick(r, W.FAMILY);
|
||
case 'Saint': return pick(r, W.SAINT);
|
||
case 'Charity': return pick(r, W.CHARITY);
|
||
case 'First': return pick(r, W.FIRSTNAME);
|
||
case 'Animal': return pick(r, W.ANIMAL);
|
||
case 'Init': return initials(r);
|
||
default: return t;
|
||
}
|
||
}
|
||
|
||
// Fill "{Adj} {Noun}" style token patterns. Possessives are written with a literal ’s in the
|
||
// pattern itself (e.g. "{First}’s Records", "{Saint}’s Charity Barn") — one mechanism, no magic.
|
||
function fill(r, pattern) {
|
||
return pattern.replace(/\{(\w+)\}/g, (_, t) => tok(r, t));
|
||
}
|
||
|
||
// ── name patterns per shop type (≥40 across the board) ─────────────────────────────
|
||
const PATTERNS = {
|
||
record: [
|
||
'{Word} Records', '{Suburb} Sound Exchange', '{Adj} Groove', '{Word} & Wax',
|
||
'Rotation', '{First}’s Records', 'The {Animal} Sound Co.', '{Suburb} Vinyl',
|
||
],
|
||
opshop: [
|
||
'St {Saint}’s Opportunity Shop', '{Charity} Op Shop', 'The {Adj} Op Shop',
|
||
'{Suburb} Recycled', '{Saint}’s Charity Barn', 'Second Time {Suburb}', 'The Op Shop',
|
||
],
|
||
toy: [
|
||
'{First}’s Toys', 'The {Adj} Toy Box', '{Word} Toys & Games', '{Animal} Playthings',
|
||
'{Suburb} Toyworld-ish', 'Marbles & Kites', 'The Toy Nook',
|
||
],
|
||
book: [
|
||
'The {Adj} Book Barn', '{Suburb} Books', '{Word} & Sons Booksellers', 'The {Animal} Bookshop',
|
||
'Dusty Pages', '{Init} Books', 'Second-Hand Reads', 'The Book Cellar',
|
||
],
|
||
video: [
|
||
'{Suburb} Video', '{Adj} Video', 'Video {Word}', '{First}’s Video',
|
||
'The Picture Show', 'Late Nite Video', 'Video Barn',
|
||
],
|
||
pawn: [
|
||
'{Suburb} Cash Converters-ish', '{First}’s Pawn & Loan', '{Adj} Cash', 'The Cash Corner',
|
||
'{Init} Trading Co.', 'Quick Cash Pawn', 'Second Chance Loans',
|
||
],
|
||
milkbar: [
|
||
'{Family} Milk Bar', '{Suburb} Milk Bar', 'The {Adj} Corner Store', '{First}’s Milk Bar',
|
||
'{Family}’s Deli & Milk Bar', 'Corner Store', 'The Milk Bar',
|
||
],
|
||
dept: [
|
||
'{Suburb} Emporium', '{Family} & Co.', 'The Grand {Word}', '{Adj} Department Store',
|
||
'{Suburb} Trading Co.', 'Palais {Word}',
|
||
],
|
||
stall: [
|
||
'{First}’s Stall', '{Word} Stall', '{Adj} Bits', 'Trash & Treasure', '{Animal} Wares',
|
||
'Bric-a-Brac', 'Sunday Stall',
|
||
],
|
||
};
|
||
|
||
// ── short signboard forms (what actually fits above the door) ──────────────────────
|
||
const SIGN_PATTERNS = {
|
||
record: ['{Word}', 'RECORDS', 'VINYL', '{Suburb} SOUND'],
|
||
opshop: ['OP SHOP', 'ST {Saint}’S', '{Charity}', 'RECYCLED'],
|
||
toy: ['TOYS', '{Adj} TOYS', 'TOY BOX', 'GAMES'],
|
||
book: ['BOOKS', 'BOOK BARN', '{Suburb} BOOKS', 'READS'],
|
||
video: ['VIDEO', '{Suburb} VIDEO', 'MOVIES', 'RENTALS'],
|
||
pawn: ['CASH', 'PAWN', 'LOANS', '{Init}'],
|
||
milkbar: ['MILK BAR', '{Family}', 'DELI', 'CORNER STORE'],
|
||
dept: ['{Suburb}', 'EMPORIUM', '{Family} & CO'],
|
||
stall: ['STALL', 'BITS', 'BRIC-A-BRAC', 'WARES'],
|
||
};
|
||
|
||
// Public: name a shop from its stable seed + type. Returns { name, sign }.
|
||
export function shopName(seed, type) {
|
||
const r = mulberry32((seed ^ 0x9e3779b9) >>> 0); // decorrelate from other seed uses
|
||
const pats = PATTERNS[type] || PATTERNS.stall;
|
||
const signs = SIGN_PATTERNS[type] || SIGN_PATTERNS.stall;
|
||
const name = fill(r, pick(r, pats)).replace(/\s+/g, ' ').trim();
|
||
let sign = fill(r, pick(r, signs)).replace(/\s+/g, ' ').trim();
|
||
// Signboard can't be too long — fall back to a punchy word from the full name.
|
||
if (sign.length > 16) {
|
||
const word = name.split(' ').sort((a, b) => b.length - a.length)[0] || sign;
|
||
sign = word.slice(0, 16).toUpperCase();
|
||
}
|
||
return { name, sign };
|
||
}
|
||
|
||
// Public: name the town from the city seed.
|
||
export function townName(citySeed) {
|
||
const r = mulberry32((citySeed ^ 0x2545f491) >>> 0);
|
||
const root = pick(r, W.TOWN_ROOT);
|
||
const suffix = pick(r, W.TOWN_SUFFIX);
|
||
const usePrefix = r() < 0.35;
|
||
const prefix = usePrefix ? pick(r, W.TOWN_PREFIX) + ' ' : '';
|
||
return `${prefix}${cap(root)}${suffix ? ' ' + suffix : ''}`.trim();
|
||
}
|