473 lines
15 KiB
JavaScript
473 lines
15 KiB
JavaScript
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;
|
|
}
|