This commit is contained in:
33333-33333 2026-05-16 13:48:08 +09:00
commit ff89ec62fd
4 changed files with 112 additions and 75 deletions

View file

@ -32,7 +32,13 @@ pressurePenaltyBase: 0.2,
pressureReproductionPenalty: 0.16 pressureReproductionPenalty: 0.16
}), }),
city: Object.freeze({ city: Object.freeze({
maxCities: 300 maxCities: 300,
agingStartYears: 180,
agingDecayPerYear: 0.002,
agingOverCapAttrition: 0.035,
agingRestoreBudgetShare: 0.18,
agingRestoreCostPerWear: 70,
agingRestoreMinWear: 0.08
}), }),
render: Object.freeze({ render: Object.freeze({
graphThrottleMs: 1400, graphThrottleMs: 1400,
@ -47,16 +53,14 @@ cityInnovation: 0.00045
}), }),
polity: Object.freeze({ polity: Object.freeze({
minimumPerCapitaFood: 0.06, minimumPerCapitaFood: 0.06,
logisticsDistance: 34 logisticsDistance: 34,
charismaMin: 0.5,
charismaMax: 3.0,
charismaAverage: 1.0
}), }),
polityAccess: Object.freeze({ polityAccess: Object.freeze({
enabled: true, enabled: true,
maxSearchDepth: 8, noAccessPenalty: 0.17
indirectPenalty: 0.012,
perHopPenalty: 0.006,
noAccessPenalty: 0.045,
lowLoyaltyTransitPenalty: 0.012,
foreignTransitPenalty: 0.008
}), }),
culture: Object.freeze({ culture: Object.freeze({
spreadRadius: 3, spreadRadius: 3,

132
engine.js
View file

@ -1488,6 +1488,8 @@ agriculturalRadius: 2,
tradeLinks: new Set(), tradeLinks: new Set(),
activeVisitors: 0, activeVisitors: 0,
age: 0, age: 0,
ageWear: 0,
peakPopulation: seedPopulation,
strength: 3, strength: 3,
sedentaryCulture: seedSedentary, sedentaryCulture: seedSedentary,
knowledge: { farming: 0, metallurgy: 0 }, knowledge: { farming: 0, metallurgy: 0 },
@ -1499,6 +1501,21 @@ tradeValue: 0,
tradeReach: 0 tradeReach: 0
}; };
} }
updateCityAging(city) {
city.ageWear ??= 0;
city.peakPopulation = Math.max(city.peakPopulation || 0, city.population || 0);
const cfg = SimConfig.city;
const ageStart = cfg.agingStartYears ?? 180;
if ((city.age || 0) <= ageStart) return;
city.ageWear = clamp(city.ageWear + (cfg.agingDecayPerYear ?? 0.002), 0, 1);
}
cityAgeCapacityFactor(city) {
return clamp(1 - (city?.ageWear || 0), 0, 1);
}
cityAgePopulationLimit(city) {
const peakPopulation = Math.max(city?.peakPopulation || 0, city?.population || 0, 1);
return Math.max(0, peakPopulation * this.cityAgeCapacityFactor(city));
}
absorbUrbanPopulation() { absorbUrbanPopulation() {
if (!this.cities.length) return; if (!this.cities.length) return;
const absorbed = []; const absorbed = [];
@ -1535,6 +1552,7 @@ return whole + (this.rng.next() < value - whole ? 1 : 0);
processCityEconomies() { processCityEconomies() {
const w = this.world; const w = this.world;
for (const city of this.cities) { for (const city of this.cities) {
this.updateCityAging(city);
city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population) / 4.5), 2, 14); city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population) / 4.5), 2, 14);
city.pheromoneOutput = clamp(Math.log2(city.population + 1) * 0.09, 0.15, 1.8); city.pheromoneOutput = clamp(Math.log2(city.population + 1) * 0.09, 0.15, 1.8);
city.knowledge ??= { farming: 0, metallurgy: 0 }; city.knowledge ??= { farming: 0, metallurgy: 0 };
@ -1579,13 +1597,22 @@ city.tradeReach = trade.reach;
city.tradeValue = 0; city.tradeValue = 0;
city.tradeReach = 0; city.tradeReach = 0;
} }
if (city.storedResources > city.population * 0.16 && city.population > 0) { const agePopulationLimit = this.cityAgePopulationLimit(city);
if (city.storedResources > city.population * 0.16 && city.population > 0 && city.population < agePopulationLimit) {
const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8); const prosperity = clamp(city.storedResources / Math.max(1, city.population) - 0.12 + trade.value * 0.012, 0, 0.8);
const births = Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015))); const birthRoom = Math.max(0, Math.floor(agePopulationLimit - city.population));
const births = Math.min(birthRoom, Math.max(1, Math.floor(city.population * (0.012 + prosperity * 0.012 + clamp(trade.value, 0, 1.8) * 0.0015))));
city.population += births; city.population += births;
city.storedResources -= births * 0.55; city.storedResources -= births * 0.55;
addBirthsToComposition(city.ethnicityComposition, births, this.rng); addBirthsToComposition(city.ethnicityComposition, births, this.rng);
} }
if (city.population > agePopulationLimit) {
const overLimit = city.population - agePopulationLimit;
const ageLoss = Math.min(city.population, Math.max(1, Math.ceil(overLimit * (SimConfig.city.agingOverCapAttrition ?? 0.035))));
city.population -= ageLoss;
removeFromComposition(city.ethnicityComposition, ageLoss);
city.strength -= Math.min(0.08, ageLoss / Math.max(1, city.population + ageLoss) * 0.18);
}
if (city.age > 20 && city.activeVisitors < 2 && city.storedResources < city.population * 0.03 && city.population > 0) { if (city.age > 20 && city.activeVisitors < 2 && city.storedResources < city.population * 0.03 && city.population > 0) {
const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010))); const attrition = Math.max(1, Math.ceil(city.population * (city.activeVisitors === 0 ? 0.025 : 0.010)));
city.population -= attrition; city.population -= attrition;
@ -1733,55 +1760,19 @@ const from = this.tradeLinkEndpointId(link.from ?? link.fromCityId ?? link.cityA
const to = this.tradeLinkEndpointId(link.to ?? link.toCityId ?? link.cityBId ?? link.bId ?? link.targetId ?? link.destinationId ?? link.target ?? link.destination ?? link.b ?? link.cityB); const to = this.tradeLinkEndpointId(link.to ?? link.toCityId ?? link.cityBId ?? link.bId ?? link.targetId ?? link.destinationId ?? link.target ?? link.destination ?? link.b ?? link.cityB);
return from != null && to != null ? [from, to] : null; return from != null && to != null ? [from, to] : null;
} }
polityTradeNeighbors(city, polityId) {
if (!city || polityId == null) return [];
const neighbors = [];
for (const link of this.tradeLinks || []) {
const endpoints = this.tradeLinkEndpointIds(link);
if (!endpoints) continue;
const [from, to] = endpoints;
const otherId = from === city.id ? to : to === city.id ? from : null;
if (otherId == null) continue;
const other = this.getCityById(otherId);
if (other?.polityId === polityId) neighbors.push(other);
}
return neighbors;
}
tradeAccessToCenter(city, center, polityId) {
if (!city || !center || polityId == null) return { reachable: false, hops: Infinity, transitCities: [] };
if (city.id === center.id) return { reachable: true, hops: 0, transitCities: [] };
if (this.hasDirectTradeConnection(city, center)) return { reachable: true, hops: 1, transitCities: [] };
const maxDepth = SimConfig.polityAccess?.maxSearchDepth ?? 8;
const visited = new Set([city.id]);
const queue = [{ city, hops: 0, transitCities: [] }];
while (queue.length) {
const current = queue.shift();
if (current.hops >= maxDepth) continue;
for (const neighbor of this.polityTradeNeighbors(current.city, polityId)) {
if (visited.has(neighbor.id)) continue;
const hops = current.hops + 1;
if (neighbor.id === center.id) return { reachable: true, hops, transitCities: current.transitCities };
visited.add(neighbor.id);
queue.push({ city: neighbor, hops, transitCities: [...current.transitCities, neighbor] });
}
}
return { reachable: false, hops: Infinity, transitCities: [] };
}
tradeAccessLoyaltyPenalty(city, center, polity) { tradeAccessLoyaltyPenalty(city, center, polity) {
const cfg = SimConfig.polityAccess; const cfg = SimConfig.polityAccess;
if (!cfg?.enabled || !city || !center || !polity || city.id === center.id || this.hasDirectTradeConnection(city, center)) return 0; if (!cfg?.enabled || !city || !center || !polity || city.id === center.id || this.hasDirectTradeConnection(city, center)) return 0;
const access = this.tradeAccessToCenter(city, center, polity.id); return clamp(cfg.noAccessPenalty, 0, 0.24);
if (!access.reachable) return clamp(cfg.noAccessPenalty, 0, 0.07);
let penalty = cfg.indirectPenalty + Math.max(0, access.hops - 1) * cfg.perHopPenalty;
const originEthnicity = this.dominantCityEthnicity(city);
for (const transitCity of access.transitCities) {
if ((transitCity.loyalty ?? 0.5) < 0.35) penalty += cfg.lowLoyaltyTransitPenalty;
const transitEthnicity = this.dominantCityEthnicity(transitCity);
if (originEthnicity !== null && transitEthnicity !== null && transitEthnicity !== originEthnicity) {
penalty += cfg.foreignTransitPenalty;
} }
} rollLeaderCharisma() {
return clamp(penalty, 0, 0.07); const cfg = SimConfig.polity;
const min = cfg?.charismaMin ?? 0.5;
const max = cfg?.charismaMax ?? 3.0;
const targetAverage = cfg?.charismaAverage ?? 1.0;
const normalizedAverage = clamp((targetAverage - min) / Math.max(0.001, max - min), 0.001, 0.999);
const exponent = (1 / normalizedAverage) - 1;
return min + (max - min) * Math.pow(this.rng.next(), exponent);
} }
effectiveDistance(cityA, cityB) { effectiveDistance(cityA, cityB) {
let distance = this.distanceBetweenCities(cityA, cityB); let distance = this.distanceBetweenCities(cityA, cityB);
@ -1799,7 +1790,7 @@ color: hslToRgb((id * 0.38196601125) % 1, 0.58, 0.62),
founded: this.year, founded: this.year,
legitimacy: this.rng.range(0.68, 0.94), legitimacy: this.rng.range(0.68, 0.94),
cohesion: this.rng.range(0.58, 0.9), cohesion: this.rng.range(0.58, 0.9),
charisma: this.rng.range(0.5, 1.5), charisma: this.rollLeaderCharisma(),
leaderStarted: this.year, leaderStarted: this.year,
leaderTenureYears: this.rng.range(24, 68), leaderTenureYears: this.rng.range(24, 68),
crisis: 0, crisis: 0,
@ -1905,17 +1896,19 @@ while (this.graphEvents.length > 160) this.graphEvents.shift();
} }
selectNewLeader(polity, forced = false) { selectNewLeader(polity, forced = false) {
if (!polity) return; if (!polity) return;
const previousCharisma = clamp(polity.charisma ?? 1, 0.5, 1.5); const charismaMin = SimConfig.polity?.charismaMin ?? 0.5;
const charismaMax = SimConfig.polity?.charismaMax ?? 3.0;
const previousCharisma = clamp(polity.charisma ?? 1, charismaMin, charismaMax);
const center = this.getCityById(polity.centerCityId); const center = this.getCityById(polity.centerCityId);
const centerStability = center ? clamp(center.loyalty ?? 0.5, 0, 1) : 0.5; const centerStability = center ? clamp(center.loyalty ?? 0.5, 0, 1) : 0.5;
const institutionalBias = ((polity.legitimacy ?? 0.7) + (polity.cohesion ?? 0.6) + centerStability) / 3; const institutionalBias = ((polity.legitimacy ?? 0.7) + (polity.cohesion ?? 0.6) + centerStability) / 3;
const randomLeader = this.rng.range(0.5, 1.5); const randomLeader = this.rollLeaderCharisma();
const continuity = forced ? 0.18 : 0.34; const continuity = forced ? 0.18 : 0.34;
const institutionalPull = 0.82 + institutionalBias * 0.36; const institutionalPull = 0.82 + institutionalBias * 0.36;
const nextCharisma = clamp( const nextCharisma = clamp(
previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull, previousCharisma * continuity + randomLeader * (1 - continuity) * institutionalPull,
0.5, charismaMin,
1.5 charismaMax
); );
polity.charisma = nextCharisma; polity.charisma = nextCharisma;
polity.leaderStarted = this.year; polity.leaderStarted = this.year;
@ -1927,7 +1920,7 @@ if (forced) polity.crisis = clamp((polity.crisis || 0) + 0.04, 0, 1.5);
} }
updatePolityLeaders() { updatePolityLeaders() {
for (const polity of this.polities) { for (const polity of this.polities) {
polity.charisma = clamp(polity.charisma ?? 1, 0.5, 1.5); polity.charisma = clamp(polity.charisma ?? 1, SimConfig.polity?.charismaMin ?? 0.5, SimConfig.polity?.charismaMax ?? 3.0);
polity.leaderStarted ??= polity.founded ?? this.year; polity.leaderStarted ??= polity.founded ?? this.year;
polity.leaderTenureYears ??= this.rng.range(24, 68); polity.leaderTenureYears ??= this.rng.range(24, 68);
const tenureYears = (this.year - polity.leaderStarted) / WEEKS_PER_YEAR; const tenureYears = (this.year - polity.leaderStarted) / WEEKS_PER_YEAR;
@ -2194,7 +2187,7 @@ tech * 24 -
instability * 12 - instability * 12 -
agePressure * 6 agePressure * 6
); );
return basePower * clamp(polity.charisma ?? 1, 0.5, 1.5); return basePower * clamp(polity.charisma ?? 1, SimConfig.polity?.charismaMin ?? 0.5, SimConfig.polity?.charismaMax ?? 3.0);
} }
polityInfluenceRange(polity, wartime = false) { polityInfluenceRange(polity, wartime = false) {
const power = this.polityPower(polity); const power = this.polityPower(polity);
@ -2679,6 +2672,34 @@ city.receivedAid = true;
city.loyalty = clamp(city.loyalty + 0.05, 0, 1); city.loyalty = clamp(city.loyalty + 0.05, 0, 1);
} }
} }
this.restoreAgedPolityCities(polity, cities);
}
}
restoreAgedPolityCities(polity, cities) {
const cfg = SimConfig.city;
if (!polity || !cities?.length || (polity.treasury || 0) <= 0) return;
const minWear = cfg.agingRestoreMinWear ?? 0.08;
let budget = Math.min(polity.treasury, (polity.treasury || 0) * (cfg.agingRestoreBudgetShare ?? 0.18));
if (budget <= 0) return;
const costPerWear = Math.max(1, cfg.agingRestoreCostPerWear ?? 70);
const candidates = [...cities]
.filter(city => (city.ageWear || 0) > minWear && city.population > 0)
.sort((a, b) => {
const aPressure = Math.max(0, (a.population || 0) - this.cityAgePopulationLimit(a));
const bPressure = Math.max(0, (b.population || 0) - this.cityAgePopulationLimit(b));
return bPressure - aPressure || (b.population || 0) - (a.population || 0);
});
for (const city of candidates) {
if (budget <= 0 || polity.treasury <= 0) break;
const wear = city.ageWear || 0;
const maxRestoreCost = Math.min(budget, polity.treasury, wear * costPerWear);
if (maxRestoreCost <= 0) continue;
const restoredWear = maxRestoreCost / costPerWear;
city.ageWear = clamp(wear - restoredWear, 0, 1);
polity.treasury -= maxRestoreCost;
budget -= maxRestoreCost;
city.receivedAid = true;
city.loyalty = clamp((city.loyalty ?? 0.5) + restoredWear * 0.24, 0, 1);
} }
} }
erodePolityLegitimacy() { erodePolityLegitimacy() {
@ -2767,8 +2788,7 @@ delta += clamp((perCapita - 0.10) * 0.07, -0.025, 0.045);
delta += this.sameDominantEthnicity(city, center) ? 0.03 : -0.012; delta += this.sameDominantEthnicity(city, center) ? 0.03 : -0.012;
delta += clamp(0.045 - distance * 0.0011, -0.025, 0.045); delta += clamp(0.045 - distance * 0.0011, -0.025, 0.045);
const directCenterTrade = this.hasDirectTradeConnection(city, center); const directCenterTrade = this.hasDirectTradeConnection(city, center);
if (directCenterTrade) delta += 0.025; if (!directCenterTrade) delta -= this.tradeAccessLoyaltyPenalty(city, center, polity);
else delta -= this.tradeAccessLoyaltyPenalty(city, center, polity);
if (city.receivedAid) delta += 0.04; if (city.receivedAid) delta += 0.04;
delta -= 0.004; delta -= 0.004;
if (city.storedResources < city.population * 0.05) delta -= 0.035; if (city.storedResources < city.population * 0.05) delta -= 0.035;

View file

@ -290,15 +290,16 @@ els.tooltip.innerHTML = `
])} ])}
${tooltipSection("City & State", [ ${tooltipSection("City & State", [
tooltipRow("Population", city?.population.toLocaleString(), city), tooltipRow("Population", city?.population.toLocaleString(), city),
tooltipRow("Age cap", city ? `${Math.floor(sim.cityAgePopulationLimit(city)).toLocaleString()} / ${Math.round((city.ageWear || 0) * 100)}% wear` : "", city),
tooltipRow("Food stock", city?.storedResources.toFixed(1), city), tooltipRow("Food stock", city?.storedResources.toFixed(1), city),
tooltipRow("Supply stress", (city?.supplyStress || 0).toFixed(2), city), tooltipRow("Supply stress", (city?.supplyStress || 0).toFixed(2), city),
tooltipRow("Trade", city ? `${city.tradeLinks.size} links, ${(city.tradeValue || 0).toFixed(2)} value` : "", city), tooltipRow("Trade", city ? `${city.tradeLinks.size} links, ${(city.tradeValue || 0).toFixed(2)} value` : "", city),
tooltipRow("Knowledge", city ? `${knowledgeLevel(city, "farming").toFixed(2)} farm / ${knowledgeLevel(city, "metallurgy").toFixed(2)} metal` : "", city), tooltipRow("Knowledge", city ? `${knowledgeLevel(city, "farming").toFixed(2)} farm / ${knowledgeLevel(city, "metallurgy").toFixed(2)} metal` : "", city),
tooltipRow("City majority", `E${cityEthnicity}`, cityEthnicity), tooltipRow("City majority", cityEthnicity != null ? `E${cityEthnicity}` : "", cityEthnicity != null),
city ? cityEthnicityPie(city) : "", city ? cityEthnicityPie(city) : "",
tooltipRow("State", polity ? `#${polity.id}` : "Independent", city), tooltipRow("State", polity ? `#${polity.id}` : "Independent", city),
tooltipRow("Loyalty", city?.loyalty.toFixed(2), city), tooltipRow("Loyalty", city?.loyalty.toFixed(2), city),
tooltipRow("Charisma", clamp(polity?.charisma ?? 1, 0.5, 1.5).toFixed(2), polity), tooltipRow("Charisma", clamp(polity?.charisma ?? 1, SimConfig.polity?.charismaMin ?? 0.5, SimConfig.polity?.charismaMax ?? 3.0).toFixed(2), polity),
tooltipRow("Leader tenure", polity ? `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / WEEKS_PER_YEAR)}y` : "", polity), tooltipRow("Leader tenure", polity ? `${Math.floor((sim.year - (polity.leaderStarted ?? polity.founded ?? sim.year)) / WEEKS_PER_YEAR)}y` : "", polity),
tooltipRow("Treasury", polity?.treasury.toFixed(1), polity), tooltipRow("Treasury", polity?.treasury.toFixed(1), polity),
tooltipRow("Capital", polity && city ? polity.centerCityId === city.id ? "yes" : "no" : "", polity) tooltipRow("Capital", polity && city ? polity.centerCityId === city.id ? "yes" : "no" : "", polity)
@ -328,33 +329,44 @@ els.tooltip.style.left = `${clamp(left, margin, Math.max(margin, hoverState.widt
els.tooltip.style.top = `${clamp(top, margin, Math.max(margin, hoverState.height - height - margin))}px`; els.tooltip.style.top = `${clamp(top, margin, Math.max(margin, hoverState.height - height - margin))}px`;
} }
function cityEthnicityPie(city) { function cityEthnicityPie(city) {
if (!city?.ethnicityComposition?.size) return ""; if (!city) return "";
const entries = [...city.ethnicityComposition] let entries = [...(city.ethnicityComposition || new Map())]
.filter(([, count]) => count > 0) .map(([id, count]) => [id, Number(count)])
.filter(([, count]) => Number.isFinite(count) && count > 0)
.sort((a, b) => b[1] - a[1]); .sort((a, b) => b[1] - a[1]);
if (!entries.length && city.population > 0) {
const tile = sim.world.idx(city.x, city.y);
const fallbackId = sim.world.dominantEthnicity[tile] > 0 ? sim.world.dominantEthnicity[tile] : null;
if (fallbackId != null) entries = [[fallbackId, city.population]];
}
const total = entries.reduce((sum, [, count]) => sum + count, 0); const total = entries.reduce((sum, [, count]) => sum + count, 0);
if (total <= 0) return ""; if (total <= 0) return "";
const top = entries.slice(0, 5); const top = entries.slice(0, 5);
const other = entries.slice(5).reduce((sum, [, count]) => sum + count, 0); const other = entries.slice(5).reduce((sum, [, count]) => sum + count, 0);
const slices = other > 0 ? [...top, [0, other]] : top; const slices = other > 0 ? [...top, [0, other]] : top;
const sliceColor = id => {
const ethnicity = id !== 0 ? sim.ethnicities.get(id) : null;
return ethnicity?.color || [122, 130, 126];
};
let cursor = 0; let cursor = 0;
const gradient = slices.map(([id, count]) => { const gradient = slices.map(([id, count]) => {
const start = cursor / total * 100; const start = cursor / total * 100;
cursor += count; cursor += count;
const end = cursor / total * 100; const end = cursor / total * 100;
const ethnicity = id ? sim.ethnicities.get(id) : null; const color = sliceColor(id);
const color = ethnicity?.color || [122, 130, 126];
return `rgb(${color.join(",")}) ${start.toFixed(2)}% ${end.toFixed(2)}%`; return `rgb(${color.join(",")}) ${start.toFixed(2)}% ${end.toFixed(2)}%`;
}).join(", "); }).join(", ");
const labels = slices.map(([id, count]) => { const labels = slices.map(([id, count]) => {
const ethnicity = id ? sim.ethnicities.get(id) : null; const color = sliceColor(id);
const color = ethnicity?.color || [122, 130, 126]; const label = id !== 0 ? `E${id}` : "Other";
const label = id ? `E${id}` : "Other";
return `<span class="ethnicity-pie-key"><i style="background: rgb(${color.join(",")})"></i>${label} ${Math.round(count / total * 100)}%</span>`; return `<span class="ethnicity-pie-key"><i style="background: rgb(${color.join(",")})"></i>${label} ${Math.round(count / total * 100)}%</span>`;
}).join(""); }).join("");
const background = slices.length === 1
? `rgb(${sliceColor(slices[0][0]).join(",")})`
: `conic-gradient(${gradient})`;
return ` return `
<div class="ethnicity-pie-wrap"> <div class="ethnicity-pie-wrap">
<div class="ethnicity-pie" style="background: conic-gradient(${gradient})"></div> <div class="ethnicity-pie" style="background: ${background}"></div>
<div class="ethnicity-pie-labels">${labels}</div> <div class="ethnicity-pie-labels">${labels}</div>
</div> </div>
`; `;

View file

@ -77,6 +77,7 @@ function dominantComposition(composition) {
let bestId = null; let bestId = null;
let bestCount = 0; let bestCount = 0;
for (const [id, count] of composition) { for (const [id, count] of composition) {
if (!Number.isFinite(count) || count <= 0) continue;
if (count > bestCount) { if (count > bestCount) {
bestId = id; bestId = id;
bestCount = count; bestCount = count;