From 88c70b92640c92bb25b5e0b755bbcb2a0a32758f Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Fri, 30 Jan 2026 16:44:09 +0100 Subject: [PATCH] refactor: migrate states generator (#1291) * refactor: migrate states generator * Update src/modules/states-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/states-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- public/modules/states-generator.js | 640 ---------------------- src/index.html | 1 - src/modules/index.ts | 1 + src/modules/names-generator.ts | 2 +- src/modules/states-generator.ts | 824 +++++++++++++++++++++++++++++ src/types/PackedGraph.ts | 4 +- 6 files changed, 829 insertions(+), 643 deletions(-) delete mode 100644 public/modules/states-generator.js create mode 100644 src/modules/states-generator.ts diff --git a/public/modules/states-generator.js b/public/modules/states-generator.js deleted file mode 100644 index 9662e648..00000000 --- a/public/modules/states-generator.js +++ /dev/null @@ -1,640 +0,0 @@ -"use strict"; - -window.States = (() => { - const generate = () => { - TIME && console.time("generateStates"); - pack.states = createStates(); - expandStates(); - normalize(); - getPoles(); - findNeighbors(); - assignColors(); - generateCampaigns(); - generateDiplomacy(); - - TIME && console.timeEnd("generateStates"); - - // for each capital create a state - function createStates() { - const states = [{i: 0, name: "Neutrals"}]; - const each5th = each(5); - const sizeVariety = byId("sizeVariety").valueAsNumber; - - pack.burgs.forEach(burg => { - if (!burg.i || !burg.capital) return; - - const expansionism = rn(Math.random() * sizeVariety + 1, 1); - const basename = burg.name.length < 9 && each5th(burg.cell) ? burg.name : Names.getCultureShort(burg.culture); - const name = Names.getState(basename, burg.culture); - const type = pack.cultures[burg.culture].type; - const coa = COA.generate(null, null, null, type); - coa.shield = COA.getShield(burg.culture, null); - states.push({ - i: burg.i, - name, - expansionism, - capital: burg.i, - type, - center: burg.cell, - culture: burg.culture, - coa - }); - }); - - return states; - } - }; - - // expand cultures across the map (Dijkstra-like algorithm) - const expandStates = () => { - TIME && console.time("expandStates"); - const {cells, states, cultures, burgs} = pack; - - cells.state = cells.state || new Uint16Array(cells.i.length); - - const queue = new FlatQueue(); - const cost = []; - - const globalGrowthRate = byId("growthRate").valueAsNumber || 1; - const statesGrowthRate = byId("statesGrowthRate")?.valueAsNumber || 1; - const growthRate = (cells.i.length / 2) * globalGrowthRate * statesGrowthRate; // limit cost for state growth - - // remove state from all cells except of locked - for (const cellId of cells.i) { - const state = states[cells.state[cellId]]; - if (state.lock) continue; - cells.state[cellId] = 0; - } - - for (const state of states) { - if (!state.i || state.removed) continue; - - const capitalCell = burgs[state.capital].cell; - cells.state[capitalCell] = state.i; - const cultureCenter = cultures[state.culture].center; - const b = cells.biome[cultureCenter]; // state native biome - queue.push({e: state.center, p: 0, s: state.i, b}, 0); - cost[state.center] = 1; - } - - while (queue.length) { - const next = queue.pop(); - - const {e, p, s, b} = next; - const {type, culture} = states[s]; - - cells.c[e].forEach(e => { - const state = states[cells.state[e]]; - if (state.lock) return; // do not overwrite cell of locked states - if (cells.state[e] && e === state.center) return; // do not overwrite capital cells - - const cultureCost = culture === cells.culture[e] ? -9 : 100; - const populationCost = cells.h[e] < 20 ? 0 : cells.s[e] ? Math.max(20 - cells.s[e], 0) : 5000; - const biomeCost = getBiomeCost(b, cells.biome[e], type); - const heightCost = getHeightCost(pack.features[cells.f[e]], cells.h[e], type); - const riverCost = getRiverCost(cells.r[e], e, type); - const typeCost = getTypeCost(cells.t[e], type); - const cellCost = Math.max(cultureCost + populationCost + biomeCost + heightCost + riverCost + typeCost, 0); - const totalCost = p + 10 + cellCost / states[s].expansionism; - - if (totalCost > growthRate) return; - - if (!cost[e] || totalCost < cost[e]) { - if (cells.h[e] >= 20) cells.state[e] = s; // assign state to cell - cost[e] = totalCost; - queue.push({e, p: totalCost, s, b}, totalCost); - } - }); - } - - burgs.filter(b => b.i && !b.removed).forEach(b => (b.state = cells.state[b.cell])); // assign state to burgs - - function getBiomeCost(b, biome, type) { - if (b === biome) return 10; // tiny penalty for native biome - if (type === "Hunting") return biomesData.cost[biome] * 2; // non-native biome penalty for hunters - if (type === "Nomadic" && biome > 4 && biome < 10) return biomesData.cost[biome] * 3; // forest biome penalty for nomads - return biomesData.cost[biome]; // general non-native biome penalty - } - - function getHeightCost(f, h, type) { - if (type === "Lake" && f.type === "lake") return 10; // low lake crossing penalty for Lake cultures - if (type === "Naval" && h < 20) return 300; // low sea crossing penalty for Navals - if (type === "Nomadic" && h < 20) return 10000; // giant sea crossing penalty for Nomads - if (h < 20) return 1000; // general sea crossing penalty - if (type === "Highland" && h < 62) return 1100; // penalty for highlanders on lowlands - if (type === "Highland") return 0; // no penalty for highlanders on highlands - if (h >= 67) return 2200; // general mountains crossing penalty - if (h >= 44) return 300; // general hills crossing penalty - return 0; - } - - function getRiverCost(r, i, type) { - if (type === "River") return r ? 0 : 100; // penalty for river cultures - if (!r) return 0; // no penalty for others if there is no river - return minmax(cells.fl[i] / 10, 20, 100); // river penalty from 20 to 100 based on flux - } - - function getTypeCost(t, type) { - if (t === 1) return type === "Naval" || type === "Lake" ? 0 : type === "Nomadic" ? 60 : 20; // penalty for coastline - if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads - if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals - return 0; - } - - TIME && console.timeEnd("expandStates"); - }; - - const normalize = () => { - TIME && console.time("normalizeStates"); - const {cells, burgs} = pack; - - for (const i of cells.i) { - if (cells.h[i] < 20 || cells.burg[i]) continue; // do not overwrite burgs - if (pack.states[cells.state[i]]?.lock) continue; // do not overwrite cells of locks states - if (cells.c[i].some(c => burgs[cells.burg[c]].capital)) continue; // do not overwrite near capital - const neibs = cells.c[i].filter(c => cells.h[c] >= 20); - const adversaries = neibs.filter(c => !pack.states[cells.state[c]]?.lock && cells.state[c] !== cells.state[i]); - if (adversaries.length < 2) continue; - const buddies = neibs.filter(c => !pack.states[cells.state[c]]?.lock && cells.state[c] === cells.state[i]); - if (buddies.length > 2) continue; - if (adversaries.length <= buddies.length) continue; - cells.state[i] = cells.state[adversaries[0]]; - } - TIME && console.timeEnd("normalizeStates"); - }; - - // calculate pole of inaccessibility for each state - const getPoles = () => { - const getType = cellId => pack.cells.state[cellId]; - const poles = getPolesOfInaccessibility(pack, getType); - - pack.states.forEach(s => { - if (!s.i || s.removed) return; - s.pole = poles[s.i] || [0, 0]; - }); - }; - - const findNeighbors = () => { - const {cells, states} = pack; - - states.forEach(s => { - if (s.removed) return; - s.neighbors = new Set(); - }); - - for (const i of cells.i) { - if (cells.h[i] < 20) continue; - const s = cells.state[i]; - - cells.c[i] - .filter(c => cells.h[c] >= 20 && cells.state[c] !== s) - .forEach(c => states[s].neighbors.add(cells.state[c])); - } - - // convert neighbors Set object into array - states.forEach(s => { - if (!s.neighbors || s.removed) return; - s.neighbors = Array.from(s.neighbors); - }); - }; - - const assignColors = () => { - TIME && console.time("assignColors"); - const colors = ["#66c2a5", "#fc8d62", "#8da0cb", "#e78ac3", "#a6d854", "#ffd92f"]; // d3.schemeSet2; - const states = pack.states; - - // assign basic color using greedy coloring algorithm - states.forEach(state => { - if (!state.i || state.removed || state.lock) return; - state.color = colors.find(color => state.neighbors.every(neibStateId => states[neibStateId].color !== color)); - if (!state.color) state.color = getRandomColor(); - colors.push(colors.shift()); - }); - - // randomize each already used color a bit - colors.forEach(c => { - const sameColored = states.filter(state => state.color === c && state.i && !state.lock); - sameColored.forEach((state, index) => { - if (!index) return; - state.color = getMixedColor(state.color); - }); - }); - - TIME && console.timeEnd("assignColors"); - }; - - // calculate states data like area, population etc. - const collectStatistics = () => { - TIME && console.time("collectStatistics"); - const {cells, states} = pack; - - states.forEach(s => { - if (s.removed) return; - s.cells = s.area = s.burgs = s.rural = s.urban = 0; - }); - - for (const i of cells.i) { - if (cells.h[i] < 20) continue; - const s = cells.state[i]; - - // collect stats - states[s].cells += 1; - states[s].area += cells.area[i]; - states[s].rural += cells.pop[i]; - if (cells.burg[i]) { - states[s].urban += pack.burgs[cells.burg[i]].population; - states[s].burgs++; - } - } - - TIME && console.timeEnd("collectStatistics"); - }; - - const wars = { - War: 6, - Conflict: 2, - Campaign: 4, - Invasion: 2, - Rebellion: 2, - Conquest: 2, - Intervention: 1, - Expedition: 1, - Crusade: 1 - }; - - const generateCampaign = state => { - const neighbors = state.neighbors.length ? state.neighbors : [0]; - return neighbors - .map(i => { - const name = i && P(0.8) ? pack.states[i].name : Names.getCultureShort(state.culture); - const start = gauss(options.year - 100, 150, 1, options.year - 6); - const end = start + gauss(4, 5, 1, options.year - start - 1); - return {name: getAdjective(name) + " " + rw(wars), start, end}; - }) - .sort((a, b) => a.start - b.start); - }; - - // generate historical conflicts of each state - const generateCampaigns = () => { - pack.states.forEach(s => { - if (!s.i || s.removed) return; - s.campaigns = generateCampaign(s); - }); - }; - - // generate Diplomatic Relationships - const generateDiplomacy = () => { - TIME && console.time("generateDiplomacy"); - const {cells, states} = pack; - const chronicle = (states[0].diplomacy = []); - const valid = states.filter(s => s.i && !states.removed); - - const neibs = {Ally: 1, Friendly: 2, Neutral: 1, Suspicion: 10, Rival: 9}; // relations to neighbors - const neibsOfNeibs = {Ally: 10, Friendly: 8, Neutral: 5, Suspicion: 1}; // relations to neighbors of neighbors - const far = {Friendly: 1, Neutral: 12, Suspicion: 2, Unknown: 6}; // relations to other - const navals = {Neutral: 1, Suspicion: 2, Rival: 1, Unknown: 1}; // relations of naval powers - - valid.forEach(s => (s.diplomacy = new Array(states.length).fill("x"))); // clear all relationships - if (valid.length < 2) return; // no states to renerate relations with - const areaMean = d3.mean(valid.map(s => s.area)); // average state area - - // generic relations - for (let f = 1; f < states.length; f++) { - if (states[f].removed) continue; - - if (states[f].diplomacy.includes("Vassal")) { - // Vassals copy relations from their Suzerains - const suzerain = states[f].diplomacy.indexOf("Vassal"); - - for (let i = 1; i < states.length; i++) { - if (i === f || i === suzerain) continue; - states[f].diplomacy[i] = states[suzerain].diplomacy[i]; - if (states[suzerain].diplomacy[i] === "Suzerain") states[f].diplomacy[i] = "Ally"; - for (let e = 1; e < states.length; e++) { - if (e === f || e === suzerain) continue; - if (states[e].diplomacy[suzerain] === "Suzerain" || states[e].diplomacy[suzerain] === "Vassal") continue; - states[e].diplomacy[f] = states[e].diplomacy[suzerain]; - } - } - continue; - } - - for (let t = f + 1; t < states.length; t++) { - if (states[t].removed) continue; - - if (states[t].diplomacy.includes("Vassal")) { - const suzerain = states[t].diplomacy.indexOf("Vassal"); - states[f].diplomacy[t] = states[f].diplomacy[suzerain]; - continue; - } - - const naval = - states[f].type === "Naval" && - states[t].type === "Naval" && - cells.f[states[f].center] !== cells.f[states[t].center]; - const neib = naval ? false : states[f].neighbors.includes(t); - const neibOfNeib = - naval || neib - ? false - : states[f].neighbors - .map(n => states[n].neighbors) - .join("") - .includes(t); - - let status = naval ? rw(navals) : neib ? rw(neibs) : neibOfNeib ? rw(neibsOfNeibs) : rw(far); - - // add Vassal - if ( - neib && - P(0.8) && - states[f].area > areaMean && - states[t].area < areaMean && - states[f].area / states[t].area > 2 - ) - status = "Vassal"; - states[f].diplomacy[t] = status === "Vassal" ? "Suzerain" : status; - states[t].diplomacy[f] = status; - } - } - - // declare wars - for (let attacker = 1; attacker < states.length; attacker++) { - const ad = states[attacker].diplomacy; // attacker relations; - if (states[attacker].removed) continue; - if (!ad.includes("Rival")) continue; // no rivals to attack - if (ad.includes("Vassal")) continue; // not independent - if (ad.includes("Enemy")) continue; // already at war - - // random independent rival - const defender = ra( - ad.map((r, d) => (r === "Rival" && !states[d].diplomacy.includes("Vassal") ? d : 0)).filter(d => d) - ); - let ap = states[attacker].area * states[attacker].expansionism; - let dp = states[defender].area * states[defender].expansionism; - if (ap < dp * gauss(1.6, 0.8, 0, 10, 2)) continue; // defender is too strong - - const an = states[attacker].name; - const dn = states[defender].name; // names - const attackers = [attacker]; - const defenders = [defender]; // attackers and defenders array - const dd = states[defender].diplomacy; // defender relations; - - // start an ongoing war - const name = `${an}-${trimVowels(dn)}ian War`; - const start = options.year - gauss(2, 3, 0, 10); - const war = [name, `${an} declared a war on its rival ${dn}`]; - const campaign = {name, start, attacker, defender}; - states[attacker].campaigns.push(campaign); - states[defender].campaigns.push(campaign); - - // attacker vassals join the war - ad.forEach((r, d) => { - if (r === "Suzerain") { - attackers.push(d); - war.push(`${an}'s vassal ${states[d].name} joined the war on attackers side`); - } - }); - - // defender vassals join the war - dd.forEach((r, d) => { - if (r === "Suzerain") { - defenders.push(d); - war.push(`${dn}'s vassal ${states[d].name} joined the war on defenders side`); - } - }); - - ap = d3.sum(attackers.map(a => states[a].area * states[a].expansionism)); // attackers joined power - dp = d3.sum(defenders.map(d => states[d].area * states[d].expansionism)); // defender joined power - - // defender allies join - dd.forEach((r, d) => { - if (r !== "Ally" || states[d].diplomacy.includes("Vassal")) return; - if (states[d].diplomacy[attacker] !== "Rival" && ap / dp > 2 * gauss(1.6, 0.8, 0, 10, 2)) { - const reason = states[d].diplomacy.includes("Enemy") ? "Being already at war," : `Frightened by ${an},`; - war.push(`${reason} ${states[d].name} severed the defense pact with ${dn}`); - dd[d] = states[d].diplomacy[defender] = "Suspicion"; - return; - } - defenders.push(d); - dp += states[d].area * states[d].expansionism; - war.push(`${dn}'s ally ${states[d].name} joined the war on defenders side`); - - // ally vassals join - states[d].diplomacy - .map((r, d) => (r === "Suzerain" ? d : 0)) - .filter(d => d) - .forEach(v => { - defenders.push(v); - dp += states[v].area * states[v].expansionism; - war.push(`${states[d].name}'s vassal ${states[v].name} joined the war on defenders side`); - }); - }); - - // attacker allies join if the defender is their rival or joined power > defenders power and defender is not an ally - ad.forEach((r, d) => { - if (r !== "Ally" || states[d].diplomacy.includes("Vassal") || defenders.includes(d)) return; - const name = states[d].name; - if (states[d].diplomacy[defender] !== "Rival" && (P(0.2) || ap <= dp * 1.2)) { - war.push(`${an}'s ally ${name} avoided entering the war`); - return; - } - const allies = states[d].diplomacy.map((r, d) => (r === "Ally" ? d : 0)).filter(d => d); - if (allies.some(ally => defenders.includes(ally))) { - war.push(`${an}'s ally ${name} did not join the war as its allies are in war on both sides`); - return; - } - - attackers.push(d); - ap += states[d].area * states[d].expansionism; - war.push(`${an}'s ally ${name} joined the war on attackers side`); - - // ally vassals join - states[d].diplomacy - .map((r, d) => (r === "Suzerain" ? d : 0)) - .filter(d => d) - .forEach(v => { - attackers.push(v); - dp += states[v].area * states[v].expansionism; - war.push(`${states[d].name}'s vassal ${states[v].name} joined the war on attackers side`); - }); - }); - - // change relations to Enemy for all participants - attackers.forEach(a => defenders.forEach(d => (states[a].diplomacy[d] = states[d].diplomacy[a] = "Enemy"))); - chronicle.push(war); // add a record to diplomatical history - } - - TIME && console.timeEnd("generateDiplomacy"); - }; - - // select a forms for listed or all valid states - const defineStateForms = list => { - TIME && console.time("defineStateForms"); - const states = pack.states.filter(s => s.i && !s.removed && !s.lock); - if (states.length < 1) return; - - const generic = {Monarchy: 25, Republic: 2, Union: 1}; - const naval = {Monarchy: 25, Republic: 8, Union: 3}; - - const median = d3.median(pack.states.map(s => s.area)); - const empireMin = states.map(s => s.area).sort((a, b) => b - a)[Math.max(Math.ceil(states.length ** 0.4) - 2, 0)]; - const expTiers = pack.states.map(s => { - let tier = Math.min(Math.floor((s.area / median) * 2.6), 4); - if (tier === 4 && s.area < empireMin) tier = 3; - return tier; - }); - - const monarchy = ["Duchy", "Grand Duchy", "Principality", "Kingdom", "Empire"]; // per expansionism tier - const republic = { - Republic: 75, - Federation: 4, - "Trade Company": 4, - "Most Serene Republic": 2, - Oligarchy: 2, - Tetrarchy: 1, - Triumvirate: 1, - Diarchy: 1, - Junta: 1 - }; // weighted random - const union = { - Union: 3, - League: 4, - Confederation: 1, - "United Kingdom": 1, - "United Republic": 1, - "United Provinces": 2, - Commonwealth: 1, - Heptarchy: 1 - }; // weighted random - const theocracy = {Theocracy: 20, Brotherhood: 1, Thearchy: 2, See: 1, "Holy State": 1}; - const anarchy = {"Free Territory": 2, Council: 3, Commune: 1, Community: 1}; - - for (const s of states) { - if (list && !list.includes(s.i)) continue; - const tier = expTiers[s.i]; - - const religion = pack.cells.religion[s.center]; - const isTheocracy = - (religion && pack.religions[religion].expansion === "state") || - (P(0.1) && ["Organized", "Cult"].includes(pack.religions[religion].type)); - const isAnarchy = P(0.01 - tier / 500); - - if (isTheocracy) s.form = "Theocracy"; - else if (isAnarchy) s.form = "Anarchy"; - else s.form = s.type === "Naval" ? rw(naval) : rw(generic); - s.formName = selectForm(s, tier); - s.fullName = getFullName(s); - } - - function selectForm(s, tier) { - const base = pack.cultures[s.culture].base; - - if (s.form === "Monarchy") { - const form = monarchy[tier]; - // Default name depends on exponent tier, some culture bases have special names for tiers - if (s.diplomacy) { - if ( - form === "Duchy" && - s.neighbors.length > 1 && - rand(6) < s.neighbors.length && - s.diplomacy.includes("Vassal") - ) - return "Marches"; // some vassal duchies on borderland - if (base === 1 && P(0.3) && s.diplomacy.includes("Vassal")) return "Dominion"; // English vassals - if (P(0.3) && s.diplomacy.includes("Vassal")) return "Protectorate"; // some vassals - } - - if (base === 31 && (form === "Empire" || form === "Kingdom")) return "Khanate"; // Mongolian - if (base === 16 && form === "Principality") return "Beylik"; // Turkic - if (base === 5 && (form === "Empire" || form === "Kingdom")) return "Tsardom"; // Ruthenian - if (base === 16 && (form === "Empire" || form === "Kingdom")) return "Khaganate"; // Turkic - if (base === 12 && (form === "Kingdom" || form === "Grand Duchy")) return "Shogunate"; // Japanese - if ([18, 17].includes(base) && form === "Empire") return "Caliphate"; // Arabic, Berber - if (base === 18 && (form === "Grand Duchy" || form === "Duchy")) return "Emirate"; // Arabic - if (base === 7 && (form === "Grand Duchy" || form === "Duchy")) return "Despotate"; // Greek - if (base === 31 && (form === "Grand Duchy" || form === "Duchy")) return "Ulus"; // Mongolian - if (base === 16 && (form === "Grand Duchy" || form === "Duchy")) return "Horde"; // Turkic - if (base === 24 && (form === "Grand Duchy" || form === "Duchy")) return "Satrapy"; // Iranian - return form; - } - - if (s.form === "Republic") { - // Default name is from weighted array, special case for small states with only 1 burg - if (tier < 2 && s.burgs === 1) { - if (trimVowels(s.name) === trimVowels(pack.burgs[s.capital].name)) { - s.name = pack.burgs[s.capital].name; - return "Free City"; - } - if (P(0.3)) return "City-state"; - } - return rw(republic); - } - - if (s.form === "Union") return rw(union); - if (s.form === "Anarchy") return rw(anarchy); - - if (s.form === "Theocracy") { - // European - if ([0, 1, 2, 3, 4, 6, 8, 9, 13, 15, 20].includes(base)) { - if (P(0.1)) return "Divine " + monarchy[tier]; - if (tier < 2 && P(0.5)) return "Diocese"; - if (tier < 2 && P(0.5)) return "Bishopric"; - } - if (P(0.9) && [7, 5].includes(base)) { - // Greek, Ruthenian - if (tier < 2) return "Eparchy"; - if (tier === 2) return "Exarchate"; - if (tier > 2) return "Patriarchate"; - } - if (P(0.9) && [21, 16].includes(base)) return "Imamah"; // Nigerian, Turkish - if (tier > 2 && P(0.8) && [18, 17, 28].includes(base)) return "Caliphate"; // Arabic, Berber, Swahili - return rw(theocracy); - } - } - - TIME && console.timeEnd("defineStateForms"); - }; - - // state forms requiring Adjective + Name, all other forms use scheme Form + Of + Name - const adjForms = [ - "Empire", - "Sultanate", - "Khaganate", - "Shogunate", - "Caliphate", - "Despotate", - "Theocracy", - "Oligarchy", - "Union", - "Confederation", - "Trade Company", - "League", - "Tetrarchy", - "Triumvirate", - "Diarchy", - "Horde", - "Marches" - ]; - - const getFullName = state => { - if (!state.formName) return state.name; - if (!state.name && state.formName) return "The " + state.formName; - const adjName = adjForms.includes(state.formName) && !/-| /.test(state.name); - return adjName ? `${getAdjective(state.name)} ${state.formName}` : `${state.formName} of ${state.name}`; - }; - - return { - generate, - expandStates, - normalize, - getPoles, - findNeighbors, - assignColors, - collectStatistics, - generateCampaign, - generateCampaigns, - generateDiplomacy, - defineStateForms, - getFullName - }; -})(); diff --git a/src/index.html b/src/index.html index cf121474..14b61949 100644 --- a/src/index.html +++ b/src/index.html @@ -8494,7 +8494,6 @@ - diff --git a/src/modules/index.ts b/src/modules/index.ts index f4a3def3..660fc100 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -8,3 +8,4 @@ import "./river-generator"; import "./burgs-generator"; import "./biomes"; import "./cultures-generator"; +import "./states-generator"; diff --git a/src/modules/names-generator.ts b/src/modules/names-generator.ts index da60beca..5805cc92 100644 --- a/src/modules/names-generator.ts +++ b/src/modules/names-generator.ts @@ -223,7 +223,7 @@ class NamesGenerator { } // generate state name based on capital or random name and culture-specific suffix - getState(name: string, culture: number, base: number): string { + getState(name: string, culture: number, base?: number): string { if (name === undefined) { ERROR && console.error("Please define a base name"); return "ERROR"; diff --git a/src/modules/states-generator.ts b/src/modules/states-generator.ts new file mode 100644 index 00000000..cb13dd76 --- /dev/null +++ b/src/modules/states-generator.ts @@ -0,0 +1,824 @@ +import { mean, median, sum } from "d3"; +import { + byId, + each, + gauss, + getAdjective, + getMixedColor, + getPolesOfInaccessibility, + getRandomColor, + minmax, + P, + ra, + rand, + rn, + rw, + trimVowels, +} from "../utils"; + +declare global { + var States: StatesModule; +} + +interface Campaign { + name: string; + start: number; + end?: number; +} + +export interface State { + i: number; + name: string; + expansionism: number; + capital: number; + type: string; + center: number; + culture: number; + coa: any; + lock?: boolean; + removed?: boolean; + pole?: [number, number]; + neighbors?: number[]; + color?: string; + cells?: number; + area?: number; + burgs?: number; + rural?: number; + urban?: number; + campaigns?: Campaign[]; + diplomacy?: string[]; + formName?: string; + fullName?: string; + form?: string; +} + +class StatesModule { + private createStates() { + const states: State[] = [{ i: 0, name: "Neutrals" } as State]; + const each5th = each(5); + const sizeVariety = (byId("sizeVariety") as HTMLInputElement).valueAsNumber; + + pack.burgs.forEach((burg) => { + if (!burg.i || !burg.capital) return; + + const expansionism = rn(Math.random() * sizeVariety + 1, 1); + const basename = + burg.name!.length < 9 && each5th(burg.cell) + ? burg.name! + : Names.getCultureShort(burg.culture!); + const name = Names.getState(basename, burg.culture!); + const type = pack.cultures[burg.culture!].type; + const coa = COA.generate(null, null, null, type); + coa.shield = COA.getShield(burg.culture, null); + states.push({ + i: burg.i, + name, + expansionism, + capital: burg.i, + type: type!, + center: burg.cell, + culture: burg.culture!, + coa, + }); + }); + + return states; + } + + private getBiomeCost(b: number, biome: number, type: string) { + if (b === biome) return 10; // tiny penalty for native biome + if (type === "Hunting") return biomesData.cost[biome] * 2; // non-native biome penalty for hunters + if (type === "Nomadic" && biome > 4 && biome < 10) + return biomesData.cost[biome] * 3; // forest biome penalty for nomads + return biomesData.cost[biome]; // general non-native biome penalty + } + + private getHeightCost(f: any, h: number, type: string) { + if (type === "Lake" && f.type === "lake") return 10; // low lake crossing penalty for Lake cultures + if (type === "Naval" && h < 20) return 300; // low sea crossing penalty for Navals + if (type === "Nomadic" && h < 20) return 10000; // giant sea crossing penalty for Nomads + if (h < 20) return 1000; // general sea crossing penalty + if (type === "Highland" && h < 62) return 1100; // penalty for highlanders on lowlands + if (type === "Highland") return 0; // no penalty for highlanders on highlands + if (h >= 67) return 2200; // general mountains crossing penalty + if (h >= 44) return 300; // general hills crossing penalty + return 0; + } + + private getRiverCost(r: any, i: number, type: string) { + if (type === "River") return r ? 0 : 100; // penalty for river cultures + if (!r) return 0; // no penalty for others if there is no river + return minmax(pack.cells.fl[i] / 10, 20, 100); // river penalty from 20 to 100 based on flux + } + + private getTypeCost(t: number, type: string) { + if (t === 1) + return type === "Naval" || type === "Lake" + ? 0 + : type === "Nomadic" + ? 60 + : 20; // penalty for coastline + if (t === 2) return type === "Naval" || type === "Nomadic" ? 30 : 0; // low penalty for land level 2 for Navals and nomads + if (t !== -1) return type === "Naval" || type === "Lake" ? 100 : 0; // penalty for mainland for navals + return 0; + } + + generate() { + TIME && console.time("generateStates"); + pack.states = this.createStates(); + this.expandStates(); + this.normalize(); + this.getPoles(); + this.findNeighbors(); + this.assignColors(); + this.generateCampaigns(); + this.generateDiplomacy(); + + TIME && console.timeEnd("generateStates"); + } + + expandStates() { + TIME && console.time("expandStates"); + const { cells, states, cultures, burgs } = pack; + + cells.state = cells.state || new Uint16Array(cells.i.length); + + const queue = new FlatQueue(); + const cost: number[] = []; + + const globalGrowthRate = + (byId("growthRate") as HTMLInputElement)?.valueAsNumber || 1; + const statesGrowthRate = + (byId("statesGrowthRate") as HTMLInputElement)?.valueAsNumber || 1; + const growthRate = + (cells.i.length / 2) * globalGrowthRate * statesGrowthRate; // limit cost for state growth + + // remove state from all cells except of locked + for (const cellId of cells.i) { + const state = states[cells.state[cellId]]; + if (state.lock) continue; + cells.state[cellId] = 0; + } + + for (const state of states) { + if (!state.i || state.removed) continue; + + const capitalCell = burgs[state.capital].cell; + cells.state[capitalCell] = state.i; + const cultureCenter = cultures[state.culture].center!; + const b = cells.biome[cultureCenter]; // state native biome + queue.push({ e: state.center, p: 0, s: state.i, b }, 0); + cost[state.center] = 1; + } + + while (queue.length) { + const next = queue.pop(); + + const { e, p, s, b } = next; + const { type, culture } = states[s]; + + cells.c[e].forEach((e) => { + const state = states[cells.state[e]]; + if (state.lock) return; // do not overwrite cell of locked states + if (cells.state[e] && e === state.center) return; // do not overwrite capital cells + + const cultureCost = culture === cells.culture[e] ? -9 : 100; + const populationCost = + cells.h[e] < 20 + ? 0 + : cells.s[e] + ? Math.max(20 - cells.s[e], 0) + : 5000; + const biomeCost = this.getBiomeCost(b, cells.biome[e], type); + const heightCost = this.getHeightCost( + pack.features[cells.f[e]], + cells.h[e], + type, + ); + const riverCost = this.getRiverCost(cells.r[e], e, type); + const typeCost = this.getTypeCost(cells.t[e], type); + const cellCost = Math.max( + cultureCost + + populationCost + + biomeCost + + heightCost + + riverCost + + typeCost, + 0, + ); + const totalCost = p + 10 + cellCost / states[s].expansionism; + + if (totalCost > growthRate) return; + + if (!cost[e] || totalCost < cost[e]) { + if (cells.h[e] >= 20) cells.state[e] = s; // assign state to cell + cost[e] = totalCost; + queue.push({ e, p: totalCost, s, b }, totalCost); + } + }); + } + + burgs + .filter((b) => b.i && !b.removed) + .forEach((b) => { + b.state = cells.state[b.cell]; // assign state to burgs + }); + TIME && console.timeEnd("expandStates"); + } + + normalize() { + TIME && console.time("normalizeStates"); + const { cells, burgs } = pack; + + for (const i of cells.i) { + if (cells.h[i] < 20 || cells.burg[i]) continue; // do not overwrite burgs + if (pack.states[cells.state[i]]?.lock) continue; // do not overwrite cells of locks states + if (cells.c[i].some((c) => burgs[cells.burg[c]].capital)) continue; // do not overwrite near capital + const neibs = cells.c[i].filter((c) => cells.h[c] >= 20); + const adversaries = neibs.filter( + (c) => + !pack.states[cells.state[c]]?.lock && + cells.state[c] !== cells.state[i], + ); + if (adversaries.length < 2) continue; + const buddies = neibs.filter( + (c) => + !pack.states[cells.state[c]]?.lock && + cells.state[c] === cells.state[i], + ); + if (buddies.length > 2) continue; + if (adversaries.length <= buddies.length) continue; + cells.state[i] = cells.state[adversaries[0]]; + } + TIME && console.timeEnd("normalizeStates"); + } + + // calculate pole of inaccessibility for each state + getPoles() { + const getType = (cellId: number) => pack.cells.state[cellId]; + const poles = getPolesOfInaccessibility(pack, getType); + + pack.states.forEach((s) => { + if (!s.i || s.removed) return; + s.pole = poles[s.i] || [0, 0]; + }); + } + + findNeighbors() { + const { cells, states } = pack; + + const stateNeighbors: Set[] = []; + + states.forEach((s) => { + if (s.removed) return; + stateNeighbors[s.i] = new Set(); + // s.neighbors = stateNeighbors[s.i]; + }); + + for (const i of cells.i) { + if (cells.h[i] < 20) continue; + const s = cells.state[i]; + + cells.c[i] + .filter((c) => cells.h[c] >= 20 && cells.state[c] !== s) + .forEach((c) => { + stateNeighbors[s].add(cells.state[c]); + }); + } + + // convert neighbors Set object into array + states.forEach((s) => { + if (!stateNeighbors[s.i] || s.removed) return; + s.neighbors = Array.from(stateNeighbors[s.i]); + }); + } + + assignColors() { + TIME && console.time("assignColors"); + const colors = [ + "#66c2a5", + "#fc8d62", + "#8da0cb", + "#e78ac3", + "#a6d854", + "#ffd92f", + ]; // d3.schemeSet2; + const states = pack.states; + + // assign basic color using greedy coloring algorithm + states.forEach((state) => { + if (!state.i || state.removed || state.lock) return; + state.color = colors.find((color) => + state.neighbors!.every( + (neibStateId) => states[neibStateId].color !== color, + ), + ); + if (!state.color) state.color = getRandomColor(); + colors.push(colors.shift() as string); + }); + + // randomize each already used color a bit + colors.forEach((c) => { + const sameColored = states.filter( + (state) => state.color === c && state.i && !state.lock, + ); + sameColored.forEach((state, index) => { + if (!index) return; + state.color = getMixedColor(state.color!); + }); + }); + + TIME && console.timeEnd("assignColors"); + } + + // calculate states data like area, population etc. + collectStatistics() { + TIME && console.time("collectStatistics"); + const { cells, states } = pack; + + states.forEach((s) => { + if (s.removed) return; + s.cells = s.area = s.burgs = s.rural = s.urban = 0; + }); + + for (const i of cells.i) { + if (cells.h[i] < 20) continue; + const s = cells.state[i]; + + // collect stats + states[s].cells! += 1; + states[s].area! += cells.area[i]; + states[s].rural! += cells.pop[i]; + if (cells.burg[i]) { + states[s].urban! += pack.burgs[cells.burg[i]].population!; + states[s].burgs!++; + } + } + + TIME && console.timeEnd("collectStatistics"); + } + + generateCampaign(state: State) { + const wars = { + War: 6, + Conflict: 2, + Campaign: 4, + Invasion: 2, + Rebellion: 2, + Conquest: 2, + Intervention: 1, + Expedition: 1, + Crusade: 1, + }; + const neighbors = state.neighbors?.length ? state.neighbors : [0]; + return neighbors + .map((i: number) => { + const name = + i && P(0.8) + ? pack.states[i].name + : Names.getCultureShort(state.culture); + const start = gauss(options.year - 100, 150, 1, options.year - 6); + const end = start + gauss(4, 5, 1, options.year - start - 1); + return { name: `${getAdjective(name)} ${rw(wars)}`, start, end }; + }) + .sort((a, b) => a.start - b.start); + } + + generateCampaigns() { + pack.states.forEach((s) => { + if (!s.i || s.removed) return; + s.campaigns = this.generateCampaign(s); + }); + } + + // generate Diplomatic Relationships + generateDiplomacy() { + TIME && console.time("generateDiplomacy"); + const { cells, states } = pack; + states[0].diplomacy = []; + // FIRST STATE IS ALWAYS NEUTRAL and contains the history of diplomacy + const chronicle = states[0].diplomacy; + const valid = states.filter((s) => s.i && !s.removed); // will filter out neutral as i is 0 => false + + const neibs = { Ally: 1, Friendly: 2, Neutral: 1, Suspicion: 10, Rival: 9 }; // relations to neighbors + const neibsOfNeibs = { Ally: 10, Friendly: 8, Neutral: 5, Suspicion: 1 }; // relations to neighbors of neighbors + const far = { Friendly: 1, Neutral: 12, Suspicion: 2, Unknown: 6 }; // relations to other + const navals = { Neutral: 1, Suspicion: 2, Rival: 1, Unknown: 1 }; // relations of naval powers + + valid.forEach((s) => { + s.diplomacy = new Array(states.length).fill("x"); // clear all relationships + }); + if (valid.length < 2) return; // no states to generate relations with + const areaMean: number = mean(valid.map((s) => s.area!)) as number; // average state area + + // generic relations + for (let f = 1; f < states.length; f++) { + if (states[f].removed) continue; + if (states[f].diplomacy!.includes("Vassal")) { + // Vassals copy relations from their Suzerains + const suzerain = states[f].diplomacy!.indexOf("Vassal"); + + for (let i = 1; i < states.length; i++) { + if (i === f || i === suzerain) continue; + states[f].diplomacy![i] = states[suzerain].diplomacy![i]; + if (states[suzerain].diplomacy![i] === "Suzerain") + states[f].diplomacy![i] = "Ally"; + for (let e = 1; e < states.length; e++) { + if (e === f || e === suzerain) continue; + if ( + states[e].diplomacy![suzerain] === "Suzerain" || + states[e].diplomacy![suzerain] === "Vassal" + ) + continue; + states[e].diplomacy![f] = states[e].diplomacy![suzerain]; + } + } + continue; + } + + for (let t = f + 1; t < states.length; t++) { + if (states[t].removed) continue; + + if (states[t].diplomacy!.includes("Vassal")) { + const suzerain = states[t].diplomacy!.indexOf("Vassal"); + states[f].diplomacy![t] = states[f].diplomacy![suzerain]; + continue; + } + + const naval = + states[f].type === "Naval" && + states[t].type === "Naval" && + cells.f[states[f].center] !== cells.f[states[t].center]; + const neib = naval ? false : states[f].neighbors!.includes(t); + const neibOfNeib = + naval || neib + ? false + : states[f] + .neighbors!.map((n) => states[n].neighbors) + .join("") + .includes(t.toString()); + + let status = naval + ? rw(navals) + : neib + ? rw(neibs) + : neibOfNeib + ? rw(neibsOfNeibs) + : rw(far); + + // add Vassal + if ( + neib && + P(0.8) && + states[f].area! > areaMean && + states[t].area! < areaMean && + states[f].area! / states[t].area! > 2 + ) + status = "Vassal"; + states[f].diplomacy![t] = status === "Vassal" ? "Suzerain" : status; + states[t].diplomacy![f] = status; + } + } + + // declare wars + for (let attacker = 1; attacker < states.length; attacker++) { + const ad = states[attacker].diplomacy as string[]; // attacker relations; + if (states[attacker].removed) continue; + if (!ad.includes("Rival")) continue; // no rivals to attack + if (ad.includes("Vassal")) continue; // not independent + if (ad.includes("Enemy")) continue; // already at war + + // random independent rival + const defender = ra( + ad + .map((r, d) => + r === "Rival" && !states[d].diplomacy!.includes("Vassal") ? d : 0, + ) + .filter((d) => d), + ); + let ap = states[attacker].area! * states[attacker].expansionism; + let dp = states[defender].area! * states[defender].expansionism; + if (ap < dp * gauss(1.6, 0.8, 0, 10, 2)) continue; // defender is too strong + + const an = states[attacker].name; + const dn = states[defender].name; // names + const attackers = [attacker]; + const defenders = [defender]; // attackers and defenders array + const dd = states[defender].diplomacy as string[]; // defender relations; + + // start an ongoing war + const name = `${an}-${trimVowels(dn)}ian War`; + const start = options.year - gauss(2, 3, 0, 10); + const war = [name, `${an} declared a war on its rival ${dn}`]; + const campaign = { name, start, attacker, defender }; + states[attacker].campaigns!.push(campaign); + states[defender].campaigns!.push(campaign); + + // attacker vassals join the war + ad.forEach((r, d) => { + if (r === "Suzerain") { + attackers.push(d); + war.push( + `${an}'s vassal ${states[d].name} joined the war on attackers side`, + ); + } + }); + + // defender vassals join the war + dd.forEach((r, d) => { + if (r === "Suzerain") { + defenders.push(d); + war.push( + `${dn}'s vassal ${states[d].name} joined the war on defenders side`, + ); + } + }); + + ap = sum(attackers.map((a) => states[a].area! * states[a].expansionism)); // attackers joined power + dp = sum(defenders.map((d) => states[d].area! * states[d].expansionism)); // defender joined power + + // defender allies join + dd.forEach((r, d) => { + if (r !== "Ally" || states[d].diplomacy!.includes("Vassal")) return; + if ( + states[d].diplomacy![attacker] !== "Rival" && + ap / dp > 2 * gauss(1.6, 0.8, 0, 10, 2) + ) { + const reason = states[d].diplomacy!.includes("Enemy") + ? "Being already at war," + : `Frightened by ${an},`; + war.push( + `${reason} ${states[d].name} severed the defense pact with ${dn}`, + ); + dd[d] = states[d].diplomacy![defender] = "Suspicion"; + return; + } + defenders.push(d); + dp += states[d].area! * states[d].expansionism; + war.push( + `${dn}'s ally ${states[d].name} joined the war on defenders side`, + ); + + // ally vassals join + states[d] + .diplomacy!.map((r, d) => (r === "Suzerain" ? d : 0)) + .filter((d) => d) + .forEach((v) => { + defenders.push(v); + dp += states[v].area! * states[v].expansionism; + war.push( + `${states[d].name}'s vassal ${states[v].name} joined the war on defenders side`, + ); + }); + }); + + // attacker allies join if the defender is their rival or joined power > defenders power and defender is not an ally + ad.forEach((r, d) => { + if ( + r !== "Ally" || + states[d].diplomacy!.includes("Vassal") || + defenders.includes(d) + ) + return; + const name = states[d].name; + if ( + states[d].diplomacy![defender] !== "Rival" && + (P(0.2) || ap <= dp * 1.2) + ) { + war.push(`${an}'s ally ${name} avoided entering the war`); + return; + } + const allies = states[d] + .diplomacy!.map((r, d) => (r === "Ally" ? d : 0)) + .filter((d) => d); + if (allies.some((ally) => defenders.includes(ally))) { + war.push( + `${an}'s ally ${name} did not join the war as its allies are in war on both sides`, + ); + return; + } + + attackers.push(d); + ap += states[d].area! * states[d].expansionism; + war.push(`${an}'s ally ${name} joined the war on attackers side`); + + // ally vassals join + states[d] + .diplomacy!.map((r, d) => (r === "Suzerain" ? d : 0)) + .filter((d) => d) + .forEach((v) => { + attackers.push(v); + // TODO: I think here is a bug, it should be ap instead of dp + ap += states[v].area! * states[v].expansionism; + war.push( + `${states[d].name}'s vassal ${states[v].name} joined the war on attackers side`, + ); + }); + }); + + // change relations to Enemy for all participants + attackers.forEach((a) => { + defenders.forEach((d: number) => { + states[a].diplomacy![d] = states[d].diplomacy![a] = "Enemy"; + }); + }); + // TODO: record war in chronicle to keep state interface clean + chronicle.push(war as any); // add a record to diplomatical history + } + TIME && console.timeEnd("generateDiplomacy"); + } + + // select a forms for listed or all valid states + defineStateForms(list: number[] | null = null) { + TIME && console.time("defineStateForms"); + const states = pack.states.filter((s) => s.i && !s.removed && !s.lock); + if (states.length < 1) return; + + const generic = { Monarchy: 25, Republic: 2, Union: 1 }; + const naval = { Monarchy: 25, Republic: 8, Union: 3 }; + + const medianState = median(pack.states.map((s) => s.area))!; + const empireMin = states.map((s) => s.area).sort((a = 0, b = 0) => b - a)[ + Math.max(Math.ceil(states.length ** 0.4) - 2, 0) + ]!; + const expTiers = pack.states.map((s) => { + let tier = Math.min(Math.floor((s.area! / medianState) * 2.6), 4); + if (tier === 4 && s.area! < empireMin) tier = 3; + return tier; + }); + + const monarchy = [ + "Duchy", + "Grand Duchy", + "Principality", + "Kingdom", + "Empire", + ]; // per expansionism tier + const republic = { + Republic: 75, + Federation: 4, + "Trade Company": 4, + "Most Serene Republic": 2, + Oligarchy: 2, + Tetrarchy: 1, + Triumvirate: 1, + Diarchy: 1, + Junta: 1, + }; // weighted random + const union = { + Union: 3, + League: 4, + Confederation: 1, + "United Kingdom": 1, + "United Republic": 1, + "United Provinces": 2, + Commonwealth: 1, + Heptarchy: 1, + }; // weighted random + const theocracy = { + Theocracy: 20, + Brotherhood: 1, + Thearchy: 2, + See: 1, + "Holy State": 1, + }; + const anarchy = { + "Free Territory": 2, + Council: 3, + Commune: 1, + Community: 1, + }; + + for (const s of states) { + if (list && !list.includes(s.i)) continue; + const tier = expTiers[s.i]; + + const religion = pack.cells.religion[s.center]; + const isTheocracy = + (religion && pack.religions[religion].expansion === "state") || + (P(0.1) && + ["Organized", "Cult"].includes(pack.religions[religion].type)); + const isAnarchy = P(0.01 - tier / 500); + + if (isTheocracy) s.form = "Theocracy"; + else if (isAnarchy) s.form = "Anarchy"; + else s.form = s.type === "Naval" ? rw(naval) : rw(generic); + + const selectForm = (s: any, tier: number) => { + const base = pack.cultures[s.culture].base; + + if (s.form === "Monarchy") { + const form = monarchy[tier]; + // Default name depends on exponent tier, some culture bases have special names for tiers + if (s.diplomacy) { + if ( + form === "Duchy" && + s.neighbors.length > 1 && + rand(6) < s.neighbors.length && + s.diplomacy.includes("Vassal") + ) + return "Marches"; // some vassal duchies on borderland + if (base === 1 && P(0.3) && s.diplomacy.includes("Vassal")) + return "Dominion"; // English vassals + if (P(0.3) && s.diplomacy.includes("Vassal")) return "Protectorate"; // some vassals + } + + if (base === 31 && (form === "Empire" || form === "Kingdom")) + return "Khanate"; // Mongolian + if (base === 16 && form === "Principality") return "Beylik"; // Turkic + if (base === 5 && (form === "Empire" || form === "Kingdom")) + return "Tsardom"; // Ruthenian + if (base === 16 && (form === "Empire" || form === "Kingdom")) + return "Khaganate"; // Turkic + if (base === 12 && (form === "Kingdom" || form === "Grand Duchy")) + return "Shogunate"; // Japanese + if ([18, 17].includes(base) && form === "Empire") return "Caliphate"; // Arabic, Berber + if (base === 18 && (form === "Grand Duchy" || form === "Duchy")) + return "Emirate"; // Arabic + if (base === 7 && (form === "Grand Duchy" || form === "Duchy")) + return "Despotate"; // Greek + if (base === 31 && (form === "Grand Duchy" || form === "Duchy")) + return "Ulus"; // Mongolian + if (base === 16 && (form === "Grand Duchy" || form === "Duchy")) + return "Horde"; // Turkic + if (base === 24 && (form === "Grand Duchy" || form === "Duchy")) + return "Satrapy"; // Iranian + return form; + } + + if (s.form === "Republic") { + // Default name is from weighted array, special case for small states with only 1 burg + if (tier < 2 && s.burgs === 1) { + if ( + trimVowels(s.name) === trimVowels(pack.burgs[s.capital].name!) + ) { + s.name = pack.burgs[s.capital].name; + return "Free City"; + } + if (P(0.3)) return "City-state"; + } + return rw(republic); + } + + if (s.form === "Union") return rw(union); + if (s.form === "Anarchy") return rw(anarchy); + + if (s.form === "Theocracy") { + // European + if ([0, 1, 2, 3, 4, 6, 8, 9, 13, 15, 20].includes(base)) { + if (P(0.1)) return `Divine ${monarchy[tier]}`; + if (tier < 2 && P(0.5)) return "Diocese"; + if (tier < 2 && P(0.5)) return "Bishopric"; + } + if (P(0.9) && [7, 5].includes(base)) { + // Greek, Ruthenian + if (tier < 2) return "Eparchy"; + if (tier === 2) return "Exarchate"; + if (tier > 2) return "Patriarchate"; + } + if (P(0.9) && [21, 16].includes(base)) return "Imamah"; // Nigerian, Turkish + if (tier > 2 && P(0.8) && [18, 17, 28].includes(base)) + return "Caliphate"; // Arabic, Berber, Swahili + return rw(theocracy); + } + }; + + s.formName = selectForm(s, tier); + s.fullName = this.getFullName(s); + } + + TIME && console.timeEnd("defineStateForms"); + } + + getFullName(state: State) { + // state forms requiring Adjective + Name, all other forms use scheme Form + Of + Name + const adjForms = [ + "Empire", + "Sultanate", + "Khaganate", + "Shogunate", + "Caliphate", + "Despotate", + "Theocracy", + "Oligarchy", + "Union", + "Confederation", + "Trade Company", + "League", + "Tetrarchy", + "Triumvirate", + "Diarchy", + "Horde", + "Marches", + ]; + if (!state.formName) return state.name; + if (!state.name && state.formName) return `The ${state.formName}`; + const adjName = + adjForms.includes(state.formName) && !/-| /.test(state.name); + return adjName + ? `${getAdjective(state.name)} ${state.formName}` + : `${state.formName} of ${state.name}`; + } +} + +window.States = new StatesModule(); diff --git a/src/types/PackedGraph.ts b/src/types/PackedGraph.ts index 4df397e4..26bfab5d 100644 --- a/src/types/PackedGraph.ts +++ b/src/types/PackedGraph.ts @@ -2,6 +2,7 @@ import type { Burg } from "../modules/burgs-generator"; import type { Culture } from "../modules/cultures-generator"; import type { PackedGraphFeature } from "../modules/features"; import type { River } from "../modules/river-generator"; +import type { State } from "../modules/states-generator"; type TypedArray = | Uint8Array @@ -48,6 +49,7 @@ export interface PackedGraph { rivers: River[]; features: PackedGraphFeature[]; burgs: Burg[]; - states: any[]; + states: State[]; cultures: Culture[]; + religions: any[]; }