From 5b98f55bc77166de6cb71b27799fac890101fb7b Mon Sep 17 00:00:00 2001
From: Lupus <66938500+Lupus7477@users.noreply.github.com>
Date: Sun, 15 Mar 2026 02:22:15 +0100
Subject: [PATCH] feat: add optional AI-based name generation for map entities
---
package-lock.json | 6 -
.../dynamic/editors/cultures-editor.js | 44 ++
.../dynamic/editors/religions-editor.js | 74 ++++
.../modules/dynamic/editors/states-editor.js | 64 +++
public/modules/ui/ai-generator.js | 3 +-
public/modules/ui/burg-editor.js | 12 +
public/modules/ui/burgs-overview.js | 34 ++
public/modules/ui/lakes-editor.js | 12 +
public/modules/ui/options.js | 87 ++++
public/modules/ui/provinces-editor.js | 27 ++
public/modules/ui/rivers-editor.js | 12 +
public/modules/ui/rivers-overview.js | 34 ++
public/modules/ui/routes-editor.js | 20 +
public/modules/ui/routes-overview.js | 33 ++
public/modules/ui/tools.js | 184 ++++++++
public/modules/ui/zones-editor.js | 37 ++
src/index.html | 80 ++++
src/modules/ai-name-generator.ts | 419 ++++++++++++++++++
src/modules/ai-providers.ts | 216 +++++++++
src/modules/index.ts | 2 +
20 files changed, 1393 insertions(+), 7 deletions(-)
create mode 100644 src/modules/ai-name-generator.ts
create mode 100644 src/modules/ai-providers.ts
diff --git a/package-lock.json b/package-lock.json
index 3396b8f5..dfad0ee6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1353,7 +1353,6 @@
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1394,7 +1393,6 @@
"integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/browser": "4.0.18",
"@vitest/mocker": "4.0.18",
@@ -1876,7 +1874,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -2163,7 +2160,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -2475,7 +2471,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -2551,7 +2546,6 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
diff --git a/public/modules/dynamic/editors/cultures-editor.js b/public/modules/dynamic/editors/cultures-editor.js
index dee807db..81e0069f 100644
--- a/public/modules/dynamic/editors/cultures-editor.js
+++ b/public/modules/dynamic/editors/cultures-editor.js
@@ -59,6 +59,7 @@ function insertEditorHtml() {
+
@@ -87,6 +88,7 @@ function addListeners() {
byId("culturesManuallyCancel").on("click", () => exitCulturesManualAssignment());
byId("culturesEditNamesBase").on("click", editNamesbase);
byId("culturesAdd").on("click", enterAddCulturesMode);
+ byId("culturesRegenerateNamesAi").on("click", regenerateCultureNamesAi);
byId("culturesExport").on("click", downloadCulturesCsv);
byId("culturesImport").on("click", () => byId("culturesCSVToLoad").click());
byId("culturesCSVToLoad").on("change", uploadCulturesData);
@@ -190,6 +192,7 @@ function culturesEditorAddLines() {
+
@@ -236,6 +239,7 @@ function culturesEditorAddLines() {
$body.querySelectorAll("fill-box").forEach($el => $el.on("click", cultureChangeColor));
$body.querySelectorAll("div > input.cultureName").forEach($el => $el.on("input", cultureChangeName));
$body.querySelectorAll("div > span.icon-cw").forEach($el => $el.on("click", cultureRegenerateName));
+ $body.querySelectorAll("div > span.icon-robot").forEach($el => $el.on("click", cultureRegenerateNameAi));
$body.querySelectorAll("div > input.cultureExpan").forEach($el => $el.on("change", cultureChangeExpansionism));
$body.querySelectorAll("div > select.cultureType").forEach($el => $el.on("change", cultureChangeType));
$body.querySelectorAll("div > select.cultureBase").forEach($el => $el.on("change", cultureChangeBase));
@@ -354,6 +358,20 @@ function cultureRegenerateName() {
pack.cultures[cultureId].name = name;
}
+async function cultureRegenerateNameAi() {
+ const cultureId = +this.parentNode.dataset.id;
+ const $line = this.parentNode;
+ try {
+ const name = await AiNames.generateName("culture", cultureId);
+ $line.querySelector("input.cultureName").value = name;
+ $line.dataset.name = name;
+ pack.cultures[cultureId].name = name;
+ pack.cultures[cultureId].code = abbreviate(name, pack.cultures.map(c => c.code));
+ } catch (err) {
+ if (err.message !== "No API key configured") tip("AI name generation failed: " + err.message, false, "error", 4000);
+ }
+}
+
function cultureChangeExpansionism() {
const culture = +this.parentNode.dataset.id;
this.parentNode.dataset.expansionism = this.value;
@@ -850,6 +868,32 @@ function closeCulturesEditor() {
exitAddCultureMode();
}
+async function regenerateCultureNamesAi() {
+ const elements = Array.from($body.querySelectorAll(":scope > div"));
+ const unlocked = elements.filter(el => {
+ const id = +el.dataset.id;
+ return id > 0 && !pack.cultures[id].lock;
+ });
+ if (!unlocked.length) return;
+
+ tip("Generating AI names...", false, "info");
+
+ try {
+ for (const el of unlocked) {
+ const cultureId = +el.dataset.id;
+ const names = await AiNames.generateNames("culture", cultureId, 1);
+ const name = names[0] || Names.getCulture(cultureId);
+ el.querySelector("input.cultureName").value = name;
+ el.dataset.name = name;
+ pack.cultures[cultureId].name = name;
+ pack.cultures[cultureId].code = abbreviate(name, pack.cultures.map(c => c.code));
+ }
+ tip("AI names generated successfully", true, "success", 3000);
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+}
+
async function uploadCulturesData() {
const file = this.files[0];
this.value = "";
diff --git a/public/modules/dynamic/editors/religions-editor.js b/public/modules/dynamic/editors/religions-editor.js
index effa90cf..649f43c1 100644
--- a/public/modules/dynamic/editors/religions-editor.js
+++ b/public/modules/dynamic/editors/religions-editor.js
@@ -74,6 +74,7 @@ function insertEditorHtml() {
+
@@ -100,6 +101,7 @@ function addListeners() {
byId("religionsManuallyCancel").on("click", () => exitReligionsManualAssignment());
byId("religionsAdd").on("click", enterAddReligionMode);
byId("religionsExport").on("click", downloadReligionsCsv);
+ byId("religionsRegenerateNamesAi").on("click", regenerateReligionNamesAi);
byId("religionsRecalculate").on("click", () => recalculateReligions(true));
}
@@ -196,12 +198,14 @@ function religionsEditorAddLines() {
+
+
@@ -236,10 +240,12 @@ function religionsEditorAddLines() {
});
$body.querySelectorAll("fill-box").forEach(el => el.on("click", religionChangeColor));
$body.querySelectorAll("div > input.religionName").forEach(el => el.on("input", religionChangeName));
+ $body.querySelectorAll("div > span.icon-robot").forEach(el => el.on("click", religionRegenerateNameAi));
$body.querySelectorAll("div > select.religionType").forEach(el => el.on("change", religionChangeType));
$body.querySelectorAll("div > input.religionForm").forEach(el => el.on("input", religionChangeForm));
$body.querySelectorAll("div > input.religionDeity").forEach(el => el.on("input", religionChangeDeity));
$body.querySelectorAll("div > span.icon-arrows-cw").forEach(el => el.on("click", regenerateDeity));
+ $body.querySelectorAll("div > span.icon-robot-deity").forEach(el => el.on("click", regenerateDeityAi));
$body.querySelectorAll("div > div.religionPopulation").forEach(el => el.on("click", changePopulation));
$body.querySelectorAll("div > select.religionExtent").forEach(el => el.on("change", religionChangeExtent));
$body.querySelectorAll("div > input.religionExpantion").forEach(el => el.on("change", religionChangeExpansionism));
@@ -363,6 +369,21 @@ function religionChangeName() {
);
}
+async function religionRegenerateNameAi() {
+ const religionId = +this.parentNode.dataset.id;
+ const $line = this.parentNode;
+ const cultureId = pack.religions[religionId].culture;
+ try {
+ const name = await AiNames.generateName("religion", cultureId);
+ $line.querySelector("input.religionName").value = name;
+ $line.dataset.name = name;
+ pack.religions[religionId].name = name;
+ pack.religions[religionId].code = abbreviate(name, pack.religions.map(r => r.code));
+ } catch (err) {
+ if (err.message !== "No API key configured") tip("AI name generation failed: " + err.message, false, "error", 4000);
+ }
+}
+
function religionChangeType() {
const religionId = +this.parentNode.dataset.id;
this.parentNode.dataset.type = this.value;
@@ -390,6 +411,23 @@ function regenerateDeity() {
this.nextElementSibling.value = deity;
}
+async function regenerateDeityAi() {
+ const religionId = +this.parentNode.dataset.id;
+ const religion = pack.religions[religionId];
+ const cultureId = religion.culture;
+ try {
+ const deity = await AiNames.generateName("deity", cultureId, {
+ religionType: religion.type,
+ religionForm: religion.form
+ });
+ this.parentNode.dataset.deity = deity;
+ pack.religions[religionId].deity = deity;
+ this.nextElementSibling.value = deity;
+ } catch (err) {
+ if (err.message !== "No API key configured") tip("AI deity generation failed: " + err.message, false, "error", 4000);
+ }
+}
+
function changePopulation() {
const religionId = +this.parentNode.dataset.id;
const religion = pack.religions[religionId];
@@ -821,6 +859,42 @@ function closeReligionsEditor() {
exitAddReligionMode();
}
+async function regenerateReligionNamesAi() {
+ const elements = Array.from($body.querySelectorAll(":scope > div"));
+ const unlocked = elements.filter(el => {
+ const id = +el.dataset.id;
+ return id > 0 && !pack.religions[id].lock;
+ });
+ if (!unlocked.length) return;
+
+ const byCulture = new Map();
+ for (const el of unlocked) {
+ const religionId = +el.dataset.id;
+ const culture = pack.religions[religionId].culture;
+ if (!byCulture.has(culture)) byCulture.set(culture, []);
+ byCulture.get(culture).push({el, religionId});
+ }
+
+ tip("Generating AI names...", false, "info");
+
+ try {
+ for (const [culture, religions] of byCulture) {
+ const names = await AiNames.generateNames("religion", culture, religions.length);
+ for (let i = 0; i < religions.length; i++) {
+ const name = names[i] || Religions.getRandomName(culture);
+ const {el, religionId} = religions[i];
+ el.querySelector("input.religionName").value = name;
+ el.dataset.name = name;
+ pack.religions[religionId].name = name;
+ pack.religions[religionId].code = abbreviate(name, pack.religions.map(r => r.code));
+ }
+ }
+ tip("AI names generated successfully", true, "success", 3000);
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+}
+
function updateLockStatus() {
if (customization) return;
diff --git a/public/modules/dynamic/editors/states-editor.js b/public/modules/dynamic/editors/states-editor.js
index 6d93a919..ad42b140 100644
--- a/public/modules/dynamic/editors/states-editor.js
+++ b/public/modules/dynamic/editors/states-editor.js
@@ -78,6 +78,7 @@ function insertEditorHtml() {
+
@@ -106,6 +107,7 @@ function addListeners() {
byId("statesManuallyCancel").on("click", () => exitStatesManualAssignment(false));
byId("statesAdd").on("click", enterAddStateMode);
byId("statesMerge").on("click", openStateMergeDialog);
+ byId("statesRegenerateNamesAi").on("click", regenerateStateNamesAi);
byId("statesExport").on("click", downloadStatesCsv);
$body.on("click", event => {
@@ -400,9 +402,11 @@ function editStateName(state) {
// add listeners
byId("stateNameEditorShortCulture").on("click", regenerateShortNameCulture);
byId("stateNameEditorShortRandom").on("click", regenerateShortNameRandom);
+ byId("stateNameEditorShortAi").on("click", regenerateShortNameAi);
byId("stateNameEditorAddForm").on("click", addCustomForm);
byId("stateNameEditorCustomForm").on("change", addCustomForm);
byId("stateNameEditorFullRegenerate").on("click", regenerateFullName);
+ byId("stateNameEditorFullAi").on("click", regenerateFullNameAi);
function regenerateShortNameCulture() {
const state = +stateNameEditor.dataset.state;
@@ -417,6 +421,16 @@ function editStateName(state) {
byId("stateNameEditorShort").value = name;
}
+ async function regenerateShortNameAi() {
+ const state = +stateNameEditor.dataset.state;
+ const culture = pack.states[state].culture;
+ try {
+ byId("stateNameEditorShort").value = await AiNames.generateName("state", culture);
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+ }
+
function addCustomForm() {
const value = stateNameEditorCustomForm.value;
const addModeActive = stateNameEditorCustomForm.style.display === "inline-block";
@@ -440,6 +454,19 @@ function editStateName(state) {
}
}
+ async function regenerateFullNameAi() {
+ const state = +stateNameEditor.dataset.state;
+ const culture = pack.states[state].culture;
+ const short = byId("stateNameEditorShort").value;
+ const form = byId("stateNameEditorSelectForm").value;
+ try {
+ const name = await AiNames.generateName("stateFullName", culture, {form: form || "State", stateName: short});
+ byId("stateNameEditorFull").value = name;
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+ }
+
function applyNameChange(s) {
const nameInput = byId("stateNameEditorShort");
const formSelect = byId("stateNameEditorSelectForm");
@@ -1492,6 +1519,43 @@ function downloadStatesCsv() {
downloadFile(csvData, name);
}
+async function regenerateStateNamesAi() {
+ const elements = Array.from($body.querySelectorAll(":scope > div"));
+ const unlocked = elements.filter(el => {
+ const id = +el.dataset.id;
+ return id > 0 && !pack.states[id].lock;
+ });
+ if (!unlocked.length) return;
+
+ const byCulture = new Map();
+ for (const el of unlocked) {
+ const stateId = +el.dataset.id;
+ const culture = pack.states[stateId].culture;
+ if (!byCulture.has(culture)) byCulture.set(culture, []);
+ byCulture.get(culture).push({el, stateId});
+ }
+
+ tip("Generating AI names...", false, "info");
+
+ try {
+ for (const [culture, states] of byCulture) {
+ const names = await AiNames.generateNames("state", culture, states.length);
+ for (let i = 0; i < states.length; i++) {
+ const name = names[i] || Names.getState(Names.getCultureShort(culture), culture);
+ const {el, stateId} = states[i];
+ const s = pack.states[stateId];
+ s.name = el.dataset.name = name;
+ el.querySelector("input.stateName").value = name;
+ s.fullName = s.formName ? `${s.formName} of ${name}` : name;
+ labels.select("[data-id='" + stateId + "']").text(s.fullName);
+ }
+ }
+ tip("AI names generated successfully", true, "success", 3000);
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+}
+
function closeStatesEditor() {
if (customization === 2) exitStatesManualAssignment(true);
if (customization === 3) exitAddStateMode();
diff --git a/public/modules/ui/ai-generator.js b/public/modules/ui/ai-generator.js
index 8ef13cf6..8662a23a 100644
--- a/public/modules/ui/ai-generator.js
+++ b/public/modules/ui/ai-generator.js
@@ -87,8 +87,9 @@ async function generateWithAnthropic({key, model, prompt, temperature, onContent
async function generateWithOllama({key, model, prompt, temperature, onContent}) {
const ollamaModelName = key; // for Ollama, 'key' is the actual model name entered by the user
+ const ollamaHost = localStorage.getItem("fmg-ai-ollama-host") || "http://localhost:11434";
- const response = await fetch("http://localhost:11434/api/generate", {
+ const response = await fetch(`${ollamaHost}/api/generate`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
diff --git a/public/modules/ui/burg-editor.js b/public/modules/ui/burg-editor.js
index 4232d239..b57e8cd5 100644
--- a/public/modules/ui/burg-editor.js
+++ b/public/modules/ui/burg-editor.js
@@ -29,6 +29,7 @@ function editBurg(id) {
byId("burgType").on("change", changeType);
byId("burgCulture").on("change", changeCulture);
byId("burgNameReCulture").on("click", generateNameCulture);
+ byId("burgNameAi").on("click", generateNameAi);
byId("burgPopulation").on("change", changePopulation);
burgBody.querySelectorAll(".burgFeature").forEach(el => el.on("click", toggleFeature));
byId("burgLinkOpen").on("click", openBurgLink);
@@ -149,6 +150,17 @@ function editBurg(id) {
changeName();
}
+ async function generateNameAi() {
+ const id = +elSelected.attr("data-id");
+ const culture = pack.burgs[id].culture;
+ try {
+ burgName.value = await AiNames.generateName("burg", culture);
+ changeName();
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+ }
+
function changePopulation() {
const id = +elSelected.attr("data-id");
const burg = pack.burgs[id];
diff --git a/public/modules/ui/burgs-overview.js b/public/modules/ui/burgs-overview.js
index 5b061fd4..b99cb816 100644
--- a/public/modules/ui/burgs-overview.js
+++ b/public/modules/ui/burgs-overview.js
@@ -30,6 +30,7 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
byId("burgsFilterCulture").addEventListener("change", burgsOverviewAddLines);
byId("burgsSearch").addEventListener("input", burgsOverviewAddLines);
byId("regenerateBurgNames").addEventListener("click", regenerateNames);
+ byId("regenerateBurgNamesAi").addEventListener("click", regenerateNamesAi);
byId("addNewBurg").addEventListener("click", enterAddBurgMode);
byId("burgsExport").addEventListener("click", downloadBurgsData);
byId("burgNamesImport").addEventListener("click", renameBurgsInBulk);
@@ -233,6 +234,39 @@ function overviewBurgs(settings = {stateId: null, cultureId: null}) {
});
}
+ async function regenerateNamesAi() {
+ const elements = Array.from(body.querySelectorAll(":scope > div"));
+ const unlocked = elements.filter(el => !pack.burgs[+el.dataset.id].lock);
+ if (!unlocked.length) return;
+
+ // Group burgs by culture for batch generation
+ const byCulture = new Map();
+ for (const el of unlocked) {
+ const burgId = +el.dataset.id;
+ const culture = pack.burgs[burgId].culture;
+ if (!byCulture.has(culture)) byCulture.set(culture, []);
+ byCulture.get(culture).push({el, burgId});
+ }
+
+ tip("Generating AI names...", false, "info");
+
+ try {
+ for (const [culture, burgs] of byCulture) {
+ const names = await AiNames.generateNames("burg", culture, burgs.length);
+ for (let i = 0; i < burgs.length; i++) {
+ const name = names[i] || Names.getCulture(culture);
+ const {el, burgId} = burgs[i];
+ el.querySelector(".burgName").value = name;
+ pack.burgs[burgId].name = el.dataset.name = name;
+ burgLabels.select("[data-id='" + burgId + "']").text(name);
+ }
+ }
+ tip("AI names generated successfully", true, "success", 3000);
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+ }
+
function enterAddBurgMode() {
if (this.classList.contains("pressed")) return exitAddBurgMode();
customization = 3;
diff --git a/public/modules/ui/lakes-editor.js b/public/modules/ui/lakes-editor.js
index 55f3fb5b..90047f5b 100644
--- a/public/modules/ui/lakes-editor.js
+++ b/public/modules/ui/lakes-editor.js
@@ -26,6 +26,7 @@ function editLake() {
byId("lakeName").on("input", changeName);
byId("lakeNameCulture").on("click", generateNameCulture);
byId("lakeNameRandom").on("click", generateNameRandom);
+ byId("lakeNameAi").on("click", generateNameAi);
byId("lakeGroup").on("change", changeLakeGroup);
byId("lakeGroupAdd").on("click", toggleNewGroupInput);
byId("lakeGroupName").on("change", createNewGroup);
@@ -140,6 +141,17 @@ function editLake() {
lake.name = lakeName.value = Names.getBase(rand(nameBases.length - 1));
}
+ async function generateNameAi() {
+ const lake = getLake();
+ const cells = Array.from(pack.cells.i.filter(i => pack.cells.f[i] === lake.i));
+ const culture = cells.length ? pack.cells.culture[cells[0]] : 0;
+ try {
+ lake.name = lakeName.value = await AiNames.generateName("lake", culture);
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+ }
+
function selectLakeGroup() {
const lake = getLake();
diff --git a/public/modules/ui/options.js b/public/modules/ui/options.js
index 07d77cb0..cfd620cc 100644
--- a/public/modules/ui/options.js
+++ b/public/modules/ui/options.js
@@ -137,6 +137,11 @@ optionsContent.addEventListener("input", event => {
else if (id === "themeHueInput") changeThemeHue(value);
else if (id === "themeColorInput") changeDialogsTheme(themeColorInput.value, transparencyInput.value);
else if (id === "transparencyInput") changeDialogsTheme(themeColorInput.value, value);
+ else if (id === "aiNamesCustomPrompt") localStorage.setItem("fmg-ai-custom-prompt", value);
+ else if (id === "aiNamesLanguageOverride") localStorage.setItem("fmg-ai-language-override", value);
+ else if (id === "aiNamesOllamaHost") localStorage.setItem("fmg-ai-ollama-host", value);
+ else if (id === "aiNamesTemperature") localStorage.setItem("fmg-ai-temperature", value);
+ else if (id === "aiNamesKey") saveAiKey();
});
optionsContent.addEventListener("change", event => {
@@ -149,6 +154,7 @@ optionsContent.addEventListener("change", event => {
else if (id === "eraInput") changeEra();
else if (id === "stateLabelsModeInput") options.stateLabelsMode = value;
else if (id === "azgaarAssistant") toggleAssistant();
+ else if (id === "aiNamesModel") changeAiModel(value);
});
optionsContent.addEventListener("click", event => {
@@ -164,6 +170,7 @@ optionsContent.addEventListener("click", event => {
else if (id === "themeColorRestore") restoreDefaultThemeColor();
else if (id === "loadGoogleTranslateButton") loadGoogleTranslate();
else if (id === "resetLanguage") resetLanguage();
+ else if (id === "aiNamesKeyHelp") openAiKeyHelp();
});
function mapSizeInputChange() {
@@ -191,6 +198,86 @@ function restoreDefaultCanvasSize() {
fitMapToScreen();
}
+// AI Name Generation settings
+const AI_MODELS = {
+ "gpt-4o-mini": "openai",
+ "chatgpt-4o-latest": "openai",
+ "gpt-4o": "openai",
+ "gpt-4-turbo": "openai",
+ o3: "openai",
+ "o3-mini": "openai",
+ "o3-pro": "openai",
+ "o4-mini": "openai",
+ "claude-opus-4-20250514": "anthropic",
+ "claude-sonnet-4-20250514": "anthropic",
+ "claude-3-5-haiku-latest": "anthropic",
+ "claude-3-5-sonnet-latest": "anthropic",
+ "claude-3-opus-latest": "anthropic",
+ "ollama (local models)": "ollama"
+};
+
+function initAiSettings() {
+ const modelSelect = byId("aiNamesModel");
+ if (!modelSelect) return;
+
+ modelSelect.options.length = 0;
+ Object.keys(AI_MODELS).forEach(m => modelSelect.options.add(new Option(m, m)));
+ modelSelect.value = localStorage.getItem("fmg-ai-model") || "gpt-4o-mini";
+ if (!modelSelect.value || !AI_MODELS[modelSelect.value]) modelSelect.value = "gpt-4o-mini";
+
+ const provider = AI_MODELS[modelSelect.value];
+ byId("aiNamesKey").value = localStorage.getItem(`fmg-ai-kl-${provider}`) || "";
+ byId("aiNamesOllamaHost").value = localStorage.getItem("fmg-ai-ollama-host") || "http://localhost:11434";
+ byId("aiNamesTemperature").value = localStorage.getItem("fmg-ai-temperature") || "1";
+ byId("aiNamesLanguageOverride").value = localStorage.getItem("fmg-ai-language-override") || "";
+ byId("aiNamesCustomPrompt").value = localStorage.getItem("fmg-ai-custom-prompt") || "";
+
+ // show/hide ollama host based on selected model
+ const ollamaRow = byId("aiNamesOllamaHost").closest("tr");
+ ollamaRow.style.display = provider === "ollama" ? "" : "none";
+}
+
+function changeAiModel(value) {
+ localStorage.setItem("fmg-ai-model", value);
+ const provider = AI_MODELS[value];
+
+ // load key for the selected provider
+ byId("aiNamesKey").value = localStorage.getItem(`fmg-ai-kl-${provider}`) || "";
+
+ // show/hide ollama host row
+ const ollamaRow = byId("aiNamesOllamaHost").closest("tr");
+ ollamaRow.style.display = provider === "ollama" ? "" : "none";
+}
+
+function saveAiKey() {
+ const model = byId("aiNamesModel").value;
+ const provider = AI_MODELS[model];
+ const key = byId("aiNamesKey").value;
+ localStorage.setItem(`fmg-ai-kl-${provider}`, key);
+}
+
+function openAiKeyHelp() {
+ const model = byId("aiNamesModel").value;
+ const provider = AI_MODELS[model];
+ const links = {
+ openai: "https://platform.openai.com/account/api-keys",
+ anthropic: "https://console.anthropic.com/account/keys",
+ ollama: "https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Ollama-text-generation"
+ };
+ openURL(links[provider] || links.openai);
+}
+
+// Initialize AI settings when Options tab is first shown
+{
+ const optionsTab = byId("optionsTab");
+ if (optionsTab) {
+ const origClick = optionsTab.onclick;
+ optionsTab.addEventListener("click", function () {
+ initAiSettings();
+ });
+ }
+}
+
// on map creation
function applyGraphSize() {
graphWidth = +mapWidthInput.value;
diff --git a/public/modules/ui/provinces-editor.js b/public/modules/ui/provinces-editor.js
index 35121b94..6f854a9d 100644
--- a/public/modules/ui/provinces-editor.js
+++ b/public/modules/ui/provinces-editor.js
@@ -542,6 +542,8 @@ function editProvinces() {
byId("provinceNameEditorShortRandom").on("click", regenerateShortNameRandom);
byId("provinceNameEditorAddForm").on("click", addCustomForm);
byId("provinceNameEditorFullRegenerate").on("click", regenerateFullName);
+ byId("provinceNameEditorShortAi").on("click", regenerateShortNameAi);
+ byId("provinceNameEditorFullAi").on("click", regenerateFullNameAi);
function regenerateShortNameCulture() {
const province = +provinceNameEditor.dataset.province;
@@ -576,6 +578,31 @@ function editProvinces() {
}
}
+ async function regenerateShortNameAi() {
+ const province = +provinceNameEditor.dataset.province;
+ const culture = pack.cells.culture[pack.provinces[province].center];
+ try {
+ const name = await AiNames.generateName("province", culture, {form: pack.provinces[province].formName});
+ byId("provinceNameEditorShort").value = name;
+ } catch (err) {
+ tip("AI generation failed: " + err.message, true, "error", 4000);
+ }
+ }
+
+ async function regenerateFullNameAi() {
+ const short = byId("provinceNameEditorShort").value;
+ const form = byId("provinceNameEditorSelectForm").value;
+ if (!form || !short) { regenerateFullName(); return; }
+ try {
+ const province = +provinceNameEditor.dataset.province;
+ const culture = pack.cells.culture[pack.provinces[province].center];
+ const fullName = await AiNames.generateName("provinceFullName", culture, {form, stateName: short});
+ byId("provinceNameEditorFull").value = fullName;
+ } catch (err) {
+ tip("AI generation failed: " + err.message, true, "error", 4000);
+ }
+ }
+
function applyNameChange(p) {
p.name = byId("provinceNameEditorShort").value;
p.formName = byId("provinceNameEditorSelectForm").value;
diff --git a/public/modules/ui/rivers-editor.js b/public/modules/ui/rivers-editor.js
index a00d488b..ab46dab2 100644
--- a/public/modules/ui/rivers-editor.js
+++ b/public/modules/ui/rivers-editor.js
@@ -45,6 +45,7 @@ function editRiver(id) {
byId("riverType").on("input", changeType);
byId("riverNameCulture").on("click", generateNameCulture);
byId("riverNameRandom").on("click", generateNameRandom);
+ byId("riverNameAi").on("click", generateNameAi);
byId("riverMainstem").on("change", changeParent);
byId("riverSourceWidth").on("input", changeSourceWidth);
byId("riverWidthFactor").on("input", changeWidthFactor);
@@ -212,6 +213,17 @@ function editRiver(id) {
if (r) r.name = riverName.value = Names.getBase(rand(nameBases.length - 1));
}
+ async function generateNameAi() {
+ const r = getRiver();
+ if (!r) return;
+ const culture = pack.cells.culture[r.mouth];
+ try {
+ r.name = riverName.value = await AiNames.generateName("river", culture);
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+ }
+
function changeParent() {
const r = getRiver();
r.parent = +this.value;
diff --git a/public/modules/ui/rivers-overview.js b/public/modules/ui/rivers-overview.js
index c062424f..36a5384b 100644
--- a/public/modules/ui/rivers-overview.js
+++ b/public/modules/ui/rivers-overview.js
@@ -25,6 +25,7 @@ function overviewRivers() {
byId("riverCreateNew").on("click", createRiver);
byId("riversBasinHighlight").on("click", toggleBasinsHightlight);
byId("riversExport").on("click", downloadRiversData);
+ byId("riversRegenerateNamesAi").on("click", regenerateRiverNamesAi);
byId("riversRemoveAll").on("click", triggerAllRiversRemove);
byId("riversSearch").on("input", riversOverviewAddLines);
@@ -215,4 +216,37 @@ function overviewRivers() {
rivers.selectAll("*").remove();
riversOverviewAddLines();
}
+
+ async function regenerateRiverNamesAi() {
+ const elements = Array.from(body.querySelectorAll(":scope > div"));
+ if (!elements.length) return;
+
+ const byCulture = new Map();
+ for (const el of elements) {
+ const riverId = +el.dataset.id;
+ const river = pack.rivers.find(r => r.i === riverId);
+ if (!river || river.lock) continue;
+ const mouth = river.mouth;
+ const culture = mouth != null ? pack.cells.culture[mouth] : 0;
+ if (!byCulture.has(culture)) byCulture.set(culture, []);
+ byCulture.get(culture).push({el, river});
+ }
+
+ tip("Generating AI names...", false, "info");
+
+ try {
+ for (const [culture, items] of byCulture) {
+ const names = await AiNames.generateNames("river", culture, items.length);
+ for (let i = 0; i < items.length; i++) {
+ const name = names[i] || Rivers.getName(items[i].river.mouth);
+ const {el, river} = items[i];
+ river.name = el.dataset.name = name;
+ el.querySelector(".riverName").textContent = name;
+ }
+ }
+ tip("AI names generated successfully", true, "success", 3000);
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+ }
}
diff --git a/public/modules/ui/routes-editor.js b/public/modules/ui/routes-editor.js
index 5556c812..314b26d7 100644
--- a/public/modules/ui/routes-editor.js
+++ b/public/modules/ui/routes-editor.js
@@ -49,6 +49,7 @@ function editRoute(id) {
byId("routeGroupEdit").on("click", editRouteGroups);
byId("routeEditStyle").on("click", editRouteGroupStyle);
byId("routeGenerateName").on("click", generateName);
+ byId("routeGenerateNameAi").on("click", generateNameAi);
function getRoute() {
const routeId = +elSelected.attr("id").slice(5);
@@ -351,6 +352,25 @@ function editRoute(id) {
route.name = routeName.value = Routes.generateName(route);
}
+ async function generateNameAi() {
+ const route = getRoute();
+ const connectedBurgs = [];
+ for (const [_x, _y, cellId] of route.points) {
+ const burgId = pack.cells.burg[cellId];
+ if (burgId && pack.burgs[burgId]) connectedBurgs.push(pack.burgs[burgId].name);
+ }
+ const culture = pack.cells.culture[route.points[0][2]] || 0;
+ try {
+ const name = await AiNames.generateName("route", culture, {
+ routeGroup: route.group,
+ connectedBurgs: connectedBurgs.slice(0, 3)
+ });
+ route.name = routeName.value = name;
+ } catch (err) {
+ if (err.message !== "No API key configured") tip("AI name generation failed: " + err.message, false, "error", 4000);
+ }
+ }
+
function showRouteElevationProfile() {
const route = getRoute();
const length = rn(route.length * distanceScale);
diff --git a/public/modules/ui/routes-overview.js b/public/modules/ui/routes-overview.js
index 883df3de..fd1ab7aa 100644
--- a/public/modules/ui/routes-overview.js
+++ b/public/modules/ui/routes-overview.js
@@ -23,6 +23,7 @@ function overviewRoutes() {
byId("routesOverviewRefresh").on("click", routesOverviewAddLines);
byId("routesCreateNew").on("click", createRoute);
byId("routesExport").on("click", downloadRoutesData);
+ byId("routesRegenerateNamesAi").on("click", regenerateRouteNamesAi);
byId("routesLockAll").on("click", toggleLockAll);
byId("routesRemoveAll").on("click", triggerAllRoutesRemove);
byId("routesSearch").on("input", routesOverviewAddLines);
@@ -190,4 +191,36 @@ function overviewRoutes() {
}
});
}
+
+ async function regenerateRouteNamesAi() {
+ const elements = Array.from(body.querySelectorAll(":scope > div"));
+ if (!elements.length) return;
+
+ const byCulture = new Map();
+ for (const el of elements) {
+ const routeId = +el.dataset.id;
+ const route = pack.routes.find(r => r.i === routeId);
+ if (!route || route.lock) continue;
+ const culture = route.points?.[0] ? pack.cells.culture[route.points[0][2]] : 0;
+ if (!byCulture.has(culture)) byCulture.set(culture, []);
+ byCulture.get(culture).push({el, route});
+ }
+
+ tip("Generating AI names...", false, "info");
+
+ try {
+ for (const [culture, items] of byCulture) {
+ const names = await AiNames.generateNames("route", culture, items.length);
+ for (let i = 0; i < items.length; i++) {
+ const name = names[i] || Routes.generateName(items[i].route);
+ const {el, route} = items[i];
+ route.name = el.dataset.name = name;
+ el.querySelector("div[data-tip='Route name']").textContent = name;
+ }
+ }
+ tip("AI names generated successfully", true, "success", 3000);
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+ }
}
diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js
index 34c1cd12..0b98663d 100644
--- a/public/modules/ui/tools.js
+++ b/public/modules/ui/tools.js
@@ -96,6 +96,7 @@ function processFeatureRegeneration(event, button) {
else if (button === "regenerateIce") regenerateIce();
else if (button === "regenerateMarkers") regenerateMarkers();
else if (button === "regenerateZones") regenerateZones(event);
+ else if (button === "regenerateAiNames") regenerateAllAiNames();
}
async function openEmblemEditor() {
@@ -583,6 +584,189 @@ function regenerateZones(event) {
}
}
+async function regenerateAllAiNames() {
+ const {states, cultures, burgs, rivers, religions, routes, zones} = pack;
+
+ tip("Regenerating all names with AI...", false, "info");
+
+ try {
+ // Cultures
+ for (const c of cultures) {
+ if (!c.i || c.removed || c.lock) continue;
+ const names = await AiNames.generateNames("culture", c.i, 1);
+ if (names[0]) c.name = names[0];
+ }
+
+ // States
+ const byCulture = new Map();
+ for (const s of states) {
+ if (!s.i || s.removed || s.lock) continue;
+ if (!byCulture.has(s.culture)) byCulture.set(s.culture, []);
+ byCulture.get(s.culture).push(s);
+ }
+ for (const [culture, items] of byCulture) {
+ const names = await AiNames.generateNames("state", culture, items.length);
+ for (let i = 0; i < items.length; i++) {
+ const s = items[i];
+ s.name = names[i] || s.name;
+ }
+ }
+
+ // State full names (batched - 1 API call per culture)
+ for (const [culture, items] of byCulture) {
+ const withForm = items.filter(s => s.formName);
+ const withoutForm = items.filter(s => !s.formName);
+ for (const s of withoutForm) s.fullName = s.name;
+ if (withForm.length) {
+ const inputs = withForm.map(s => ({shortName: s.name, form: s.formName}));
+ const fullNames = await AiNames.generateFullNamesBatch(inputs, culture);
+ for (let i = 0; i < withForm.length; i++) {
+ withForm[i].fullName = fullNames[i] || `${withForm[i].formName} of ${withForm[i].name}`;
+ }
+ }
+ }
+
+ // Burgs
+ const burgsByCulture = new Map();
+ for (const b of burgs) {
+ if (!b.i || b.removed || b.lock) continue;
+ if (!burgsByCulture.has(b.culture)) burgsByCulture.set(b.culture, []);
+ burgsByCulture.get(b.culture).push(b);
+ }
+ for (const [culture, items] of burgsByCulture) {
+ const names = await AiNames.generateNames("burg", culture, items.length);
+ for (let i = 0; i < items.length; i++) {
+ items[i].name = names[i] || items[i].name;
+ }
+ }
+
+ // Rivers
+ const riversByCulture = new Map();
+ for (const r of rivers) {
+ if (!r.i || r.lock) continue;
+ const culture = r.mouth != null ? pack.cells.culture[r.mouth] : 0;
+ if (!riversByCulture.has(culture)) riversByCulture.set(culture, []);
+ riversByCulture.get(culture).push(r);
+ }
+ for (const [culture, items] of riversByCulture) {
+ const names = await AiNames.generateNames("river", culture, items.length);
+ for (let i = 0; i < items.length; i++) {
+ items[i].name = names[i] || items[i].name;
+ }
+ }
+
+ // Religions (names + deities in one batch per culture)
+ const relByCulture = new Map();
+ for (const r of religions) {
+ if (!r.i || r.removed || r.lock) continue;
+ if (!relByCulture.has(r.culture)) relByCulture.set(r.culture, []);
+ relByCulture.get(r.culture).push(r);
+ }
+ for (const [culture, items] of relByCulture) {
+ const inputs = items.map(r => ({type: r.type, form: r.form}));
+ const results = await AiNames.generateReligionsBatch(inputs, culture);
+ for (let i = 0; i < items.length; i++) {
+ if (results[i]) {
+ if (results[i].name) items[i].name = results[i].name;
+ items[i].deity = results[i].deity;
+ }
+ }
+ }
+
+ // Routes (batched by culture, chunked for large maps)
+ const CHUNK_SIZE = 50;
+ const routesByCulture = new Map();
+ for (const route of routes) {
+ if (!route.points || route.lock) continue;
+ const culture = route.points[0] ? pack.cells.culture[route.points[0][2]] : 0;
+ if (!routesByCulture.has(culture)) routesByCulture.set(culture, []);
+ routesByCulture.get(culture).push(route);
+ }
+ for (const [culture, items] of routesByCulture) {
+ for (let start = 0; start < items.length; start += CHUNK_SIZE) {
+ const chunk = items.slice(start, start + CHUNK_SIZE);
+ const names = await AiNames.generateNames("route", culture, chunk.length);
+ for (let i = 0; i < chunk.length; i++) {
+ chunk[i].name = names[i] || chunk[i].name;
+ }
+ }
+ }
+
+ // Zones (translate types + generate context-aware descriptions)
+ if (zones.length) {
+ const uniqueTypes = [...new Set(zones.map(z => z.type))];
+ const translatedTypes = await AiNames.translateTerms(uniqueTypes, 0);
+ const typeMap = Object.fromEntries(uniqueTypes.map((t, i) => [t, translatedTypes[i]]));
+
+ for (const z of zones) {
+ z.type = typeMap[z.type] || z.type;
+ }
+
+ // Build context for each zone (biome + nearest burg)
+ const zoneInputs = zones.map(z => {
+ const cells = z.cells || [];
+ const centerCell = cells[Math.floor(cells.length / 2)] || cells[0];
+
+ let biome = "unknown";
+ if (centerCell != null && pack.cells.biome[centerCell] != null) {
+ biome = biomesData.name[pack.cells.biome[centerCell]] || "unknown";
+ }
+
+ let nearBurg = "";
+ for (const cellId of cells) {
+ const burgId = pack.cells.burg[cellId];
+ if (burgId && pack.burgs[burgId]) { nearBurg = pack.burgs[burgId].name; break; }
+ }
+
+ return {type: z.type, biome, nearBurg};
+ });
+
+ const descriptions = await AiNames.generateZoneDescriptionsBatch(zoneInputs, 0);
+ for (let i = 0; i < zones.length; i++) {
+ if (descriptions[i]) zones[i].name = descriptions[i];
+ }
+ }
+
+ // Provinces
+ const provinces = pack.provinces;
+ const provsByCulture = new Map();
+ for (const p of provinces) {
+ if (!p.i || p.removed || p.lock) continue;
+ const culture = pack.cells.culture[p.center];
+ if (!provsByCulture.has(culture)) provsByCulture.set(culture, []);
+ provsByCulture.get(culture).push(p);
+ }
+ for (const [culture, items] of provsByCulture) {
+ const names = await AiNames.generateNames("province", culture, items.length);
+ for (let i = 0; i < items.length; i++) {
+ items[i].name = names[i] || items[i].name;
+ }
+ }
+
+ // Province full names (batched - 1 API call per culture)
+ for (const [culture, items] of provsByCulture) {
+ const withForm = items.filter(p => p.formName);
+ const withoutForm = items.filter(p => !p.formName);
+ for (const p of withoutForm) p.fullName = p.name;
+ if (withForm.length) {
+ const inputs = withForm.map(p => ({shortName: p.name, form: p.formName}));
+ const fullNames = await AiNames.generateFullNamesBatch(inputs, culture);
+ for (let i = 0; i < withForm.length; i++) {
+ withForm[i].fullName = fullNames[i] || `${withForm[i].formName} of ${withForm[i].name}`;
+ }
+ }
+ }
+
+ // Redraw labels
+ drawStateLabels();
+ drawBurgLabels();
+
+ tip("All AI names regenerated successfully", true, "success", 4000);
+ } catch (error) {
+ tip("AI name regeneration failed: " + error.message, true, "error", 5000);
+ }
+}
+
function unpressClickToAddButton() {
addFeature.querySelectorAll("button.pressed").forEach(b => b.classList.remove("pressed"));
restoreDefaultEvents();
diff --git a/public/modules/ui/zones-editor.js b/public/modules/ui/zones-editor.js
index 85888c4e..8b675dfc 100644
--- a/public/modules/ui/zones-editor.js
+++ b/public/modules/ui/zones-editor.js
@@ -29,6 +29,7 @@ function editZones() {
byId("zonesManuallyApply").on("click", applyZonesManualAssignent);
byId("zonesManuallyCancel").on("click", cancelZonesManualAssignent);
byId("zonesAdd").on("click", addZonesLayer);
+ byId("zonesRegenerateNamesAi").on("click", regenerateZoneNamesAi);
byId("zonesExport").on("click", downloadZonesData);
byId("zonesRemove").on("click", e => e.target.classList.toggle("pressed"));
@@ -45,6 +46,7 @@ function editZones() {
}
if (ev.target.closest("fill-box")) changeFill(ev.target.closest("fill-box").getAttribute("fill"), zone);
+ else if (ev.target.classList.contains("icon-robot")) generateZoneNameAi(zone, line);
else if (ev.target.classList.contains("zonePopulation")) changePopulation(zone);
else if (ev.target.classList.contains("zoneRemove")) zoneRemove(zone);
else if (ev.target.classList.contains("zoneHide")) toggleVisibility(zone);
@@ -94,6 +96,7 @@ function editZones() {
}">
+
${cells.length}
@@ -402,6 +405,17 @@ function editZones() {
zones.select("#zone" + zone.i).attr("data-description", value);
}
+ async function generateZoneNameAi(zone, line) {
+ try {
+ const name = await AiNames.generateName("zone", 0, {zoneType: zone.type});
+ zone.name = name;
+ zones.select("#zone" + zone.i).attr("data-description", name);
+ line.querySelector("input.zoneName").value = name;
+ } catch (err) {
+ if (err.message !== "No API key configured") tip("AI name generation failed: " + err.message, false, "error", 4000);
+ }
+ }
+
function changeType(zone, value) {
zone.type = value;
zones.select("#zone" + zone.i).attr("data-type", value);
@@ -492,4 +506,27 @@ function editZones() {
}
});
}
+
+ async function regenerateZoneNamesAi() {
+ const elements = Array.from(body.querySelectorAll(":scope > div.states"));
+ if (!elements.length) return;
+
+ tip("Generating AI names...", false, "info");
+
+ try {
+ const names = await AiNames.generateNames("zone", 0, elements.length);
+ for (let i = 0; i < elements.length; i++) {
+ const el = elements[i];
+ const zone = pack.zones.find(z => z.i === +el.dataset.id);
+ if (!zone) continue;
+ const name = names[i] || zone.name;
+ zone.name = name;
+ zones.select("#zone" + zone.i).attr("data-description", name);
+ el.querySelector("input.zoneName").value = name;
+ }
+ tip("AI names generated successfully", true, "success", 3000);
+ } catch (error) {
+ tip(error.message, true, "error", 4000);
+ }
+ }
}
diff --git a/src/index.html b/src/index.html
index e5426654..6e9b3d16 100644
--- a/src/index.html
+++ b/src/index.html
@@ -1667,6 +1667,7 @@
+
|
@@ -2076,6 +2077,59 @@
+ AI Name Generation:
+
+
@@ -2906,6 +2967,7 @@
class="icon-book pointer"
>
+
🔊
@@ -3011,6 +3073,7 @@
🔊
+
@@ -3455,6 +3518,11 @@
data-tip="Generate random name for the burg"
class="icon-globe pointer"
>
+
@@ -4498,6 +4566,7 @@
class="icon-book pointer"
>
+
@@ -4608,6 +4677,7 @@
data-tick="0"
class="icon-arrows-cw pointer"
>
+
@@ -4773,6 +4843,7 @@
class="icon-book pointer"
>
+
@@ -4844,6 +4915,7 @@
data-tip="Click to re-generate full name"
class="icon-arrows-cw pointer"
>
+
+
@@ -5444,6 +5517,11 @@
data-tip="Regenerate burg names based on assigned culture"
class="icon-retweet"
>
+
+