new name system?

This commit is contained in:
33333-33333 2026-05-21 02:55:58 +09:00
commit db8265384c
5 changed files with 525 additions and 232 deletions

View file

@ -57,9 +57,9 @@
<div class="card-title">Name Override IDs</div> <div class="card-title">Name Override IDs</div>
<p>Add entries to <code>names.js</code> in <code>CUSTOM_NAMES</code>.</p> <p>Add entries to <code>names.js</code> in <code>CUSTOM_NAMES</code>.</p>
<pre class="example">export const CUSTOM_NAMES = { <pre class="example">export const CUSTOM_NAMES = {
"city-0": "Aohara", "city-0": "CA",
"port-0": "Shirahama", "port-0": "PB",
"castle-0": "Kurono" "castle-0": "KC"
};</pre> };</pre>
<div id="nameIds" class="id-list"></div> <div id="nameIds" class="id-list"></div>
</section> </section>

View file

@ -1,4 +1,4 @@
import { CUSTOM_NAMES, NAME_PARTS, contextualDefaultName } from "./names.js"; import { createNameDebug, generateEntityName } from "./names.js";
import { import {
applyLandscapeUnitAdminPartition, applyLandscapeUnitAdminPartition,
generateAdminRegions, generateAdminRegions,
@ -561,21 +561,11 @@ function tagInsidePrefecture(points, prefectureMask) {
return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) })); return points.map((p) => ({ ...p, insidePrefecture: Boolean(prefectureMask[indexOf(p.x, p.y)]) }));
} }
function defaultName(seed, id, entity, nameFields, attempt = 0) { function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null, nameDebug = null) {
return contextualDefaultName(seed, id, entity, NAME_PARTS, nameFields, attempt);
}
function attachIdsAndNames(points, prefix, seed, kindOverride = null, nameFields = null, usedNames = null) {
return points.map((p, i) => { return points.map((p, i) => {
const id = `${prefix}-${i}`; const id = `${prefix}-${i}`;
const kind = kindOverride || p.kind; const kind = kindOverride || p.kind;
let name = CUSTOM_NAMES[id]; const name = generateEntityName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, usedNames, nameDebug);
if (!name) {
for (let attempt = 0; attempt < 8; attempt++) {
name = defaultName(seed + prefix.length * 1000, id, { ...p, kind }, nameFields, attempt);
if (!usedNames || !usedNames.has(name)) break;
}
}
if (usedNames) usedNames.add(name); if (usedNames) usedNames.add(name);
return { return {
...p, ...p,
@ -2912,26 +2902,27 @@ export function generateMap(seedInput = 114514, options = {}) {
const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0); const abandonedRailways = branchRailways.filter((_, i) => i % 3 === 0);
let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" })); let castleRuins = castles.filter((_, i) => i % 2 === 1).map((c) => ({ ...c, kind: "Castle Ruins" }));
const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0); const preservedOldRoads = premodernRoads.filter((_, i) => i % 2 === 0);
const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland }; const nameFields = { elevation, slope, sea, river, plain, agriculture, ridgeField, valleyField, basinField, coastalLowland, flowAccum, landuse, populationDensity };
const usedNames = new Set(Object.values(CUSTOM_NAMES)); const usedNames = new Set();
const nameDebug = createNameDebug();
villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames); villages = attachIdsAndNames(tagInsidePrefecture(villages, prefectureMask), "village", seed, null, nameFields, usedNames, nameDebug);
ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames); ports = attachIdsAndNames(tagInsidePrefecture(ports, prefectureMask), "port", seed, null, nameFields, usedNames, nameDebug);
crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames); crossings = attachIdsAndNames(tagInsidePrefecture(crossings, prefectureMask), "crossing", seed, null, nameFields, usedNames, nameDebug);
passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames); passes = attachIdsAndNames(tagInsidePrefecture(passes, prefectureMask), "pass", seed, null, nameFields, usedNames, nameDebug);
markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames); markets = attachIdsAndNames(tagInsidePrefecture(markets, prefectureMask), "market", seed, null, nameFields, usedNames, nameDebug);
castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames); castles = attachIdsAndNames(tagInsidePrefecture(castles, prefectureMask), "castle", seed, null, nameFields, usedNames, nameDebug);
castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames); castleTowns = attachIdsAndNames(tagInsidePrefecture(castleTowns, prefectureMask), "castleTown", seed, null, nameFields, usedNames, nameDebug);
modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames); modernCities = attachIdsAndNames(tagInsidePrefecture(modernCities, prefectureMask), "city", seed, null, nameFields, usedNames, nameDebug);
stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames); stations = attachIdsAndNames(tagInsidePrefecture(stations, prefectureMask), "station", seed, null, nameFields, usedNames, nameDebug);
industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames); industrialZones = attachIdsAndNames(tagInsidePrefecture(industrialZones, prefectureMask), "industrial", seed, null, nameFields, usedNames, nameDebug);
interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames); interchanges = attachIdsAndNames(tagInsidePrefecture(interchanges, prefectureMask), "interchange", seed, null, nameFields, usedNames, nameDebug);
logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames); logisticsParks = attachIdsAndNames(tagInsidePrefecture(logisticsParks, prefectureMask), "logistics", seed, null, nameFields, usedNames, nameDebug);
satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames); satelliteCities = attachIdsAndNames(tagInsidePrefecture(satelliteCities, prefectureMask), "satellite", seed, null, nameFields, usedNames, nameDebug);
newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames); newTowns = attachIdsAndNames(tagInsidePrefecture(newTowns, prefectureMask), "newtown", seed, null, nameFields, usedNames, nameDebug);
castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames); castleRuins = attachIdsAndNames(tagInsidePrefecture(castleRuins, prefectureMask), "castleRuin", seed, null, nameFields, usedNames, nameDebug);
externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames); externalGateways = attachIdsAndNames(tagInsidePrefecture(externalGateways, prefectureMask), "gateway", seed, "External Gateway", nameFields, usedNames, nameDebug);
const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames); const adminCenters = attachIdsAndNames(tagInsidePrefecture(adminCentersRaw, prefectureMask), "admin", seed, "Municipal Center", nameFields, usedNames, nameDebug);
const entitiesForNames = [ const entitiesForNames = [
...modernCities, ...modernCities,
@ -3019,5 +3010,6 @@ export function generateMap(seedInput = 114514, options = {}) {
smallStreams, smallStreams,
externalGateways, externalGateways,
entitiesForNames, entitiesForNames,
nameDebug,
}, options); }, options);
} }

