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>
<p>Add entries to <code>names.js</code> in <code>CUSTOM_NAMES</code>.</p>
<pre class="example">export const CUSTOM_NAMES = {
"city-0": "Aohara",
"port-0": "Shirahama",
"castle-0": "Kurono"
"city-0": "CA",
"port-0": "PB",
"castle-0": "KC"
};</pre>
<div id="nameIds" class="id-list"></div>
</section>

View file

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

621
names.js
View file

@ -1,67 +1,233 @@
import { MAP_H, MAP_W, indexOf, inside } from "./grid.js";
import { hash2 } from "./random.js";
// Default and custom place-name resources.
// Edit only this file to change generated names or override individual IDs.
export const NAME_PARTS = {
// May appear only at the beginning of a name.
prefixOnly: ["青", "白", "黒", "赤", "藍", "大", "小", "新", "古", "浅", "深", "上", "下", "東", "西", "南", "北", "中", "本", "広", "元", "奥", "前", "高", "長", "丸", "一", "壱", "二", "三", "四", "五", "六", "七", "八", "九", "十", "五十", "百", "千", "万", "阿", "佐々", "代々", "千代", "御", "宇", "須", "伊", "牟", "美",],
// Generic morphemes that may appear at the beginning or in the middle.
free: [
//nature
"川", "山", "花", "森", "松", "竹", "梅", "柳", "栃", "杉", "萩", "荻", "柿", "栗", "椎", "榊", "篠", "桑", "笹", "蓮", "橘", "柏", "樫", "麻", "桜", "岡", "木", "林", "森", "根", "葉", "葦", "日", "月", "君",
"野", "尾", "砂", "鮎", "鯉", "鯖", "雁", "菊", "錦", "稲", "稗", "朝", "霧", "坂", "葵", "茜", "犬", "馬", "熊", "猿", "鶴", "鹿", "鷲", "鷹", "亀", "龍", "竜", "燕", "鴨",
//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_KANJI_POOLS = {
modifiers: [],
inlandTerrain: [],
waterTerrain: [],
coastalTerrain: [],
plants: [],
postfixes: [],
archaicPrefixes: [],
archaicSuffixes: [],
settlementWords: [],
};
export const NAME_PROBABILITIES = {
prefixOnly: 0.36,
contextFreeAsMain: 0.34,
additionalContextFree: 0.22,
secondFree: 0.18,
finalContextSuffix: 0.10,
finalSuffixOnly: 0.50,
finalFree: 0.20,
twoCharacter: 0.80,
customName: 0.20,
forcedName: 1.0,
retryCount: 24,
categoryFallback: {
modifiers: 1.0,
inlandTerrain: 1.0,
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 CUSTOM_NAMES = {
// "city-0": "Aohara",
// "port-0": "Shirahama",
// "castle-0": "Kurono",
export const NAME_TEMPLATES = {
modifierTerrain: {
slots: ["modifier", "terrain"],
baseWeight: 1.0,
},
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) {
if (!sea) return false;
for (let dy = -radius; dy <= radius; dy++) {
@ -74,157 +240,234 @@ function isNearSea(x, y, sea, radius = 3) {
return false;
}
function boundedIndex(entity) {
return indexOf(Math.max(0, Math.min(MAP_W - 1, entity.x)), Math.max(0, Math.min(MAP_H - 1, entity.y)));
function contextWeightsFor(context, probabilities = NAME_PROBABILITIES) {
return probabilities.contextCategoryWeights?.[context] || probabilities.contextCategoryWeights?.generic || {};
}
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, entity.y, fields?.sea);
const river = (fields?.river?.[i] || 0) > 0.35 || (fields?.valleyField?.[i] || 0) > 0.42;
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;
function poolWeight(poolKey, context, probabilities = NAME_PROBABILITIES) {
const weights = contextWeightsFor(context, probabilities);
const fallback = probabilities.categoryFallback?.[poolKey] ?? 1.0;
if (poolKey === "archaicPrefixes" || poolKey === "archaicSuffixes") return (weights.archaic ?? 1.0) * fallback;
return (weights[poolKey] ?? 1.0) * fallback;
}
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("pass") || kind.includes("mountain")) return "mountain";
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 (river) return "river";
if (mountain && !plain) return "mountain";
if (plain) return "plain";
return "plain";
return "generic";
}
export function chooseNameSuffixPool(entity, fields) {
const context = chooseNameContext(entity, fields);
return NAME_PARTS.contextSuffixes[context] || NAME_PARTS.contextSuffixes.plain;
}
function chooseContextFreePool(entity, fields, nameParts) {
const context = chooseNameContext(entity, fields);
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);
export function resolveSlotPool(slot, context, pools = NAME_KANJI_POOLS, probabilities = NAME_PROBABILITIES, seed = 0, id = "", attempt = 0) {
const keys = slotPoolKeys(slot, context, probabilities);
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] };
for (const key of weightedKeyOrder(keys, context, probabilities, 1559)) {
if (pools[key]?.length) return { key, pool: pools[key] };
}
appendPart(parts, finalPart);
return null;
}
function trimNameParts(parts) {
if (parts.length <= 3) return parts;
return [parts[0], ...parts.slice(-2)];
export function validateGeneratedName(name, options = {}) {
const value = String(name || "");
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) {
const prefixKey = id.split("-")[0];
const n = Number(id.split("-")[1] || 0) + attempt * 997;
const prefixOnly = nameParts.prefixOnly || nameParts.prefixes || [""];
const free = nameParts.free || nameParts.infixes || [""];
const suffixOnly = nameParts.suffixOnly || nameParts.suffixes || [""];
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) || "原";
const name = generateTemplateName(seed, id, entity, fields, attempt, null);
return name || deterministicDiagnosticName(seed, id, attempt);
}
export function chooseNameSuffixPool() {
return NAME_KANJI_POOLS.postfixes;
}

View file

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

94
test.js
View file

@ -1,11 +1,26 @@
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";
const result = document.getElementById("result");
const logLines = [];
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) {
if (condition) logLines.push(`OK: ${message}`);
else {
@ -79,16 +94,26 @@ try {
const allNameable = map.entitiesForNames || [];
const uniqueNames = new Set(allNameable.map((item) => item.name));
const duplicateNameRatio = allNameable.length ? 1 - uniqueNames.size / allNameable.length : 0;
const coastalSuffixes = ["\u6d5c", "\u6e4a", "\u6e2f", "\u6d66", "\u6d25", "\u5d0e", "\u6e7e"];
const mountainSuffixes = ["\u8c37", "\u5ce0", "\u5c3e\u6839", "\u5c71", "\u6ca2", "\u9e93"];
const farInlandCoastalNames = allNameable.filter((p) => {
const i = indexOf(p.x, p.y);
return !p.portClass && (map.coastalLowland[i] || 0) < 0.12 && coastalSuffixes.some((suffix) => p.name?.endsWith(suffix));
}).length;
const flatCoastalMountainNames = allNameable.filter((p) => {
const i = indexOf(p.x, p.y);
return (map.coastalLowland[i] || 0) > 0.35 && (map.slope[i] || 0) < 0.18 && mountainSuffixes.some((suffix) => p.name?.endsWith(suffix));
}).length;
const activePoolChars = new Set(Object.values(NAME_KANJI_POOLS).flat().flatMap((part) => Array.from(String(part))));
const namedEntityCount = [
...map.villages,
...map.ports,
...map.crossings,
...map.passes,
...map.markets,
...map.castles,
...map.castleTowns,
...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
? map.villages.reduce((sum, p) => sum + (map.settlementCluster?.[indexOf(p.x, p.y)] || 0), 0) / map.villages.length
: 0;
@ -119,6 +144,20 @@ try {
const adminMetrics = adminBoundaryMetrics(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.sea.length === size, "sea 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(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) => 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) => 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(farInlandCoastalNames <= Math.max(2, Math.ceil(allNameable.length * 0.12)), "coastal suffixes are not overused far inland");
assert(flatCoastalMountainNames <= Math.max(2, Math.ceil(allNameable.length * 0.10)), "mountain suffixes are not overused on flat coastal lowlands");
assert(map.nameDebug && Array.isArray(map.nameDebug.emptyPools), "nameDebug reports empty pools");
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(saneEndpointRatio >= 0.76, "transport endpoints stay near meaningful generated nodes");
if (Object.keys(CUSTOM_NAMES).length > 0) {
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(Object.keys(CUSTOM_NAMES).length === 0 || NAME_PROBABILITIES.customName < 1, "CUSTOM_NAMES are probabilistic by default");
assert(
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.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]) {
const seeded = generateMap(seed);
const metrics = adminBoundaryMetrics(seeded);