mirror of
https://github.com/Azgaar/Fantasy-Map-Generator.git
synced 2026-02-04 17:41:23 +01:00
* refactor: migrate provinces generator to new module structure * fix: after merge fixes of state * refactor: fixed a bug so had to update tests
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
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();
|