621
names.js
View file

@ -1,67 +1,233 @@
import { MAP_H, MAP_W, indexOf, inside } from "./grid.js"; import { MAP_H, MAP_W, indexOf, inside } from "./grid.js";
import { hash2 } from "./random.js"; import { hash2 } from "./random.js";
// Default and custom place-name resources. export const NAME_KANJI_POOLS = {
// Edit only this file to change generated names or override individual IDs. modifiers: [],
inlandTerrain: [],
export const NAME_PARTS = { waterTerrain: [],
// May appear only at the beginning of a name. coastalTerrain: [],
prefixOnly: ["青", "白", "黒", "赤", "藍", "大", "小", "新", "古", "浅", "深", "上", "下", "東", "西", "南", "北", "中", "本", "広", "元", "奥", "前", "高", "長", "丸", "一", "壱", "二", "三", "四", "五", "六", "七", "八", "九", "十", "五十", "百", "千", "万", "阿", "佐々", "代々", "千代", "御", "宇", "須", "伊", "牟", "美",], plants: [],
postfixes: [],
// Generic morphemes that may appear at the beginning or in the middle. archaicPrefixes: [],
free: [ archaicSuffixes: [],
//nature settlementWords: [],
"川", "山", "花", "森", "松", "竹", "梅", "柳", "栃", "杉", "萩", "荻", "柿", "栗", "椎", "榊", "篠", "桑", "笹", "蓮", "橘", "柏", "樫", "麻", "桜", "岡", "木", "林", "森", "根", "葉", "葦", "日", "月", "君",
"野", "尾", "砂", "鮎", "鯉", "鯖", "雁", "菊", "錦", "稲", "稗", "朝", "霧", "坂", "葵", "茜", "犬", "馬", "熊", "猿", "鶴", "鹿", "鷲", "鷹", "亀", "龍", "竜", "燕", "鴨",
//objects
"畠", "坪", "室", "門", "脇", "末", "硯", "釜", "眞", "綾", "倉", "蔵", "鎌", "窯", "弓", "巻", "槙", "薪", "秦", "幡", "井", "真", "田", "井", "口", "関", "矢", "手", "戸", "庄", "村", "土", "鋸", "酒", "笙", "彦", "比", "碑", "多", "垣", "辻",
//others
"豊", "志", "野辺", "那", "納", "延", "芳", "越", "喜", "久", "方", "賀", "雅", "角", "刈", "神", "乃", "曲", "成", "幸", "徳", "甲", "由", "和", "清", "弘", "保", "徳", "福", "富", "住", "幸", "弥", "住", "生", "昌", "相", "香", "吉", "𠮷", "佐", "多", "保", "加", "間", "衣"
],
// May appear only at the end of a name.
suffixOnly: ["宿", "口", "摩", "磨", "茂", "平", "幡", "島", "豊", "川", "多", "田", "谷", "和", "屋"],
// Context-aware morphemes that may appear at the beginning or in the middle.
contextFree: {
coastal: ["浜", "浦", "津", "湊", "潮", "磯", "島", "磯", "渚", "潮", "汐", "淵", "渕", "泊", "葦", "芦"],
river: ["瀬", "川", "橋", "渡", "沢", "澤", "淵", "沼", "堀", "渕", "淵"],
plain: ["原", "野", "田", "畠", "里", "平", "牧", "塚"],
mountain: ["山", "谷", "沢", "峰", "尾", "鞍", "久保", "迫", "岳", "窪", "玖保", "久芳", "檜", "桧", "入", "奥"],
historic: ["城", "館", "宮", "寺", "社", "陣", "府", "町", "妙", "忌", "地蔵", "裏", "表"],
suburban: [],
},
// Context-aware morphemes that may appear only at the end of a name.
contextSuffixes: {
coastal: ["浜", "湊", "灘", "浦", "津", "崎", "嶋", "潟", "洲"],
river: ["川", "瀬", "橋", "渡", "沼", "沢", "滝", "瀧"],
plain: ["原", "野", "田", "里", "村", "平", "鼻", "栖"],
mountain: ["谷", "峡", "尾根", "山", "沢", "麓", "嶽", "隅", "陰", "奥"],
historic: ["城", "通", "館", "宮", "町", "寺", "院", "門", "柵", "稲荷"],
suburban: ["丘", "台", "野", "原", "新町", "ヶ丘"],
},
}; };
export const NAME_PROBABILITIES = { export const NAME_PROBABILITIES = {
prefixOnly: 0.36, customName: 0.20,
contextFreeAsMain: 0.34, forcedName: 1.0,
additionalContextFree: 0.22, retryCount: 24,
secondFree: 0.18,
finalContextSuffix: 0.10, categoryFallback: {
finalSuffixOnly: 0.50, modifiers: 1.0,
finalFree: 0.20, inlandTerrain: 1.0,
twoCharacter: 0.80, waterTerrain: 1.0,
coastalTerrain: 1.0,
plants: 1.0,
postfixes: 1.0,
archaicPrefixes: 1.0,
archaicSuffixes: 1.0,
settlementWords: 1.0,
},
contextCategoryWeights: {
generic: {
inlandTerrain: 1.0,
waterTerrain: 0.6,
coastalTerrain: 0.2,
plants: 0.8,
postfixes: 0.7,
archaic: 0.25,
settlementWords: 0.6,
},
coastal: {
inlandTerrain: 0.25,
waterTerrain: 0.8,
coastalTerrain: 1.0,
plants: 0.5,
postfixes: 0.7,
archaic: 0.2,
settlementWords: 0.6,
},
river: {
inlandTerrain: 0.5,
waterTerrain: 1.0,
coastalTerrain: 0.1,
plants: 0.6,
postfixes: 0.7,
archaic: 0.25,
settlementWords: 0.6,
},
plain: {
inlandTerrain: 1.0,
waterTerrain: 0.35,
coastalTerrain: 0.05,
plants: 0.9,
postfixes: 0.8,
archaic: 0.25,
settlementWords: 0.7,
},
mountain: {
inlandTerrain: 1.0,
waterTerrain: 0.45,
coastalTerrain: 0.02,
plants: 0.85,
postfixes: 0.55,
archaic: 0.35,
settlementWords: 0.45,
},
historic: {
inlandTerrain: 0.65,
waterTerrain: 0.45,
coastalTerrain: 0.25,
plants: 0.6,
postfixes: 0.9,
archaic: 0.75,
settlementWords: 0.9,
},
suburban: {
inlandTerrain: 0.85,
waterTerrain: 0.25,
coastalTerrain: 0.15,
plants: 0.55,
postfixes: 0.85,
archaic: 0.10,
settlementWords: 1.0,
},
},
}; };
export const CONTEXT_SUFFIXES = NAME_PARTS.contextSuffixes; export const NAME_TEMPLATES = {
modifierTerrain: {
export const CUSTOM_NAMES = { slots: ["modifier", "terrain"],
// "city-0": "Aohara", baseWeight: 1.0,
// "port-0": "Shirahama", },
// "castle-0": "Kurono", plantTerrain: {
slots: ["plant", "terrain"],
baseWeight: 0.85,
},
terrainPostfix: {
slots: ["terrain", "postfix"],
baseWeight: 0.75,
},
archaicPair: {
slots: ["archaicPrefix", "archaicSuffix"],
baseWeight: 0.35,
},
archaicTerrain: {
slots: ["archaicPrefix", "terrain"],
baseWeight: 0.30,
},
modifierSettlement: {
slots: ["modifier", "settlement"],
baseWeight: 0.65,
},
}; };
export const NAME_TEMPLATE_WEIGHTS = {
generic: {
modifierTerrain: 1.0,
plantTerrain: 0.8,
terrainPostfix: 0.7,
archaicPair: 0.25,
archaicTerrain: 0.25,
modifierSettlement: 0.55,
},
coastal: {
modifierTerrain: 0.8,
plantTerrain: 0.55,
terrainPostfix: 0.9,
archaicPair: 0.2,
archaicTerrain: 0.25,
modifierSettlement: 0.55,
},
river: {
modifierTerrain: 0.9,
plantTerrain: 0.85,
terrainPostfix: 0.85,
archaicPair: 0.25,
archaicTerrain: 0.25,
modifierSettlement: 0.55,
},
plain: {
modifierTerrain: 1.0,
plantTerrain: 0.95,
terrainPostfix: 0.8,
archaicPair: 0.25,
archaicTerrain: 0.25,
modifierSettlement: 0.7,
},
mountain: {
modifierTerrain: 1.0,
plantTerrain: 0.9,
terrainPostfix: 0.5,
archaicPair: 0.35,
archaicTerrain: 0.45,
modifierSettlement: 0.35,
},
historic: {
modifierTerrain: 0.6,
plantTerrain: 0.45,
terrainPostfix: 0.75,
archaicPair: 0.8,
archaicTerrain: 0.55,
modifierSettlement: 0.95,
},
suburban: {
modifierTerrain: 0.85,
plantTerrain: 0.45,
terrainPostfix: 0.65,
archaicPair: 0.1,
archaicTerrain: 0.1,
modifierSettlement: 1.0,
},
};
export const CUSTOM_NAMES = {};
export const FORCED_NAMES = {};
// Legacy export kept only so older imports do not fail.
export const NAME_PARTS = {};
const POOL_KEYS = Object.keys(NAME_KANJI_POOLS);
const TERRAIN_POOL_KEYS = ["inlandTerrain", "waterTerrain", "coastalTerrain"];
const ASCII_DIAGNOSTIC_PREFIX = "N";
function stableHash(value) {
const text = String(value || "");
let h = 2166136261;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
function roll(seed, id, attempt, salt) {
const h = stableHash(id);
return hash2(h + attempt * 1009 + salt * 9173, seed + salt * 1013, (h ^ seed ^ salt) >>> 0);
}
function pick(pool, seed, id, attempt, salt) {
if (!pool?.length) return null;
const index = Math.floor(roll(seed, id, attempt, salt) * pool.length) % pool.length;
return pool[index] || null;
}
function countChars(value) {
return Array.from(String(value || "")).length;
}
function incrementCounter(counter, key, amount = 1) {
counter[key] = (counter[key] || 0) + amount;
}
function boundedIndex(entity) {
const x = Math.max(0, Math.min(MAP_W - 1, Math.round(entity?.x || 0)));
const y = Math.max(0, Math.min(MAP_H - 1, Math.round(entity?.y || 0)));
return indexOf(x, y);
}
function isNearSea(x, y, sea, radius = 3) { function isNearSea(x, y, sea, radius = 3) {
if (!sea) return false; if (!sea) return false;
for (let dy = -radius; dy <= radius; dy++) { for (let dy = -radius; dy <= radius; dy++) {
@ -74,157 +240,234 @@ function isNearSea(x, y, sea, radius = 3) {
return false; return false;
} }
function boundedIndex(entity) { function contextWeightsFor(context, probabilities = NAME_PROBABILITIES) {
return indexOf(Math.max(0, Math.min(MAP_W - 1, entity.x)), Math.max(0, Math.min(MAP_H - 1, entity.y))); return probabilities.contextCategoryWeights?.[context] || probabilities.contextCategoryWeights?.generic || {};
} }
function chooseNameContext(entity, fields) { function poolWeight(poolKey, context, probabilities = NAME_PROBABILITIES) {
const kind = String(entity.kind || "").toLowerCase(); const weights = contextWeightsFor(context, probabilities);
const i = boundedIndex(entity); const fallback = probabilities.categoryFallback?.[poolKey] ?? 1.0;
const coastal = (fields?.coastalLowland?.[i] || 0) > 0.35 || isNearSea(entity.x, entity.y, fields?.sea); if (poolKey === "archaicPrefixes" || poolKey === "archaicSuffixes") return (weights.archaic ?? 1.0) * fallback;
const river = (fields?.river?.[i] || 0) > 0.35 || (fields?.valleyField?.[i] || 0) > 0.42; return (weights[poolKey] ?? 1.0) * fallback;
const plain = (fields?.plain?.[i] || 0) > 0.48 || (fields?.agriculture?.[i] || 0) > 0.46 || (fields?.basinField?.[i] || 0) > 0.35; }
const mountain = (fields?.elevation?.[i] || 0) > 0.52 || (fields?.slope?.[i] || 0) > 0.35 || (fields?.ridgeField?.[i] || 0) > 0.42;
if (kind.includes("port") || kind.includes("harbor") || entity.portClass) return "coastal"; function slotPoolKeys(slot, context, probabilities = NAME_PROBABILITIES) {
if (slot === "terrain") {
return weightedKeyOrder(TERRAIN_POOL_KEYS, context, probabilities, 113);
}
const map = {
modifier: "modifiers",
plant: "plants",
postfix: "postfixes",
archaicPrefix: "archaicPrefixes",
archaicSuffix: "archaicSuffixes",
settlement: "settlementWords",
};
return map[slot] ? [map[slot]] : [];
}
function weightedKeyOrder(keys, context, probabilities, salt) {
return [...keys].sort((a, b) => {
const wa = poolWeight(a, context, probabilities);
const wb = poolWeight(b, context, probabilities);
if (wb !== wa) return wb - wa;
return stableHash(`${a}:${salt}`) - stableHash(`${b}:${salt}`);
});
}
function weightedChoice(entries, seed, id, attempt, salt) {
const active = entries.filter((entry) => entry.weight > 0);
const total = active.reduce((sum, entry) => sum + entry.weight, 0);
if (total <= 0) return null;
let target = roll(seed, id, attempt, salt) * total;
for (const entry of active) {
target -= entry.weight;
if (target <= 0) return entry.key;
}
return active[active.length - 1]?.key || null;
}
function pickWeightedPoolKey(keys, context, pools, probabilities, seed, id, attempt, salt) {
const entries = keys.map((key) => ({
key,
weight: poolWeight(key, context, probabilities) * (pools[key]?.length ? 1 : 0),
}));
return weightedChoice(entries, seed, id, attempt, salt);
}
export function createNameDebug(probabilities = NAME_PROBABILITIES, pools = NAME_KANJI_POOLS) {
const emptyPools = POOL_KEYS.filter((key) => !pools[key]?.length);
const poolsPresent = Object.fromEntries(POOL_KEYS.map((key) => [key, Boolean(pools[key]?.length)]));
return {
effectiveCustomNameProbability: probabilities.customName,
poolsPresent,
emptyPools,
selectedTemplateCounts: {},
selectedContextCounts: {},
customNamesUsed: 0,
forcedNamesUsed: 0,
generatedNamesUsed: 0,
invalidNamesRejected: 0,
oneCharacterNamesPrevented: 0,
duplicateRetries: 0,
fallbackAttempts: 0,
legacyFallbackUsed: 0,
};
}
export function chooseNameContext(entity, fields) {
const kind = String(entity?.kind || "").toLowerCase();
const i = boundedIndex(entity);
const coastal = (fields?.coastalLowland?.[i] || 0) > 0.35 || isNearSea(entity?.x || 0, entity?.y || 0, fields?.sea);
const river = (fields?.river?.[i] || 0) > 0.35 || (fields?.flowAccum?.[i] || 0) > 0.55 || (fields?.valleyField?.[i] || 0) > 0.42;
const plain = (fields?.plain?.[i] || 0) > 0.48 || (fields?.agriculture?.[i] || 0) > 0.46;
const mountain = (fields?.elevation?.[i] || 0) > 0.52 || (fields?.slope?.[i] || 0) > 0.35 || (fields?.ridgeField?.[i] || 0) > 0.42;
const urban = (fields?.populationDensity?.[i] || 0) > 0.35 || [2, 3, 4, 7, 8].includes(fields?.landuse?.[i]);
if (kind.includes("port") || kind.includes("harbor") || entity?.portClass) return "coastal";
if (kind.includes("crossing") || kind.includes("bridge")) return "river"; if (kind.includes("crossing") || kind.includes("bridge")) return "river";
if (kind.includes("pass") || kind.includes("mountain")) return "mountain"; if (kind.includes("pass") || kind.includes("mountain")) return "mountain";
if (kind.includes("castle") || kind.includes("market")) return "historic"; if (kind.includes("castle") || kind.includes("market")) return "historic";
if (kind.includes("new town") || kind.includes("satellite")) return "suburban"; if (kind.includes("newtown") || kind.includes("new town") || kind.includes("satellite") || kind.includes("station") || urban) return "suburban";
if (coastal && !mountain) return "coastal"; if (coastal && !mountain) return "coastal";
if (river) return "river"; if (river) return "river";
if (mountain && !plain) return "mountain"; if (mountain && !plain) return "mountain";
if (plain) return "plain"; if (plain) return "plain";
return "plain"; return "generic";
} }
export function chooseNameSuffixPool(entity, fields) { export function resolveSlotPool(slot, context, pools = NAME_KANJI_POOLS, probabilities = NAME_PROBABILITIES, seed = 0, id = "", attempt = 0) {
const context = chooseNameContext(entity, fields); const keys = slotPoolKeys(slot, context, probabilities);
return NAME_PARTS.contextSuffixes[context] || NAME_PARTS.contextSuffixes.plain; if (!keys.length) return null;
} const selectedKey = pickWeightedPoolKey(keys, context, pools, probabilities, seed, id, attempt, 1501 + stableHash(slot));
if (selectedKey) return { key: selectedKey, pool: pools[selectedKey] };
function chooseContextFreePool(entity, fields, nameParts) { for (const key of weightedKeyOrder(keys, context, probabilities, 1559)) {
const context = chooseNameContext(entity, fields); if (pools[key]?.length) return { key, pool: pools[key] };
return nameParts.contextFree?.[context] || nameParts.contextFree?.plain || nameParts.free || [""];
}
function pick(pool, seed, n, salt) {
if (!pool?.length) return "";
const index = Math.floor(hash2(n + salt * 101, seed + salt * 1009, seed + n * 37 + salt) * pool.length) % pool.length;
return pool[index];
}
function chance(seed, n, salt, probability) {
return hash2(n + salt * 173, seed + salt * 811, seed + n * 43 + salt) < probability;
}
function characterLength(value) {
return Array.from(String(value || "")).length;
}
function oneCharacterPool(pool, fallbackPool = pool) {
const filtered = (pool || []).filter((part) => characterLength(part) === 1);
return filtered.length ? filtered : (fallbackPool || [""]);
}
function appendPart(parts, part) {
if (!part) return;
const previous = parts[parts.length - 1] || "";
if (previous && (previous === part || previous.endsWith(part) || part.startsWith(previous))) return;
parts.push(part);
}
function appendFinalPart(parts, part, fallbackPool, seed, n, salt) {
let finalPart = part;
if (finalPart && parts.some((existing) => existing === finalPart || existing.endsWith(finalPart))) {
finalPart = pick(fallbackPool, seed, n, salt + 1);
} }
appendPart(parts, finalPart); return null;
} }
function trimNameParts(parts) { export function validateGeneratedName(name, options = {}) {
if (parts.length <= 3) return parts; const value = String(name || "");
return [parts[0], ...parts.slice(-2)]; const length = countChars(value);
if (!value) return { valid: false, reason: "empty" };
if (value.includes("\uFFFD")) return { valid: false, reason: "replacement" };
if (!options.allowAsciiDiagnostic && value.startsWith(ASCII_DIAGNOSTIC_PREFIX) && /^N[0-9A-Z]+$/.test(value)) {
return { valid: false, reason: "asciiDiagnostic" };
}
if (!options.allowOneCharacter && length < 2) return { valid: false, reason: "oneCharacter" };
if (!options.allowLong && length > 4) return { valid: false, reason: "tooLong" };
return { valid: true, reason: "valid" };
}
function chooseTemplate(context, seed, id, attempt) {
const contextWeights = NAME_TEMPLATE_WEIGHTS[context] || NAME_TEMPLATE_WEIGHTS.generic;
const entries = Object.entries(NAME_TEMPLATES).map(([key, template]) => ({
key,
weight: (template.baseWeight ?? 1.0) * (contextWeights?.[key] ?? 0),
}));
return weightedChoice(entries, seed, id, attempt, 2003);
}
function generateTemplateNameDetails(seed, id, entity, fields, attempt, usedNames, pools = NAME_KANJI_POOLS, probabilities = NAME_PROBABILITIES) {
const context = chooseNameContext(entity, fields);
const templateKey = chooseTemplate(context, seed, id, attempt);
const template = NAME_TEMPLATES[templateKey];
if (!template) return { name: null, context, templateKey: null };
const parts = [];
for (let slotIndex = 0; slotIndex < template.slots.length; slotIndex++) {
const slot = template.slots[slotIndex];
const slotPool = resolveSlotPool(slot, context, pools, probabilities, seed, id, attempt + slotIndex);
if (!slotPool?.pool?.length) return { name: null, context, templateKey };
const part = pick(slotPool.pool, seed, id, attempt, 2503 + slotIndex * 127 + stableHash(slotPool.key));
if (!part) return { name: null, context, templateKey };
parts.push(part);
}
const name = parts.join("");
const validation = validateGeneratedName(name);
if (!validation.valid) return { name: null, context, templateKey, invalidReason: validation.reason };
if (usedNames?.has(name)) return { name: null, context, templateKey, duplicate: true };
return { name, context, templateKey };
}
export function generateTemplateName(seed, id, entity, fields, attempt = 0, usedNames = null) {
return generateTemplateNameDetails(seed, id, entity, fields, attempt, usedNames).name;
}
function deterministicDiagnosticName(seed, id, attempt = 0) {
const h = Math.floor(roll(seed, id, attempt, 3001) * 0xb640).toString(36).toUpperCase();
return `${ASCII_DIAGNOSTIC_PREFIX}${h.padStart(3, "0")}`;
}
function uniqueDiagnosticName(seed, id, usedNames, debug, startAttempt = 0) {
for (let offset = 0; offset < 128; offset++) {
const name = deterministicDiagnosticName(seed, id, startAttempt + offset);
if (!usedNames?.has(name)) return name;
debug.duplicateRetries++;
}
return `${ASCII_DIAGNOSTIC_PREFIX}${stableHash(`${seed}:${id}`).toString(36).toUpperCase()}`;
}
function tryCustomName(seed, id, usedNames, debug) {
const customName = CUSTOM_NAMES[id];
if (!customName) return null;
if (roll(seed, id, 0, 3501) >= NAME_PROBABILITIES.customName) return null;
const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true });
if (!validation.valid) {
if (validation.reason === "oneCharacter") debug.oneCharacterNamesPrevented++;
else debug.invalidNamesRejected++;
return null;
}
if (usedNames?.has(customName)) {
debug.duplicateRetries++;
return null;
}
debug.customNamesUsed++;
return customName;
}
export function generateEntityName(seed, id, entity, fields, usedNames = null, debug = createNameDebug()) {
debug ||= createNameDebug();
const forcedName = FORCED_NAMES[id];
if (forcedName) {
debug.forcedNamesUsed++;
return forcedName;
}
const customName = tryCustomName(seed, id, usedNames, debug);
if (customName) return customName;
const retryCount = Math.max(1, NAME_PROBABILITIES.retryCount || 1);
for (let attempt = 0; attempt < retryCount; attempt++) {
const result = generateTemplateNameDetails(seed, id, entity, fields, attempt, usedNames);
if (result.context) incrementCounter(debug.selectedContextCounts, result.context);
if (result.templateKey) incrementCounter(debug.selectedTemplateCounts, result.templateKey);
if (result.duplicate) {
debug.duplicateRetries++;
continue;
}
if (result.invalidReason) {
if (result.invalidReason === "oneCharacter") debug.oneCharacterNamesPrevented++;
else debug.invalidNamesRejected++;
continue;
}
if (result.name) {
debug.generatedNamesUsed++;
return result.name;
}
}
debug.fallbackAttempts++;
return uniqueDiagnosticName(seed, id, usedNames, debug, retryCount);
} }
export function contextualDefaultName(seed, id, entity, nameParts = NAME_PARTS, fields, attempt = 0) { export function contextualDefaultName(seed, id, entity, nameParts = NAME_PARTS, fields, attempt = 0) {
const prefixKey = id.split("-")[0]; const name = generateTemplateName(seed, id, entity, fields, attempt, null);
const n = Number(id.split("-")[1] || 0) + attempt * 997; return name || deterministicDiagnosticName(seed, id, attempt);
}
const prefixOnly = nameParts.prefixOnly || nameParts.prefixes || [""];
const free = nameParts.free || nameParts.infixes || [""]; export function chooseNameSuffixPool() {
const suffixOnly = nameParts.suffixOnly || nameParts.suffixes || [""]; return NAME_KANJI_POOLS.postfixes;
const contextFree = chooseContextFreePool(entity, fields, nameParts);
const contextSuffixes = chooseNameSuffixPool(entity, fields);
const probabilities = nameParts.probabilities || NAME_PROBABILITIES;
if (chance(seed, n, 7 + prefixKey.length, probabilities.twoCharacter ?? 0.80)) {
const onePrefixOnly = oneCharacterPool(prefixOnly);
const oneFree = oneCharacterPool(free);
const oneSuffixOnly = oneCharacterPool(suffixOnly, oneFree);
const oneContextFree = oneCharacterPool(contextFree, oneFree);
const oneContextSuffixes = oneCharacterPool(contextSuffixes, oneSuffixOnly);
const usePrefix = chance(seed, n, 11 + prefixKey.length, probabilities.prefixOnly);
const useContextAsMain = chance(seed, n, 13 + prefixKey.length, probabilities.contextFreeAsMain ?? probabilities.contextFree ?? 0.34);
const firstPart = usePrefix
? pick(onePrefixOnly, seed, n, 101)
: pick(useContextAsMain ? oneContextFree : oneFree, seed, n, useContextAsMain ? 211 : 223);
const finalContextProbability = probabilities.finalContextSuffix ?? 0.10;
const finalSuffixProbability = probabilities.finalSuffixOnly ?? 0.50;
const finalFreeProbability = probabilities.finalFree ?? Math.max(0, 1 - finalContextProbability - finalSuffixProbability);
const finalTotal = Math.max(0.0001, finalContextProbability + finalSuffixProbability + finalFreeProbability);
const finalRoll = hash2(n + prefixKey.length * 23, seed + n * 37, seed + 7919);
const contextThreshold = finalContextProbability / finalTotal;
const suffixThreshold = (finalContextProbability + finalSuffixProbability) / finalTotal;
let finalPart;
if (finalRoll < contextThreshold) {
finalPart = pick(oneContextSuffixes, seed, n, 503);
} else if (finalRoll < suffixThreshold) {
finalPart = pick(oneSuffixOnly, seed, n, 509);
} else {
finalPart = pick(oneFree, seed, n, 521);
}
if (finalPart === firstPart) finalPart = pick(oneSuffixOnly, seed, n, 601);
if (finalPart === firstPart) finalPart = pick(oneFree, seed, n, 607);
return `${firstPart || pick(oneFree, seed, n, 613)}${finalPart || pick(oneSuffixOnly, seed, n, 617)}`;
}
const parts = [];
if (chance(seed, n, 11 + prefixKey.length, probabilities.prefixOnly)) {
appendPart(parts, pick(prefixOnly, seed, n, 101));
}
const useContextAsMain = chance(seed, n, 13 + prefixKey.length, probabilities.contextFreeAsMain ?? probabilities.contextFree ?? 0.34);
appendPart(parts, pick(useContextAsMain ? contextFree : free, seed, n, useContextAsMain ? 211 : 223));
if (!useContextAsMain && chance(seed, n, 17 + prefixKey.length, probabilities.additionalContextFree ?? probabilities.contextFree ?? 0.22)) {
appendPart(parts, pick(contextFree, seed, n, 307));
}
if (chance(seed, n, 19 + prefixKey.length, probabilities.secondFree)) {
appendPart(parts, pick(free, seed, n, 401));
}
const finalRoll = hash2(n + prefixKey.length * 23, seed + n * 37, seed + 7919);
let finalPart;
const finalContextProbability = probabilities.finalContextSuffix ?? 0.30;
const finalSuffixProbability = probabilities.finalSuffixOnly ?? 0.50;
const finalFreeProbability = probabilities.finalFree ?? Math.max(0, 1 - finalContextProbability - finalSuffixProbability);
const finalTotal = Math.max(0.0001, finalContextProbability + finalSuffixProbability + finalFreeProbability);
const contextThreshold = finalContextProbability / finalTotal;
const suffixThreshold = (finalContextProbability + finalSuffixProbability) / finalTotal;
if (finalRoll < contextThreshold) {
finalPart = pick(contextSuffixes, seed, n, 503);
} else if (finalRoll < suffixThreshold) {
finalPart = pick(suffixOnly, seed, n, 509);
} else {
finalPart = pick(free, seed, n, 521);
}
appendFinalPart(parts, finalPart, suffixOnly, seed, n, 601);
const name = trimNameParts(parts).join("");
return name || pick(suffixOnly, seed, n, 601) || "原";
} }

View file

@ -90,20 +90,20 @@ function discreteColor(map, x, y, mode) {
let color; let color;
if (map.sea[i]) { if (map.sea[i]) {
// Google Map風の海色 // Google Map like styled
color = [170, 218, 255]; color = [170, 218, 255];
} else if (mode === "landuse") { } else if (mode === "landuse") {
const colors = { const colors = {
0: [230, 242, 220], // 農地 0: [230, 242, 220], // farmland
1: [235, 245, 225], // 平地 1: [235, 245, 225], // plain
2: [235, 230, 220], // 旧市街 2: [235, 230, 220], // old city
3: [224, 202, 190], // 中心市街地 / CBD 3: [224, 202, 190], // CBD
4: [245, 240, 230], // 郊外 4: [245, 240, 230], // suburb
5: [220, 220, 225], // 工業地域 5: [220, 220, 225], // industrial area
6: [225, 235, 225], // 物流エリア 6: [225, 235, 225], // logistics area
7: [238, 242, 248], // ニュータウン 7: [238, 242, 248], // new town
8: [248, 242, 230], // 沿道開発 8: [248, 242, 230], // coastal development
9: [225, 238, 220], // その他 9: [225, 238, 220], // others
}; };
color = colors[map.landuse[i]] || colors[0]; color = colors[map.landuse[i]] || colors[0];
} else if (mode === "admin") { } else if (mode === "admin") {

94
test.js
View file

@ -1,11 +1,26 @@
import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js"; import { generateMap, MAP_W, MAP_H, indexOf } from "./mapGenerator.js";
import { CUSTOM_NAMES } from "./names.js"; import {
CUSTOM_NAMES,
FORCED_NAMES,
NAME_KANJI_POOLS,
NAME_PARTS,
NAME_PROBABILITIES,
NAME_TEMPLATES,
NAME_TEMPLATE_WEIGHTS,
generateTemplateName,
} from "./names.js";
import { adminBoundaryMetrics, majorCityCoreIntegrity } from "./qualityMetrics.js"; import { adminBoundaryMetrics, majorCityCoreIntegrity } from "./qualityMetrics.js";
const result = document.getElementById("result"); const result = document.getElementById("result");
const logLines = []; const logLines = [];
let failed = 0; let failed = 0;
const [namesSource, mapGeneratorSource, testSource] = await Promise.all([
fetch("./names.js").then((response) => response.text()),
fetch("./mapGenerator.js").then((response) => response.text()),
fetch("./test.js").then((response) => response.text()),
]);
function assert(condition, message) { function assert(condition, message) {
if (condition) logLines.push(`OK: ${message}`); if (condition) logLines.push(`OK: ${message}`);
else { else {
@ -79,16 +94,26 @@ try {
const allNameable = map.entitiesForNames || []; const allNameable = map.entitiesForNames || [];
const uniqueNames = new Set(allNameable.map((item) => item.name)); const uniqueNames = new Set(allNameable.map((item) => item.name));
const duplicateNameRatio = allNameable.length ? 1 - uniqueNames.size / allNameable.length : 0; const duplicateNameRatio = allNameable.length ? 1 - uniqueNames.size / allNameable.length : 0;
const coastalSuffixes = ["\u6d5c", "\u6e4a", "\u6e2f", "\u6d66", "\u6d25", "\u5d0e", "\u6e7e"]; const activePoolChars = new Set(Object.values(NAME_KANJI_POOLS).flat().flatMap((part) => Array.from(String(part))));
const mountainSuffixes = ["\u8c37", "\u5ce0", "\u5c3e\u6839", "\u5c71", "\u6ca2", "\u9e93"]; const namedEntityCount = [
const farInlandCoastalNames = allNameable.filter((p) => { ...map.villages,
const i = indexOf(p.x, p.y); ...map.ports,
return !p.portClass && (map.coastalLowland[i] || 0) < 0.12 && coastalSuffixes.some((suffix) => p.name?.endsWith(suffix)); ...map.crossings,
}).length; ...map.passes,
const flatCoastalMountainNames = allNameable.filter((p) => { ...map.markets,
const i = indexOf(p.x, p.y); ...map.castles,
return (map.coastalLowland[i] || 0) > 0.35 && (map.slope[i] || 0) < 0.18 && mountainSuffixes.some((suffix) => p.name?.endsWith(suffix)); ...map.castleTowns,
}).length; ...map.modernCities,
...map.stations,
...map.industrialZones,
...map.interchanges,
...map.logisticsParks,
...map.satelliteCities,
...map.newTowns,
...map.castleRuins,
...map.externalGateways,
...map.adminCenters,
].filter((item) => item?.id && item?.name).length;
const villageClusterMean = map.villages.length const villageClusterMean = map.villages.length
? map.villages.reduce((sum, p) => sum + (map.settlementCluster?.[indexOf(p.x, p.y)] || 0), 0) / map.villages.length ? map.villages.reduce((sum, p) => sum + (map.settlementCluster?.[indexOf(p.x, p.y)] || 0), 0) / map.villages.length
: 0; : 0;
@ -119,6 +144,20 @@ try {
const adminMetrics = adminBoundaryMetrics(map); const adminMetrics = adminBoundaryMetrics(map);
const cityCoreIntegrity = majorCityCoreIntegrity(map); const cityCoreIntegrity = majorCityCoreIntegrity(map);
assert(NAME_KANJI_POOLS && Array.isArray(NAME_KANJI_POOLS.modifiers), "NAME_KANJI_POOLS exists");
assert(NAME_TEMPLATES && NAME_TEMPLATES.modifierTerrain?.slots?.length === 2, "NAME_TEMPLATES exists");
assert(NAME_TEMPLATE_WEIGHTS && NAME_TEMPLATE_WEIGHTS.generic?.modifierTerrain > 0, "NAME_TEMPLATE_WEIGHTS exists");
assert(NAME_PROBABILITIES && NAME_PROBABILITIES.contextCategoryWeights?.generic, "NAME_PROBABILITIES exists");
const removedContextModule = "placeName" + "Context.js";
assert(!namesSource.includes(removedContextModule) && !mapGeneratorSource.includes(removedContextModule) && !testSource.includes(removedContextModule), "removed name-context import is absent");
assert(Object.keys(NAME_KANJI_POOLS).every((key) => Array.isArray(NAME_KANJI_POOLS[key])), "name category pools are centralized arrays");
assert(Object.values(NAME_KANJI_POOLS).every((pool) => pool.length === 0), "default name category pools are empty");
assert(Object.keys(NAME_PARTS).length === 0, "legacy NAME_PARTS has no hidden candidates");
const removedContextSuffixConst = "CONTEXT" + "_SUFFIXES";
const removedContextSuffixKey = "context" + "Suffixes";
assert(!namesSource.includes(removedContextSuffixConst) && !namesSource.includes(removedContextSuffixKey), "hidden context suffix arrays are absent");
assert(!/export\s+const\s+NAME_PROBABILITIES[\s\S]*export\s+const\s+NAME_PROBABILITIES/.test(namesSource), "NAME_PROBABILITIES has one source");
assert(map.elevation.length === size, "elevation length matches map size"); assert(map.elevation.length === size, "elevation length matches map size");
assert(map.sea.length === size, "sea length matches map size"); assert(map.sea.length === size, "sea length matches map size");
assert(map.river.length === size, "river length matches map size"); assert(map.river.length === size, "river length matches map size");
@ -204,17 +243,24 @@ try {
assert(capitalInside, "prefectural capital is inside the prefecture"); assert(capitalInside, "prefectural capital is inside the prefecture");
assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized"); assert(maxModernEndpointDegree <= 10, "modern transport endpoints are not over-centralized");
assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names"); assert(map.entitiesForNames.every((item) => item.id && item.name), "nameable entities have ids and names");
assert(map.entitiesForNames.every((item) => typeof item.name === "string"), "nameable entity names are strings");
assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters"); assert(map.entitiesForNames.every((item) => !String(item.name).includes("\uFFFD")), "generated names contain no replacement characters");
assert(map.entitiesForNames.every((item) => Array.from(String(item.name)).length >= 2), "one-character generated names are prevented");
assert(map.entitiesForNames.every((item) => String(item.name).length > 0), "empty generated names are prevented");
assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low"); assert(duplicateNameRatio < 0.18, "generated place-name duplicates stay low");
assert(farInlandCoastalNames <= Math.max(2, Math.ceil(allNameable.length * 0.12)), "coastal suffixes are not overused far inland"); assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools");
assert(flatCoastalMountainNames <= Math.max(2, Math.ceil(allNameable.length * 0.10)), "mountain suffixes are not overused on flat coastal lowlands"); assert(map.nameDebug.emptyPools.length === Object.keys(NAME_KANJI_POOLS).length, "empty default pools are visible in nameDebug");
assert(map.nameDebug.selectedTemplateCounts && typeof map.nameDebug.selectedTemplateCounts === "object", "nameDebug selectedTemplateCounts exists");
assert(map.nameDebug.selectedContextCounts && typeof map.nameDebug.selectedContextCounts === "object", "nameDebug selectedContextCounts exists");
assert(
map.nameDebug.generatedNamesUsed + map.nameDebug.customNamesUsed + map.nameDebug.forcedNamesUsed + map.nameDebug.fallbackAttempts === namedEntityCount,
"nameDebug accounting covers named entities"
);
assert(generateTemplateName(777, "probe-0", { x: 10, y: 10, kind: "Probe" }, {}, 0, new Set()) === null, "empty pools do not use hidden fallback candidates");
assert(activePoolChars.size === 0, "no active pool characters exist until configured");
assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells"); assert(villageClusterMean > 0.16, "villages prefer clustered valley, basin, coastal, and agricultural cells");
assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes"); assert(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes");
if (Object.keys(CUSTOM_NAMES).length > 0) { assert(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default");
assert(map.entitiesForNames.every((item) => !CUSTOM_NAMES[item.id] || item.name === CUSTOM_NAMES[item.id]), "CUSTOM_NAMES override generated names");
} else {
assert(true, "CUSTOM_NAMES override hook remains available");
}
assert( assert(
map.adminCenters.length !== other.adminCenters.length || map.adminCenters.length !== other.adminCenters.length ||
@ -230,6 +276,18 @@ try {
assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed"); assert(JSON.stringify(againA.adminCenters.map((item) => [item.id, item.x, item.y, item.name])) === JSON.stringify(againB.adminCenters.map((item) => [item.id, item.x, item.y, item.name])), "municipal centers are deterministic for the same seed");
assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed"); assert(JSON.stringify([...againA.adminId]) === JSON.stringify([...againB.adminId]), "municipal adminId snapping is deterministic for the same seed");
CUSTOM_NAMES["city-0"] = "C1";
const customSameA = generateMap(321);
const customSameB = generateMap(321);
const sameTargetA = customSameA.modernCities.find((item) => item.id === "city-0");
const sameTargetB = customSameB.modernCities.find((item) => item.id === "city-0");
const customSeedMaps = [301, 302, 303, 304, 305, 306, 307, 308].map((seedValue) => generateMap(seedValue));
const customTargets = customSeedMaps.map((seeded) => seeded.modernCities.find((item) => item.id === "city-0")).filter(Boolean);
const customHits = customTargets.filter((item) => item.name === "C1").length;
assert(sameTargetA?.name === sameTargetB?.name, "custom-name probability is deterministic for the same seed");
assert(!FORCED_NAMES["city-0"] && customTargets.length > 0 && customHits < customTargets.length, "CUSTOM_NAMES do not force every seed");
delete CUSTOM_NAMES["city-0"];
for (const seed of [101, 2026, 54321]) { for (const seed of [101, 2026, 54321]) {
const seeded = generateMap(seed); const seeded = generateMap(seed);
const metrics = adminBoundaryMetrics(seeded); const metrics = adminBoundaryMetrics(seeded);