From 32227518d2050b22789e494f73e46f79d3b2e9ff Mon Sep 17 00:00:00 2001 From: Marc Emmanuel Date: Wed, 4 Mar 2026 12:53:07 +0100 Subject: [PATCH] refactor: migrate military generator (#1330) * refactor: migrate military gnerator * fix: build + lint * Update src/modules/military-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/military-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/military-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: clean up regiment notes and improve emblem retrieval logic * Update src/modules/military-generator.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- public/modules/military-generator.js | 406 ----------------- src/index.html | 1 - src/modules/index.ts | 1 + src/modules/military-generator.ts | 630 +++++++++++++++++++++++++++ src/modules/states-generator.ts | 2 + src/renderers/draw-military.ts | 54 ++- 6 files changed, 658 insertions(+), 436 deletions(-) delete mode 100644 public/modules/military-generator.js create mode 100644 src/modules/military-generator.ts diff --git a/public/modules/military-generator.js b/public/modules/military-generator.js deleted file mode 100644 index b16d84cb..00000000 --- a/public/modules/military-generator.js +++ /dev/null @@ -1,406 +0,0 @@ -"use strict"; - -window.Military = (function () { - const generate = function () { - TIME && console.time("generateMilitary"); - const {cells, states} = pack; - const {p} = cells; - const valid = states.filter(s => s.i && !s.removed); // valid states - if (!options.military) options.military = getDefaultOptions(); - - const expn = d3.sum(valid.map(s => s.expansionism)); // total expansion - const area = d3.sum(valid.map(s => s.area)); // total area - const rate = { - x: 0, - Ally: -0.2, - Friendly: -0.1, - Neutral: 0, - Suspicion: 0.1, - Enemy: 1, - Unknown: 0, - Rival: 0.5, - Vassal: 0.5, - Suzerain: -0.5 - }; - - const stateModifier = { - melee: {Nomadic: 0.5, Highland: 1.2, Lake: 1, Naval: 0.7, Hunting: 1.2, River: 1.1}, - ranged: {Nomadic: 0.9, Highland: 1.3, Lake: 1, Naval: 0.8, Hunting: 2, River: 0.8}, - mounted: {Nomadic: 2.3, Highland: 0.6, Lake: 0.7, Naval: 0.3, Hunting: 0.7, River: 0.8}, - machinery: {Nomadic: 0.8, Highland: 1.4, Lake: 1.1, Naval: 1.4, Hunting: 0.4, River: 1.1}, - naval: {Nomadic: 0.5, Highland: 0.5, Lake: 1.2, Naval: 1.8, Hunting: 0.7, River: 1.2}, - armored: {Nomadic: 1, Highland: 0.5, Lake: 1, Naval: 1, Hunting: 0.7, River: 1.1}, - aviation: {Nomadic: 0.5, Highland: 0.5, Lake: 1.2, Naval: 1.2, Hunting: 0.6, River: 1.2}, - magical: {Nomadic: 1, Highland: 2, Lake: 1, Naval: 1, Hunting: 1, River: 1} - }; - - const cellTypeModifier = { - nomadic: { - melee: 0.2, - ranged: 0.5, - mounted: 3, - machinery: 0.4, - naval: 0.3, - armored: 1.6, - aviation: 1, - magical: 0.5 - }, - wetland: { - melee: 0.8, - ranged: 2, - mounted: 0.3, - machinery: 1.2, - naval: 1.0, - armored: 0.2, - aviation: 0.5, - magical: 0.5 - }, - highland: { - melee: 1.2, - ranged: 1.6, - mounted: 0.3, - machinery: 3, - naval: 1.0, - armored: 0.8, - aviation: 0.3, - magical: 2 - } - }; - - const burgTypeModifier = { - nomadic: { - melee: 0.3, - ranged: 0.8, - mounted: 3, - machinery: 0.4, - naval: 1.0, - armored: 1.6, - aviation: 1, - magical: 0.5 - }, - wetland: { - melee: 1, - ranged: 1.6, - mounted: 0.2, - machinery: 1.2, - naval: 1.0, - armored: 0.2, - aviation: 0.5, - magical: 0.5 - }, - highland: {melee: 1.2, ranged: 2, mounted: 0.3, machinery: 3, naval: 1.0, armored: 0.8, aviation: 0.3, magical: 2} - }; - - valid.forEach(s => { - s.temp = {}; - const d = s.diplomacy; - - const expansionRate = minmax(s.expansionism / expn / (s.area / area), 0.25, 4); // how much state expansionism is realized - const diplomacyRate = d.some(d => d === "Enemy") - ? 1 - : d.some(d => d === "Rival") - ? 0.8 - : d.some(d => d === "Suspicion") - ? 0.5 - : 0.1; // peacefulness - const neighborsRateRaw = s.neighbors - .map(n => (n ? pack.states[n].diplomacy[s.i] : "Suspicion")) - .reduce((s, r) => (s += rate[r]), 0.5); - const neighborsRate = minmax(neighborsRateRaw, 0.3, 3); // neighbors rate - s.alert = minmax(rn(expansionRate * diplomacyRate * neighborsRate, 2), 0.1, 5); // alert rate (area modifier) - s.temp.platoons = []; - - // apply overall state modifiers for unit types based on state features - for (const unit of options.military) { - if (!stateModifier[unit.type]) continue; - - let modifier = stateModifier[unit.type][s.type] || 1; - if (unit.type === "mounted" && s.formName.includes("Horde")) modifier *= 2; - else if (unit.type === "naval" && s.form === "Republic") modifier *= 1.2; - s.temp[unit.name] = modifier * s.alert; - } - }); - - const getType = cell => { - if ([1, 2, 3, 4].includes(cells.biome[cell])) return "nomadic"; - if ([7, 8, 9, 12].includes(cells.biome[cell])) return "wetland"; - if (cells.h[cell] >= 70) return "highland"; - return "generic"; - }; - - function passUnitLimits(unit, biome, state, culture, religion) { - if (unit.biomes && !unit.biomes.includes(biome)) return false; - if (unit.states && !unit.states.includes(state)) return false; - if (unit.cultures && !unit.cultures.includes(culture)) return false; - if (unit.religions && !unit.religions.includes(religion)) return false; - return true; - } - - // rural cells - for (const i of cells.i) { - if (!cells.pop[i]) continue; - - const biome = cells.biome[i]; - const state = cells.state[i]; - const culture = cells.culture[i]; - const religion = cells.religion[i]; - - const stateObj = states[state]; - if (!state || stateObj.removed) continue; - - let modifier = cells.pop[i] / 100; // basic rural army in percentages - if (culture !== stateObj.culture) modifier = stateObj.form === "Union" ? modifier / 1.2 : modifier / 2; // non-dominant culture - if (religion !== cells.religion[stateObj.center]) - modifier = stateObj.form === "Theocracy" ? modifier / 2.2 : modifier / 1.4; // non-dominant religion - if (cells.f[i] !== cells.f[stateObj.center]) - modifier = stateObj.type === "Naval" ? modifier / 1.2 : modifier / 1.8; // different landmass - const type = getType(i); - - for (const unit of options.military) { - const perc = +unit.rural; - if (isNaN(perc) || perc <= 0 || !stateObj.temp[unit.name]) continue; - if (!passUnitLimits(unit, biome, state, culture, religion)) continue; - if (unit.type === "naval" && !cells.haven[i]) continue; // only near-ocean cells create naval units - - const cellTypeMod = type === "generic" ? 1 : cellTypeModifier[type][unit.type]; // cell specific modifier - const army = modifier * perc * cellTypeMod; // rural cell army - const total = rn(army * stateObj.temp[unit.name] * populationRate); // total troops - if (!total) continue; - - let [x, y] = p[i]; - let n = 0; - - // place naval units to sea - if (unit.type === "naval") { - const haven = cells.haven[i]; - [x, y] = p[haven]; - n = 1; - } - - stateObj.temp.platoons.push({ - cell: i, - a: total, - t: total, - x, - y, - u: unit.name, - n, - s: unit.separate, - type: unit.type - }); - } - } - - // burgs - for (const b of pack.burgs) { - if (!b.i || b.removed || !b.state || !b.population) continue; - - const biome = cells.biome[b.cell]; - const state = b.state; - const culture = b.culture; - const religion = cells.religion[b.cell]; - - const stateObj = states[state]; - let m = (b.population * urbanization) / 100; // basic urban army in percentages - if (b.capital) m *= 1.2; // capital has household troops - if (culture !== stateObj.culture) m = stateObj.form === "Union" ? m / 1.2 : m / 2; // non-dominant culture - if (religion !== cells.religion[stateObj.center]) m = stateObj.form === "Theocracy" ? m / 2.2 : m / 1.4; // non-dominant religion - if (cells.f[b.cell] !== cells.f[stateObj.center]) m = stateObj.type === "Naval" ? m / 1.2 : m / 1.8; // different landmass - const type = getType(b.cell); - - for (const unit of options.military) { - const perc = +unit.urban; - if (isNaN(perc) || perc <= 0 || !stateObj.temp[unit.name]) continue; - if (!passUnitLimits(unit, biome, state, culture, religion)) continue; - if (unit.type === "naval" && (!b.port || !cells.haven[b.cell])) continue; // only ports create naval units - - const mod = type === "generic" ? 1 : burgTypeModifier[type][unit.type]; // cell specific modifier - const army = m * perc * mod; // urban cell army - const total = rn(army * stateObj.temp[unit.name] * populationRate); // total troops - if (!total) continue; - - let [x, y] = p[b.cell]; - let n = 0; - - // place naval to sea - if (unit.type === "naval") { - const haven = cells.haven[b.cell]; - [x, y] = p[haven]; - n = 1; - } - - stateObj.temp.platoons.push({ - cell: b.cell, - a: total, - t: total, - x, - y, - u: unit.name, - n, - s: unit.separate, - type: unit.type - }); - } - } - - const expected = 3 * populationRate; // expected regiment size - const mergeable = (n0, n1) => (!n0.s && !n1.s) || n0.u === n1.u; // check if regiments can be merged - - // remove all existing regiment notes before regenerating - for (let i = notes.length - 1; i >= 0; i--) { - if (notes[i].id.startsWith("regiment")) notes.splice(i, 1); - } - - // get regiments for each state - valid.forEach(s => { - s.military = createRegiments(s.temp.platoons, s); - delete s.temp; // do not store temp data - }); - - function createRegiments(nodes, s) { - if (!nodes.length) return []; - - nodes.sort((a, b) => a.a - b.a); // form regiments in cells with most troops - const tree = d3.quadtree( - nodes, - d => d.x, - d => d.y - ); - - nodes.forEach(node => { - tree.remove(node); - const overlap = tree.find(node.x, node.y, 20); - if (overlap && overlap.t && mergeable(node, overlap)) { - merge(node, overlap); - return; - } - if (node.t > expected) return; - const r = (expected - node.t) / (node.s ? 40 : 20); // search radius - const candidates = findAllInQuadtree(node.x, node.y, r, tree); - for (const c of candidates) { - if (c.t < expected && mergeable(node, c)) { - merge(node, c); - break; - } - } - }); - - // add n0 to n1's ultimate parent - function merge(n0, n1) { - if (!n1.childen) n1.childen = [n0]; - else n1.childen.push(n0); - if (n0.childen) n0.childen.forEach(n => n1.childen.push(n)); - n1.t += n0.t; - n0.t = 0; - } - - // parse regiments data - const regiments = nodes - .filter(n => n.t) - .sort((a, b) => b.t - a.t) - .map((r, i) => { - const u = {}; - u[r.u] = r.a; - (r.childen || []).forEach(n => (u[n.u] = u[n.u] ? (u[n.u] += n.a) : n.a)); - return {i, a: r.t, cell: r.cell, x: r.x, y: r.y, bx: r.x, by: r.y, u, n: r.n, name, state: s.i}; - }); - - // generate name for regiments - regiments.forEach(r => { - r.name = getName(r, regiments); - r.icon = getEmblem(r); - generateNote(r, s); - }); - - return regiments; - } - - TIME && console.timeEnd("generateMilitary"); - }; - - const getDefaultOptions = function () { - return [ - {icon: "⚔️", name: "infantry", rural: 0.25, urban: 0.2, crew: 1, power: 1, type: "melee", separate: 0}, - {icon: "🏹", name: "archers", rural: 0.12, urban: 0.2, crew: 1, power: 1, type: "ranged", separate: 0}, - {icon: "🐴", name: "cavalry", rural: 0.12, urban: 0.03, crew: 2, power: 2, type: "mounted", separate: 0}, - {icon: "💣", name: "artillery", rural: 0, urban: 0.03, crew: 8, power: 12, type: "machinery", separate: 0}, - {icon: "🌊", name: "fleet", rural: 0, urban: 0.015, crew: 100, power: 50, type: "naval", separate: 1} - ]; - }; - - // utilize si function to make regiment total text fit regiment box - const getTotal = reg => (reg.a > (reg.n ? 999 : 99999) ? si(reg.a) : reg.a); - - const getName = function (r, regiments) { - const cells = pack.cells; - const proper = r.n - ? null - : cells.province[r.cell] && pack.provinces[cells.province[r.cell]] - ? pack.provinces[cells.province[r.cell]].name - : cells.burg[r.cell] && pack.burgs[cells.burg[r.cell]] - ? pack.burgs[cells.burg[r.cell]].name - : null; - const number = nth(regiments.filter(reg => reg.n === r.n && reg.i < r.i).length + 1); - const form = r.n ? "Fleet" : "Regiment"; - return `${number}${proper ? ` (${proper}) ` : ` `}${form}`; - }; - - // get default regiment emblem - const getEmblem = function (r) { - if (!r.n && !Object.values(r.u).length) return "🔰"; // "Newbie" regiment without troops - if ( - !r.n && - pack.states[r.state].form === "Monarchy" && - pack.cells.burg[r.cell] && - pack.burgs[pack.cells.burg[r.cell]].capital - ) - return "👑"; // "Royal" regiment based in capital - const mainUnit = Object.entries(r.u).sort((a, b) => b[1] - a[1])[0][0]; // unit with more troops in regiment - const unit = options.military.find(u => u.name === mainUnit); - return unit.icon; - }; - - const generateNote = function (r, s) { - const cells = pack.cells; - const base = - cells.burg[r.cell] && pack.burgs[cells.burg[r.cell]] - ? pack.burgs[cells.burg[r.cell]].name - : cells.province[r.cell] && pack.provinces[cells.province[r.cell]] - ? pack.provinces[cells.province[r.cell]].fullName - : null; - const station = base ? `${r.name} is ${r.n ? "based" : "stationed"} in ${base}. ` : ""; - - const composition = r.a - ? Object.keys(r.u) - .map(t => `— ${t}: ${r.u[t]}`) - .join("\r\n") - : null; - const troops = composition - ? `\r\n\r\nRegiment composition in ${options.year} ${options.eraShort}:\r\n${composition}.` - : ""; - - const campaign = s.campaigns ? ra(s.campaigns) : null; - const year = campaign - ? rand(campaign.start, campaign.end || options.year) - : gauss(options.year - 100, 150, 1, options.year - 6); - const conflict = campaign ? ` during the ${campaign.name}` : ""; - const legend = `Regiment was formed in ${year} ${options.era}${conflict}. ${station}${troops}`; - const id = `regiment${s.i}-${r.i}`; - const existing = notes.find(n => n.id === id); - if (existing) { - existing.name = r.name; - existing.legend = legend; - } else { - notes.push({id, name: r.name, legend}); - } - }; - - return { - generate, - getDefaultOptions, - getName, - generateNote, - getTotal, - getEmblem - }; -})(); diff --git a/src/index.html b/src/index.html index 88753f3a..054f45ba 100644 --- a/src/index.html +++ b/src/index.html @@ -8534,7 +8534,6 @@ - diff --git a/src/modules/index.ts b/src/modules/index.ts index ad5c2de3..787e3989 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -15,5 +15,6 @@ import "./religions-generator"; import "./provinces-generator"; import "./emblem"; import "./ice"; +import "./military-generator"; import "./markers-generator"; import "./fonts"; diff --git a/src/modules/military-generator.ts b/src/modules/military-generator.ts new file mode 100644 index 00000000..daec9332 --- /dev/null +++ b/src/modules/military-generator.ts @@ -0,0 +1,630 @@ +import { quadtree, sum } from "d3"; +import { + findAllInQuadtree, + gauss, + minmax, + nth, + ra, + rand, + rn, + si, +} from "../utils"; +import type { State } from "./states-generator"; + +declare global { + var Military: MilitaryModule; +} + +export interface MilitaryRegiment { + i: number; + t: number; // total troops + name: string; + a: number; // regiment army + s: number; // separate flag (for fleets) + cell: number; + x: number; + y: number; + bx: number; // base x (for movement) + by: number; // base y (for movement) + u: Record; // units composition + n: number; // naval unit flag + type: string; // unit type + icon?: string; + children?: MilitaryRegiment[]; // merged regiments + state: number; + angle?: number; +} + +interface Platoon { + cell: number; + a: number; // platoon army + t: number; // total troops in platoon + x: number; + y: number; + u: string; // unit type + n: number; // naval unit flag + s: number; // separate flag (for fleets) + type: string; // unit type + children?: Platoon[]; // merged platoons +} + +class MilitaryModule { + generate() { + TIME && console.time("generateMilitary"); + const { cells, states } = pack; + const { p } = cells; + const valid = states.filter((s) => s.i && !s.removed); // valid states + if (!options.military) options.military = this.getDefaultOptions(); + + const expn = sum(valid.map((s) => s.expansionism)); // total expansion + const area = sum(valid.map((s) => s.area)); // total area + const rate = { + x: 0, + Ally: -0.2, + Friendly: -0.1, + Neutral: 0, + Suspicion: 0.1, + Enemy: 1, + Unknown: 0, + Rival: 0.5, + Vassal: 0.5, + Suzerain: -0.5, + }; + + const stateModifier = { + melee: { + Nomadic: 0.5, + Highland: 1.2, + Lake: 1, + Naval: 0.7, + Hunting: 1.2, + River: 1.1, + }, + ranged: { + Nomadic: 0.9, + Highland: 1.3, + Lake: 1, + Naval: 0.8, + Hunting: 2, + River: 0.8, + }, + mounted: { + Nomadic: 2.3, + Highland: 0.6, + Lake: 0.7, + Naval: 0.3, + Hunting: 0.7, + River: 0.8, + }, + machinery: { + Nomadic: 0.8, + Highland: 1.4, + Lake: 1.1, + Naval: 1.4, + Hunting: 0.4, + River: 1.1, + }, + naval: { + Nomadic: 0.5, + Highland: 0.5, + Lake: 1.2, + Naval: 1.8, + Hunting: 0.7, + River: 1.2, + }, + armored: { + Nomadic: 1, + Highland: 0.5, + Lake: 1, + Naval: 1, + Hunting: 0.7, + River: 1.1, + }, + aviation: { + Nomadic: 0.5, + Highland: 0.5, + Lake: 1.2, + Naval: 1.2, + Hunting: 0.6, + River: 1.2, + }, + magical: { + Nomadic: 1, + Highland: 2, + Lake: 1, + Naval: 1, + Hunting: 1, + River: 1, + }, + }; + + const cellTypeModifier = { + nomadic: { + melee: 0.2, + ranged: 0.5, + mounted: 3, + machinery: 0.4, + naval: 0.3, + armored: 1.6, + aviation: 1, + magical: 0.5, + }, + wetland: { + melee: 0.8, + ranged: 2, + mounted: 0.3, + machinery: 1.2, + naval: 1.0, + armored: 0.2, + aviation: 0.5, + magical: 0.5, + }, + highland: { + melee: 1.2, + ranged: 1.6, + mounted: 0.3, + machinery: 3, + naval: 1.0, + armored: 0.8, + aviation: 0.3, + magical: 2, + }, + }; + + const burgTypeModifier = { + nomadic: { + melee: 0.3, + ranged: 0.8, + mounted: 3, + machinery: 0.4, + naval: 1.0, + armored: 1.6, + aviation: 1, + magical: 0.5, + }, + wetland: { + melee: 1, + ranged: 1.6, + mounted: 0.2, + machinery: 1.2, + naval: 1.0, + armored: 0.2, + aviation: 0.5, + magical: 0.5, + }, + highland: { + melee: 1.2, + ranged: 2, + mounted: 0.3, + machinery: 3, + naval: 1.0, + armored: 0.8, + aviation: 0.3, + magical: 2, + }, + }; + + valid.forEach((s) => { + s.temp = {}; + const d = s.diplomacy!; + + const expansionRate = minmax( + s.expansionism / expn / (s.area! / area), + 0.25, + 4, + ); // how much state expansionism is realized + const diplomacyRate = d.some((d) => d === "Enemy") + ? 1 + : d.some((d) => d === "Rival") + ? 0.8 + : d.some((d) => d === "Suspicion") + ? 0.5 + : 0.1; // peacefulness + const neighborsRateRaw = s + .neighbors!.map((n) => + n ? pack.states[n].diplomacy![s.i] : "Suspicion", + ) + .reduce((s, r) => s + rate[r as keyof typeof rate], 0.5); + const neighborsRate = minmax(neighborsRateRaw, 0.3, 3); // neighbors rate + s.alert = minmax( + rn(expansionRate * diplomacyRate * neighborsRate, 2), + 0.1, + 5, + ); // alert rate (area modifier) + s.temp.platoons = []; + + // apply overall state modifiers for unit types based on state features + for (const unit of options.military) { + if (!stateModifier[unit.type as keyof typeof stateModifier]) continue; + + let modifier = + stateModifier[unit.type as keyof typeof stateModifier][ + s.type as keyof (typeof stateModifier)[keyof typeof stateModifier] + ] || 1; + if (unit.type === "mounted" && s.formName!.includes("Horde")) + modifier *= 2; + else if (unit.type === "naval" && s.form === "Republic") + modifier *= 1.2; + s.temp[unit.name] = modifier * s.alert; + } + }); + + const getType = (cell: number) => { + if ([1, 2, 3, 4].includes(cells.biome[cell])) return "nomadic"; + if ([7, 8, 9, 12].includes(cells.biome[cell])) return "wetland"; + if (cells.h[cell] >= 70) return "highland"; + return "generic"; + }; + + function passUnitLimits( + unit: any, + biome: number, + state: number, + culture: number, + religion: number, + ) { + if (unit.biomes && !unit.biomes.includes(biome)) return false; + if (unit.states && !unit.states.includes(state)) return false; + if (unit.cultures && !unit.cultures.includes(culture)) return false; + if (unit.religions && !unit.religions.includes(religion)) return false; + return true; + } + + // rural cells + for (const i of cells.i) { + if (!cells.pop[i]) continue; + + const biome = cells.biome[i]; + const state = cells.state[i]; + const culture = cells.culture[i]; + const religion = cells.religion[i]; + + const stateObj = states[state]; + if (!state || stateObj.removed) continue; + + let modifier = cells.pop[i] / 100; // basic rural army in percentages + if (culture !== stateObj.culture) + modifier = stateObj.form === "Union" ? modifier / 1.2 : modifier / 2; // non-dominant culture + if (religion !== cells.religion[stateObj.center]) + modifier = + stateObj.form === "Theocracy" ? modifier / 2.2 : modifier / 1.4; // non-dominant religion + if (cells.f[i] !== cells.f[stateObj.center]) + modifier = stateObj.type === "Naval" ? modifier / 1.2 : modifier / 1.8; // different landmass + const type = getType(i); + + for (const unit of options.military) { + const perc = +unit.rural; + if (Number.isNaN(perc) || perc <= 0 || !stateObj.temp[unit.name]) + continue; + if (!passUnitLimits(unit, biome, state, culture, religion)) continue; + if (unit.type === "naval" && !cells.haven[i]) continue; // only near-ocean cells create naval units + + const cellTypeMod = + type === "generic" + ? 1 + : cellTypeModifier[type as keyof typeof cellTypeModifier][ + unit.type as keyof (typeof cellTypeModifier)[keyof typeof cellTypeModifier] + ]; // cell specific modifier + const army = modifier * perc * cellTypeMod; // rural cell army + const total = rn(army * stateObj.temp[unit.name] * populationRate); // total troops + if (!total) continue; + + let [x, y] = p[i]; + let n = 0; + + // place naval units to sea + if (unit.type === "naval") { + const haven = cells.haven[i]; + [x, y] = p[haven]; + n = 1; + } + + stateObj.temp.platoons.push({ + cell: i, + a: total, + t: total, + x, + y, + u: unit.name, + n, + s: unit.separate, + type: unit.type, + }); + } + } + + // burgs + for (const b of pack.burgs) { + if (!b.i || b.removed || !b.state || !b.population) continue; + + const biome = cells.biome[b.cell]; + const state = b.state; + const culture = b.culture; + const religion = cells.religion[b.cell]; + + const stateObj = states[state]; + let m = (b.population * urbanization) / 100; // basic urban army in percentages + if (b.capital) m *= 1.2; // capital has household troops + if (culture !== stateObj.culture) + m = stateObj.form === "Union" ? m / 1.2 : m / 2; // non-dominant culture + if (religion !== cells.religion[stateObj.center]) + m = stateObj.form === "Theocracy" ? m / 2.2 : m / 1.4; // non-dominant religion + if (cells.f[b.cell] !== cells.f[stateObj.center]) + m = stateObj.type === "Naval" ? m / 1.2 : m / 1.8; // different landmass + const type = getType(b.cell); + + for (const unit of options.military) { + const perc = +unit.urban; + if (Number.isNaN(perc) || perc <= 0 || !stateObj.temp[unit.name]) + continue; + if (!passUnitLimits(unit, biome, state, culture!, religion)) continue; + if (unit.type === "naval" && (!b.port || !cells.haven[b.cell])) + continue; // only ports create naval units + + const mod = + type === "generic" + ? 1 + : burgTypeModifier[type as keyof typeof burgTypeModifier][ + unit.type as keyof (typeof burgTypeModifier)[keyof typeof burgTypeModifier] + ]; // cell specific modifier + const army = m * perc * mod; // urban cell army + const total = rn(army * stateObj.temp[unit.name] * populationRate); // total troops + if (!total) continue; + + let [x, y] = p[b.cell]; + let n = 0; + + // place naval to sea + if (unit.type === "naval") { + const haven = cells.haven[b.cell]; + [x, y] = p[haven]; + n = 1; + } + + stateObj.temp.platoons.push({ + cell: b.cell, + a: total, + t: total, + x, + y, + u: unit.name, + n, + s: unit.separate, + type: unit.type, + }); + } + } + + const expected = 3 * populationRate; // expected regiment size + const mergeable = ( + n0: { s: number; u: string }, + n1: { s: number; u: string }, + ) => (!n0.s && !n1.s) || n0.u === n1.u; // check if regiments can be merged + const createRegiments = ( + nodes: Platoon[], + s: State, + ): MilitaryRegiment[] => { + if (!nodes.length) return []; + nodes.sort((a, b) => a.a - b.a); // form regiments starting from cells with fewest troops + const tree = quadtree( + nodes, + (d) => d.x, + (d) => d.y, + ); + + // add n0 to n1's ultimate parent + const merge = ( + n0: { s: number; u: string; t: number; children?: any[] }, + n1: { s: number; u: string; t: number; children?: any[] }, + ) => { + if (!n1.children) n1.children = [n0]; + else n1.children.push(n0); + if (n0.children) + n0.children.forEach((n) => { + n1.children!.push(n); + }); + n1.t += n0.t; + n0.t = 0; + }; + + nodes.forEach((node) => { + tree.remove(node); + const overlap = tree.find(node.x, node.y, 20); + if (overlap && overlap.t && mergeable(node, overlap)) { + merge(node, overlap); + return; + } + if (node.t > expected) return; + const r = (expected - node.t) / (node.s ? 40 : 20); // search radius + const candidates = findAllInQuadtree(node.x, node.y, r, tree); + for (const c of candidates) { + if (c.t < expected && mergeable(node, c)) { + merge(node, c); + break; + } + } + }); + + // parse regiments data + const regiments: Omit[] = nodes + .filter((n) => n.t) + .sort((a, b) => b.t - a.t) + .map((r, i) => { + const u: Record = {}; + u[r.u] = r.a; + (r.children ?? []).forEach((n) => { + u[n.u] = (u[n.u] ?? 0) + n.a; + }); + return { + i, + a: r.t, + cell: r.cell, + x: r.x, + y: r.y, + bx: r.x, + by: r.y, + u, + n: r.n, + name: "", + state: s.i, + }; + }); + + // generate name for regiments + regiments.forEach((r: Omit) => { + r.name = this.getName( + r as MilitaryRegiment, + regiments as MilitaryRegiment[], + ); + r.icon = this.getEmblem(r as MilitaryRegiment); + this.generateNote(r as MilitaryRegiment, s); + }); + + return regiments as MilitaryRegiment[]; + }; + + // remove all existing regiment notes before regenerating + for (let i = notes.length - 1; i >= 0; i--) { + if (notes[i].id.startsWith("regiment")) notes.splice(i, 1); + } + + // get regiments for each state + valid.forEach((s) => { + s.military = createRegiments(s.temp.platoons, s); + delete s.temp; // do not store temp data + }); + + TIME && console.timeEnd("generateMilitary"); + } + + getDefaultOptions() { + return [ + { + icon: "⚔️", + name: "infantry", + rural: 0.25, + urban: 0.2, + crew: 1, + power: 1, + type: "melee", + separate: 0, + }, + { + icon: "🏹", + name: "archers", + rural: 0.12, + urban: 0.2, + crew: 1, + power: 1, + type: "ranged", + separate: 0, + }, + { + icon: "🐴", + name: "cavalry", + rural: 0.12, + urban: 0.03, + crew: 2, + power: 2, + type: "mounted", + separate: 0, + }, + { + icon: "💣", + name: "artillery", + rural: 0, + urban: 0.03, + crew: 8, + power: 12, + type: "machinery", + separate: 0, + }, + { + icon: "🌊", + name: "fleet", + rural: 0, + urban: 0.015, + crew: 100, + power: 50, + type: "naval", + separate: 1, + }, + ]; + } + + getName(r: MilitaryRegiment, regiments: MilitaryRegiment[]) { + const cells = pack.cells; + const proper = r.n + ? null + : cells.province[r.cell] && pack.provinces[cells.province[r.cell]] + ? pack.provinces[cells.province[r.cell]].name + : cells.burg[r.cell] && pack.burgs[cells.burg[r.cell]] + ? pack.burgs[cells.burg[r.cell]].name + : null; + const number = nth( + regiments.filter((reg) => reg.n === r.n && reg.i < r.i).length + 1, + ); + const form = r.n ? "Fleet" : "Regiment"; + return `${number}${proper ? ` (${proper}) ` : ` `}${form}`; + } + + // utilize si function to make regiment total text fit regiment box + getTotal(reg: MilitaryRegiment) { + return reg.a > (reg.n ? 999 : 99999) ? si(reg.a) : reg.a; + } + + generateNote(r: MilitaryRegiment, s: State) { + const cells = pack.cells; + const base = + cells.burg[r.cell] && pack.burgs[cells.burg[r.cell]] + ? pack.burgs[cells.burg[r.cell]].name + : cells.province[r.cell] && pack.provinces[cells.province[r.cell]] + ? pack.provinces[cells.province[r.cell]].fullName + : null; + const station = base + ? `${r.name} is ${r.n ? "based" : "stationed"} in ${base}. ` + : ""; + + const composition = r.a + ? Object.keys(r.u) + .map((t) => `— ${t}: ${r.u[t as keyof typeof r.u]}`) + .join("\r\n") + : null; + const troops = composition + ? `\r\n\r\nRegiment composition in ${options.year} ${options.eraShort}:\r\n${composition}.` + : ""; + + const campaign = s.campaigns ? ra(s.campaigns) : null; + const year = campaign + ? rand(campaign.start, campaign.end || options.year) + : gauss(options.year - 100, 150, 1, options.year - 6); + const conflict = campaign ? ` during the ${campaign.name}` : ""; + const legend = `Regiment was formed in ${year} ${options.era}${conflict}. ${station}${troops}`; + const id = `regiment${s.i}-${r.i}`; + const existing = notes.find((n) => n.id === id); + if (existing) { + existing.name = r.name; + existing.legend = legend; + } else { + notes.push({ id, name: r.name, legend }); + } + } + + // get default regiment emblem + getEmblem(r: MilitaryRegiment) { + if (!r.n && !Object.values(r.u).length) return "🔰"; // "Newbie" regiment without troops + if ( + !r.n && + pack.states[r.state].form === "Monarchy" && + pack.cells.burg[r.cell] && + pack.burgs[pack.cells.burg[r.cell]].capital + ) + return "👑"; // "Royal" regiment based in capital + const mainUnit = Object.entries(r.u).sort((a, b) => b[1] - a[1])[0][0]; // unit with more troops in regiment + const unit = options.military.find( + (u: { name: string; icon: string }) => u.name === mainUnit, + ); + return unit ? unit.icon : "⚔️"; + } +} +window.Military = new MilitaryModule(); diff --git a/src/modules/states-generator.ts b/src/modules/states-generator.ts index 54232835..507c3a0b 100644 --- a/src/modules/states-generator.ts +++ b/src/modules/states-generator.ts @@ -52,6 +52,8 @@ export interface State { form?: string; military?: any[]; provinces?: number[]; + temp?: any; + alert?: number; } class StatesModule { diff --git a/src/renderers/draw-military.ts b/src/renderers/draw-military.ts index dc5f1da2..69c294e6 100644 --- a/src/renderers/draw-military.ts +++ b/src/renderers/draw-military.ts @@ -1,24 +1,13 @@ import { color, easeSinInOut, transition } from "d3"; +import type { MilitaryRegiment } from "../modules/military-generator"; import { rn } from "../utils"; -interface Regiment { - i: number; - name: string; - x: number; - y: number; - n?: number; - angle?: number; - icon: string; - state: number; -} - declare global { var drawMilitary: () => void; - var drawRegiments: (regiments: Regiment[], stateId: number) => void; - var drawRegiment: (reg: Regiment, stateId: number) => void; - var moveRegiment: (reg: Regiment, x: number, y: number) => void; + var drawRegiments: (regiments: MilitaryRegiment[], stateId: number) => void; + var drawRegiment: (reg: MilitaryRegiment, stateId: number) => void; + var moveRegiment: (reg: MilitaryRegiment, x: number, y: number) => void; var armies: import("d3").Selection; - var Military: { getTotal: (reg: Regiment) => number }; } const militaryRenderer = (): void => { @@ -34,12 +23,15 @@ const militaryRenderer = (): void => { TIME && console.timeEnd("drawMilitary"); }; -const drawRegimentsRenderer = (regiments: Regiment[], s: number): void => { +const drawRegimentsRenderer = ( + regiments: MilitaryRegiment[], + s: number, +): void => { const size = +armies.attr("box-size"); - const w = (d: Regiment) => (d.n ? size * 4 : size * 6); + const w = (d: MilitaryRegiment) => (d.n ? size * 4 : size * 6); const h = size * 2; - const x = (d: Regiment) => rn(d.x - w(d) / 2, 2); - const y = (d: Regiment) => rn(d.y - size, 2); + const x = (d: MilitaryRegiment) => rn(d.x - w(d) / 2, 2); + const y = (d: MilitaryRegiment) => rn(d.y - size, 2); const stateColor = pack.states[s]?.color; const baseColor = stateColor && stateColor[0] === "#" ? stateColor : "#999"; @@ -83,9 +75,9 @@ const drawRegimentsRenderer = (regiments: Regiment[], s: number): void => { .attr("x", (d) => x(d) - size) .attr("y", (d) => d.y) .text((d) => - d.icon.startsWith("http") || d.icon.startsWith("data:image") + d.icon!.startsWith("http") || d.icon!.startsWith("data:image") ? "" - : d.icon, + : d.icon!, ); g.append("image") .attr("class", "regimentImage") @@ -94,13 +86,13 @@ const drawRegimentsRenderer = (regiments: Regiment[], s: number): void => { .attr("height", h) .attr("width", h) .attr("href", (d) => - d.icon.startsWith("http") || d.icon.startsWith("data:image") - ? d.icon + d.icon!.startsWith("http") || d.icon!.startsWith("data:image") + ? d.icon! : "", ); }; -const drawRegimentRenderer = (reg: Regiment, stateId: number): void => { +const drawRegimentRenderer = (reg: MilitaryRegiment, stateId: number): void => { const size = +armies.attr("box-size"); const w = reg.n ? size * 4 : size * 6; const h = size * 2; @@ -149,9 +141,9 @@ const drawRegimentRenderer = (reg: Regiment, stateId: number): void => { .attr("x", x1 - size) .attr("y", reg.y) .text( - reg.icon.startsWith("http") || reg.icon.startsWith("data:image") + reg.icon!.startsWith("http") || reg.icon!.startsWith("data:image") ? "" - : reg.icon, + : reg.icon!, ); g.append("image") .attr("class", "regimentImage") @@ -161,14 +153,18 @@ const drawRegimentRenderer = (reg: Regiment, stateId: number): void => { .attr("width", h) .attr( "href", - reg.icon.startsWith("http") || reg.icon.startsWith("data:image") - ? reg.icon + reg.icon!.startsWith("http") || reg.icon!.startsWith("data:image") + ? reg.icon! : "", ); }; // move one regiment to another -const moveRegimentRenderer = (reg: Regiment, x: number, y: number): void => { +const moveRegimentRenderer = ( + reg: MilitaryRegiment, + x: number, + y: number, +): void => { const el = armies .select(`g#army${reg.state}`) .select(`g#regiment${reg.state}-${reg.i}`);