map/names.js
2026-05-22 14:13:35 +09:00

579 lines
19 KiB
JavaScript

import { MAP_H, MAP_W, hash2, indexOf, inside } from "./mapUtils.js";
export const NAME_KANJI_POOLS = {
modifiers: [
"大", "小", "上", "下", "中", "奥", "脇",
"東", "西", "南", "北",
"新", "古", "本", "元",
"高", "長", "広", "深", "浅",
"白", "黒", "青", "赤",
"奥", "前", "後", "内", "外",
"美", "吉", "福", "幸", "徳",
"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "百", "千", "万",
"霧", "霞", "朝", "日", "天", "雨", "晴",
"早", "安", "真", "丸", "平", "勝", "吹", "舞", "鎌", "釜", "笠",
],
inlandTerrain: [
"山", "野", "荒", "野", "沢",
"森", "林", "岡", "丘", "坂",
"峰", "峠", "嶺", "尾", "平", "坪", "延", "燧",
"窪", "久", "迫", "久保", "玖保", "漥", "佐古", "作古", "峪",
"塚", "牧", "畑", "田", "森", "幡多", "幡", "畠", "秦",
"聡", "郷", "里",
"馬", "鹿", "亀", "鷲", "鷹",
"湯",
],
waterTerrain: [
"川", "河", "江", "瀬", "淵", "渕",
"池", "沼", "泉", "井",
"滝", "梅", "沢", "澤", "谷", "津",
"水", "清", "渡", "橋", "堀",
"溝", "浦", "洲"
],
coastalTerrain: [
"津", "浦", "ヶ浦", "津", "崎",
"島", "磯", "潟", "湊", "津",
"州", "洲", "瀬", "砂", "潮", "塩", "汐",
"泊", "江", "浦", "灘", "入",
"戸", "門",
"鯵", "鰐", "漁", "魚"
],
plants: [
"松", "杉", "桜", "梅", "栗",
"竹", "楠", "藤", "萩", "葦",
"菅", "榎", "椿", "桐", "柳",
"橘", "柏", "槙", "柿", "桃",
"梨", "桑", "麻", "芦", "茅",
"粟", "稲", "麦", "稗", "米", "飯", "糠",
"榊", "楢", "檜", "椎", "柚", "茨", "葛", "蘆", "菖", "蒲", "蓮", "桑"
],
postfixes: [
"田", "川", "山", "岡", "森", "林",
"島",
"江", "瀬", "井", "戸", "口",
"辺", "里", "郷", "村", "町",
"宿", "庄", "台", "坂", "橋",
"本", "内", "窪", "平", "塚",
"畑", "牧", "前", "見", "中", "羽", "生", "塚", "部",
],
archaicPrefixes: [
"阿", "吾", "安", "有", "衣", "伊", "以", "井", "宇", "羽", "江", "恵", "尾", "小", "於",
"可", "加", "賀", "香", "鹿", "賀", "嘉", "喜", "紀", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子", "巨", "己",
"佐", "紗", "左", "志", "師", "須", "瀬", "曽", "蘇",
"多", "太", "知", "津", "土",
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
"波", "氷", "比", "肥", "布", "夫", "戸", "保", "穂",
"間", "磨", "摩", "見", "牟", "武", "目", "女", "毛", "裳",
"弥", "矢", "夜", "耶", "由", "与",
"和", "輪",
"出", "播", "但",
"因", "伯", "筑", "肥", "豊",
"日", "紀", "志", "尾", "駿",
"甲", "信", "越", "備", "能",
"薩", "隠", "美", "三", "若",
"遠", "近", "能", "加", "賀", "度",
"越", "淡", "壱", "衣", "古", "彦", "多", "志", "布", "治"
],
archaicSuffixes: [
"井", "羽", "江", "恵", "尾", "於",
"賀", "鹿", "喜", "吉", "伎", "久", "玖", "気", "家", "祁", "古", "子",
"佐", "紗", "左", "志", "路", "師", "須", "瀬", "曽", "蘇",
"多", "太", "知", "津", "豆", "土",
"那", "奈", "名", "仁", "尼", "根", "乃", "能",
"波", "布", "夫", "戸", "保", "穂",
"間", "磨", "摩", "馬", "見", "牟", "武", "目", "女", "毛", "裳", "茂",
"弥", "矢", "夜", "耶", "由", "与", "予",
"良", "利", "礼", "呂",
"和", "輪",
"陀", "芸", "雲",
"幡", "耆", "摩",
"張", "江", "河", "斐", "濃",
"岐", "防", "門", "隅", "向",
"居", "前", "中", "後", "波",
"勢", "渡", "城", "紫", "野", "度",
"津", "島", "信", "登", "賀", "志",
"良", "美", "智", "茂", "代", "古", "麻", "彦", "比古", "子"
],
settlementWords: [
"里", "郷", "村", "町", "宿", "邑", "垣", "坪",
"庄", "ノ庄", "之庄", "院", "宮", "ノ宮", "之宮", "寺", "社", "堂",
"城", "館", "屋", "家", "所",
"市", "場", "府", "関", "地蔵", "辻", "角", "堰",
"ヶ沢", "ヶ谷", "ヶ浜", "ヶ崎", "ヶ島", "ヶ浦", "ヶ津", "ヶ丘",
]
};
export const NAME_PROBABILITIES = {
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 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++) {
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 {
effectiveCustomNameProbability: probabilities.customName,
poolsPresent,
emptyPools,
selectedTemplateCounts: {},
selectedContextCounts: {},
customNamesUsed: 0,
forcedNamesUsed: 0,
generatedNamesUsed: 0,
invalidNamesRejected: 0,
oneCharacterNamesPrevented: 0,
rejectedOneCharacterNames: 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 (!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++;
if (validation.reason === "oneCharacter") debug.rejectedOneCharacterNames++;
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++;
debug.rejectedOneCharacterNames++;
}
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;
}