This commit is contained in:
33333-33333 2026-05-13 13:19:12 +09:00
commit 597b276825
2 changed files with 450 additions and 38 deletions

View file

@ -67,6 +67,7 @@
<div><dt>Ethnicities</dt><dd id="ethnicities">0</dd></div>
<div><dt>Cities</dt><dd id="cities">0</dd></div>
<div><dt>States</dt><dd id="polities">0</dd></div>
<div><dt>Wars</dt><dd id="wars">0</dd></div>
<div><dt>Trade routes</dt><dd id="routes">0</dd></div>
<div><dt>Farming knowledge</dt><dd id="farmingKnowledge">0.000</dd></div>
<div><dt>Metallurgy knowledge</dt><dd id="metallurgyKnowledge">0.000</dd></div>

487
script.js
View file

@ -35,9 +35,14 @@ const SimConfig = Object.freeze({
pressurePenaltyBase: 0.2,
pressureReproductionPenalty: 0.16
}),
city: Object.freeze({
maxCities: 300
}),
render: Object.freeze({
graphThrottleMs: 700,
statsThrottleMs: 350
statsThrottleMs: 350,
historyWindowYears: 2000,
historySamplePaddingYears: 240
}),
save: Object.freeze({
key: SAVE_KEY,
@ -84,6 +89,7 @@ const els = {
ethnicities: document.getElementById("ethnicities"),
cities: document.getElementById("cities"),
polities: document.getElementById("polities"),
wars: document.getElementById("wars"),
routes: document.getElementById("routes"),
farmingKnowledge: document.getElementById("farmingKnowledge"),
metallurgyKnowledge: document.getElementById("metallurgyKnowledge"),
@ -429,12 +435,14 @@ class Simulation {
this.ethnicities = new Map();
this.cities = [];
this.polities = [];
this.wars = [];
this.polityHistory = new Map();
this.deadPolityHistories = [];
this.tradeLinks = [];
this.nextEthnicity = 1;
this.nextCity = 1;
this.nextPolity = 1;
this.nextWar = 1;
this.year = 0;
this.deaths = 0;
this.maxAgents = Math.max(
@ -1306,7 +1314,7 @@ class Simulation {
if (group.count < 3) continue;
const avgSedentary = group.sedentary / group.count;
let city = this.cities.find(c => Math.abs(c.x - (i % w.size)) + Math.abs(c.y - Math.floor(i / w.size)) < 7);
if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < 80) {
if (!city && canFoundCities && foundedThisTick < foundingLimit && this.cities.length < SimConfig.city.maxCities) {
const foundingChance = clamp((avgSedentary - 0.18) * 2.4 * 3, 0.04, 0.98);
if (avgSedentary < 0.22 || this.rng.next() > foundingChance) continue;
city = this.createCity(i % w.size, Math.floor(i / w.size), group);
@ -1652,7 +1660,8 @@ class Simulation {
treasury: polity.treasury || 0,
avgLoyalty: this.averagePolityLoyalty(polity)
});
while (history.samples.length > 160) history.samples.shift();
const maxSamples = SimConfig.render.historyWindowYears + SimConfig.render.historySamplePaddingYears;
while (history.samples.length > maxSamples) history.samples.shift();
}
samplePolityHistories() {
@ -1696,6 +1705,128 @@ class Simulation {
city.receivedAid = false;
}
isPolityAtWar(polityId) {
return this.wars.some(war => war.ended === null && (war.aPolityId === polityId || war.bPolityId === polityId));
}
getWarBetween(aId, bId) {
return this.wars.find(war =>
war.ended === null &&
((war.aPolityId === aId && war.bPolityId === bId) || (war.aPolityId === bId && war.bPolityId === aId))
) || null;
}
startWar(a, b) {
if (!a || !b || a.id === b.id) return null;
if (this.getWarBetween(a.id, b.id)) return null;
if (this.isPolityAtWar(a.id) || this.isPolityAtWar(b.id)) return null;
const id = this.nextWar++;
const war = {
id,
aPolityId: a.id,
bPolityId: b.id,
started: this.year,
lastActionYear: this.year,
intensity: this.rng.range(0.45, 1.05),
exhaustionA: 0,
exhaustionB: 0,
ended: null
};
this.wars.push(war);
a.crisis = clamp((a.crisis || 0) + 0.04, 0, 1.5);
b.crisis = clamp((b.crisis || 0) + 0.04, 0, 1.5);
return war;
}
endWar(war) {
if (war && war.ended === null) war.ended = this.year;
}
totalPolityPopulation(polity) {
return this.getPolityCities(polity)
.reduce((sum, city) => sum + Math.max(0, city.population || 0), 0);
}
averagePolityTechnology(polity) {
const cities = this.getPolityCities(polity);
if (!cities.length) return 0;
let total = 0;
let count = 0;
for (const city of cities) {
if (!city?.knowledge) continue;
total += ((city.knowledge.farming || 0) + (city.knowledge.metallurgy || 0)) * 0.5;
count++;
}
return count ? total / count : 0;
}
polityPower(polity) {
const cities = this.getPolityCities(polity);
if (!cities.length) return 0;
const population = cities.reduce((sum, c) => sum + Math.max(0, c.population || 0), 0);
const treasury = Math.max(0, polity.treasury || 0);
const avgLoyalty = this.averagePolityLoyalty ? this.averagePolityLoyalty(polity) : 0.5;
const tech = this.averagePolityTechnology(polity);
const instability = this.polityInstability ? this.polityInstability(polity) : 0;
const agePressure = this.polityAgePressure ? this.polityAgePressure(polity) : 0;
return Math.max(0,
Math.sqrt(population) * 1.35 +
Math.sqrt(treasury) * 1.15 +
avgLoyalty * 16 +
tech * 24 -
instability * 12 -
agePressure * 6
);
}
polityInfluenceRange(polity, wartime = false) {
const power = this.polityPower(polity);
const base = 18 + Math.sqrt(power) * 2.2;
const range = wartime ? base * 1.15 : base * 0.75;
return clamp(range, wartime ? 24 : 18, wartime ? 60 : 42);
}
distanceToNearestPolityCity(polity, city) {
if (!polity || !city) return Infinity;
let best = Infinity;
for (const source of this.getPolityCities(polity)) {
const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city);
if (distance < best) best = distance;
}
return best;
}
nearestPolityCity(polity, city) {
if (!polity || !city) return null;
let best = null;
let bestDistance = Infinity;
for (const source of this.getPolityCities(polity)) {
const distance = this.effectiveDistance ? this.effectiveDistance(source, city) : this.distanceBetweenCities(source, city);
if (distance < bestDistance) {
best = source;
bestDistance = distance;
}
}
return best;
}
polityDistance(a, b) {
const aCities = this.getPolityCities(a);
const bCities = this.getPolityCities(b);
if (!aCities.length || !bCities.length) return Infinity;
let best = Infinity;
for (const aCity of aCities) {
for (const bCity of bCities) {
const distance = this.effectiveDistance ? this.effectiveDistance(aCity, bCity) : this.distanceBetweenCities(aCity, bCity);
if (distance < best) best = distance;
}
}
return best;
}
polityAge(polity) {
return (this.year - (polity?.founded ?? this.year)) / MONTHS_PER_YEAR;
}
@ -1853,6 +1984,288 @@ class Simulation {
}
}
absorbIndependentCities() {
if (this.year % years(5) !== 0) return;
for (const polity of this.polities) {
const cities = this.getPolityCities(polity);
if (!cities.length) continue;
const power = this.polityPower(polity);
const range = this.polityInfluenceRange(polity, false);
let absorbed = 0;
const candidates = this.cities
.filter(city => city.polityId === null && city.population > 0)
.map(city => ({
city,
distance: this.distanceToNearestPolityCity(polity, city)
}))
.filter(x => x.distance <= range)
.sort((a, b) => a.distance - b.distance);
for (const { city, distance } of candidates) {
if (absorbed >= 1) break;
const cityInfluence = this.cityInfluence ? this.cityInfluence(city) : Math.sqrt(city.population || 1);
const proximity = 1 / (1 + distance * 0.07);
const pressure = (power / Math.max(1, cityInfluence)) * proximity;
if (pressure > 0.75 && this.rng.next() < clamp(pressure * 0.055, 0.01, 0.22)) {
this.addCityToPolity(city, polity, 0.42);
city.population = Math.max(1, Math.floor(city.population * this.rng.range(0.95, 0.99)));
absorbed++;
}
}
}
}
maybeStartWars() {
if (this.year % years(10) !== 0) return;
for (const a of this.polities) {
if (this.isPolityAtWar(a.id)) continue;
const aPower = this.polityPower(a);
if (aPower <= 0) continue;
let started = false;
for (const b of this.polities) {
if (started) break;
if (a.id >= b.id) continue;
if (this.isPolityAtWar(b.id)) continue;
if (this.getWarBetween(a.id, b.id)) continue;
const distance = this.polityDistance(a, b);
if (distance > 46) continue;
const bPower = this.polityPower(b);
if (bPower <= 0) continue;
const larger = Math.max(aPower, bPower);
const smaller = Math.min(aPower, bPower);
const advantage = larger / Math.max(1, smaller);
const proximity = clamp((46 - distance) / 46, 0, 1);
const aInstability = this.polityInstability ? this.polityInstability(a) : 0;
const bInstability = this.polityInstability ? this.polityInstability(b) : 0;
const aAge = this.polityAgePressure ? this.polityAgePressure(a) : 0;
const bAge = this.polityAgePressure ? this.polityAgePressure(b) : 0;
const aOverextension = this.polityOverextension ? this.polityOverextension(a) : 0;
const bOverextension = this.polityOverextension ? this.polityOverextension(b) : 0;
const asymmetryPressure = clamp((advantage - 1.15) / 2.5, 0, 1);
const weakSideInstability = aPower > bPower ? bInstability : aInstability;
const weakSideAge = aPower > bPower ? bAge : aAge;
const weakSideOverextension = aPower > bPower ? bOverextension : aOverextension;
const generalInstability = Math.max(aInstability, bInstability) * 0.04;
const borderFriction = this.borderFriction(a, b, distance);
const chance =
0.002 +
proximity * 0.010 +
asymmetryPressure * 0.010 +
weakSideInstability * 0.020 +
weakSideAge * 0.012 +
weakSideOverextension * 0.012 +
borderFriction * 0.010 +
generalInstability;
if (this.rng.next() < clamp(chance, 0, 0.08)) {
this.startWar(a, b);
started = true;
}
}
}
}
borderFriction(a, b, distance) {
if (!Number.isFinite(distance)) return 0;
const proximity = clamp((34 - distance) / 34, 0, 1);
const aCities = this.getPolityCities(a).length;
const bCities = this.getPolityCities(b).length;
const sizePressure = clamp((aCities + bCities - 3) / 8, 0, 1);
return proximity * (0.35 + sizePressure * 0.65);
}
updateWars() {
if (this.year % years(2) !== 0) return;
for (const war of this.wars) {
if (war.ended !== null) continue;
const a = this.getPolityById(war.aPolityId);
const b = this.getPolityById(war.bPolityId);
if (!a || !b) {
war.ended = this.year;
continue;
}
this.applyWarPressure(war, a, b);
this.applyWarPressure(war, b, a);
this.applyWarExhaustion(war, a, b);
this.maybeEndWar(war);
}
this.wars = this.wars.filter(w => w.ended === null);
}
applyWarPressure(war, attacker, defender) {
const attackerCities = this.getPolityCities(attacker);
const defenderCities = this.getPolityCities(defender);
if (!attackerCities.length || !defenderCities.length) return;
const attackerPower = this.polityPower(attacker);
const defenderPower = this.polityPower(defender);
if (attackerPower <= defenderPower * 1.05) return;
const range = this.polityInfluenceRange(attacker, true);
const candidates = defenderCities
.filter(city => city.id !== defender.centerCityId || defenderCities.length <= 2)
.map(city => ({
city,
distance: this.distanceToNearestPolityCity(attacker, city)
}))
.filter(x => x.distance <= range)
.sort((a, b) => a.distance - b.distance);
if (!candidates.length) return;
const topCandidates = candidates.slice(0, 3);
const selected = topCandidates[this.rng.int(topCandidates.length)];
const pressure = this.warAbsorptionPressure(
attacker,
defender,
selected.city,
selected.distance,
attackerPower,
defenderPower
);
const chance = clamp(pressure * 0.065 * (war.intensity || 0.75), 0.01, 0.45);
if (pressure > 0.95 && this.rng.next() < chance) {
this.captureCityInWar(selected.city, attacker, defender, war, pressure);
} else {
selected.city.loyalty = clamp((selected.city.loyalty ?? 0.5) - pressure * 0.012, 0, 1);
}
}
warAbsorptionPressure(attacker, defender, city, distance, attackerPower, defenderPower) {
const powerRatio = attackerPower / Math.max(1, defenderPower);
const proximity = 1 / (1 + distance * 0.055);
const defenderInstability = this.polityInstability ? this.polityInstability(defender) : 0;
const defenderAge = this.polityAgePressure ? this.polityAgePressure(defender) : 0;
const defenderOverextension = this.polityOverextension ? this.polityOverextension(defender) : 0;
const loyaltyWeakness = 1 - clamp(city.loyalty ?? 0.5, 0, 1);
const techAdvantage = 1 + clamp(
this.averagePolityTechnology(attacker) - this.averagePolityTechnology(defender),
-0.25,
0.45
);
const nearest = this.nearestPolityCity(attacker, city);
const ethnicityFactor = nearest && this.sameDominantEthnicity(city, nearest) ? 1.10 : 0.92;
const tradeFactor = nearest && this.hasDirectTradeConnection && this.hasDirectTradeConnection(nearest, city) ? 1.15 : 1.0;
return (
powerRatio *
proximity *
techAdvantage *
ethnicityFactor *
tradeFactor *
(1 + defenderInstability * 0.32) *
(1 + defenderAge * 0.18) *
(1 + defenderOverextension * 0.14) *
(0.65 + loyaltyWeakness * 0.72)
);
}
captureCityInWar(city, attacker, defender, war, pressure) {
const lossRate = clamp(
0.04 + pressure * 0.025 + (war.intensity || 0.75) * 0.025,
0.05,
0.22
);
const loss = Math.floor((city.population || 0) * lossRate);
if (loss > 0) {
city.population = Math.max(1, city.population - loss);
if (typeof removeFromComposition === "function" && city.ethnicityComposition) {
removeFromComposition(city.ethnicityComposition, loss);
}
this.deaths += Math.floor(loss * 0.35);
}
this.removeCityFromPolity(city);
this.addCityToPolity(city, attacker, 0.28);
city.loyalty = clamp(city.loyalty ?? 0.28, 0.20, 0.36);
war.lastActionYear = this.year;
if (war.aPolityId === attacker.id) {
war.exhaustionA = clamp((war.exhaustionA || 0) + 0.04, 0, 1);
war.exhaustionB = clamp((war.exhaustionB || 0) + 0.08, 0, 1);
} else {
war.exhaustionB = clamp((war.exhaustionB || 0) + 0.04, 0, 1);
war.exhaustionA = clamp((war.exhaustionA || 0) + 0.08, 0, 1);
}
attacker.treasury = Math.max(0, (attacker.treasury || 0) - loss * 0.015);
attacker.crisis = clamp((attacker.crisis || 0) + 0.035, 0, 1.5);
defender.crisis = clamp((defender.crisis || 0) + 0.12, 0, 1.5);
defender.legitimacy = clamp((defender.legitimacy ?? 0.7) - 0.05, 0, 1);
}
applyWarExhaustion(war, a, b) {
const intensity = war.intensity || 0.75;
const costA = 0.25 * intensity + this.getPolityCities(a).length * 0.035;
const costB = 0.25 * intensity + this.getPolityCities(b).length * 0.035;
a.treasury = Math.max(0, (a.treasury || 0) - costA);
b.treasury = Math.max(0, (b.treasury || 0) - costB);
a.crisis = clamp((a.crisis || 0) + 0.004 * intensity, 0, 1.5);
b.crisis = clamp((b.crisis || 0) + 0.004 * intensity, 0, 1.5);
war.exhaustionA = clamp((war.exhaustionA || 0) + 0.006 * intensity, 0, 1);
war.exhaustionB = clamp((war.exhaustionB || 0) + 0.006 * intensity, 0, 1);
}
maybeEndWar(war) {
const a = this.getPolityById(war.aPolityId);
const b = this.getPolityById(war.bPolityId);
if (!a || !b) {
war.ended = this.year;
return;
}
const age = this.year - war.started;
const noActionFor = this.year - war.lastActionYear;
const exhaustion = Math.max(war.exhaustionA || 0, war.exhaustionB || 0);
if (age > years(90)) {
war.ended = this.year;
return;
}
if (age > years(10) && noActionFor > years(18)) {
war.ended = this.year;
return;
}
if (exhaustion > 0.85 && this.rng.next() < 0.35) {
war.ended = this.year;
}
}
collectAndRedistributeResources() {
for (const polity of this.polities) {
const cities = this.getPolityCities(polity);
@ -2090,13 +2503,21 @@ class Simulation {
this.cleanupPolities();
this.foundPolities();
this.expandPolities();
if (this.reinforcePolityTradeRoutes) this.reinforcePolityTradeRoutes();
this.collectAndRedistributeResources();
this.erodePolityLegitimacy();
this.triggerPolityCrises();
this.applyOldStateStress();
if (this.erodePolityLegitimacy) this.erodePolityLegitimacy();
if (this.triggerPolityCrises) this.triggerPolityCrises();
if (this.applyOldStateStress) this.applyOldStateStress();
this.updateCityLoyalty();
this.absorbIndependentCities();
this.maybeStartWars();
this.updateWars();
this.splitUnloyalCities();
this.cleanupPolities();
if (this.samplePolityHistories) this.samplePolityHistories();
}
@ -2431,6 +2852,7 @@ class Simulation {
nextEthnicity: this.nextEthnicity,
nextCity: this.nextCity,
nextPolity: this.nextPolity,
nextWar: this.nextWar,
maxAgents: this.maxAgents,
world: {
size: this.world.size,
@ -2477,6 +2899,7 @@ class Simulation {
crisis: p.crisis,
lastCrisisYear: p.lastCrisisYear
})),
wars: this.wars,
polityHistory: [...this.polityHistory.values()],
deadPolityHistories: this.deadPolityHistories
};
@ -2491,6 +2914,7 @@ class Simulation {
sim.nextEthnicity = state.nextEthnicity || 1;
sim.nextCity = state.nextCity || 1;
sim.nextPolity = state.nextPolity || 1;
sim.nextWar = state.nextWar || 1;
sim.maxAgents = state.maxAgents || 30000;
sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array));
@ -2559,6 +2983,19 @@ class Simulation {
crisis: p.crisis ?? 0,
lastCrisisYear: p.lastCrisisYear ?? sim.year
}));
sim.wars = (state.wars || []).map(w => ({
id: w.id,
aPolityId: w.aPolityId,
bPolityId: w.bPolityId,
started: w.started || sim.year,
lastActionYear: w.lastActionYear || w.started || sim.year,
intensity: clamp(w.intensity ?? 0.75, 0.35, 1.25),
exhaustionA: clamp(w.exhaustionA ?? 0, 0, 1),
exhaustionB: clamp(w.exhaustionB ?? 0, 0, 1),
ended: w.ended ?? null
})).filter(w => w.ended === null);
const maxWarId = sim.wars.reduce((max, war) => Math.max(max, war.id || 0), 0);
sim.nextWar = Math.max(sim.nextWar, maxWarId + 1);
sim.polityHistory = new Map((state.polityHistory || []).map(h => [h.id, {
id: h.id,
color: h.color || hslToRgb((h.id * 0.38196601125) % 1, 0.58, 0.62),
@ -2615,8 +3052,6 @@ function render() {
} else if (mode === "pressure") {
const v = clamp(w.pressure[i] / 12, 0, 1);
color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v);
} else if (mode === "cities") {
color = w.city[i] >= 0 ? mix([74, 105, 58], [226, 198, 121], clamp(w.farmland[i], 0, 1)) : terrainInfo[w.terrain[i]].color;
} else if (mode === "polities") {
color = terrainInfo[w.terrain[i]].color;
if (w.city[i] >= 0) {
@ -2638,7 +3073,6 @@ function render() {
ctx.putImageData(image, 0, 0);
drawTradeLinks();
drawFarmlandRings(mode);
drawAgentsAndCities(mode);
maybeRenderStateGraph();
els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
@ -2666,28 +3100,6 @@ function drawTradeLinks() {
ctx.restore();
}
function drawFarmlandRings(mode) {
if (mode !== "cities") return;
const w = sim.world;
ctx.save();
ctx.globalAlpha = 0.78;
ctx.fillStyle = "#d9c45f";
for (const city of sim.cities) {
const radius = Math.max(1, city.agriculturalRadius);
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const d = Math.abs(dx) + Math.abs(dy);
if (d !== radius) continue;
const x = city.x + dx;
const y = city.y + dy;
if (x < 0 || y < 0 || x >= w.size || y >= w.size) continue;
if (w.terrain[w.idx(x, y)] !== Terrain.WATER) ctx.fillRect(x, y, 1, 1);
}
}
}
ctx.restore();
}
function drawAgentsAndCities(mode) {
ctx.save();
for (const city of sim.cities) {
@ -2886,6 +3298,7 @@ function updateStats(force = false) {
els.ethnicities.textContent = livingEthnicities.length.toLocaleString();
els.cities.textContent = sim.cities.length.toLocaleString();
els.polities.textContent = sim.polities.length.toLocaleString();
if (els.wars) els.wars.textContent = sim.wars.length.toLocaleString();
els.routes.textContent = sim.tradeLinks.length.toLocaleString();
els.farmingKnowledge.textContent = `${(farmingTotal / agentCount).toFixed(4)} (${farmingHolders})`;
els.metallurgyKnowledge.textContent = `${(metallurgyTotal / agentCount).toFixed(4)} (${metallurgyHolders})`;
@ -2907,8 +3320,6 @@ function setLegend() {
els.legend.innerHTML = "<span>Color = dominant regional lineage. Mixed tiles brighten toward gray.</span>";
} else if (mode === "pressure") {
els.legend.innerHTML = "<span><i style=\"background:#d24943\"></i>High local population pressure</span>";
} else if (mode === "cities") {
els.legend.innerHTML = "<span><i style=\"background:#e2c679\"></i>City nodes, farmland radius, and stored urban population</span>";
} else if (mode === "polities") {
els.legend.innerHTML = "<span>Color = city-centered state. Uncolored cities are independent.</span>";
} else if (mode === "technology") {
@ -2946,7 +3357,9 @@ function renderStateGraph() {
g.fillStyle = "#101315";
g.fillRect(0, 0, w, h);
const historyWindowStart = Math.max(0, sim.year - years(10000));
const historyWindowSpan = years(SimConfig.render.historyWindowYears);
const historyWindowEnd = sim.year;
const historyWindowStart = Math.max(0, historyWindowEnd - historyWindowSpan);
const histories = sim.getAllPolityHistories()
.filter(history => (history.ended ?? sim.year) >= historyWindowStart)
.map(history => ({
@ -2963,13 +3376,11 @@ function renderStateGraph() {
return;
}
let minYear = Infinity;
let maxYear = sim.year;
let minYear = historyWindowStart;
let maxYear = historyWindowEnd;
for (const history of histories) {
minYear = Math.min(minYear, history.visibleFounded);
maxYear = Math.max(maxYear, history.ended ?? 0, history.visibleSamples[history.visibleSamples.length - 1]?.year ?? 0);
}
if (!Number.isFinite(minYear)) minYear = 0;
if (els.historyRange) els.historyRange.textContent = `${formatGraphYear(minYear)}-${formatGraphYear(maxYear)}`;
const paddingLeft = 34;