diff --git a/index.html b/index.html index 96927b9..8cb1f7f 100644 --- a/index.html +++ b/index.html @@ -57,9 +57,9 @@
Name Override IDs

Add entries to names.js in CUSTOM_NAMES.

export const CUSTOM_NAMES = {
-  "city-0": "Aohara",
-  "port-0": "Shirahama",
-  "castle-0": "Kurono"
+  "city-0": "CA",
+  "port-0": "PB",
+  "castle-0": "KC"
 };
diff --git a/mapGenerator.js b/mapGenerator.js index 23d5cc6..60da722 100644 --- a/mapGenerator.js +++ b/mapGenerator.js @@ -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); } diff --git a/names.js b/names.js index db59dfe..da7859a 100644 --- a/names.js +++ b/names.js @@ -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; } diff --git a/renderer.js b/renderer.js index 9a25052..d4acb27 100644 --- a/renderer.js +++ b/renderer.js @@ -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") { diff --git a/test.js b/test.js index 7a70125..441e172 100644 --- a/test.js +++ b/test.js @@ -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);