import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js"; export const CUSTOM_NAME_LIST = ["加茂", "瑞穂", "天神", "弁天", "千歳", "朝日", "春日", "新庄", "常盤", "追分", "清水", "曙", "暁", "旭", "鳥羽", "長門", "吉野", "吉田", "美郷", "美里", "美山"]; export const NAME_KANJI_POOLS = { modifiers: [ "大", "小", "上", "下", "中", "奥", "脇", "東", "西", "南", "北", "新", "古", "本", "高", "長", "広", "深", "浅", "明", "重", "荒", "富", "白", "黒", "青", "赤", "藍", "奥", "前", "後", "内", "外", "美", "吉", "福", "幸", "徳", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "霞", "朝", "日", "天", "土", "砂", "石", "岩", "卯", "辰", "串","駒", "来", "武", "箱", "羽", "鳴", "横", "永", "早", "清", "芳", "安", "泰", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠", "掘", "鎚", "綾", "槌", "徳", "栄" ], inlandTerrain: [ "山", "野", "野", "沢", "森", "林", "岡", "丘", "坂", "峰", "峠", "嶺", "尾", "平", "坪", "延", "窪", "久", "迫", "久保", "玖保", "佐古", "作古", "塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦", "植", "生", "郷", "里", "馬", "鹿", "亀", "鷲", "鷹", "鶴", "鴨", "竜", "龍", "牛", "鳥", "湯", ], waterTerrain: [ "川", "河", "江", "瀬", "淵", "渕", "池", "沼", "泉", "井", "滝", "梅", "沢", "澤", "谷", "津", "水", "渡", "橋", "堀", "溝", "浦", "渚" ], coastalTerrain: [ "津", "浦", "津", "崎", "島", "磯", "潟", "湊", "津", "州", "洲", "瀬", "砂", "潮", "塩", "浜", "泊", "江", "浦", "灘", "入", "戸", "門", ], plants: [ "松", "杉", "桜", "梅", "栗", "竹", "楠", "藤", "萩", "葦", "菅", "榎", "椿", "桐", "柳", "橘", "柏", "槙", "柿", "桃", "稲", "梨", "桑", "麻", "芦", "茅", "根", "粟", "稲", "稗", "米", "飯", "糠", "茜", "葵", "篠", "芝", "柴", "榊", "楢", "檜", "椎", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑", "瓜" ], postfixes: [ "田", "川", "山", "岡", "森", "林", "島", "尻", "江", "瀬", "井", "戸", "口", "辺", "里", "郷", "村", "町", "宿", "庄", "台", "坂", "橋", "明", "本", "内", "窪", "平", "塚", "根", "串", "畑", "牧", "前", "見", "中", "羽", "生", "駒", "来", "富", "塚", "部", "栄", "永", "平", ], archaicPrefixes: [ "阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小", "可", "加", "賀", "香", "鹿", "賀", "嘉", "喜", "紀", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "巨", "己", "佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇", "総", "多", "太", "知", "津", "土", "那", "奈", "名", "仁", "尼", "根", "乃", "能", "波", "氷", "比", "肥", "布", "夫", "戸", "保", "穂", "間", "磨", "摩", "見", "牟", "武", "目", "女", "毛", "裳", "弥", "矢", "夜", "耶", "由", "与", "和", "輪", "出", "播", "但", "因", "伯", "筑", "肥", "豊", "日", "紀", "志", "尾", "甲", "信", "越", "備", "能", "薩", "隠", "美", "三", "若", "遠", "近", "能", "加", "賀", "度", "飾", "越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治", "加茂", ], archaicSuffixes: [ "井", "羽", "江", "恵", "尾", "賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇", "総", "多", "太", "知", "津", "豆", "土", "登", "那", "奈", "名", "仁", "尼", "根", "乃", "能", "波", "布", "夫", "戸", "保", "穂", "間", "磨", "摩", "馬", "見", "牟", "武", "目", "女", "毛", "裳", "茂", "弥", "矢", "夜", "耶", "由", "与", "予", "良", "利", "礼", "呂", "和", "輪", "陀", "芸", "雲", "幡", "耆", "摩", "張", "江", "河", "斐", "濃", "岐", "門", "隅", "向", "飾", "居", "前", "中", "後", "勢", "渡", "城", "紫", "野", "度", "津", "島", "信", "登", "賀", "志", "良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子", "加茂", ], settlementWords: [ "里", "郷", "村", "町", "宿", "垣", "坪", "軒", "惣", "庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂", "城", "館", "屋", "家", "所", "市", "場", "関", "地蔵", "辻", "角", "堰", "通", "路", "道", "筋", ] }; export const NAME_PROBABILITIES = { customNameList: 0.25, 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 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, }, }; // 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 isKanjiChar(ch) { return /[\u3400-\u9FFF\uF900-\uFAFF]/u.test(ch); } function hasRepeatedKanji(value) { const seen = new Set(); for (const ch of Array.from(String(value || ""))) { if (!isKanjiChar(ch)) continue; if (seen.has(ch)) return true; seen.add(ch); } return false; } 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++) { for (let dx = -radius; dx <= radius; dx++) { const nx = x + dx; const ny = y + dy; if (inside(nx, ny) && sea[indexOf(nx, ny)]) return true; } } return false; } function contextWeightsFor(context, probabilities = NAME_PROBABILITIES) { return probabilities.contextCategoryWeights?.[context] || probabilities.contextCategoryWeights?.generic || {}; } 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; } 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 { effectiveCustomNameListProbability: probabilities.customNameList, poolsPresent, emptyPools, selectedTemplateCounts: {}, selectedContextCounts: {}, generatedNamesUsed: 0, customNameListUsed: 0, invalidNamesRejected: 0, repeatedKanjiNamesRejected: 0, shortNamesRejected: 0, duplicateRetries: 0, fallbackAttempts: 0, legacyFallbackUsed: 0, oneKanjiAppendFallbackUsed: 0, derivedNameCount: 0, maxDerivedPerBase: 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("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 "generic"; } 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] }; } return null; } 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 (Number.isFinite(options.minLength) && length < options.minLength) return { valid: false, reason: "tooShort" }; if (Number.isFinite(options.maxLength) && length > options.maxLength) return { valid: false, reason: "tooLong" }; if (!options.allowRepeatedKanji && hasRepeatedKanji(value)) return { valid: false, reason: "repeatedKanji" }; 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 || (template.slots?.length || 0) > 2) 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 tryCustomNameList(seed, id, usedNames, debug) { if (!CUSTOM_NAME_LIST.length) return null; if (roll(seed, id, 0, 3527) >= (NAME_PROBABILITIES.customNameList ?? 0)) return null; const start = Math.floor(roll(seed, id, 0, 3539) * CUSTOM_NAME_LIST.length) % CUSTOM_NAME_LIST.length; for (let offset = 0; offset < CUSTOM_NAME_LIST.length; offset++) { const customName = CUSTOM_NAME_LIST[(start + offset) % CUSTOM_NAME_LIST.length]; const validation = validateGeneratedName(customName, { allowAsciiDiagnostic: true }); if (!validation.valid) { if (validation.reason === "tooShort") { debug.shortNamesRejected++; } else if (validation.reason === "repeatedKanji") { debug.repeatedKanjiNamesRejected++; } else { debug.invalidNamesRejected++; } continue; } if (usedNames?.has(customName)) { debug.duplicateRetries++; continue; } debug.customNameListUsed++; return customName; } return null; } export function generateEntityName(seed, id, entity, fields, usedNames = null, debug = createNameDebug()) { debug ||= createNameDebug(); const listedCustomName = tryCustomNameList(seed, id, usedNames, debug); if (listedCustomName) return listedCustomName; 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 === "tooShort") { debug.shortNamesRejected++; } else if (result.invalidReason === "repeatedKanji") debug.repeatedKanjiNamesRejected++; 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 name = generateTemplateName(seed, id, entity, fields, attempt, null); return name || deterministicDiagnosticName(seed, id, attempt); } export function chooseNameSuffixPool() { return NAME_KANJI_POOLS.postfixes; }