import { MAP_H, MAP_W, indexOf, inside } from "./grid.js"; import { hash2 } from "./random.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, 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("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++; 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 name = generateTemplateName(seed, id, entity, fields, attempt, null); return name || deterministicDiagnosticName(seed, id, attempt); } export function chooseNameSuffixPool() { return NAME_KANJI_POOLS.postfixes; }