PROCITY/web/js/citygen/names.js
m3ultra 8b7ca9aae4 Lane A (CityGen): deterministic town generator, registry, names, 2D map, self-check
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>
2026-07-14 12:06:41 +10:00

115 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();
}