refactor: migrate provinces generator to new module structure (#1295)

* refactor: migrate provinces generator to new module structure

* fix: after merge fixes of state

* refactor: fixed a bug so had to update tests
This commit is contained in:
Marc Emmanuel 2026-02-01 22:16:04 +01:00 committed by GitHub
parent 454178fa99
commit 0f19902a56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 399 additions and 259 deletions

View file

@ -1,257 +0,0 @@
"use strict";
window.Provinces = (function () {
const forms = {
Monarchy: {County: 22, Earldom: 6, Shire: 2, Landgrave: 2, Margrave: 2, Barony: 2, Captaincy: 1, Seneschalty: 1},
Republic: {Province: 6, Department: 2, Governorate: 2, District: 1, Canton: 1, Prefecture: 1},
Theocracy: {Parish: 3, Deanery: 1},
Union: {Province: 1, State: 1, Canton: 1, Republic: 1, County: 1, Council: 1},
Anarchy: {Council: 1, Commune: 1, Community: 1, Tribe: 1},
Wild: {Territory: 10, Land: 5, Region: 2, Tribe: 1, Clan: 1, Dependency: 1, Area: 1}
};
const generate = (regenerate = false, regenerateLockedStates = false) => {
TIME && console.time("generateProvinces");
const localSeed = regenerate ? generateSeed() : seed;
Math.random = aleaPRNG(localSeed);
const {cells, states, burgs} = pack;
const provinces = [0]; // 0 index is reserved for "no province"
const provinceIds = new Uint16Array(cells.i.length);
const isProvinceLocked = province => province.lock || (!regenerateLockedStates && states[province.state]?.lock);
const isProvinceCellLocked = cell => provinceIds[cell] && isProvinceLocked(provinces[provinceIds[cell]]);
if (regenerate) {
pack.provinces.forEach(province => {
if (!province.i || province.removed || !isProvinceLocked(province)) return;
const newId = provinces.length;
for (const i of cells.i) {
if (cells.province[i] === province.i) provinceIds[i] = newId;
}
province.i = newId;
provinces.push(province);
});
}
const provincesRatio = +byId("provincesRatio").value;
const max = provincesRatio == 100 ? 1000 : gauss(20, 5, 5, 100) * provincesRatio ** 0.5; // max growth
// generate provinces for selected burgs
states.forEach(s => {
s.provinces = [];
if (!s.i || s.removed) return;
if (provinces.length) s.provinces = provinces.filter(p => p.state === s.i).map(p => p.i); // locked provinces ids
if (s.lock && !regenerateLockedStates) return; // don't regenerate provinces of a locked state
const stateBurgs = burgs
.filter(b => b.state === s.i && !b.removed && !provinceIds[b.cell])
.sort((a, b) => b.population * gauss(1, 0.2, 0.5, 1.5, 3) - a.population)
.sort((a, b) => b.capital - a.capital);
if (stateBurgs.length < 2) return; // at least 2 provinces are required
const provincesNumber = Math.max(Math.ceil((stateBurgs.length * provincesRatio) / 100), 2);
const form = Object.assign({}, forms[s.form]);
for (let i = 0; i < provincesNumber; i++) {
const provinceId = provinces.length;
const center = stateBurgs[i].cell;
const burg = stateBurgs[i];
const c = stateBurgs[i].culture;
const nameByBurg = P(0.5);
const name = nameByBurg ? stateBurgs[i].name : Names.getState(Names.getCultureShort(c), c);
const formName = rw(form);
form[formName] += 10;
const fullName = name + " " + formName;
const color = getMixedColor(s.color);
const kinship = nameByBurg ? 0.8 : 0.4;
const type = Burgs.getType(center, burg.port);
const coa = COA.generate(stateBurgs[i].coa, kinship, null, type);
coa.shield = COA.getShield(c, s.i);
s.provinces.push(provinceId);
provinces.push({i: provinceId, state: s.i, center, burg: burg.i, name, formName, fullName, color, coa});
}
});
// expand generated provinces
const queue = new FlatQueue();
const cost = [];
provinces.forEach(p => {
if (!p.i || p.removed || isProvinceLocked(p)) return;
provinceIds[p.center] = p.i;
queue.push({e: p.center, province: p.i, state: p.state, p: 0}, 0);
cost[p.center] = 1;
});
while (queue.length) {
const {e, p, province, state} = queue.pop();
cells.c[e].forEach(e => {
if (isProvinceCellLocked(e)) return; // do not overwrite cell of locked provinces
const land = cells.h[e] >= 20;
if (!land && !cells.t[e]) return; // cannot pass deep ocean
if (land && cells.state[e] !== state) return;
const evevation = cells.h[e] >= 70 ? 100 : cells.h[e] >= 50 ? 30 : cells.h[e] >= 20 ? 10 : 100;
const totalCost = p + evevation;
if (totalCost > max) return;
if (!cost[e] || totalCost < cost[e]) {
if (land) provinceIds[e] = province; // assign province to a cell
cost[e] = totalCost;
queue.push({e, province, state, p: totalCost}, totalCost);
}
});
}
// justify provinces shapes a bit
for (const i of cells.i) {
if (cells.burg[i]) continue; // do not overwrite burgs
if (isProvinceCellLocked(i)) continue; // do not overwrite cell of locked provinces
const neibs = cells.c[i]
.filter(c => cells.state[c] === cells.state[i] && !isProvinceCellLocked(c))
.map(c => provinceIds[c]);
const adversaries = neibs.filter(c => c !== provinceIds[i]);
if (adversaries.length < 2) continue;
const buddies = neibs.filter(c => c === provinceIds[i]).length;
if (buddies.length > 2) continue;
const competitors = adversaries.map(p => adversaries.reduce((s, v) => (v === p ? s + 1 : s), 0));
const max = d3.max(competitors);
if (buddies >= max) continue;
provinceIds[i] = adversaries[competitors.indexOf(max)];
}
// add "wild" provinces if some cells don't have a province assigned
const noProvince = Array.from(cells.i).filter(i => cells.state[i] && !provinceIds[i]); // cells without province assigned
states.forEach(s => {
if (!s.i || s.removed) return;
if (s.lock && !regenerateLockedStates) return;
if (!s.provinces.length) return;
const coreProvinceNames = s.provinces.map(p => provinces[p]?.name);
const colonyNamePool = [s.name, ...coreProvinceNames].filter(name => name && !/new/i.test(name));
const getColonyName = () => {
if (colonyNamePool.length < 1) return null;
const index = rand(colonyNamePool.length - 1);
const spliced = colonyNamePool.splice(index, 1);
return spliced[0] ? `New ${spliced[0]}` : null;
};
let stateNoProvince = noProvince.filter(i => cells.state[i] === s.i && !provinceIds[i]);
while (stateNoProvince.length) {
// add new province
const provinceId = provinces.length;
const burgCell = stateNoProvince.find(i => cells.burg[i]);
const center = burgCell ? burgCell : stateNoProvince[0];
const burg = burgCell ? cells.burg[burgCell] : 0;
provinceIds[center] = provinceId;
// expand province
const cost = [];
cost[center] = 1;
queue.push({e: center, p: 0}, 0);
while (queue.length) {
const {e, p} = queue.pop();
cells.c[e].forEach(nextCellId => {
if (provinceIds[nextCellId]) return;
const land = cells.h[nextCellId] >= 20;
if (cells.state[nextCellId] && cells.state[nextCellId] !== s.i) return;
const ter = land ? (cells.state[nextCellId] === s.i ? 3 : 20) : cells.t[nextCellId] ? 10 : 30;
const totalCost = p + ter;
if (totalCost > max) return;
if (!cost[nextCellId] || totalCost < cost[nextCellId]) {
if (land && cells.state[nextCellId] === s.i) provinceIds[nextCellId] = provinceId; // assign province to a cell
cost[nextCellId] = totalCost;
queue.push({e: nextCellId, p: totalCost}, totalCost);
}
});
}
// generate "wild" province name
const c = cells.culture[center];
const f = pack.features[cells.f[center]];
const color = getMixedColor(s.color);
const provCells = stateNoProvince.filter(i => provinceIds[i] === provinceId);
const singleIsle = provCells.length === f.cells && !provCells.find(i => cells.f[i] !== f.i);
const isleGroup = !singleIsle && !provCells.find(i => pack.features[cells.f[i]].group !== "isle");
const colony = !singleIsle && !isleGroup && P(0.5) && !isPassable(s.center, center);
const name = (() => {
const colonyName = colony && P(0.8) && getColonyName();
if (colonyName) return colonyName;
if (burgCell && P(0.5)) return burgs[burg].name;
return Names.getState(Names.getCultureShort(c), c);
})();
const formName = (() => {
if (singleIsle) return "Island";
if (isleGroup) return "Islands";
if (colony) return "Colony";
return rw(forms["Wild"]);
})();
const fullName = name + " " + formName;
const dominion = colony ? P(0.95) : singleIsle || isleGroup ? P(0.7) : P(0.3);
const kinship = dominion ? 0 : 0.4;
const type = Burgs.getType(center, burgs[burg]?.port);
const coa = COA.generate(s.coa, kinship, dominion, type);
coa.shield = COA.getShield(c, s.i);
provinces.push({i: provinceId, state: s.i, center, burg, name, formName, fullName, color, coa});
s.provinces.push(provinceId);
// check if there is a land way within the same state between two cells
function isPassable(from, to) {
if (cells.f[from] !== cells.f[to]) return false; // on different islands
const passableQueue = [from],
used = new Uint8Array(cells.i.length),
state = cells.state[from];
while (passableQueue.length) {
const current = passableQueue.pop();
if (current === to) return true; // way is found
cells.c[current].forEach(c => {
if (used[c] || cells.h[c] < 20 || cells.state[c] !== state) return;
passableQueue.push(c);
used[c] = 1;
});
}
return false; // way is not found
}
// re-check
stateNoProvince = noProvince.filter(i => cells.state[i] === s.i && !provinceIds[i]);
}
});
cells.province = provinceIds;
pack.provinces = provinces;
TIME && console.timeEnd("generateProvinces");
};
// calculate pole of inaccessibility for each province
const getPoles = () => {
const getType = cellId => pack.cells.province[cellId];
const poles = getPolesOfInaccessibility(pack, getType);
pack.provinces.forEach(province => {
if (!province.i || province.removed) return;
province.pole = poles[province.i] || [0, 0];
});
};
return {generate, getPoles};
})();

View file

@ -8494,7 +8494,6 @@
<script defer src="config/heightmap-templates.js"></script> <script defer src="config/heightmap-templates.js"></script>
<script defer src="config/precreated-heightmaps.js"></script> <script defer src="config/precreated-heightmaps.js"></script>
<script defer src="modules/ice.js?v=1.111.0"></script> <script defer src="modules/ice.js?v=1.111.0"></script>
<script defer src="modules/provinces-generator.js?v=1.106.0"></script>
<script defer src="modules/religions-generator.js?v=1.106.0"></script> <script defer src="modules/religions-generator.js?v=1.106.0"></script>
<script defer src="modules/military-generator.js?v=1.107.0"></script> <script defer src="modules/military-generator.js?v=1.107.0"></script>
<script defer src="modules/markers-generator.js?v=1.107.0"></script> <script defer src="modules/markers-generator.js?v=1.107.0"></script>

View file

@ -10,3 +10,4 @@ import "./biomes";
import "./cultures-generator"; import "./cultures-generator";
import "./routes-generator"; import "./routes-generator";
import "./states-generator"; import "./states-generator";
import "./provinces-generator";

View file

@ -0,0 +1,393 @@
import Alea from "alea";
import { max } from "d3";
import {
byId,
gauss,
generateSeed,
getMixedColor,
getPolesOfInaccessibility,
P,
rand,
rw,
} from "../utils";
declare global {
var Provinces: ProvinceModule;
}
export interface Province {
i: number;
removed?: boolean;
state: number;
lock?: boolean;
center: number;
burg: number;
name: string;
formName: string;
fullName: string;
color: string;
coa: any;
pole?: [number, number];
}
class ProvinceModule {
forms: Record<string, Record<string, number>> = {
Monarchy: {
County: 22,
Earldom: 6,
Shire: 2,
Landgrave: 2,
Margrave: 2,
Barony: 2,
Captaincy: 1,
Seneschalty: 1,
},
Republic: {
Province: 6,
Department: 2,
Governorate: 2,
District: 1,
Canton: 1,
Prefecture: 1,
},
Theocracy: { Parish: 3, Deanery: 1 },
Union: {
Province: 1,
State: 1,
Canton: 1,
Republic: 1,
County: 1,
Council: 1,
},
Anarchy: { Council: 1, Commune: 1, Community: 1, Tribe: 1 },
Wild: {
Territory: 10,
Land: 5,
Region: 2,
Tribe: 1,
Clan: 1,
Dependency: 1,
Area: 1,
},
};
generate(regenerate = false, regenerateLockedStates = false) {
TIME && console.time("generateProvinces");
const localSeed = regenerate ? generateSeed() : seed;
Math.random = Alea(localSeed);
const { cells, states, burgs } = pack;
const provinces: Province[] = [0 as unknown as Province]; // 0 index is reserved for "no province"
const provinceIds = new Uint16Array(cells.i.length);
const isProvinceLocked = (province: Province) =>
province.lock ||
(!regenerateLockedStates && states[province.state]?.lock);
const isProvinceCellLocked = (cell: number) =>
provinceIds[cell] && isProvinceLocked(provinces[provinceIds[cell]]);
if (regenerate) {
pack.provinces.forEach((province) => {
if (!province.i || province.removed || !isProvinceLocked(province))
return;
const newId = provinces.length;
for (const i of cells.i) {
if (cells.province[i] === province.i) provinceIds[i] = newId;
}
province.i = newId;
provinces.push(province);
});
}
const provincesRatio = (byId("provincesRatio") as HTMLInputElement)
.valueAsNumber;
const maxGrowth =
provincesRatio === 100
? 1000
: gauss(20, 5, 5, 100) * provincesRatio ** 0.5; // max growth
// generate provinces for selected burgs
states.forEach((s) => {
s.provinces = [];
if (!s.i || s.removed) return;
if (provinces.length)
s.provinces = provinces.filter((p) => p.state === s.i).map((p) => p.i); // locked provinces ids
if (s.lock && !regenerateLockedStates) return; // don't regenerate provinces of a locked state
const stateBurgs = burgs
.filter((b) => b.state === s.i && !b.removed && !provinceIds[b.cell]) // burgs in this state without province assigned
.sort(
(a, b) => b.population! * gauss(1, 0.2, 0.5, 1.5, 3) - a.population!,
) // biggest population first
.sort((a, b) => b.capital! - a.capital!); // capitals first
if (stateBurgs.length < 2) return; // at least 2 provinces are required
const provincesNumber = Math.max(
Math.ceil((stateBurgs.length * provincesRatio) / 100),
2,
);
const form = Object.assign({}, this.forms[s.form!]);
for (let i = 0; i < provincesNumber; i++) {
const provinceId = provinces.length;
const center = stateBurgs[i].cell;
const burg = stateBurgs[i];
const c = stateBurgs[i].culture!;
const nameByBurg = P(0.5);
const name = nameByBurg
? stateBurgs[i].name!
: Names.getState(Names.getCultureShort(c), c);
const formName = rw(form);
form[formName] += 10;
const fullName = `${name} ${formName}`;
const color = getMixedColor(s.color!);
const kinship = nameByBurg ? 0.8 : 0.4;
const type = Burgs.getType(center, burg.port);
const coa = COA.generate(stateBurgs[i].coa, kinship, null, type);
coa.shield = COA.getShield(c, s.i);
s.provinces.push(provinceId);
provinces.push({
i: provinceId,
state: s.i,
center,
burg: burg.i!,
name,
formName,
fullName,
color,
coa,
});
}
});
// expand generated provinces
const queue = new FlatQueue();
const cost: number[] = [];
provinces.forEach((p) => {
if (!p.i || p.removed || isProvinceLocked(p)) return;
provinceIds[p.center] = p.i;
queue.push({ e: p.center, province: p.i, state: p.state, p: 0 }, 0);
cost[p.center] = 1;
});
while (queue.length) {
const { e, p, province, state } = queue.pop();
cells.c[e].forEach((e) => {
if (isProvinceCellLocked(e)) return; // do not overwrite cell of locked provinces
const land = cells.h[e] >= 20;
if (!land && !cells.t[e]) return; // cannot pass deep ocean
if (land && cells.state[e] !== state) return;
const evevation =
cells.h[e] >= 70
? 100
: cells.h[e] >= 50
? 30
: cells.h[e] >= 20
? 10
: 100;
const totalCost = p + evevation;
if (totalCost > maxGrowth) return;
if (!cost[e] || totalCost < cost[e]) {
if (land) provinceIds[e] = province; // assign province to a cell
cost[e] = totalCost;
queue.push({ e, province, state, p: totalCost }, totalCost);
}
});
}
// justify provinces shapes a bit
for (const i of cells.i) {
if (cells.burg[i]) continue; // do not overwrite burgs
if (isProvinceCellLocked(i)) continue; // do not overwrite cell of locked provinces
const neibs = cells.c[i]
.filter(
(c) => cells.state[c] === cells.state[i] && !isProvinceCellLocked(c),
)
.map((c) => provinceIds[c]);
const adversaries = neibs.filter((c) => c !== provinceIds[i]);
if (adversaries.length < 2) continue;
const buddies = neibs.filter((c) => c === provinceIds[i]).length;
if (buddies > 2) continue;
const competitors = adversaries.map((p) =>
adversaries.reduce((s, v) => (v === p ? s + 1 : s), 0),
);
const maxBuddies = max(competitors) as number;
if (buddies >= maxBuddies) continue;
provinceIds[i] = adversaries[competitors.indexOf(maxBuddies)];
}
// add "wild" provinces if some cells don't have a province assigned
const noProvince = Array.from(cells.i).filter(
(i) => cells.state[i] && !provinceIds[i],
); // cells without province assigned
states.forEach((s) => {
if (!s.i || s.removed) return;
if (s.lock && !regenerateLockedStates) return;
if (!s.provinces?.length) return;
const coreProvinceNames = s.provinces.map((p) => provinces[p]?.name);
const colonyNamePool = [s.name, ...coreProvinceNames].filter(
(name) => name && !/new/i.test(name),
);
const getColonyName = () => {
if (colonyNamePool.length < 1) return null;
const index = rand(colonyNamePool.length - 1);
const spliced = colonyNamePool.splice(index, 1);
return spliced[0] ? `New ${spliced[0]}` : null;
};
let stateNoProvince = noProvince.filter(
(i) => cells.state[i] === s.i && !provinceIds[i],
);
while (stateNoProvince.length) {
// add new province
const provinceId = provinces.length;
const burgCell = stateNoProvince.find((i) => cells.burg[i]);
const center = burgCell ? burgCell : stateNoProvince[0];
const burg = burgCell ? cells.burg[burgCell] : 0;
provinceIds[center] = provinceId;
// expand province
const cost: number[] = [];
cost[center] = 1;
queue.push({ e: center, p: 0 }, 0);
while (queue.length) {
const { e, p } = queue.pop();
cells.c[e].forEach((nextCellId) => {
if (provinceIds[nextCellId]) return;
const land = cells.h[nextCellId] >= 20;
if (cells.state[nextCellId] && cells.state[nextCellId] !== s.i)
return;
const ter = land
? cells.state[nextCellId] === s.i
? 3
: 20
: cells.t[nextCellId]
? 10
: 30;
const totalCost = p + ter;
if (totalCost > maxGrowth) return;
if (!cost[nextCellId] || totalCost < cost[nextCellId]) {
if (land && cells.state[nextCellId] === s.i)
provinceIds[nextCellId] = provinceId; // assign province to a cell
cost[nextCellId] = totalCost;
queue.push({ e: nextCellId, p: totalCost }, totalCost);
}
});
}
// generate "wild" province name
const c = cells.culture[center];
const f = pack.features[cells.f[center]];
const color = getMixedColor(s.color!);
const provCells = stateNoProvince.filter(
(i) => provinceIds[i] === provinceId,
);
const singleIsle =
provCells.length === f.cells &&
!provCells.find((i) => cells.f[i] !== f.i);
const isleGroup =
!singleIsle &&
!provCells.find((i) => pack.features[cells.f[i]].group !== "isle");
const colony =
!singleIsle && !isleGroup && P(0.5) && !isPassable(s.center, center);
const name = (() => {
const colonyName = colony && P(0.8) && getColonyName();
if (colonyName) return colonyName;
if (burgCell && P(0.5)) return burgs[burg].name;
return Names.getState(Names.getCultureShort(c), c);
})();
const formName = (() => {
if (singleIsle) return "Island";
if (isleGroup) return "Islands";
if (colony) return "Colony";
return rw(this.forms["Wild"]);
})();
const fullName = `${name} ${formName}`;
const dominion = colony
? P(0.95)
: singleIsle || isleGroup
? P(0.7)
: P(0.3);
const kinship = dominion ? 0 : 0.4;
const type = Burgs.getType(center, burgs[burg]?.port);
const coa = COA.generate(s.coa, kinship, dominion, type);
coa.shield = COA.getShield(c, s.i);
provinces.push({
i: provinceId,
state: s.i,
center,
burg,
name: name!,
formName,
fullName,
color,
coa,
});
s.provinces.push(provinceId);
// check if there is a land way within the same state between two cells
function isPassable(from: number, to: number) {
if (cells.f[from] !== cells.f[to]) return false; // on different islands
const passableQueue = [from],
used = new Uint8Array(cells.i.length),
state = cells.state[from];
while (passableQueue.length) {
const current = passableQueue.pop() as number;
if (current === to) return true; // way is found
cells.c[current].forEach((c) => {
if (used[c] || cells.h[c] < 20 || cells.state[c] !== state)
return;
passableQueue.push(c);
used[c] = 1;
});
}
return false; // way is not found
}
// re-check
stateNoProvince = noProvince.filter(
(i) => cells.state[i] === s.i && !provinceIds[i],
);
}
});
cells.province = provinceIds;
pack.provinces = provinces;
TIME && console.timeEnd("generateProvinces");
}
// calculate pole of inaccessibility for each province
getPoles() {
const getType = (cellId: number) => pack.cells.province[cellId];
const poles = getPolesOfInaccessibility(pack, getType);
pack.provinces.forEach((province) => {
if (!province.i || province.removed) return;
province.pole = poles[province.i] || [0, 0];
});
}
}
window.Provinces = new ProvinceModule();

View file

@ -50,6 +50,7 @@ export interface State {
formName?: string; formName?: string;
fullName?: string; fullName?: string;
form?: string; form?: string;
provinces?: number[];
} }
class StatesModule { class StatesModule {

View file

@ -1,6 +1,7 @@
import type { Burg } from "../modules/burgs-generator"; import type { Burg } from "../modules/burgs-generator";
import type { Culture } from "../modules/cultures-generator"; import type { Culture } from "../modules/cultures-generator";
import type { PackedGraphFeature } from "../modules/features"; import type { PackedGraphFeature } from "../modules/features";
import type { Province } from "../modules/provinces-generator";
import type { River } from "../modules/river-generator"; import type { River } from "../modules/river-generator";
import type { Route } from "../modules/routes-generator"; import type { Route } from "../modules/routes-generator";
import type { State } from "../modules/states-generator"; import type { State } from "../modules/states-generator";
@ -39,6 +40,7 @@ export interface PackedGraph {
religion: TypedArray; // cell religion id religion: TypedArray; // cell religion id
state: number[]; // cell state id state: number[]; // cell state id
area: TypedArray; // cell area area: TypedArray; // cell area
province: TypedArray; // cell province id
routes: Record<number, Record<number, number>>; routes: Record<number, Record<number, number>>;
}; };
vertices: { vertices: {
@ -56,4 +58,5 @@ export interface PackedGraph {
cultures: Culture[]; cultures: Culture[];
routes: Route[]; routes: Route[];
religions: any[]; religions: any[];
provinces: Province[];
} }

File diff suppressed because one or more lines are too long